Cookiebot

Mini App API

The token flow a Telegram Mini App uses, and every endpoint behind it

The Mini App is a page opened inside Telegram. It receives initData — a signed string naming the user who opened it — and exchanges that for a token, then calls the same HTTP API the web console does.

Everything here is live on cb-api. There is no Mini App yet; this is the surface it will be built against.

Base URL is the deployment's API host. Verify tokens against /.well-known/jwks.json, and discover the endpoints below from /.well-known/openid-configuration rather than hard-coding them.

Every endpoint on this page is described in /openapi.json, served by every deployment — request bodies, response shapes and the refusals each one can answer with. Point a client generator at it rather than typing these types out twice. (The Swagger page at /docs is local-only; the document is not.)

The full API reference is generated from that document: every operation with its scopes, parameters, response fields and a copyable request. This page is the narrative; that one is the index.

1. Get a token

POST /oauth2/token
Content-Type: application/json

{
  "grant_type": "urn:cookiebot:params:oauth:grant-type:telegram-miniapp",
  "init_data": "<window.Telegram.WebApp.initData, verbatim>"
}
{
  "access_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6…",
  "token_type": "Bearer",
  "expires_in": 900,
  "refresh_token": "s7Q1…",
  "scope": "groups:read groups:write audit:read"
}

Send initData exactly as Telegram gave it — same order, same encoding. Every field in it is part of what Telegram signed, so a client that reorders, re-encodes or drops one produces a payload that cannot verify.

A form-encoded body works too, for OAuth client libraries that insist on it.

The three grants

grant_typeProofUsed by
urn:cookiebot:params:oauth:grant-type:telegram-miniappinit_datathe Mini App
urn:cookiebot:params:oauth:grant-type:telegram-loginauth_data — the login widget's payloadthe web console
refresh_tokenrefresh_tokenboth

Refreshing

POST /oauth2/token
{ "grant_type": "refresh_token", "refresh_token": "s7Q1…" }

Every refresh rotates. The response carries a new refresh token and the old one is spent. Store the new one and forget the old one — presenting a spent token is treated as theft:

A refresh token that comes back after it was already used revokes every token in its family, logging that session out entirely. There is no way for the server to tell a buggy client from a stolen token, and letting a thief refresh alongside the real user is the worse of the two failures. A Mini App that refreshes from two tabs at once will hit this — refresh in one place.

POST /oauth2/revoke with {"token": "…"} ends a session deliberately. It answers 200 for an unknown token as well (RFC 7009): the caller's goal is that the token cannot be used, and it cannot.

What the access token carries

Claim
subthe Telegram user id
scopespace-separated, from the deployment's CB_MINIAPP_SCOPES
audcookiebot-miniapp by default
typaccess
iss, iat, exp, kidas /login has always minted them

RS256, signed by the keys /.well-known/jwks.json publishes. It expires in fifteen minutes by default — hold it in memory, refresh when it expires.

A token from the older /login endpoint carries no scope claim, and is treated as groups:read only. That is what the web console could do before this API existed; a console that wants to write asks /oauth2/token for a scoped token instead.

2. Find out who you are

GET /me
Authorization: Bearer <access_token>
{
  "user_id": 424243,
  "scopes": ["audit:read", "groups:read", "groups:write"],
  "audience": "cookiebot-miniapp",
  "is_bot_admin": false,
  "groups": [
    { "group_id": -1001234567890, "title": "Furry Chat", "username": null,
      "chat_type": "supergroup", "role": "creator", "anonymous": false }
  ]
}

The list comes from what the bot has seen, not from a live call to Telegram: a promotion appears once the bot has handled an admin-gated command in that group.

is_bot_admin says whether this caller runs the deployment rather than a group — draw the /admin screens from that flag, not from a 403 you had to provoke. It is read from the tenant, so it stays honest even if the token in your hand still carries admin:read from before ownership changed.

3. Read and change a group's settings

Every endpoint below is scoped to one group, and every one of them answers 404 if the caller does not administer it — the same answer an unknown group gets, on purpose.

Scope
GET/groups/{group_id}/configgroups:read
PATCH/groups/{group_id}/configgroups:write
GET PUT/groups/{group_id}/rulesgroups:read / groups:write
GET PUT/groups/{group_id}/welcomegroups:read / groups:write
GET/groups/{group_id}/auditaudit:read
GET/groups/{group_id}/analytics/…groups:read
PATCH /groups/-1001234567890/config
Authorization: Bearer <access_token>

{ "captcha_timeout_seconds": 600, "media_restrict_seconds": 900 }
{
  "group_id": -1001234567890,
  "config": { "captcha_timeout_seconds": 600, "media_restrict_seconds": 900, "…": "…" },
  "changed": ["captcha_timeout_seconds", "media_restrict_seconds"]
}

Only the fields you send change. Send only what the admin actually edited: a form that PATCHes every field will overwrite whatever another admin changed while it was open.

The fields, their meanings and the two most groups get wrong are on Configuring a group. Values outside the bounds the Telegram menu enforces are a 422, and so is a field name the API does not know — a typo silently ignored is a setting an admin believes they changed.

