Skip to content

GraphQL

POST /graphql

A GraphQL API next to the REST /v1 API, for clients that want to choose the fields they receive or combine several requests into one round trip. It is backed by the same services as REST: same caching, request coalescing, validation and feature flags, and the same data.

It is on by default; turn it off with ENABLE_GRAPHQL=false, after which /graphql answers 404.

Endpoint

Method What
POST /graphql {"query": …, "variables": {…}, "operationName": …} as JSON, or a JSON array of such objects (a batch, see Limits)
GET /graphql In a browser: the GraphiQL IDE. With ?query=…&variables=…: runs a query

GraphiQL, GET queries and schema introspection are available while docs are enabled; DISABLE_DOCS=true turns all three off (POST keeps working).

curl http://localhost:8000/graphql \
  -H 'content-type: application/json' \
  -d '{"query": "{ body(channel: \"durov\") { channel { title { string } } } }"}'
{"data": {"body": {"channel": {"title": {"string": "Pavel Durov"}}}}}

Schema

Every root field mirrors a REST endpoint and returns the same data:

Field Returns REST equivalent
body(channel, position) ChannelBody GET /v1/body/{channel}
more(channel, direction, position) More GET /v1/more/{channel}/{direction}/{position}
post(channel, id) Post GET /v1/post/{channel}/{identifier}
preview(channel) Preview GET /v1/preview/{channel}
previews(channels) [Preview!] POST /v1/previews
feed(channels) [FeedPost!] POST /v1/feed

Field names are the REST JSON keys (preview_link, to_message, is_verified, …), so a GraphQL result is exactly the REST response with the fields you did not ask for left out. The data models therefore describe both APIs. The differences:

  • preview and previews return Preview objects with an extra username field; previews is a list (in request order, unknown channels omitted) instead of a map keyed by username.
  • Feed items are FeedPost: a Post plus channel, score, duplicates and topic (REST: _score, _duplicates, _topic). duplicates is an empty list when nothing was collapsed into the post. Fields absent from the raw feed data are null.
  • Fixed sets of strings are enums whose values are the REST strings: MediaType (image, video, …), EntityType, ReactionType and Direction (after, before).
  • Date.unix is a Long scalar: a plain JSON integer that may exceed 32 bits.

Root fields are nullable: when one fails it is null with an entry in errors, and the other fields of the operation still return data.

Full schema (SDL)
"""Channel profile shown above the posts."""
type Channel {
  username: String!
  title: ParsedAndRaw!
  description: ParsedAndRaw
  avatar: String
  counters: Counters!

  """Header labels, e.g. `verified`."""
  labels: [String!]
}

"""Channel page: profile, posts and pagination."""
type ChannelBody {
  channel: Channel!
  content: ChannelContent!
  meta: Meta!
}

"""Posts of a channel page."""
type ChannelContent {
  posts: [Post!]!
}

"""Channel statistics as formatted by Telegram (e.g. `1.2M`)."""
type Counters {
  subscribers: String!
  photos: String
  videos: String
  files: String
  links: String
}

"""Publication time."""
type Date {
  """ISO-8601 timestamp with offset."""
  string: String!
  unix: Long!
}

"""Load posts newer (`after`) or older (`before`) than a position."""
enum Direction {
  after
  before
}

"""Duration of time-based media."""
type Duration {
  """Human-readable value as shown by Telegram, e.g. `2:30`."""
  formatted: String!

  """Duration in seconds."""
  raw: Int!
}

"""Kind of a formatting entity."""
enum EntityType {
  italic
  bold
  code
  spoiler
  strikethrough
  underline
  text_link
  url
  pre
  emoji
  animoji
  hashtag
}

"""A repost of the same story collapsed into a feed post."""
type FeedDuplicate {
  channel: String
  id: Int!
}

"""A feed post with its channel and ranking score."""
type FeedPost {
  id: Int!
  content: PostContent!
  footer: Footer!
  forwarded: Forwarded
  view: String!
  channel: Channel!

  """
  Ranking score (`_score` in the REST feed); the feed is in ranking order.
  """
  score: Float!

  """Reposts of the same story collapsed into this post (`_duplicates`)."""
  duplicates: [FeedDuplicate!]!

  """Subject shared with other feed posts (`_topic`), if any."""
  topic: FeedTopic
}

"""Subject a feed post shares with other posts of the feed."""
type FeedTopic {
  id: Int!
  keywords: [String!]!
}

"""Post metadata shown under the message."""
type Footer {
  views: String
  edited: Boolean
  author: ParsedAndRaw
  date: Date!
}

