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 } } } }"}'
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:
previewandpreviewsreturnPreviewobjects with an extrausernamefield;previewsis a list (in request order, unknown channels omitted) instead of a map keyed by username.- Feed items are
FeedPost: aPostpluschannel,score,duplicatesandtopic(REST:_score,_duplicates,_topic).duplicatesis an empty list when nothing was collapsed into the post. Fields absent from the raw feed data arenull. - Fixed sets of strings are enums whose values are the REST strings:
MediaType(image,video, …),EntityType,ReactionTypeandDirection(after,before). Date.unixis aLongscalar: 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 } }
}
}
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)andpost(id: 531)) share one upstream request. - Root fields are resolved concurrently;
previewsandfeedfetch their channels in parallel (still bounded byTELEGRAM_MAX_CONCURRENCY). - Responses come from the same caches as REST (see
Freshness), so mixing both APIs does not double the
load on t.me.
GETqueries from Cloudflare get the sameCache-Controlas 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.