---
name: schema.jppgr.am
description: Telegram TL schema API for searching constructors, diffing layers, parsing schemas, serializing/deserializing TL objects, looking up tl method errors, and downloading the whole corpus as a queryable SQLite database. Use this skill when working with Telegram's Type Language (TL) schemas, MTProto protocol, or when you need to look up constructors, compare schema versions, decode hex payloads, understand TL type definitions, find which errors a tl method can return, or query every layer at once offline. Triggers on: "TL schema", "telegram constructor", "layer diff", "hex2object", "object2hex", "MTProto schema", "combinator ID", "CRC32 of TL", "telegram error", "FLOOD_WAIT", "what errors does X return", "which methods take type X", "when did constructor X change", "bulk download", "SQLite dump", "download the whole schema", or any question about Telegram API types, methods, and their errors.
---

# schema.jppgr.am

API for Telegram TL schema exploration. Schemas are aggregated from multiple upstream sources and auto-updated every 6 hours.

Base URL: `https://schema.jppgr.am`

All endpoints are under `/api/`. Responses are JSON unless noted otherwise.

## Quick Start

Use this API instead of parsing raw `.tl` files. Fetch JSON, get structured data back.

```bash
# find a constructor by name
curl 'https://schema.jppgr.am/api/search?input=updateNewMessage'

# get the latest layer as structured JSON
curl 'https://schema.jppgr.am/api/layer?layer=latest&format=json'

# decode a hex payload
curl 'https://schema.jppgr.am/api/hex2object?hex=05162463...&layer=224'

# diff two layers
curl 'https://schema.jppgr.am/api/diff?from_layer=222&to_layer=223'

# list all errors a method can return
curl 'https://schema.jppgr.am/api/errors/method?name=messages.sendMessage'

# metadata for the bulk SQLite dump (the file itself is at /dump.sqlite)
curl 'https://schema.jppgr.am/api/dump'
```