"""Original source of a forwarded post."""
type Forwarded {
  """Source channel or user; empty when the sender hides their name."""
  name: ParsedAndRaw!
  url: String

  """Signature shown next to the source, e.g. the bot that posted it."""
  author: ParsedAndRaw
}

"""Inline keyboard button."""
type InlineButton {
  title: String!
  url: String!
}

"""Integer that may exceed 32 bits (Unix timestamps)."""
scalar Long

"""Media attachment of a post."""
type MediaItem {
  url: String
  thumb: String

  """Encoded waveform of a voice message."""
  waves: String
  duration: Duration
  type: MediaType!

  """False when Telegram does not expose the file (e.g. a too large video)."""
  available: Boolean
}

"""Kind of a media attachment."""
enum MediaType {
  image
  video
  voice
  roundvideo
  sticker
  gif
}

"""Pagination metadata."""
type Meta {
  offset: Offset!
}

"""A batch of posts loaded relative to a position."""
type More {
  posts: [Post!]!
  meta: Meta!
}

"""Pagination cursors: pass them to `more` to load adjacent posts."""
type Offset {
  before: Int
  after: Int
}

"""Text in two forms: plain `string` and the original `html`."""
type ParsedAndRaw {
  string: String!
  html: String!
}

"""Poll attached to a post."""
type Poll {
  question: String!
  type: String
  votes: String
  options: [PollOption!]!
}

"""One answer option of a poll."""
type PollOption {
  name: String!
  percent: Int!
}

"""A channel post."""
type Post {
  id: Int!
  content: PostContent!
  footer: Footer!
  forwarded: Forwarded
  view: String!
}

"""Everything a post can contain."""
type PostContent {
  text: Text
  media: [MediaItem!]
  poll: Poll
  inline: [InlineButton!]
  reply: Reply
  preview_link: PreviewLink
  reacts: [Reaction!]
}

"""Channel preview."""
type Preview {
  """The requested channel username."""
  username: String!
  channel: PreviewChannel
}

"""Channel summary from the `t.me/<channel>` landing page."""
type PreviewChannel {
  title: String!
  subscribers: String!
  description: String
  avatar: String
  is_verified: Boolean!
}

"""Link preview card generated by Telegram."""
type PreviewLink {
  title: String
  url: String!
  site_name: String
  description: ParsedAndRaw
  thumb: String
}

"""Read-only access to public Telegram channels."""
type Query {
  """
  Channel profile, its latest posts (or posts around `position`) and pagination. REST: `GET /v1/body/{channel}`.
  """
  body(
    """Channel username, e.g. `durov`."""
    channel: String!

    """Open the page around this post ID."""
    position: Int = null
  ): ChannelBody

  """
  Posts newer or older than `position` (anchor excluded); use `meta.offset` to keep paginating. REST: `GET /v1/more/{channel}/{direction}/{position}`.
  """
  more(
    """Channel username, e.g. `durov`."""
    channel: String!
    direction: Direction!

    """Anchor post ID."""
    position: Int!
  ): More

  """A single post. REST: `GET /v1/post/{channel}/{identifier}`."""
  post(
    """Channel username, e.g. `durov`."""
    channel: String!

    """Post ID."""
    id: Int!
  ): Post

  """
  Short channel summary: title, subscribers, description, avatar. REST: `GET /v1/preview/{channel}`. Requires `ENABLE_PREVIEWS`.
  """
  preview(
    """Channel username, e.g. `durov`."""
    channel: String!
  ): Preview

  """
  Previews of several channels, fetched in parallel; unknown channels are omitted. REST: `POST /v1/previews`. Requires `ENABLE_PREVIEWS`.
  """
  previews(
    """1 to 100 channel usernames."""
    channels: [String!]!
  ): [Preview!]

  """
  Posts of several channels ranked by score, best first. REST: `POST /v1/feed`. Requires `ENABLE_FEED`.
  """
  feed(
    """1 to 100 channel usernames."""
    channels: [String!]!
  ): [FeedPost!]
}

"""One reaction counter of a post."""
type Reaction {
  count: String!
  type: ReactionType!
  emoji: String
  emoji_id: String
  emoji_image: String
}

"""Kind of a reaction."""
enum ReactionType {
  telegram_stars
  emoji
  custom_emoji
}

"""The message this post replies to."""
type Reply {
  cover: String
  name: ParsedAndRaw!
  text: ParsedAndRaw!
  to_message: Int!
}

"""Post text with optional formatting entities."""
type Text {
  string: String!
  html: String!
  entities: [TextEntity!]
}

"""Formatting entity inside a post text; offsets refer to `string`."""
type TextEntity {
  offset: Int!
  length: Int!
  type: EntityType!
  language: String
  url: String
}