PUT /rules and PUT /welcome take {"body": "…"} and write the same rows /newrules and /newwelcome do. A group that never set one reads back "body": null rather than 404 — not having a welcome message is a normal state, not a missing resource.

4. Read the audit trail

GET /groups/-1001234567890/audit?limit=50
{
  "group_id": -1001234567890,
  "events": [
    {
      "id": "019267f1-…",
      "ts": "2026-08-15T18:22:10.481Z",
      "action": "config.updated",
      "surface": "miniapp",
      "actor_user_id": 424243,
      "actor_kind": "admin",
      "summary": "changed captcha_timeout_seconds",
      "before": { "captcha_timeout_seconds": 300 },
      "after": { "captcha_timeout_seconds": 600 },
      "trace_id": "8f1c…"
    }
  ],
  "next_before": "019267f1-…"
}

Newest first. Pass next_before back as ?before= for the following page; it is null on the last one. ?action= and ?actor_user_id= narrow the list.

Actions today are config.updated, rules.updated and welcome.updated. surface says where the change came from — telegram for the /config menu, /newrules and /newwelcome, miniapp for this API. An anonymous admin produces a row with no actor_user_id: Telegram gives the bot GroupAnonymousBot rather than an account, and a guess would be worse than a gap.

5. If you run the bot

Everything above is scoped to one group. These seven are scoped to the whole deployment, and only its owners can reach them — a tenant owner_id, or CB_OWNER_ID, which is what the owner-only Telegram commands already answer to.

GET/admin/overviewreach, the window's totals and the LLM budget, in one request
GET/admin/analytics/dailyone row per day, summed across every active group
GET/admin/analytics/groupsthe busiest groups in the window
GET/admin/analytics/commandsevery command, and how many groups use each
GET/admin/analytics/llmfleet-wide spend, per provider and model
GET/admin/groupsthe group directory, keyset-paginated on group_id
GET/admin/tenanthow this deployment is configured

All seven take admin:read, and all seven take the same ?start=/?end= window the per-group analytics take.

GET /admin/overview
Authorization: Bearer <access_token>
{
  "start": "2026-07-22", "end": "2026-08-20",
  "tenant_id": "cookiebot", "display_name": "Cookiebot",
  "reach":  { "groups": 128, "groups_left": 9, "members": 41207, "admins": 361 },
  "totals": { "days": 30, "peak_groups": 96, "messages": 1840223, "commands": 51204,
              "captcha_solve_rate": 0.87, "llm_cost_usd": 32.9, "…": "…" },
  "budget": { "monthly_llm_budget_usd": 50.0, "spent_usd": 32.9, "remaining_usd": 17.1 }
}

admin:read is granted, not requested. /oauth2/token adds it to a session only when the subject really is an owner. Asking for it does nothing; a non-owner's token cannot carry it, so it cannot reach these endpoints however the request is edited. It is also not re-evaluated on refresh — an owner removed today keeps the scope until their refresh token expires or the session is revoked.

These refuse with 403, not 404 — the opposite of every group endpoint above, and deliberate. A group endpoint hides behind a 404 because whether a chat id exists is worth hiding. /admin/… is a fixed path in this very document: there is nothing to hide, and a 404 would tell an owner holding a stale token exactly what it tells an outsider.

Two things are deliberately absent. There is no write — every field on /admin/tenant changes how the bot behaves, and a Mini App form one mis-tap from emptying owner_ids would be an outage nobody could undo through the same API. And there is no member data: no message, no member name, no group's rules. An owner who wants a group's settings calls /groups/{id}/config like anybody else.

Errors

StatusMeaning
400An OAuth error body: invalid_request, invalid_grant, unsupported_grant_type
401No token, or one that does not verify. WWW-Authenticate: Bearer
403Your token is missing a scope (the challenge names which) — or, on /admin/…, you do not run this deployment
404The group does not exist, or you do not administer it — deliberately the same answer
422A value or field name the endpoint will not accept

For operators

SettingDefault
CB_MINIAPP_ACCESS_TOKEN_TTL_SECONDS900access-token life
CB_MINIAPP_REFRESH_TOKEN_TTL_SECONDS604800refresh-token life
CB_MINIAPP_INIT_DATA_MAX_AGE_SECONDS86400how old initData may be — on, unlike the widget's window
CB_MINIAPP_AUDIENCEcookiebot-miniappaud on the tokens
CB_MINIAPP_SCOPESgroups:read groups:write audit:readwhat a Mini App session may do
CB_MINIAPP_ADMIN_SCOPESadmin:readadded only for a session whose subject is an owner
CB_OWNER_ID0an owner in addition to the tenant's owner_ids; 0 means none
CB_WEBHUB_ALLOWED_ORIGINS—the Mini App's origin goes here; never *

Refresh tokens live in refresh_tokens as SHA-256 hashes — a database dump is not a set of sessions. Expired rows are dropped by cb_api.sessions.purge_expired; revoked-but-unexpired rows stay, because they are what turns a replay into a detected replay rather than an unknown token.

On this page