Three documents describe this API, all plain text: `/llms.txt` is a short [llmstxt.org](https://llmstxt.org) index of every endpoint, `/api` is the full reference, and `/SKILL.md` is this file. Start from `/llms.txt` if you are discovering the API for the first time.

## Common Tasks

**"What constructors exist for type X?"** → Search for the type name, then look at the results across layers. The search endpoint returns line-level matches with layer numbers.

**"What changed between layer 222 and 223?"** → Use the diff endpoint. It returns added/removed/changed constructors with both old and new definitions for changed ones.

**"When was constructor X added?"** → Search for it, then look at which layers have matches. The lowest layer number in results is when it appeared.

**"Which methods take an `InputPeer` (or any other type) as a parameter?"** → `GET /api/search?structured=true&input=uses:=InputPeer`. `uses:` unwraps `flags.N?` and `Vector<>`, so every wrapping is caught, and results come back grouped by signature instead of by line.

**"How many times did constructor X change, and when?"** → `GET /api/search?structured=true&input=name:=X`. Each result is one distinct `name#id` with the `layers` it spans; `versions` is how many signature versions that name has in total.

**"I only know the ctor id (e.g. `0x3ae56482`), which layer(s) does it live in?"** → Use `/api/find-ctor?id=0x3ae56482&hint=224`. Returns the layers that contain that ctor id, ranked by distance to the optional `hint`. Handy when a telegram client (often android/ios) sends payloads from a layer one or two behind the latest schema.

**"What does this hex payload decode to?"** → Use hex2object with the hex string and the layer number the payload was created with. Returns the full JSON object with `_` field indicating the constructor name.

**"I have a TL definition and need the constructor ID"** → The combinator ID is the CRC32 of the normalized definition. Use the parse endpoint to get the ID, or compute it yourself: strip `#hexid`, `;`, brackets, collapse whitespace, then CRC32 the result.

**"I need the full type structure with params and flags"** → Use the parse endpoint. It returns structured entries with unwrapped types, flag metadata, and vector detection.

**"What errors can method X return?"** → Use `/api/errors/method?name=X`. Returns every error the method can raise plus method-level flags (`user_only`, `bot_only`, `business_supported`, `unauthed_allowed`).

**"Which methods raise error FOO?"** → Use `/api/errors?q=FOO` or `/api/errors?code=400` to look up an error by name/description; each entry's `methods` field lists every method that raises it.

**"What's a config key, premium limit, or push template?"** → Use `/api/config?q=<substring>` to search all constant groups + push templates, or `/api/config?group=<name>` for a whole group (client config keys, premium limits, suggestions, web events, push notifications).

**"I want to link a human to a definition"** → Use the html pages at the site root: `https://schema.jppgr.am/method/messages.sendMessage`, `/constructor/inputPeerUser`, `/type/InputPeer`, `/error/PEER_ID_INVALID` (write `%d` as `X`, e.g. `/error/FLOOD_WAIT_X`). They mirror core.telegram.org's url scheme and add errors and id history across every archived layer; a wrong kind or case redirects to the right page.

## Usage Pattern

1. Start with `GET /api/layers` to see what's available
2. Use `GET /api/search` to find constructors/methods by name
3. Use `GET /api/layer?layer=N&format=json` to get a specific layer's lines
4. Use `GET /api/parse?layer=N` to get structured type information
5. Use `GET /api/diff` to compare versions

## Troubleshooting

- **Search returns too many results** → Add `limit=50` and use `caseSensitive=true` or `wholeWord=true` to narrow down
- **Structured query matches nothing** → Set `structured=true`; without it the grammar is matched literally as text. `regex=true` / `wholeWord=true` also force the raw path
- **hex2object fails** → Make sure you're using the correct layer number for the payload. Different layers have different constructor IDs
- **Layer not found** → Check `/api/layers` first. Preview layers (single-source) may not be available on all deployments
- **Parse returns empty** → The input must be valid TL syntax with `name#id params = Type;` format
- **Results look out of date** → `GET /api/health`. It returns 503 and names the stale artifacts; the layer list, error database and config snapshot are written by separate stages and can drift to different layers

## Health

```
GET /api/health
```
```json
{
  "ok": false,
  "layers": 120,
  "maxLayer": 224,
  "artifacts": { "index": 241382, "meta": 63961394, "errors": 2680241559, "config": 2680241569, "mtproto": 325802 },
  "stale": ["errors", "config"],
  "lastCycle": { "startedAt": "...", "finishedAt": "...", "steps": { "index": "ok", "docs": "failed" } },
  "failedStages": ["docs"]
}
```
Whether the 6-hourly refresh is landing. `artifacts` is per-file age in milliseconds, `null` when the file is missing. `stale` lists anything older than 24h. Returns 503 when anything is stale or a stage failed, 200 otherwise; the body is the same either way. Check this first when an answer looks wrong for the layer you expected.

## Conflicts

```
GET /api/conflicts?layer=224&nearby=true
```
```json
{
  "generatedAt": "2026-08-02T18:04:00.000Z",
  "unsound": [
    {
      "layer": 224,
      "name": "updateNewAuthorization",
      "declaredId": "8951abef",
      "computedId": "9036464a",
      "line": "updateNewAuthorization#8951abef flags:# ... hash:flags.0?int ...",
      "candidates": [{ "layer": 223, "line": "updateNewAuthorization#8951abef flags:# ... hash:long ...", "distance": 1 }]
    }
  ],
  "conflicts": []
}
```
Definitions the merge pipeline could not fully vouch for. Detection only — nothing here is applied automatically.

`unsound` entries have a body that does not hash to the id they declare, so a client dispatching on that id would not get this body. `candidates` are sound definitions carrying the same declared id elsewhere, nearest layer first. A candidate is evidence, not an answer: ids are reused across eras, so a distant match is usually an unrelated combinator.

`conflicts` entries are one ctor name that two sources shipped under different ids, both crc-sound. Crc cannot arbitrate, so a human pins the winner in the override ledger; `resolved` is true once one has.

Params: `layer`, `kind` (`unsound` | `conflict`), `nearby=true` (candidates within 5 layers), `limit` (default and max 500). `totals` counts everything matching the filters, while the arrays are capped by `limit` and each entry keeps at most its 5 nearest candidates. 404 until the watcher has generated the report.

## Layers

### List all layers
```
GET /api/layers
```
```json
{ "layers": [{ "layer": 229, "lineCount": 2473, "ctorCount": 1658, "methodCount": 814, "sources": ["danog", "tdesktop", "tdlib", "tgscheme", "weba", "webk"], "contentSource": { "source": "weba", "commit": "a82ed2ba029fc54a2ce0d9380c027780e07a5df5", "committedAt": "2026-09-22T12:26:01.000Z" } }] }
```
`ctorCount` / `methodCount` count the definitions in the layer's `---types---` / `---functions---` sections. `preview: true` means the layer is only available from a single source (beta/unreleased).

Telegram edits the current layer in place, so each source may ship several revisions of one layer. `contentSource` is the revision the layer's text comes from. A revision whose ids match another source's neighbouring layer better than its own is treated as mislabelled and ignored. Of the rest, a revision whose every definition another source also ships beats one carrying definitions nobody else backs; after that sources rank tdesktop, weba and webk, then corefork and core, then danog/schemas, then tdlib, then TGScheme/Schema (the Android schema, which keeps constructors the other clients dropped), and the newest commit breaks ties. `commit` and `committedAt` are `null` for the scraped docs sites.

### Release dates
```
GET /api/layers
GET /api/layers?at=2024-01-01
```
```json
{ "at": "2024-01-01", "layer": 170, "releasedAt": "2023-12-26", "releaseSource": "tdesktop" }
```
Every entry from `GET /api/layers` carries `releasedAt` (a `YYYY-MM-DD` day) and `releaseSource`.

Telegram publishes no layer dates anywhere, so these are approximations: the earliest commit date in an upstream client repo whose schema snapshot declares that layer. 159 of 179 archived layers have one; the rest predate those histories and report `null` instead of a guess.

- **answering "when did layer N ship?"** - read `releasedAt` off `/api/layers`
- **answering "which layer was current in January 2024?"** - `?at=2024-01-01`, which resolves to the newest layer released on or before that date
- a malformed date is a 400, a date before every known release is a 404

### Get a single layer
```
GET /api/layer?layer=224
GET /api/layer?layer=latest
```
Returns raw `.tl` text by default.
- `format=json` → `{ "layer": 224, "lines": ["user#d23c81a3 ..."] }`
- `format=pretty` → expanded text with annotations (see expand options)

## Sources

```
GET /api/sources
```
```json
{ "latestLayer": 229,
  "sources": [{ "source": "core", "latestLayer": 223, "latestReleasedAt": "2026-01-26",
                "layerCount": 105, "layersBehind": 6, "daysBehind": 176 }],
  "docsLag": { "docsLayer": 223, "clientLayer": 229, "layersBehind": 6, "daysBehind": 176 } }
```
Which layer each upstream ships, and how far Telegram's own documentation trails its own clients.

- **answering "is core.telegram.org current?"** - read `docsLag`. It is normally behind: the docs site documents a schema the official clients have already moved past, so a constructor you cannot find there may simply be newer than the docs
- `layersBehind` counts archived layers, not raw subtraction; `daysBehind` uses the approximate release dates and is `null` when either is unknown
- `q` filters `sources` by name substring without changing `docsLag`

## Find ctor

```
GET /api/find-ctor?id=0x3ae56482&hint=224
```
```json
{ "id": "0x3ae56482", "names": ["message"], "layers": [{ "layer": 224, "name": "message" }, { "layer": 223, "name": "message" }] }
```
`id` accepts hex (`0x..` or bare) or decimal. With `hint`, the response is sorted by `|layer - hint|` ascending (ties: lower layer first). Without `hint`, newest-first. Empty `layers` means no layer in the index has this ctor id.

```
GET /api/find-ctor?prefix=5bb9
```
```json
{ "prefix": "5bb9", "total": 1,
  "matches": [{ "id": "0x5bb98608", "names": ["updatePinnedChannelMessages"],
                "layers": [210, 211], "firstLayer": 133, "lastLayer": 229 }] }
```
`prefix` takes 2 to 8 hex digits for when a truncated dump gave you only the first bytes. Mutually exclusive with `id`. `layers` is capped at the 20 most recent; `firstLayer`/`lastLayer` always span the full range. `total` counts all matches before `limit` (default 50, max 500).

## Search

```
GET /api/search?input=user&caseSensitive=true&regex=false&wholeWord=false&limit=500
```
```json
{ "query": "user", "results": [{ "layer": 224, "matches": [{ "lineIndex": 42, "line": "user#d23c81a3 ..." }] }], "total": 150 }
```
`input` must be at least 2 characters on the raw path.

With `regex=true`, `input` is compiled as a RE2 pattern — linear-time and ReDoS-safe, supporting standard regex syntax minus backreferences and lookaround. Unsupported patterns return no matches.

### Structured Search

`structured=true` runs `input` as a query over parsed definitions instead of raw lines, and returns results grouped by signature rather than by line.

Dispatch is explicit, never guessed:
- no `structured` param → raw line matching, always
- `structured=true` → structured query
- `regex=true` or `wholeWord=true` → raw line matching wins, even when `structured=true` is also set

The 2-character minimum is a raw-path rule: a structured query only has to parse into at least one token, so `is:ctor` is accepted. Empty or whitespace-only `input` is a 400 either way.

Omitting the param preserves current behaviour exactly. The raw response never contains a `mode` field; the structured one always sets `"mode": "structured"`.

The switch is an explicit opt-in rather than auto-detection because six grammar prefixes are also real TL parameter names in the schema: `id:long`, `type:SecureValueType`, `name:string`, `result:BotInlineResult`, `layer:int`, `field:string`. `input=id:long` is a legitimate raw search matching 22,963 lines across the index — sniffing the grammar would silently turn it into a ctor-id search for `long` and return nothing.

#### Limits and flags

- The structured path returns 400 when `input` parses to zero tokens, when the query exceeds **12 tokens or 4 text terms**, or when `limit` is not a number
- Text terms are every token except `is:` `has:` `fields:` `layer:` — plain words and field-prefixed tokens like `uses:=InputPeer` alike. Those four are exempt from the 4-term cap (they are O(1) predicates) but still count toward the 12 — each text term costs a full scan of the index, which is what the cap bounds
- Structured `limit` is clamped to 1..1000 (default 500). The raw path's `limit` is untouched: `limit=-5` there still returns nothing, and a non-numeric `limit` is not an error
- `caseSensitive` is a **no-op in structured mode** — the grammar always matches case-insensitively
- `structured` is read leniently (`true` or `1`), but `regex` / `wholeWord` only engage the raw matcher when they are exactly `true`; `regex=1` has never been a regex search. The structured gate reads them leniently, so `structured=1&regex=1` takes the raw path without enabling regex
- A repeated query param takes its last value: `?input=a&input=b` searches for `b`
- A type slot keeps at most **4** comma-separated tests and silently drops the rest. Because the tests AND together, dropping one *broadens* the result: `field:a:x1,x2,x3,x4,x5` round-trips as `field:a:x1,x2,x3,x4`

#### Fields

| field | aliases | matches |
|---|---|---|
| `name:` | | constructor / method name |
| `result:` | `returns:` `ret:` `union:` | result type |
| `field:` | `param:` `arg:` | parameter name |
| `type:` | | raw parameter type, `flags.N?` prefix and `Vector<>` included |
| `uses:` | | unwrapped leaf parameter type |
| `id:` | | constructor id hex |
| `is:` | | `ctor` or `method` |
| `has:` | | `flags` or `vector` |
| `fields:` | | parameter count — `N` `>N` `<N` `>=N` `<=N` |
| `layer:` | | layer number — `N` `>N` `<N` `>=N` `<=N` |

Plain terms sharing a field are OR-ed, different fields are AND-ed. A **paired** term (`field:<name>:<...>` / `uses:<type>:<...>`) forms its own AND group, so two paired terms on the same field intersect rather than union — on layer 224 `uses:=InputPeer uses:=Chat` is 328, but `uses:=InputPeer:-? uses:=Chat:-?` is 0. `is:` `has:` `fields:` `layer:` are pure predicates: they narrow results without contributing a match reason. A term with no recognised prefix is free text, matched against name, id, result type, parameter names and parameter types.

#### Modifiers

- `=` — exact instead of substring: `name:=user` matches only `user`, `name:user` also matches `userFull`
- `-` — negation: `-is:method`, `is:ctor -inputPeer`
- Both apply uniformly to `field:` tokens and to bare free-text terms
- An exact free-text term is compared against name, id, result type, parameter names and parameter types, and skips the whole-line substring fallback a non-exact term still gets
- `field:<name>:<shape>` / `uses:<type>:<shape>` — pairs both halves against the same parameter: `field:peer:InputPeer`; each half takes its own `=` / `-`

#### Parameter shape

A type slot accepts **shape tests** instead of a type name. Two axes, each negatable with a leading `-`, comma-separated (max 4) to require several at once.

- `?` / `-?` — the parameter is / is not behind a flag bit
- `[]` / `-[]` — the parameter is / is not `Vector<>`-wrapped
- In a **pair** slot the test binds to one parameter, which a second standalone token cannot do: `field:peer:-?` is a `peer` parameter that is not optional, and `uses:=InputPeer:-[]` is `InputPeer` in any non-Vector form, **including** `flags.0?InputPeer`
- `uses:=InputPeer:?,-[]` — both tests, on one parameter
- **Standalone**, a negated test negates the whole token: `type:-[]` means "this definition has no Vector parameter" (1888 on layer 224), *not* "has a non-Vector parameter" (1865). `type:[]` (465) and `type:-[]` partition the layer. Use the pair form when you mean the per-parameter reading
- `=` is a no-op on a shape test — `type:=?` and `type:?` are the same query

`has:vector` answers a different question: it is definition-scoped and also true when the **result** type is a vector, so it is broader than `type:[]`.

#### `type:` vs `uses:`

The distinction that is easiest to get wrong:
- `type:` matches the **raw** parameter type as written in the schema, so `type:flags.0?` and `type:Vector<` are meaningful queries
- `uses:` matches the **leaf** type after unwrapping `flags.N?` and `Vector<>`, so `uses:=InputPeer` catches `InputPeer`, `flags.0?InputPeer` and `Vector<InputPeer>` alike
- `uses:` looks at **parameters only** and never matches the result type — `result:=InputPeer` (alias `union:`) answers "what returns this"
- Neither field alone can express "unwrap the flag prefix but keep the vector" — that is what the shape tests are for. `type:=InputPeer` misses `flags.0?InputPeer`, and `uses:=InputPeer` cannot exclude `Vector<InputPeer>`

#### SearchGroup

```json
{
  "query": "name:=inputPeerUser",
  "mode": "structured",
  "results": [
    {
      "name": "inputPeerUser",
      "id": "dde8a54c",
      "line": "inputPeerUser#dde8a54c user_id:long access_hash:long = InputPeer;",
      "isFunction": false,
      "namespace": "",
      "layers": [133, 134, "...", 224],
      "firstLayer": 133,
      "lastLayer": 224,
      "versions": 2
    }
  ],
  "total": 2,
  "totalLayers": 120
}
```
One group per distinct `name#id`. The combinator ID is the CRC32 of the normalized definition, so a distinct ID *is* a distinct signature version: an unchanged constructor repeated across a hundred layers collapses into one group.

- `layers` is ascending; `firstLayer` / `lastLayer` are its bounds. These cover **only what the query matched**
- `versions` is **total signature history** for that name across the whole index, and is deliberately *not* narrowed by a `layer:` filter. The asymmetry looks inconsistent until you read the field as a claim about the schema rather than about the result: `name:=messages.sendMessage layer:>223` returns `layers: [224]` with `versions: 10`, meaning "matched in layer 224; this method has had 10 signatures in total"
- `line` is the definition as of `lastLayer`
- `total` is the number of groups, `totalLayers` the number of layers scanned
- sorted by `lastLayer` desc, then `firstLayer` desc, then `name` asc

#### Examples

```bash
# everything taking an InputPeer parameter in any wrapping
# 15,382 raw line hits collapse into 445 groups
curl 'https://schema.jppgr.am/api/search?structured=true&input=uses:%3DInputPeer'

# signature history of one constructor: 2 versions, #7b8e7de6 (105..132) then #dde8a54c (133..224)
curl 'https://schema.jppgr.am/api/search?structured=true&input=name:%3DinputPeerUser'

# every method present in a layer above 220
curl 'https://schema.jppgr.am/api/search?structured=true&input=is:method%20layer:%3E220'
```

## Combinator ids

```
GET /api/crc32?definition=inputUserSelf%23f7c1b13f%20%3D%20InputUser%3B
POST /api/crc32     (body: raw tl schema text)
```
```json
{ "definition": "inputUserSelf#f7c1b13f = InputUser;", "normalized": "inputUserSelf = InputUser", "id": "0xf7c1b13f", "declaredId": "0xf7c1b13f", "sound": true }
```
Do not reimplement the normalization. The id is the CRC32 of the definition after the id is stripped, `bytes` is folded to `string`, generics are unwrapped and `?true` fields are dropped — getting any of those wrong yields a plausible but wrong id. `sound` reports whether the declared id matches the computed one, which is how the merge pipeline decides whether an upstream line was transcribed correctly. POST a whole schema to check every line at once; at most 2000 results come back, with `truncated` set when the body held more.

## History

```
GET /api/history?name=messages.sendMessage
GET /api/history?id=0x418d4e0b
```
```json
{
  "name": "account.deleteAccount",
  "firstLayer": 23,
  "lastLayer": 224,
  "ids": [{ "id": "418d4e0b", "from": 23, "to": 142 }, { "id": "a2c0cf74", "from": 143, "to": 224 }],
  "present": [[23, 224]],
  "missingIn": []
}
```
When a definition appeared, every id it has carried, and where it is absent. Use this instead of scanning `/api/search` results across layers: it is one call and it distinguishes a real removal from a layer that was never archived. `ids` and `present` are runs over the archived sequence, so an unarchived layer never splits a lifetime. `missingIn` lists archived layers where the definition is genuinely gone. Querying by `id` returns every name that id has carried.

### Field history
```
GET /api/field-history?name=message&field=via_bot_id
```
```json
{ "name": "message", "firstLayer": 2, "lastLayer": 229,
  "fields": [{ "field": "via_bot_id",
    "runs": [{ "type": "flags.11?int", "from": 46, "to": 132 },
             { "type": "flags.11?long", "from": 133, "to": 229 }],
    "addedIn": 46, "removedIn": null, "everPresent": false }] }
```
Per-field lifetime. Omit `field` to get every field of the definition.

A `run` is a contiguous span where the field kept the same raw type, so the example above is the real `int` -> `long` peer-id migration at layer 133. `removedIn` is the first archived layer where the definition exists but the field does not, `null` while still present.

- **answering "when did this field appear?"** - `addedIn`
- **answering "did this field's type ever change?"** - more than one entry in `runs`
- runs are contiguous over archived layers, not raw numbers, so the 49 unarchived layer numbers never look like a removal

## Compatibility

```
POST /api/compat
Content-Type: application/json

{ "target": 229, "ids": ["0x7b8e7de6", "0xdde8a54c", "deadbeef"] }
```
```json
{ "target": 229,
  "summary": { "ok": 1, "reassigned": 1, "removed": 0, "unknown": 1, "invalid": 0 },
  "results": [{ "id": "0x7b8e7de6", "status": "reassigned", "name": "inputPeerUser",
                "targetId": "0xdde8a54c", "lastSeen": 132 }] }
```
Feed it the constructor ids a client dispatches on and it reports what breaks at `target`. Up to 2000 ids; `target` accepts `"latest"`.

- `reassigned` is the case to care about: the name still exists but under a new id, so the client's dispatch silently stops matching. `targetId` is the id to switch to
- `removed` means the name is gone at `target`; `unknown` means the id is in no archived layer; `invalid` means unparseable hex, which does not fail the request
- ids are reused across eras, so for a contested id the answer resolves to the name that held it most recently at or before `target` - which may be a later unrelated definition

## Flag bits

```
GET /api/flags?name=message
GET /api/flags/reused?limit=20
```
```json
{ "total": 65, "reuses": [{ "name": "connectedBot", "flagField": "flags", "bit": 0,
                            "from": "can_reply", "to": "device", "atLayer": 226 }] }
```
`GET /api/flags?name=` returns, per flags field, which bit carried which field across layers. `GET /api/flags/reused` lists every bit in the corpus that was later reused for a *different* field - the footgun that breaks a client still reading the old one.

- one bit can gate several fields at once (`message` has `fwd_from_id` and `fwd_date` both on `flags.2`), so occupants are joined with `+`
- a run also ends when the type changes under an unchanged name, so the type shown is never stale
- a reuse is only reported when the occupant set both lost and gained a name; merely adding a co-gated field is not a reuse

## Diff

### Between layers
```
GET /api/diff?from_layer=222&to_layer=223
```
```json
{ "from_layer": 222, "to_layer": 223, "added": ["..."], "removed": ["..."], "changed": [{ "old": "...", "new": "..." }], "renamed": [{ "old": "...", "new": "..." }] }
```
`renamed` pairs a removal and an addition sharing a constructor id and an identical body — a ctor that moved namespace. Without it those show as one deletion plus one unrelated creation, which is the most misleading thing a schema diff can say. The match is deliberately narrow: a shared id with a different body is id reuse across eras, and a name that survives under a new id is an ordinary change.

### Breaking-change classification
```
GET /api/diff?from_layer=228&to_layer=229&classify=true
```
```json
{ "summary": { "breaking": 14, "dangerous": 4, "safe": 2 },
  "classified": [{ "name": "messages.sendMessage", "severity": "breaking",
    "findings": [{ "kind": "field-added-unflagged", "severity": "breaking",
                   "field": "ephemeral_receiver_bot_id", "to": "long",
                   "reason": "new unconditional parameter ... shifts the wire position of every parameter after it" }] }] }
```
`classify=true` adds `summary` and `classified`; every pre-existing key keeps its exact shape. Works on `POST /api/diff` too, as `classify: true` in the body. `summary` counts definitions by their worst finding, not findings.

TL parameters are positional and untagged, which drives the whole taxonomy:

- **breaking** - `id-changed`, `definition-removed`, `field-removed`, `field-type-changed`, `field-reordered`, `field-added-unflagged`, `result-type-changed`
- **dangerous** - `field-renamed` (same position and type, so the wire is unchanged but generated bindings break), `renamed`, `union-constructor-added`, `field-flag-relaxed`
- **safe** - `definition-added`, `field-added-flagged`

Appending a flag-gated field is safe because the protocol is layer-negotiated: a client never sees a bit its own layer did not declare. Adding an *unconditional* field is breaking for the same reason positions matter.

- **answering "what breaks if I bump to layer N?"** - this endpoint, then `POST /api/compat` with your dispatch ids for the id-level view
- layer 132 to 133 is the reference case: 166 `id-changed` plus 192 `field-type-changed`, the `int` to `long` peer-id migration
- a change matching no kind is reported as `other` at `dangerous` rather than dropped

### Between arbitrary schemas or a layer
```
POST /api/diff
Content-Type: application/json

{ "from": "user#d23c81a3 id:int name:string = User;", "to": "user#d23c81a3 id:long name:string = User;" }
```
Each side of `from` / `to` is either raw tl text or `{ "layer": <n | "latest"> }`, so you can diff a layer against custom text:
```
{ "from": { "layer": 225 }, "to": "user#d23c81a3 id:long name:string = User;" }
```
Response shape is the same; `from_layer` / `to_layer` appear only for sides given as a layer.

Trailing inline `// ...` comments are stripped from diff output and ignored when comparing lines.

## Parse

### Parse a layer
```
GET /api/parse?layer=224
```
```json
{
  "layer": 224,
  "types": [{ "name": "user", "id": "d23c81a3", "params": [...], "type": "User" }],
  "functions": [{ "name": "users.getUsers", "id": "d91a548", "params": [...], "type": "User", "vector": true }]
}
```

### Parse arbitrary text
```
POST /api/parse
Content-Type: text/plain

user#d23c81a3 id:int first_name:string = User;
```
Same response shape without `layer`.

Both support `format=pretty` for expanded text output.

### ParsedEntry
```json
{ "name": "user", "id": "d23c81a3", "params": [...], "type": "User", "vector": true }
```
`vector: true` when the return type is a vector. `type` is always the unwrapped leaf, for bare `vector<t>` as well as boxed `Vector<T>`.

### ParsedParam
```json
{ "name": "access_hash", "type": "long", "repr": "flags.0?long", "optional": true, "vector": false, "flag": { "field": "flags", "bit": 0 } }
```
`type` is the unwrapped leaf type. `repr` is the raw string as it appears in the schema.

## TL Codec

### Hex → Object
```
GET /api/hex2object?hex=05162463...&layer=224
GET /api/hex2object?hex=05162463...&layer=auto&hint=220
GET /api/hex2object?hex=15c4b51c...&layer=229&type=Vector<int>
```
```json
{ "ok": true, "result": { "_": "user", "id": "12345", "first_name": "John" } }
```
`long`, `int128` and `int256` decode to strings and `bytes` to an array of byte values. `long` is signed, so a negative `access_hash` reads back negative. Hex with an odd length or non-hex characters is rejected instead of being decoded as garbage, as is a vector whose element count cannot fit in the bytes that are left.

A `bytesRemaining` field appears when the object decoded without consuming the whole payload. The result is still returned, but the layer or the hex is wrong.

`layer` accepts a number, `latest`, or `auto`. Use `auto` when you have a payload but not the layer it was made with: it decodes against the newest layer and, on an unknown constructor id, retries the layers that define that id — closest to `hint` first, newest first without one, up to 8 candidates. The response then carries `via` and `tried`. A candidate that decodes but leaves bytes over loses to one that consumes the whole payload, because a constructor id often outlives the body layout it belonged to. An explicit numeric layer never falls back.

A boxed vector decodes on its own — its elements carry constructor ids, and those ids are what validate the read. A vector of **bare** elements (`Vector<int>`, `Vector<long>`) is byte-identical to a boxed one on the wire, so nothing in the payload says which it is: pass `type=Vector<int>` or the first element is read as a constructor id and the decode fails. `type` still reports `bytesRemaining`, and an empty `type=` is treated as absent.

A wrong `type` fails loudly rather than quietly: a name the layer does not define is `unknown type: X`, and a payload that decodes to a constructor outside the named type is rejected too, so `type=User` on a `Bool` payload is a 400 instead of a plausible-looking object.

### Object → Hex
```
GET /api/object2hex?object={"_":"user","id":"12345"}&layer=224
```
```json
{ "ok": true, "hex": "05162463..." }
```
Pass `long` / `int128` / `int256` as strings. A bare json number loses precision past 2^53, so `-6100526118417511800` silently encodes as `-6100526118417511424`. `bytes` values must be arrays of byte values, and payloads are capped at 1 MiB.

A top-level json array encodes as a boxed vector, so a value decoded by `hex2object` round-trips straight back through this endpoint.

Methods returning `Vector<T>` (`users.getUsers`, `account.getAllSecureValues`, 38 of them on layer 224) work in both directions.

## Errors

The errors database is sourced from telegram's official `/api/errors.json` and includes per-error method lists, descriptions, method classification flags, and the schema `layer` the snapshot corresponds to.

### List / filter errors
```
GET /api/errors
GET /api/errors?code=400
GET /api/errors?method=messages.sendMessage
GET /api/errors?q=flood
```
```json
{
  "total": 5,
  "results": [
    {
      "code": 400,
      "name": "ABOUT_TOO_LONG",
      "description": "About string too long.",
      "methods": ["account.updateProfile"]
    }
  ]
}
```
- `total` is the unfiltered match count; `results` is sorted by code and name, then sliced by `limit` (default 500, max 1000)
- `q` resolves a concrete error against its template, so `q=FLOOD_WAIT_42` finds `FLOOD_WAIT_%d` and the entry carries `matched` and `params`
- error names with `%d` (e.g. `FLOOD_WAIT_%d`) are placeholders for embedded numbers
- `methods: []` means a catch-all/network error not tied to specific methods (303 migrate codes, generic 500/-503)
- filtering by `method` returns full `methods` arrays — the field shows every method that raises the error, not just the queried one
- both errors endpoints include a top-level `layer` (the schema layer of the error DB snapshot) when available

### Errors for a specific method
```
GET /api/errors/method?name=messages.sendMessage
```
```json
{
  "method": "messages.sendMessage",
  "flags": { "business_supported": true },
  "errors": [
    { "code": 400, "name": "BALANCE_TOO_LOW", "description": "...", "methods": [...] }
  ]
}
```
Returns 404 when the method is unknown. `flags` contains only the flags that apply: `user_only`, `bot_only`, `business_supported`, `unauthed_allowed`.

### Method capabilities
```
GET /api/methods?capability=bot
GET /api/methods?capability=unauthed
GET /api/methods?q=sendMessage
```
```json
{ "layer": 227, "total": 40,
  "counts": { "user": 40, "bot": 7, "business": 1, "unauthed": 40, "both": 7 },
  "methods": [{ "name": "auth.sendCode", "user": true, "bot": false,
                "business": false, "unauthed": true, "errorCount": 13 }] }
```
Which methods a user, a bot, a connected business bot, or an unauthenticated caller may invoke. `capability` is `user` / `bot` / `business` / `unauthed` / `any`.

- telegram's `user_only` and `bot_only` are exclusion lists, not grants, so a method in neither is callable by both
- the method universe comes from the newest archived layer, so a method in none of the lists still appears with all four flags false
- **answering "can my bot call this?"** - look up the name and read `bot`
- `layer` is the stamp on the errors database, which can lag the schema layer the method list came from

## Config

Telegram's machine-readable api constants and push notification templates, mirrored from the official `/api/config.json` and refreshed every 6 hours.

### Get / search constants
```
GET /api/config
GET /api/config?group=config%20description
GET /api/config?q=stars_revenue
```
```json
{
  "layer": 225,
  "constants": {
    "config description": {
      "description": "[Client configuration keys ...](...)",
      "type": "map",
      "entries": { "caption_length_limit_premium": "The maximum UTF-8 length of media captions ..." }
    }
  },
  "push": { "AUTH_REGION": "New login from unrecognized device {1}, location: {2}" },
  "push_arguments": { "AUTH_REGION": { "1": "Device name", "2": "Location" } }
}
```
- no params → the full config (`layer`, `constants`, `push`, `push_arguments`)
- `layer` is telegram's own stamp on `config.json`, labelling that docs dump. The config is not layer-versioned, so do not read it as "the config for layer N"
- `group=<name>` → a single constant group (`{ layer, group, description, type, entries }`), 404 if unknown
- `q=<substring>` → `{ layer, query, constants, push }` filtered to constant groups (by name/description/entry key/value) and push templates matching the substring
- `type` is `"map"` (keyed `entries` object) or `"list"` (`entries` array)

### Live app config values

The docs site publishes key *descriptions*; `help.getAppConfig` serves the actual *values*. When the appconfig watcher is deployed, every `/api/config` response carries both:

```json
{
  "appconfig": {
    "fetchedAt": "2026-08-03T04:26:49.545Z",
    "values": { "channels_limit_default": 500, "rich_message_max_blocks": 500 },
    "undocumented": ["rich_message_max_blocks"]
  }
}
```
- `values` is scoped to whatever the response returned, so `?q=channels_limit_default` costs two values rather than the whole map
- `undocumented` names keys telegram serves but never documented - the `rich_message_*` caps, `tdesktop_config_map`, `message_length_limit_*`. Scraping cannot find these, so `?q=rich_message` is the only way to reach them
- a documented key missing from `values` is not an error: telegram gates config per account and region, so this is one account's view
- values telegram derives from the fetching account (`phone_country_iso2`) are stripped before publishing, so that key is documented but never carries a value
- **answering "is this cap server-delivered?"** - search the cap name. A hit in `values` means telegram ships it and a client should read it from config; no hit means it is a client-side constant
- the whole `appconfig` object is absent when the watcher is not running; treat that as "no live values", never as an error

## MTProto

### Schema metadata
```
GET /api/mtproto
```
```json
{ "tdesktop": { "lines": [...], "lineCount": 450 }, "tdlib": { "lines": [...], "lineCount": 430 } }
```

### Raw schema text
```
GET /api/mtproto/raw?source=tdesktop
```
Returns plain text.

## Feed

```
GET /api/feed?limit=20
```
Returns `application/atom+xml`, newest layer first, one entry per archived layer.

Each entry diffs its layer against the previous *archived* layer and summarises as `+14 added, -2 removed, 8 changed, 3 re-ided`. A re-id is a definition that kept its name but changed constructor id - the breaking case a client sees as an unknown ctor.

- entry ids are stable urns (`urn:jppgram:layer:229`), so readers dedupe correctly
- `updated` carries the layer's approximate release date, the same value `/api/layers` reports

### Per-definition feed
```
GET /api/feed?name=inputPeerUser
```
One entry per archived layer in which that single definition changed - monitoring rather than news. A maintainer cares that a type they depend on moved, not that a layer shipped.

- summaries name the id change, fields added or removed, and any type change: layer 133 reads `constructor id changed from 0x7b8e7de6 to 0xdde8a54c; field types changed: user_id int -> long`
- the oldest entry is titled `added in layer N` rather than `changed`
- entries use `urn:jppgram:definition:<name>:<layer>`, distinct from the all-layers feed, so subscribing to both does not collapse them
- 404 when the name is in no archived layer

## Bulk Dump

```
GET /api/dump
```
```json
{
  "generatedAt": "2026-09-12T14:03:11.000Z",
  "bytes": 112173056,
  "gzipBytes": 34161683,
  "sha256": "fb66e1e5...",
  "layers": 179,
  "maxLayer": 229,
  "tables": { "layers": 179, "sources": 637, "definitions": 257103, "params": 744131,
              "docs": 10286, "errors": 818, "error_methods": 4201, "method_capabilities": 703 },
  "columns": { "params": ["layer", "name", "param", "type", "position", "flag_field", "flag_bit"],
               "...": ["one entry per table, column names in declaration order"] },
  "url": "https://schema.jppgr.am/dump.sqlite",
  "gzipUrl": "https://schema.jppgr.am/dump.sqlite.gz"
}
```
The whole corpus as one SQLite file: every archived layer, every definition, parameter, doc string, error and method capability. Roughly 107 MB, or 33 MB over the wire with gzip. `/api/dump` serves only the metadata; the file itself is at `/dump.sqlite`, pre-gzipped at `/dump.sqlite.gz`, and a plain request sent with `Accept-Encoding: gzip` gets the compressed body too. Both live at the site root, not under `/api/`. 404 until the dump has been built.

`generatedAt` advances only when the content changed, and the file carries no build timestamp, so the same corpus rebuilt on the same image produces byte-identical bytes. That makes mirroring one cheap poll of this endpoint plus a download when `sha256` moves. A SQLite version bump in the base image rewrites the file too, so a new `sha256` means re-download, not that the schema necessarily moved.

`columns` is read out of the shipped SQLite file at build time rather than written by hand, so it always names the columns the download actually has. `tables` and `columns` together are the whole schema, which is enough to write a query before downloading 33 MB to find out.

Tables, so you know what you can ask before spending the bandwidth:

- `dump_meta(key, value)` - `schema_version`, `layers`, `max_layer`, `newest_release`
- `layers(layer, line_count, ctor_count, method_count, released_at, release_source, preview)` - one row per archived layer, the same numbers `/api/layers` reports
- `sources(layer, source)` - which upstreams ship each layer
- `definitions(layer, name, id, return_type, is_function, namespace, line)` - one row per definition per layer, not per signature, so a name is present once for every layer that has it
- `params(layer, name, param, type, position, flag_field, flag_bit)` - one row per parameter, ordered by `position`; `flag_field` / `flag_bit` are NULL for a parameter not behind a flag bit
- `docs(kind, name, param, description)` - `kind` is `type`, `constructor` or `method`; `param` is NULL for the entity's own description and the field name for a per-field one
- `errors(code, name, description)` and `error_methods(code, name, method)` - the errors database, joined on `(code, name)`
- `method_capabilities(method, user_only, bot_only, business_supported, unauthed_allowed)` - per-method capability lists
- SQLite has no boolean type, so `preview`, `is_function` and the four capability columns are 0 or 1

Indexes cover `definitions(name)`, `definitions(id)`, `params(name, flag_field, flag_bit, position)`, `params(flag_field, flag_bit)`, `docs(name)` and `error_methods(method)`.

```sql
-- which layer first shipped a name
SELECT MIN(layer) FROM definitions WHERE name = 'messages.sendMessage';

-- names whose constructor id changed across eras
SELECT name, COUNT(DISTINCT id) AS ids FROM definitions
  GROUP BY name HAVING ids > 1 ORDER BY ids DESC;

-- a flag bit whose field name changed between consecutive layers
SELECT a.flag_bit AS bit, a.param AS was, b.param AS now, b.layer
  FROM params a JOIN params b USING (name, flag_field, flag_bit, position)
 WHERE a.name = 'message' AND a.param <> b.param AND b.layer = a.layer + 1;
```
This is why there is no precomputed reuse table in the dump: it ships the primitives, and every derived question - id reuse, flag-bit reuse, first-shipped-in, field lifetimes - is one `GROUP BY` or self-join away. The live endpoints answer the same questions one name at a time; the dump answers them for the whole corpus at once, at a fixed, citable date.

## SQL Console

```
POST /api/sql
Content-Type: application/json

{ "sql": "SELECT MIN(layer) AS first_layer FROM definitions WHERE name = 'messages.sendMessage'" }
```
```json
{
  "columns": ["first_layer"],
  "rows": [[2]],
  "rowCount": 1,
  "truncated": false,
  "ms": 4
}
```
One read-only query against the same file `/dump.sqlite` serves, so every answer here is reproducible locally by downloading that artifact and running the query yourself. Tables, columns and indexes are the ones listed under `## Bulk Dump` above; nothing else is reachable.

The schema answers for itself too: `SELECT name FROM sqlite_master WHERE type = 'table'` lists the tables, `SELECT name, type FROM pragma_table_info('params')` lists one table's columns and types, and `SELECT sql FROM sqlite_master WHERE name = 'params'` returns its full DDL. The table-valued `pragma_table_info(...)` passes the statement gate even though a bare `PRAGMA` statement does not.

`rows` are arrays positionally matching `columns`, not objects keyed by column name - that is the thing to get right first. `limit` in the body defaults to 200 and is capped at 1000. `truncated` is true when that row cap or the 4 MB serialized-size cap stopped the walk, which means the rows you got are a prefix, not the answer.

One statement per request, and it has to be a `SELECT` or a `WITH`; recursive CTEs are allowed. Anything else is a 400 naming the reason: `only a single statement is allowed`, `only SELECT and WITH queries are allowed`, `ATTACH, DETACH, PRAGMA and VACUUM are not allowed`. A malformed body is a 400 too. Writes are impossible rather than refused - the database is opened read-only, so SQLite itself answers `attempt to write a readonly database`.

429 `too many concurrent queries`, 504 `query timed out` at 10 s, 503 `sql runner is unavailable` where the executor is not deployed, 503 `dump has not been built yet` until the `dump` pipeline stage has written the artifact - the same condition under which `/api/dump` answers 404. Rate limited per IP at the edge, which also answers 429 but with `too many requests`, so the body distinguishes being throttled from the executor being saturated.

For a single lookup the dedicated endpoints are cheaper and more stable, so prefer them. `/api/sql` is for corpus-wide questions that have no endpoint - id reuse, flag-bit archaeology, cross-layer counts. For anything you will ask repeatedly, download the dump and query it locally instead.

## Expand Options

When using `format=pretty` on `/api/layer` or `/api/parse`, these query params control annotations:

| param | default | what it shows |
|---|---|---|
| `constructor_ids` | true | decimal constructor ID |
| `param_count` | false | number of parameters |
| `flag_bits_used` | false | how many of 32 bits are used |
| `bit_number` | true | which flag field and bit |
| `paired_fields` | true | fields sharing the same flag bit |
| `type_descriptions` | true | constructor/method description |
| `field_descriptions` | false | per-field description |

**Example:**
```
GET /api/layer?layer=224&format=pretty&field_descriptions=true&param_count=true
```

## Sources and Licensing

Unofficial index of Telegram's TL schema, not affiliated with Telegram Messenger Inc.

| source | upstream license | layers |
|---|---|---|
| tdesktop | GPL-3.0 with OpenSSL exception | 149 |
| tdlib | BSL-1.0 | 134 |
| danog/schemas | none published | 118 |
| corefork.telegram.org | none published | 114 |
| core.telegram.org | none published | 105 |
| telegram-tt (weba) | GPL-3.0 | 70 |
| tweb (webk) | GPL-3.0 | 46 |
| TGScheme/Schema | none published | 38 |

181 layers ship in total and most are corroborated by several sources. Per-layer provenance is in `data/meta.json` and surfaced by `/api/layers` and `/api/conflicts`.

Combinator IDs are computed as the CRC32 of the normalized definition rather than copied from any source, so `/api/crc32` reproduces any of them from the definition text alone.

Type, field, error and config descriptions are documentation text written by Telegram and reproduced from core.telegram.org and corefork.telegram.org. Every config group carries a link back to the page it came from.

Live `help.getAppConfig` values are factual data Telegram serves to any requesting client, with account-derived values stripped before publishing.

### Notice and license texts
```
GET /api/notice
GET /api/license
GET /api/license?name=gpl-3.0
```
Returns plain text.

`/api/notice` is the provenance ledger: what is original to this project, every upstream source with its license, and which artifacts reproduce Telegram documentation text.

`/api/license` returns this project's own terms. `?name=gpl-3.0` or `?name=bsl-1.0` returns the retained upstream text the shipped layer data depends on, since definitions come from tdesktop, telegram-tt and tweb (GPL-3.0) and tdlib (BSL-1.0). 400 when `name` is neither.
