# 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 from `GET /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](<https://geiserx.github.io/Telegram-Archive/viewer/access/index.md>) 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:

```
{"success": true, "role": "master", "username": "admin"}
```

`role` is `master` or `viewer`.

```
curl -s -c jar.txt -H 'Content-Type: application/json' \
  -d '{"username":"admin","password":"your-password"}' \
  http://localhost:8000/api/login

curl -s -b jar.txt http://localhost:8000/api/chats
```

```
import requests

s = requests.Session()
r = s.post(
    "http://localhost:8000/api/login",
    json={"username": "admin", "password": "your-password"},
)
r.raise_for_status()
chats = s.get("http://localhost:8000/api/chats").json()
```

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
```

```
{"success": true, "role": "token", "username": "token:family", "no_download": false}
```

`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.

```
curl -s -b jar.txt -X POST http://localhost:8000/api/logout
```

The master can end other people's sessions through the admin routes. See [End sessions](<#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](<#transcripts>). [Exposing the viewer safely](<https://geiserx.github.io/Telegram-Archive/viewer/exposing/index.md>) 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`.

```
curl -s -b jar.txt http://localhost:8000/api/status
```

```
{
  "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](<https://geiserx.github.io/Telegram-Archive/configuration/listener/#mass-operation-protection>) 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`](<https://geiserx.github.io/Telegram-Archive/configuration/schedule/#sync-window>) above 0 it records only those posted inside the window. See [Checking that it runs](<https://geiserx.github.io/Telegram-Archive/configuration/listener/#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](<#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](<#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](<#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](<https://geiserx.github.io/Telegram-Archive/configuration/media/index.md>)) 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>).

### 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](<#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:

```
curl -s -b jar.txt 'http://localhost:8000/api/tags/%23holiday?scope=all&limit=50'
```

`/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`:

```
curl -s -b jar.txt 'http://localhost:8000/api/changes?since=2026-09-28T00:00:00Z&limit=200'
```

## 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](<#message-versions>): `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](<#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](<#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](<https://geiserx.github.io/Telegram-Archive/reference/cli/#export-changed-since>). 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](<#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](<#messages>). |
| `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](<#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](<#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](<#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](<#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](<https://geiserx.github.io/Telegram-Archive/operations/upgrading/#upgrading-to-90>).

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](<https://geiserx.github.io/Telegram-Archive/configuration/transcription/index.md>).

## 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](<https://geiserx.github.io/Telegram-Archive/viewer/live-updates/index.md>).

## 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](<https://geiserx.github.io/Telegram-Archive/viewer/access/index.md>).

### 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"}
```

> [!WARNING]
>
> **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`:

```
curl -s -b jar.txt -X POST "http://localhost:8000/api/admin/sessions/end-all?keep_current=true"
```

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](<https://geiserx.github.io/Telegram-Archive/viewer/access/#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. |

> [!WARNING]
>
> **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](<https://geiserx.github.io/Telegram-Archive/viewer/exposing/index.md>) 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`.
