HTTP API¶
The viewer's web app uses a JSON API, and scripts can call the same routes. This page covers how to log in, which login each route needs, and each route's parameters and responses.
Basics¶
- The viewer listens on port 8000. Every path on this page is relative to that address, or to the address of your reverse proxy.
- Chats are addressed by an opaque
ref, never by the Telegram chat id. Get refs fromGET /api/chats. - Request bodies are JSON. Send
Content-Type: application/json. - Dates are ISO 8601. A timestamp without an offset is read as UTC.
The viewer serves its own generated OpenAPI document at /openapi.json, and it needs no login. The /docs and /redoc pages also exist, but they render blank, because the viewer's content security policy allows no scripts from other hosts. The document lists paths and query parameters, but it has no request bodies, no response shapes and no permission rules. Use this page for those.
Authentication¶
There are no API keys for the /api routes. A script logs in, keeps the session cookie and sends it back on every request.
The viewer supports three login modes. Logins, viewer accounts and share links explains how to turn each one on.
| Mode | Turned on by | What a script does |
|---|---|---|
| Password | VIEWER_USERNAME and VIEWER_PASSWORD both set |
Posts to /api/login and keeps the cookie |
| Proxy identity | AUTH_PROXY_HEADER set |
Goes through the proxy, which sets the identity header. Without the header the viewer falls back to the session cookie. |
| Anonymous mode | ALLOW_ANONYMOUS_VIEWER=true with neither of the above |
Calls routes directly with no login. It never gets master rights. |
With no mode configured, every protected route answers 503 Viewer authentication is not configured.
Log in with a password¶
POST /api/login takes {"username": "...", "password": "..."}. It checks viewer accounts first, then the master credentials from the environment. On success it sets the viewer_auth cookie and answers:
role is master or viewer.
Login errors:
| Status | Meaning |
|---|---|
| 400 | The body is not JSON, or a field is missing |
| 401 | Invalid credentials |
| 429 | Too many attempts from this client IP |
| 503 | The database was unreachable and the master credentials did not match |
When password login is off but anonymous mode is on, the route answers {"success": true, "message": ...} and sets no cookie.
The session cookie¶
The cookie viewer_auth holds an opaque random session key. It is HttpOnly and SameSite=Lax, and lasts AUTH_SESSION_DAYS days, default 30. The viewer marks it Secure when SECURE_COOKIES=true. When SECURE_COOKIES is unset, it marks it Secure only if the login request came over https or carried X-Forwarded-Proto: https. A client does not send a Secure cookie over plain http. If the cookie is Secure, call the viewer over https.
Sessions are stored in the database and survive a viewer restart. Each user holds at most 10 sessions. The 11th login ends the oldest one, so a script that logs in on every run can push a browser session out. Log out at the end of each run, or reuse one cookie jar.
Log in with a share token¶
A share token is a 64-character hex string created by the master. POST /auth/token takes {"token": "..."} and sets the viewer_auth cookie:
curl -s -c jar.txt -H 'Content-Type: application/json' \
-d '{"token":"<64 hex characters>"}' \
http://localhost:8000/auth/token
username is token: followed by the token's label. A token with no label gets token:token:<id>, so give every token a label. The session sees only the token's chats. Invalid, revoked and expired tokens all answer 401 Invalid or expired token. Each use updates the token's last_used_at and use_count.
A share link in the browser carries the token as #token=<value>. The web app reads it and posts it to /auth/token for you.
Check the session¶
GET /api/auth/check needs no login. It answers:
{"authenticated": true, "auth_required": true, "role": "viewer", "username": "alice", "no_download": false}
Proxy users also get "proxy_auth": true. When no login mode is configured, the answer carries "setup_required": true.
Log out¶
POST /api/logout ends the session named by the cookie, closes that session's WebSocket connections, deletes every push subscription of that user and clears the cookie. It always answers {"success": true}. When the request carried a session cookie, the answer also sends Clear-Site-Data: "cache" so the browser drops the media it kept. A request without the cookie, such as another site's form post, does not get the header. That header empties the browser's HTTP cache for the viewer only. The service worker, cookies and local settings stay, and the viewer's static files download again on the next visit. Browsers apply it over HTTPS only.
The master can end other people's sessions through the admin routes. See End sessions.
Rate limit¶
/api/login and /auth/token share one limit: 15 attempts per client IP in 5 minutes, then 429. The client IP is the socket peer unless TRUST_PROXY_HEADERS=true, in which case the viewer reads X-Forwarded-For or X-Real-IP. The only other rate limit is on asking for a transcript. See Transcripts. Exposing the viewer safely explains the proxy setup.
Who can call what¶
| Level | Who passes | Routes |
|---|---|---|
| Public | Anyone | GET /, GET /sw.js, /static/*, GET /api/health, GET /api/auth/check, GET /api/push/config, GET /api/notifications/settings, POST /api/login, POST /api/logout, POST /auth/token, /docs, /redoc, /openapi.json |
| Any login | master, viewer, share token, anonymous | Accounts, chat list, folders, stats, search, tags, change feed, transcription status, push subscriptions |
| Chat entitlement | Any login that can see the chat | Every route with {chat_ref} in the path |
| Master only | The master login or a proxy admin | /api/status, /api/stats/refresh, /api/admin/*, /media/open/*, /media/open-path/* |
| Internal | Other containers, not clients | /internal/push, /api/transcriptions/callback |
Master routes answer 403 Admin access required to everyone else. They also answer 403 when the request carries X-Viewer-Only: true, even with a master cookie. That header also makes the master credentials fail at login.
A chat route answers the same 404 Chat not found for a ref that does not exist, a malformed ref and a chat the caller may not see. What a caller may see combines the operator's DISPLAY_CHAT_IDS filter with the caller's allowed_accounts and allowed_chat_refs grants.
Logins with no_download set get 403 Downloads disabled for this account on media bytes, thumbnails, transcript routes and export. In message payloads their media file_path and url are null, downloaded is false, transcripts are removed and a shared contact's raw_data.contact.vcard is left out. Avatars stay available to them.
Health and status¶
| Method and path | Login | Purpose |
|---|---|---|
GET /api/health |
Public | Whether the database answers |
GET /api/stats |
Any | Archive totals and runtime flags |
GET /api/status |
Master | Backup, listener, media and database health in one call |
POST /api/stats/refresh |
Master | Recalculate the stats now and return them |
GET /api/health answers {"status": "ok", "database": "connected"}. When the database probe fails it answers HTTP 503 with {"status": "degraded", "database": "unreachable"}. The viewer container's health check calls this route.
GET /api/stats answers the stored daily figures and some runtime settings and flags:
| Kind | Fields |
|---|---|
| Daily figures | chats, messages, media_files, total_size_mb, stats_calculated_at, last_backup_time |
| Settings and flags | timezone, stats_calculation_hour, show_stats, listener_active, listener_active_since, backup_in_progress, push_notifications, push_enabled, enable_notifications |
For a restricted login the counts cover only its chats. POST /api/stats/refresh recounts the whole archive, which takes a while on a large one. Do not call it on a schedule.
GET /api/status holds counts and timestamps only, never chat names or content. It is sent with Cache-Control: private, no-store.
{
"backup": {"last_run": "2026-09-28T03:00:12", "in_progress": false},
"stats_calculated_at": "2026-09-28T00:00:05",
"listeners": [
{
"account_id": 1,
"active": true,
"active_since": "2026-09-27T18:04:40",
"last_update_at": "2026-09-28T09:41:12.503118+00:00",
"deletions_blocked": 0,
"last_rate_limit_at": null
}
],
"media": {"downloaded": 1200, "pending": 3, "exhausted": 0, "skipped": 45},
"database": {"backend": "postgresql", "size_bytes": 734003200}
}
backend is sqlite or postgresql. A monitoring script can alert when backup.last_run is too old, when a listener is not active, or when media.exhausted grows. A listener is active while it stamps its heartbeat, every 30 seconds, and for three minutes after the last stamp. active_since stays on a listener that is not active when it stopped without shutting down, as after a killed container; a clean stop clears it. listener_active in /api/stats follows the same rule.
Each listener row also carries:
| Field | Value |
|---|---|
active_since |
When the listener started, in the backup's local time with no UTC offset. null after a clean stop. |
last_update_at |
When the listener last handled any Telegram update for the account, UTC. Written with the heartbeat, so up to 30 seconds behind, and kept after the listener stops. null until the listener has handled one. |
deletions_blocked |
Deletions the rate limiter refused since the listener last started. Starts again at 0 on every start, a watchdog restart included. null until a listener of this version has started. |
last_rate_limit_at |
When the rate limiter last tripped for the account, UTC, kept across restarts. null if it never has. |
An active listener whose last_update_at stops moving is connected but receiving nothing: alert when it is older than what your chats normally go without an update. A deletions_blocked above 0 means deletions were not recorded; the SYNC_DELETIONS_EDITS backup pass records them. With SYNC_DELETIONS_EDITS_DAYS above 0 it records only those posted inside the window. See Checking that it runs.
Accounts, chats and folders¶
All of these need any login.
| Method and path | Parameters | Response |
|---|---|---|
GET /api/accounts |
None | {accounts: [{id, label}]}. label is TG_ACCOUNT_<N>_LABEL when set; otherwise default for the first account and account<N> for the others. |
GET /api/chats |
limit default 50, 1 to 1000. offset. search matches title, name or username. archived true or false. folder_id. |
{chats, total, limit, offset, has_more}. Each chat carries ref, avatar_url, accounts and preview, its newest message for the list's second line. See The chat list preview. |
GET /api/chats/{chat_ref} |
None | One chat in the shape of a list row, without preview |
GET /api/folders |
None | {folders} with chat counts limited to what the caller can see |
GET /api/archived/count |
None | {count} |
The chat list preview¶
preview is the chat's newest message that was not deleted in Telegram, or null when the chat has none. It is read from the copy of the chat the row's ref opens, under the same restrictions as the chat's messages, so it never shows a message the caller could not open in the chat. It carries text and a kind only, never a file or a transcript. GET /api/chats/{chat_ref} does not return it.
| Field | Value |
|---|---|
message_id |
The message's id in the chat |
date |
When it was sent, UTC |
text |
The text on one line, cut to 100 characters with an ellipsis. A poll with no text gives its question. A service row gives its sentence, for example Esme joined the group. null when there is no text. |
sender |
You for the account's own message in a group or a private chat, the sender's first name for anyone else in a group, and null in a channel, for the other person in a private chat and for a service row |
kind |
text, service, poll, the archive's media type (photo, video, video_note, voice, audio, animation, sticker, document, geo, geo_live, venue, contact, dice, game, invoice, story, giveaway, giveaway_results, webpage, unsupported), or message for a message with neither text nor a media record |
outgoing |
Whether the account sent it |
action, action_title |
For a service row with no stored sentence, from before 7.28: its action_type and new_title, so a client can word it. null otherwise. |
Example:
"preview": {
"message_id": 1288,
"date": "2026-09-30T08:57:00",
"text": "Perfect, save me a seat",
"sender": "Esme",
"kind": "text",
"outgoing": false,
"action": null,
"action_title": null
}
Messages¶
All of these need a login that can see the chat.
| Method and path | Parameters | Response |
|---|---|---|
GET /api/chats/{chat_ref}/messages |
limit default 50, 1 to 500. offset. search. topic_id. deleted_only and edited_only, default false. Cursor: before_date plus before_id, before_id alone, or after_id. See Paging through messages. |
A JSON array of messages, newest first |
GET /api/chats/{chat_ref}/messages/{message_id}/versions |
limit default 100, up to 500 |
Earlier versions of an edited message, newest first: [{chat_id, message_id, text, date, captured_at, source, entities, rich_message}], with media on a version whose photo or file an edit replaced. See Message versions |
GET /api/chats/{chat_ref}/pinned |
None | Pinned messages, newest first, each with the snapshots, reactions, removed_reactions, reaction_history and reaction_history_omitted the messages route gives it |
GET /api/chats/{chat_ref}/messages/by-date |
date as YYYY-MM-DD. timezone as an IANA name, optional; defaults to the viewer's configured timezone. topic_id. |
The first message on or after local midnight of that day, or 404 |
GET /api/chats/{chat_ref}/messages/dates |
month as YYYY-MM and timezone, both required. topic_id. |
{month, timezone, topic_id, dates: ["YYYY-MM-DD", ...]} |
GET /api/chats/{chat_ref}/topics |
None | {topics} for a forum chat |
GET /api/chats/{chat_ref}/stats |
None | {chat_id, messages, media_files, total_size_bytes, total_size_mb, first_message_date, last_message_date, deleted_messages, edited_messages}, cached for 60 seconds. deleted_messages counts the messages deleted in Telegram that the archive kept, edited_messages the messages edited at least once |
GET /api/chats/{chat_ref}/avatars |
None | [{photo_id, seen_at, url, available}] |
Paging through messages¶
Offset paging gets slower as the offset grows. For long walks use the cursor: pass the date and id of the last message you received as before_date and before_id.
curl -s -b jar.txt \
'http://localhost:8000/api/chats/<ref>/messages?limit=200&before_date=2026-09-01T10:15:00&before_id=48213'
A lone before_id=N returns messages with ids below N. after_id=N returns messages with ids above N.
deleted_only=true keeps only the messages deleted in Telegram that the archive kept, the rows with is_deleted set to 1, newest first. It works with or without search: without it you get every kept deletion of the chat, with it the deletions that match. It combines with topic_id and the cursors like the other filters. It is a read and stays inside the chat the ref names, so a viewer restricted to some chats or accounts, or a share link, gets the same 404 for another chat as without it.
curl -s -b jar.txt \
'http://localhost:8000/api/chats/<ref>/messages?limit=50&offset=0&deleted_only=true'
edited_only=true keeps only the edited messages, newest first, the same way. A message counts as edited when Telegram marks it (edit_date is set and edit_hide is not 1) or the archive kept at least one earlier version of it (version_count above 0): either can hold without the other, for a message first archived after its edit or an earlier empty text the archive filled in. edit_hide is Telegram's flag for that edit_date: 1 when Telegram says the edit is not to be shown, which it does when only the reactions changed, 0 when it shows, and null when the source did not report it: a message archived before the archive kept the flag, or one from a Telegram export import. A null flag counts as shown. It is the rule the viewer's pencil follows, and edited_messages in /api/chats/{chat_ref}/stats counts the same rows. Like deleted_only, it is a read that combines with search, topic_id and the cursors and stays inside the chat the ref names.
curl -s -b jar.txt \
'http://localhost:8000/api/chats/<ref>/messages?limit=50&offset=0&edited_only=true'
Each message nests its media. media.id is the media key, {message_id}_{type}, and media.url is /media/{chat_ref}/{key}. media.downloaded is false for a row waiting for a download, such as a file check-media --repair marked to download again; such a row can keep its file_path. A kind with no file (geo, contact, poll, venue, geo_live and the others in Media) has media.url null, even when an older release left a file_path on it. The one exception is a location, a venue or a live location whose row holds its map picture (file_name of the form map_<16 hex>.jpg or .png): its media.url is the picture, served by the media route under the chat's rules, and file_name, file_size, mime_type, width and height describe it. A login whose downloads are off still gets null. A location Telegram served no picture for has skip_reason map_not_served. What it holds is in raw_data under a key named after the kind:
| Key | Fields |
|---|---|
geo |
lat, long, accuracy_radius |
contact |
first_name, last_name, phone_number, vcard, user_id (0 when the number has no Telegram account) |
venue |
title, address, provider, venue_id, venue_type, lat, long, accuracy_radius |
geo_live |
lat, long, period, heading, accuracy_radius, at (when that position was current, UTC), earlier (positions earlier reads saw, oldest first) |
poll |
question, answers, closed, public_voters, multiple_choice, quiz, results |
A field Telegram did not send is left out, so a location Telegram sent without a point has no lat or long. A message with no media row (the listener writes none for these kinds) still has its key in raw_data. A reply also carries reply_to_media_type, the kind of the message it answers, which comes from that message's raw_data when it has no media row, and reply_to_media_title, a venue's title, when it answers a venue. sender_avatar_url points at /media/avatar/{chat_ref}/{message_id}. Transcripts are attached when transcription is on.
In GET /api/chats/{chat_ref}/messages each message carries its reactions in two lists. reactions holds the live ones, one entry per emoji with its count. removed_reactions holds the reactions taken back that the archive kept, newest first: one entry per emoji, from its latest drop, with emoji, count (how many went), count_before (the count before the drop, above count when some stayed), removed_at (when the archive noticed, in UTC) and back_at (when an emoji taken back to zero was seen again, or null). An emoji that came back is in both lists. reaction_history lists every state of the message's reactions the archive kept, oldest first: emoji, count (0 when taken back), previous_count (null for the first state), observed_at and source (listener, backup, or baseline for a state copied from the reactions kept before 9.0). This route caps reaction_history so a busy post stays small: it holds the message's 20 newest states, plus each emoji's newest state, its latest drop and the first state with a count after that drop, so removed_reactions reads the same as from every state. reaction_history_omitted is how many older states it left out, 0 when the list is complete. Both exports return every state. Neither list names a person, because the archive stores counts per emoji. reactions[].user_ids can still list ids from rows written one per reactor before 7.23.0, until the backup or the listener reconciles that message again. /messages/by-date returns only reactions.
In GET /api/chats/{chat_ref}/messages each message also carries snapshots, the newest state the archive kept of its poll and of its link preview. See Poll and link preview snapshots.
Poll and link preview snapshots¶
raw_data.poll and raw_data.webpage hold a poll and a link preview as the archive first captured them, and never change. When a later read shows another state, the archive adds a snapshot: a poll's votes, results or closing, or the preview card's fields. A read that shows the newest kept state adds nothing. The listener writes them from edit events and poll updates, and the backup and the sync from their reads. Live locations are not followed.
snapshots is an object keyed by kind, poll or preview, and empty when the archive kept no later state. Each entry describes the newest state of that kind:
| Field | Meaning |
|---|---|
payload |
The whole state, in the shape of raw_data.poll or raw_data.webpage. A poll state from an update that carried the results alone holds only results. |
observed_at |
When the archive saw it, by the archive's own clock, in UTC. |
source |
The path that saw it: listener, sync or backup. |
count |
How many states of that kind the archive kept after the first capture. |
differs_from_first |
False when the newest state is the same as the first capture again, for example after a vote was taken back. |
The snapshots read through the same chat and account as the messages: a viewer restricted to some accounts sees only the snapshots its accounts wrote. Both exports list every state, oldest first.
Message versions¶
Each earlier version of an edited message has these fields:
| Field | Meaning |
|---|---|
text |
The text of that version. |
date |
When that text became current, by Telegram's clock: the send time for the original, the edit time for each later one. An edit time Telegram hides (a reaction) is not an edit, so a text that carried one is dated at the send time. |
captured_at |
When the archive saw it, by the archive's own clock. |
source |
The path that saw it: listener, sync, backup or import. Null for a version archived before the archive kept it: unknown. |
entities |
The formatting of that version, in the shape of the message's raw_data.entities: [{type, offset, length, ...}], a custom emoji's document_id as a string (see Custom emoji). Null when it had none, or for a version archived before the archive kept it. |
rich_message |
The block tree of a Rich Text Editor message, in the shape of the message's raw_data.rich_message. Null when that version had none. |
media |
Present only when an edit replaced the message's photo or file: the media this version was shown with, as [{type, file_name, file_size, mime_type, width, height, duration, downloaded, skip_reason, first_seen, date, captured_at, source, url}]. skip_reason says why a file was not downloaded (oversize or filtered, or null), and first_seen is when the archive first recorded that media (null when unknown). url is /media/{chat_ref}/{message_id}_v{n}, where n counts the message's earlier media in the order they were kept, from 1. It is null when the file was not downloaded; a login without downloads gets url null and no_download true. |
media_only |
True on an entry that holds only earlier media, when the text version of that moment could not be written. Its text is null. |
Only the listener sees each edit as it happens, and only while it runs: edits made while it was away reach it as one. The sync, a backup and an import read the text current at that moment, so several edits between two reads leave one version. When any version has one of those sources, or no source, the number of versions is a lower bound on the number of edits. The same holds when the oldest version's date is later than the message's date: the archive first saw the message already edited.
Search, tags and the change feed¶
All of these need any login. Results cover only chats the caller can see.
| Method and path | Parameters | Response |
|---|---|---|
GET /api/search/messages |
q required, 1 to 500 characters. limit default 20, up to 100. offset up to 5000. |
{query, limit, offset, has_more, indexed, results} |
GET /api/tags/{tag} |
scope is chat, mine or all, default all. chat_ref, required with scope=chat. limit default 50, up to 200. offset. |
{tag, results, has_more, truncated} |
GET /api/changes |
since ISO, inclusive. cursor from next_cursor, to page older. before ISO, exclusive (older date-only bound). limit default 50, up to 200. chat_ref for one chat. reactions=true adds reactions taken back. |
{changes, next_cursor, next_before} |
/api/search/messages is a word-prefix full-text search across chats, newest first. Each result has id, date, text, sender_name, sender_account_id, is_deleted, topic_title, matched_in and a chat object with ref, title, first_name, last_name, username, type, is_forum and avatar_url.
/api/tags/{tag} takes a #hashtag or a $CASHTAG and answers 400 for anything else. Encode # as %23 in the URL:
/api/changes lists deletions, edits and new transcripts, newest first, and with reactions=true the reactions taken back. Each change has kind, date, cursor, chat with ref, title and type, message_id and sender_name, plus:
kind |
Extra fields |
|---|---|
deleted |
text |
edited |
old_text, new_text |
transcript |
text, language |
reaction |
text (the message's current text), emoji, count (how many went), count_before, count_after |
A reaction row is one drop the archive kept in the reaction history: a count below the one before it, dated when the archive noticed. A partial drop and a complete one both list, and so does a removal kept before 9.0. A drop that two accounts holding one channel both saw lists once. They are left out without reactions=true, since they come and go far more often than the rest.
chat_ref narrows the feed to one chat. For a channel or group that several accounts hold, that is every copy the caller may see, and each change is listed once under the copy the feed for every chat lists it under, so a row's chat.ref can name another account's copy of the same chat. A private chat narrows to the copy that ref names, since each account's private chat with one person is a different conversation. A chat the caller cannot see answers 404, the same as an unknown ref. Paging works the same way.
Hard deletions never appear, and no-download logins get no transcript rows. The response is sent with Cache-Control: private, no-store.
To walk the feed, pass next_cursor from each answer as cursor on the next request, keeping the other parameters, until next_cursor comes back null. The walk is lossless: every change in the window is listed exactly once, however many share one timestamp and whatever the page size. Changes are ordered by date, newest first, then by kind (deleted, edited, transcript, reaction), then by the row's own keys, and the cursor names the place of one row in that order, so the next page starts right after it. Each row's cursor works the same way, if you stop partway and resume from a row you kept. It is opaque: sealed with a key the archive keeps in its database, so it names no chat id, cannot be edited, and still works after a restart. A malformed or edited cursor answers 400.
before still works for clients written before cursor: it is an exclusive date bound, and next_before is the last row's date. Paging with it skips the changes that share that exact date, so a burst of deletions stored at one instant can lose rows at a page boundary. Use cursor for anything that has to be complete, such as keeping evidence.
To poll for new changes, pass the time of your last poll as since:
Media¶
A media key has the form {message_id}_{type}, for example 42_photo or 7_video_note. Get keys from message payloads or from the media gallery route. An earlier photo or file an edit replaced has the key {message_id}_v{n}, from the url of its message version: n is its place among the message's earlier media, 1 for the first one kept. It works on the file, thumbnail and open routes, and the transcript routes read its transcripts; asking for a new transcript of it answers 404. A key for an earlier media the message does not have answers 404.
| Method and path | Login | Purpose |
|---|---|---|
GET /media/{chat_ref}/{media_key} |
Chat entitlement | The original file. Add ?download=1 to force an attachment. |
GET /media/thumb/{size}/{chat_ref}/{media_key} |
Chat entitlement | A generated WebP thumbnail. size is 200 or 400. |
GET /media/avatar/{chat_ref} |
Chat entitlement | The chat's avatar. photo_id picks an older one. |
GET /media/avatar/{chat_ref}/{message_id} |
Chat entitlement | The avatar of that message's sender |
GET /api/custom-emoji |
Any chat | What the archive knows of custom emoji. See Custom emoji. |
GET /media/emoji/{document_id} |
Any chat | The file of one custom emoji |
GET /api/chats/{chat_ref}/media |
Chat entitlement | The chat's media gallery |
GET /api/chats/{chat_ref}/media/counts |
Chat entitlement | {type: count} for the chat |
POST /media/open/{chat_ref}/{media_key} |
Master | Run MEDIA_OPEN_CMD on the file, on the viewer's host |
POST /media/open-path/{chat_ref}/{media_key} |
Master | Run MEDIA_OPEN_PATH_CMD on the file, on the viewer's host |
The viewer serves files a browser can show inline. It sends other files, and any request with download=1, as an attachment.
Media, thumbnails and avatars are sent with Cache-Control: private, no-cache, an ETag and a Last-Modified. The browser may keep a copy, but it asks the server before each reuse, and the viewer runs the same login and chat checks on that request. A session that still has access gets 304 Not Modified and no body, so the file is not sent again. A logged-out browser gets 401, never the kept copy. Copies cached by an older release, which did not ask, can still be reused until their old lifetime runs out, up to a day for thumbnails and avatars; logging out once over HTTPS clears them. Editing a viewer, or changing a token's chats or downloads, ends its sessions, so that browser gets 401 too. A session that no longer sees the chat gets 404. Originals and thumbnails answer 403 to a login whose downloads are off; avatars stay available to it. When its session ends, the viewer page reloads itself at the same address, so the next login on the same tab does not see the chat that was open until the server allows it again. The files under /static are not behind a login and keep their own caching.
Custom emoji¶
A reaction made with a custom emoji has emoji set to custom_<document_id>. A custom emoji in text is an entity {type: "custom_emoji", offset, length, document_id}, where document_id is a string of decimal digits in every viewer answer and live frame: the messages, pinned and by-date routes, message versions, and the new_message and edit frames. Before 9.3.0 it was a JSON number, which a browser rounds. The chat exports keep it a number. GET /api/custom-emoji?ids=<id>,<id>,... takes 1 to 100 decimal document ids and answers 400 for anything else. It answers an object keyed by the id as a string: a document id is larger than a JSON number holds without rounding in a browser. Each value is {kind, alt, text_color, ready}. kind is image, tgs or webm when the file is on disk, else null; alt is the ordinary emoji Telegram gives as its meaning; text_color is true when Telegram draws the emoji in the colour of the text; ready is true when the file can be fetched. An id the archive does not know is left out.
GET /media/emoji/{document_id} serves the file as image/webp, application/x-tgsticker or video/webm, and answers 404 for an id with no file. A file is named after its id and never rewritten, so it is sent with Cache-Control: private, max-age=31536000, immutable.
Both routes need a login that can read at least one chat, and answer 403 to a grant with no chat. They are not tied to a chat: the file is the same public emoji pack file for every chat. A share link and a login whose downloads are off can call them, as for avatars.
After an edit replaced a message's media, the url of its current media carries ?v={n}, so a browser never shows cached bytes of the old media under it. The routes ignore the parameter; add download=1 with &.
The gallery route takes types as a comma list, limit default 50, up to 200, and either before_id or after_id. Both take a media key; before_id pages to older items and after_id to newer ones. Sending both is a 400. It answers {items, has_more}, where each item has id set to the media key plus thumb_url and media_url, and the message's text, is_deleted and deleted_at.
The open routes answer {"ok": true} on success and 404 Not configured when their command is unset. /media/open accepts only types the viewer shows inline and answers 415 for others. /media/open-path accepts any type. Both answer 400 File name cannot be passed to the command when the file name cannot be passed to the command, and 500 when the command fails to start or exits non-zero within half a second.
Export¶
GET /api/chats/{chat_ref}/export streams a chat as one JSON file, or with format=html as a ZIP of HTML pages and media (see HTML export). It needs a login that can see the chat and answers 403 for no-download logins, in both formats. It is sent with Cache-Control: private, no-store, so neither the browser nor a proxy keeps a copy.
| Parameter | Meaning |
|---|---|
format |
json (the default) or html. A request without it gets the same JSON as before html existed, byte for byte. |
from |
Start, inclusive, ISO 8601 |
to |
A bare date such as 2026-06-30 includes that whole day. A full timestamp is exclusive. |
changed_since |
ISO 8601 time. Keeps only the messages whose archive record changed at or after it: stored for the first time, seen deleted, or given a new text version, earlier media, poll or preview state or reaction state. A time without Z or an offset is UTC; a T or a space separates date and time. In a raw URL a + offset must be written %2B, or it reads as a space and the time is refused; curl -G --data-urlencode 'changed_since=2026-06-01T05:59:55+02:00' encodes it for you. |
from must be before to, or the route answers 400. A changed_since that is not ISO 8601, an empty one included, answers 400. Without any of them, the export holds the whole chat. changed_since narrows from and to, and each message it keeps carries its whole history. A change the archive writes into the message row itself (a pin, a media download or a transcript that finishes later) does not select a message, and a message removed with DELETION_MODE=hard is gone from every export. It follows the same rules as the command's --changed-since, including the recipe for exporting on a schedule in Exporting only what changed. In a viewer export the chat ref and the message id together are the key to merge files on. Save the stream to a file:
curl -s -b jar.txt -o june.json \
'http://localhost:8000/api/chats/<ref>/export?from=2026-06-01&to=2026-06-30'
curl -s -b jar.txt -o changes.json \
'http://localhost:8000/api/chats/<ref>/export?changed_since=2026-06-01T03:59:55Z'
The response is an application/json attachment named <chat name>_export.json:
{
"chat": {"id": -1001234567890, "ref": "<ref>", "type": "channel", "title": "Example", "username": null},
"filters": {"from": "2026-06-01", "to": "2026-06-30"},
"messages": []
}
filters appears only when from, to or changed_since was given. The window picks messages by their send date. With changed_since, filters also holds changed_since, the UTC time it was read as (ISO 8601, no offset).
Each message has id, date, sender (name, username), text, is_outgoing and reply_to, and these fields that say what the archive kept:
| Field | Content |
|---|---|
is_deleted |
true when the message was deleted in Telegram. The archive keeps it, so the export includes it. |
deleted_at |
When the archive noticed the deletion, ISO 8601 UTC, or null. |
edit_date |
The message's edit time in Telegram, ISO 8601 UTC, or null. Telegram also moves it when only the reactions change. |
edit_hide |
Telegram's flag for that edit_date: 1 when it says the edit is not to be shown, as when only the reactions changed, 0 when it shows, and null when the source did not report it (a message archived before the archive kept the flag, or one from a Telegram export import). A null flag counts as shown. With edit_hide 1 Telegram shows no edit mark on the message. |
media |
The message's current media, as a list of export media. Usually one entry. A message can hold more than one media row, and the first entry is the one the viewer shows. An empty list means the message has no media. |
versions |
Every earlier version the archive kept of the message, oldest first, whatever its date. An empty list means the archive kept none. |
reaction_history |
Every state of the message's reactions the archive kept, oldest first, whatever its date: emoji, count (0 when taken back), previous_count, observed_at (ISO 8601 UTC) and source, as in the messages list. |
snapshots |
Every later state of the message's poll or link preview the archive kept, oldest first. Each has kind (poll or preview), payload, observed_at (ISO 8601 UTC) and source, as in Poll and link preview snapshots. The first capture of a poll is under media_payload.poll; the export has no raw_data, so the first capture of a link preview is not in it. An empty list means no later state was kept. |
transcripts |
Present only when the message's media has transcripts: every transcript row, newest first. Each names its media by media_id. |
A location, a contact, a poll or another kind with no file has a media_payload object, keyed and shaped as in raw_data (see Paging through messages).
Each entry of versions has these fields:
| Field | Content |
|---|---|
text |
The text of that version. Null on a media_only entry. |
date |
When that text was current in Telegram, ISO 8601 UTC. |
captured_at |
When the archive saw it replaced, ISO 8601 UTC. |
source, entities, rich_message |
As in Message versions. |
media |
The earlier media this version was shown with, kept when an edit replaced the photo or file, as a list of export media. Empty when the edit kept the media. |
media_only |
Present and true only on an entry that holds earlier media and no text: the text version of that moment could not be written. |
The export pairs earlier media with versions the way the edit history does. It sits under the text version with the same date.
Export media¶
Each media entry, current or earlier, has these fields. None is a file path, and the JSON export holds no files.
| Field | Content |
|---|---|
media_id |
The id the media's transcripts name. An earlier media keeps the id it had before the edit replaced it. |
type |
photo, video, voice, audio, document and the other media types. |
file_name |
The stored file name, or null. |
file_size |
Bytes, or null. |
mime_type |
Or null. |
width, height |
Pixels, or null. |
duration |
Seconds, or null. |
So every transcript in the file names a media listed in the same file, on its message or under one of its versions. A voice note whose audio an edit replaced, with a transcript of each audio:
{
"id": 1270,
"text": "",
"edit_date": "2026-10-01T10:40:00",
"media": [
{"media_id": "-1001900000001_1270_voice_v1", "type": "voice", "file_name": "1270_second.ogg",
"file_size": 9000, "mime_type": "audio/ogg", "width": null, "height": null, "duration": 9}
],
"versions": [
{
"text": "", "date": "2026-10-01T10:38:00", "captured_at": "2026-10-01T10:40:00",
"source": "listener", "entities": null, "rich_message": null,
"media": [
{"media_id": "-1001900000001_1270_voice", "type": "voice", "file_name": "1270_first.ogg",
"file_size": 7000, "mime_type": "audio/ogg", "width": null, "height": null, "duration": 7}
]
}
],
"transcripts": [
{"media_id": "-1001900000001_1270_voice_v1", "text": "Meet at seven thirty at the north lot.", "...": "..."},
{"media_id": "-1001900000001_1270_voice", "text": "Meet at eight at the south lot.", "...": "..."}
]
}
Before 9.0 the file also ended with a flat message_versions list. It is gone: every version is under its message. See Upgrading to 9.0.
Everything in the file is read from one snapshot of the archive, so a backup writing during the export cannot make a message disagree with its versions, its media, its reaction history, its snapshots or its transcripts. The export reads a message's versions, media, reaction states and snapshots as it writes that message, so a long chat is never held in memory at once. If they ever stop lining up with the messages, the export stops with an error instead of writing messages without them. The file then ends early and is not valid JSON.
HTML export¶
With format=html the route answers an application/zip attachment named <chat name>_export.zip. from, to and changed_since work as they do for JSON. Three more parameters choose the media:
| Parameter | Meaning |
|---|---|
media |
A comma list of the types to copy: photo, video, voice, video_note, gif, sticker, file. Without it the export is text only, so a caller always asks for the media it wants. file covers documents and audio files. |
max_file_mb |
A whole number from 1 to 3900, default 50. A file larger than this many MB stays out of the ZIP. The ceiling keeps every entry out of ZIP64, which macOS Archive Utility cannot extract from a streamed ZIP. |
players |
true (default) or false. true puts a <video> or <audio> player in the page, with a link to the file beside it. false writes a card that links to the file, as Telegram Desktop does. |
These answer 400: a format other than json or html, an unknown name in media (the message names it), a max_file_mb outside 1 to 3900, and media, max_file_mb or players sent with format=json. The 403 for a no-download login comes before any of them.
curl -s -b jar.txt -o june.zip \
'http://localhost:8000/api/chats/<ref>/export?format=html&media=photo,video,voice&max_file_mb=100&from=2026-06-01&to=2026-06-30'
The ZIP holds one folder, ChatExport_<chat name>/, laid out the way Telegram Desktop lays out its exports:
| Path | Content |
|---|---|
index.html |
The chat, the filters, the export time in UTC, the Telegram Archive version, the media options, the count of files left out by reason, and a link to each page with its first and last day. |
messages.html, messages2.html, … |
The messages, oldest first. A page ends after 1000 messages or 40 players. |
css/style.css |
The one stylesheet, light or dark from the system setting. |
photos/ |
photo |
video_files/ |
video; with players=true, a _thumb.webp poster beside each when one could be made |
voice_messages/ |
voice |
round_video_messages/ |
video_note; with players=true, a _thumb.webp poster beside each when one could be made |
animations/ |
gif |
stickers/ |
sticker |
files/ |
file |
A media folder appears only when it holds a file. The names inside are ours, <message id>_<n> plus the file's extension, so no stored name can reach outside the folder. The original file name shows in the page and in the link's download attribute. The pages load no script, font or remote file. Each one sets a Content Security Policy that blocks scripts.
A file left out keeps its card without a link, with the reason: not included in this export, larger than the limit, not downloaded to the archive, or missing from the archive's storage. The ZIP is written while it streams, so the response has no Content-Length. The messages are first read from one snapshot of the archive into a temporary file on the server, about the size of the JSON export, so the snapshot closes before the download starts however slow the client is. If a file fails to read after its entry has started, the response stops before the ZIP's closing directory, and unzip reports the archive as broken instead of handing back a short one.
Transcripts¶
| Method and path | Login | Purpose |
|---|---|---|
GET /api/transcription/status |
Any | {enabled, configured, server_name, server_version}. Never includes the server URL. |
GET /api/chats/{chat_ref}/media/{media_key}/transcripts |
Chat entitlement | Transcripts of one media item, newest first |
POST /api/chats/{chat_ref}/media/{media_key}/transcripts |
Chat entitlement | Ask for a transcript now |
GET /api/media/{media_id}/transcripts |
Chat entitlement | The same, addressed by the storage media id. Returns full rows. |
POST /api/media/{media_id}/transcripts |
Chat entitlement | The same, addressed by the storage media id |
Each transcript has id, status, error, text, language, source, engine_name, engine_version, preset, models, confidence, duration_s, requested_at, completed_at and turns.
The POST routes queue a request, or return the one already open, and make no outbound call themselves. They answer 409 when transcription is off, when the media has no sound, or when the file is not downloaded yet. For anyone but the master they answer 429 with a Retry-After header after TRANSCRIPTION_ASK_RATE_LIMIT presses in 10 minutes from the same client, or when TRANSCRIPTION_ASK_MAX_OPEN pressed files already wait for the backup. A press on a file whose request is already open returns that request and is never refused. In an open viewer (ALLOW_ANONYMOUS_VIEWER=true) the same holds for a file whose newest result is done or skipped: the press returns it and queues nothing. A login's press on such a file queues it again. For the rate limit, Retry-After and the detail say when the next press is allowed. For the cap, room only comes back when a drain of the backup sends waiting files, so Retry-After is a polling hint of 15 minutes, the default TRANSCRIPTION_DRAIN_INTERVAL_MINUTES. All transcript routes except status answer 403 for no-download logins. Setup is in Voice transcription.
Live updates over WebSocket¶
/ws/updates is a WebSocket and is not in the OpenAPI document. There is no server-sent events endpoint.
The viewer checks the upgrade request the same way it checks any HTTP request, using the viewer_auth cookie or the proxy header. A failed login closes the socket with code 4001. The Origin header must match Host or appear in CORS_ORIGINS, or the socket closes with 4003. The viewer holds at most MAX_WS_CONNECTIONS sockets, default 200, and refuses more with close code 1013. Each socket follows at most MAX_WS_SUBSCRIPTIONS_PER_CONNECTION chats, default 16.
The client sends:
{"action": "subscribe", "chat_ref": "<ref>"}
{"action": "unsubscribe", "chat_ref": "<ref>"}
{"action": "ping"}
The server replies with subscribed, subscribe_denied, unsubscribed or pong. A denial past the subscription limit carries reason: "subscription_limit".
Event frames all carry type and chat_ref:
type |
Fields |
|---|---|
new_message |
message |
edit |
message_id, new_text, edit_date, edit_hide, and entities when the frame carries the new formatting (left out when new_text was cut to fit). media when the edit replaced the photo or file (see below) |
delete |
message_id, deletion_mode, deleted_at |
pin |
message_ids, pinned |
reaction |
message_id, reactions (the live set; an emoji missing from it was taken back) |
transcript |
message_id, transcript_id, status |
The nested media of a new_message frame's message, and of an edit frame, has the shape /api/chats/{ref}/messages gives a message's media: id is the {message_id}_{type} key, and url is the ref-addressed /media/ URL, or null when the file is not on disk. A location, a venue or a live location carries the URL of its map picture the same way. The URL of media an edit replaced ends in ?v={n}, so a browser never shows the earlier file from its cache. A login whose downloads are off gets url and file_path null and no_download: true, as on the messages route. An edit frame without media says the media did not change. It is also left out when the frame would pass PostgreSQL's notification size limit, after the entities; the text is never left out.
The viewer closes a socket with 4001 Session revoked when its session ends: logout, expiry, eviction, an admin change to the viewer account or share token behind it, or the end-all action. How updates reach the viewer is in Live updates and notifications.
Push notifications¶
| Method and path | Login | Purpose |
|---|---|---|
GET /api/push/config |
Public | {mode, enabled, vapid_public_key} |
GET /api/notifications/settings |
Public | {enabled, mode, websocket_url: "/ws/updates"} |
POST /api/push/subscribe |
Any | Register a Web Push subscription |
POST /api/push/unsubscribe |
Any | Remove a Web Push subscription |
/api/notifications/settings answers {"enabled": false, "reason": "Not authenticated"} when password login is on and the cookie is missing or expired.
POST /api/push/subscribe takes {endpoint, keys: {p256dh, auth}, chat_ref}, with chat_ref optional to follow one chat. It answers 400 unless PUSH_NOTIFICATIONS=full, 400 for an invalid endpoint, and 400 when the body sends chat_id instead of chat_ref. On success it answers {"status": "subscribed", "chat_ref": ...}. POST /api/push/unsubscribe takes {endpoint} and answers {"status": "unsubscribed"} or {"status": "not_found"}. It answers 400 Push notifications not enabled when push is off.
Administration¶
Every route here is master only, and every write lands in the audit log. The concepts behind them are in Logins, viewer accounts and share links.
Viewer accounts¶
| Method and path | Purpose |
|---|---|
GET /api/admin/viewers |
List accounts: {viewers: [{id, username, allowed_accounts, allowed_chat_refs, is_active, no_download, created_by, created_at, updated_at}]} |
POST /api/admin/viewers |
Create an account |
PUT /api/admin/viewers/{id} |
Change an account and end its sessions |
DELETE /api/admin/viewers/{id} |
Delete an account and end its sessions |
GET /api/admin/chats |
Every chat, for picking grants: {chats: [{id, ref, account_id, title, type, username, first_name, last_name}]} |
The create body:
| Field | Rule |
|---|---|
username |
Required, at least 3 characters. Must not match the master name. |
password |
Required, at least 8 characters |
allowed_accounts |
A list of account ids, or null for all |
allowed_chat_refs |
A list of chat refs, or null for all. An empty list grants nothing. |
is_active |
Default true |
no_download |
Default false |
A taken name answers 409. The old field allowed_chat_ids answers 400. Create a viewer who sees two chats:
curl -s -b jar.txt -H 'Content-Type: application/json' \
-d '{"username":"alice","password":"a-long-password","allowed_chat_refs":["<ref1>","<ref2>"]}' \
http://localhost:8000/api/admin/viewers
{"id": 3, "username": "alice", "allowed_accounts": null, "allowed_chat_refs": ["<ref1>", "<ref2>"], "is_active": 1, "no_download": 0}
PUT takes any of the same fields. It answers 400 when the body changes nothing and 404 for an unknown id.
Share tokens¶
| Method and path | Purpose |
|---|---|
GET /api/admin/tokens |
List tokens: {tokens: [{id, label, created_by, allowed_accounts, allowed_chat_refs, is_revoked, no_download, expires_at, last_used_at, use_count, created_at}]} |
POST /api/admin/tokens |
Create a token |
PUT /api/admin/tokens/{id} |
Change label, allowed_chat_refs, is_revoked or no_download |
DELETE /api/admin/tokens/{id} |
Delete a token and end its sessions |
The create body takes label, allowed_chat_refs, no_download and expires_at as ISO 8601. allowed_chat_refs is required and must be a non-empty list: a share token always has a scope.
curl -s -b jar.txt -H 'Content-Type: application/json' \
-d '{"label":"family","allowed_chat_refs":["<ref1>"],"no_download":true,"expires_at":"2026-12-31T23:59:59Z"}' \
http://localhost:8000/api/admin/tokens
{"id": 5, "label": "family", "token": "<64 hex characters>", "allowed_chat_refs": ["<ref1>"], "no_download": 1, "expires_at": "2026-12-31T23:59:59", "created_at": "2026-09-28T10:00:00"}
The token is shown once
Only the create response contains the plaintext token. The viewer stores a hash. Save it now, or revoke the token and create another.
Build a share link as https://<viewer address>/#token=<token>.
End sessions¶
There is no route that lists or ends sessions by id. These calls end sessions:
| Call | Ends |
|---|---|
POST /api/logout |
The caller's own session |
PUT /api/admin/viewers/{id} |
Every session of that viewer, with its sockets and push subscriptions |
DELETE /api/admin/viewers/{id} |
The same, and removes the account |
PUT /api/admin/tokens/{id} changing is_revoked, allowed_chat_refs or no_download |
Every session opened with that token, with its sockets and push subscriptions |
DELETE /api/admin/tokens/{id} |
The same, and removes the token |
POST /api/admin/sessions/end-all |
Every session: the master login's, every viewer account's and every share token's, with their sockets and push subscriptions. keep_current=true, in the query string or as the JSON body {"keep_current": true}, keeps the caller's own session. Answers {success, ended, current_session_ended} and clears the cookie and sends Clear-Site-Data: "cache" when the caller's session ended. |
Lock a viewer out without deleting the account:
curl -s -b jar.txt -X PUT -H 'Content-Type: application/json' \
-d '{"is_active":false}' http://localhost:8000/api/admin/viewers/3
Revoke a share token:
curl -s -b jar.txt -X PUT -H 'Content-Type: application/json' \
-d '{"is_revoked":true}' http://localhost:8000/api/admin/tokens/5
End every session but your own, for example after changing VIEWER_PASSWORD:
Only POST /api/admin/sessions/end-all ends a session of the master login. Changing VIEWER_PASSWORD does not. A second viewer on the same database drops the ended sessions within 60 seconds. See A second viewer.
Audit log and settings¶
| Method and path | Parameters | Response |
|---|---|---|
GET /api/admin/audit |
limit default 100, up to 500. offset. username. action. |
{logs, limit, offset} |
GET /api/admin/settings |
None | {settings} |
PUT /api/admin/settings/{key} |
Body {"value": ...} |
{key, value}. The value is stored as a string. |
Internal endpoints¶
These routes serve other parts of the system. Clients should not call them.
| Route | Caller | Rules |
|---|---|---|
POST /internal/push |
The backup container, in SQLite mode | Accepts only loopback and private source addresses. The viewer takes its secret from INTERNAL_PUSH_SECRET. In SQLite mode it falls back to the .push-secret file it creates next to the database. When a secret exists, every caller sends Authorization: Bearer <secret>. When none exists, only loopback callers get in; others get 403. PostgreSQL uses database notifications instead. |
POST /api/transcriptions/callback |
Your transcription server | Exists only when transcription is on and TRANSCRIPTION_WEBHOOK_SECRET holds a whsec_ secret. Checks Standard Webhooks signatures, rejects bodies over 256 KiB and answers 204 to every genuine delivery. Not in the OpenAPI document. |
Keep internal routes off the internet
Block /internal/ at your reverse proxy. Expose the transcription callback only if your transcription server reaches the viewer through the proxy. Exposing the viewer safely has an nginx example.
Errors and headers¶
Errors come back as {"detail": "..."}.
| Status | Meaning |
|---|---|
| 400 | Bad JSON, a missing field or an invalid parameter |
| 401 | Not logged in, or wrong credentials or token |
| 403 | Master only, X-Viewer-Only set, or downloads disabled for this login |
| 404 | Not found, including chats the caller may not see |
| 409 | A name clash, or a transcript that cannot be made |
| 413 | A transcription callback body over 256 KiB |
| 415 | /media/open on a type the viewer does not show inline |
| 429 | Login rate limit, or a transcript ask limit. Comes with Retry-After on transcript asks |
| 500 | Any other failure. The detail is always Internal server error |
| 503 | Database unreachable, or no login mode configured. POST /auth/token answers 500 with Database not available instead |
CORS_ORIGINS defaults to * with credentials off, so a browser page on another origin cannot send the cookie. List explicit origins to turn credentials on for them. Every response carries X-Content-Type-Options: nosniff, X-Frame-Options: SAMEORIGIN, Referrer-Policy: strict-origin-when-cross-origin and a same-origin Content-Security-Policy.