Examples

Latest post IDs, views and pagination cursor of a channel:

{
  body(channel: "durov") {
    content { posts { id footer { views date { unix } } } }
    meta { offset { before } }
  }
}

The next page, with variables:

query Older($channel: String!, $before: Int!) {
  more(channel: $channel, direction: before, position: $before) {
    posts { id content { text { string } } }
    meta { offset { before } }
  }
}
{"channel": "durov", "before": 531}

Several channels in one request, using aliases and a fragment:

{
  durov: body(channel: "durov") { ...Latest }
  telegram: body(channel: "telegram") { ...Latest }
  previews(channels: ["durov", "telegram"]) {
    username
    channel { title subscribers is_verified }
  }
}

fragment Latest on ChannelBody {
  channel { title { string } }
  content { posts { id content { media { type url thumb } } } }
}

A ranked feed (requires ENABLE_FEED):

{
  feed(channels: ["durov", "telegram", "stranaua"]) {
    id
    score
    channel { username }
    content { text { string } }
  }
}

A batch of two operations in one HTTP request:

[
  {"query": "{ post(channel: \"durov\", id: 531) { view } }"},
  {"query": "{ preview(channel: \"telegram\") { channel { title } } }"}
]

The response is an array with one result per operation, in order.

Efficiency

  • Each page is fetched at most once per HTTP request: equal root fields (aliases, repeated fragments, the operations of a batch) share one result, and fields that need the same page at the same time (body(position: 531) and post(id: 531)) share one upstream request.
  • Root fields are resolved concurrently; previews and feed fetch their channels in parallel (still bounded by TELEGRAM_MAX_CONCURRENCY).
  • Responses come from the same caches as REST (see Freshness), so mixing both APIs does not double the load on t.me. GET queries from Cloudflare get the same Cache-Control as REST responses.

Limits

Every operation is checked before anything is fetched. All limits are configurable:

Setting Default Rejects
GRAPHQL_MAX_DEPTH 10 Selections nested deeper than this below a root field (root field = 0, introspection excepted)
GRAPHQL_MAX_COMPLEXITY 1500 Operations whose cost exceeds this
GRAPHQL_MAX_ALIASES 15 Documents with more aliases
GRAPHQL_MAX_TOKENS 2000 Documents longer than this many tokens
GRAPHQL_MAX_BATCH_SIZE 10 Batches with more operations (400 Too many operations)

Cost is 1 per selected field (fragments expanded) plus 10 per channel a root field may fetch: 10 for body, more, post and preview, 10 × the number of channels for previews and feed (the actual list length, also when passed as a variable). A feed over the maximum 100 channels costs about 1 000 + its fields, so it fits the default limit; two of them do not.

Limits apply to each operation of a batch. The rate limit (ENABLE_RATE_LIMIT) counts HTTP requests, so a batch counts once.

Errors

GraphQL errors are returned with HTTP 200 in the errors list, each with a stable extensions.code:

{
  "data": {"body": null},
  "errors": [
    {
      "message": "Requested Telegram resource was not found",
      "locations": [{"line": 1, "column": 3}],
      "path": ["body"],
      "extensions": {"code": "NOT_FOUND"}
    }
  ]
}
extensions.code REST status Cause
BAD_INPUT 422 Invalid username, post ID/position out of 1..10000000, or not 1–100 channels
NOT_FOUND 404 Channel or post does not exist (or is private)
BAD_GATEWAY 502 t.me answered with a page the parser could not interpret
UNAVAILABLE 503 t.me is unreachable or answered with an error
FEATURE_DISABLED 503 preview/previews with ENABLE_PREVIEWS=false, feed with ENABLE_FEED=false
QUERY_TOO_COMPLEX — The operation costs more than GRAPHQL_MAX_COMPLEXITY
GRAPHQL_PARSE_FAILED — Syntax error, or more than GRAPHQL_MAX_TOKENS tokens
GRAPHQL_VALIDATION_FAILED — Unknown field or argument, wrong type, depth/alias limit, introspection while disabled
INTERNAL_SERVER_ERROR 500 Unexpected server error; the message is masked and the details are logged

As in REST, input is validated before feature flags are checked, so invalid arguments are BAD_INPUT even while a feature is disabled. Errors in the BAD_INPUT…FEATURE_DISABLED rows are execution errors: they null only their field. The others are raised before execution and return "data": null.

Requests that are not GraphQL at all are rejected with an HTTP status: 400 for a body that is not JSON, has no query, or is a too large batch, 400 for GET queries while docs are disabled, 429 when rate limited.