Skip to content

API reference

A developer who integrates with, debugs or changes the HTTP surface of ai-sticker-pack: the kiosk page, the landing page’s lead delivery, the admin panel or a monitoring probe.

  • One FastAPI application (bot/api.py) serves everything: kiosk endpoints, the static kiosk page, /health, POST /leads and the admin panel. Production base URL: https://stickers.eventtalks.uz. Locally: http://localhost:8000.
  • Kiosk endpoints have no authentication. Their only protection is the unguessable session token and the state machine: nothing can be chosen or uploaded before consent is given in the bot.
  • POST /leads requires the shared secret header X-Stickers-Leads-Secret.
  • /admin and /admin/api/* require HTTP Basic; state-changing calls also require the header X-Admin-Action: 1.
  • In maintenance, POST /session, POST /session/{token}/template and POST /session/{token}/photo answer 503 with {"detail": {"code": "maintenance", …}}.
  • All rate limiters live in process memory and reset on restart. The API must run as a single worker process.
  • Bodies are JSON unless stated otherwise. Errors raised by the framework have the shape {"detail": …}; the leads endpoint uses its own shape {"ok": false, "error": …}.
  • <token> is a session token: 16 URL-safe characters generated by the server.
  • Session statuses: idle → linked → consented → template_chosen → photo_taken, plus expired. See State machines.
  • Some error texts are in Russian because the kiosk shows or logs them. They are quoted verbatim below.
  • FastAPI’s generated documentation routes (/docs, /redoc, /openapi.json) are not disabled in code. Routes declared with include_in_schema=False (/, /robots.txt, /kiosk-theme.js, /kiosk/light, /admin, /leads) do not appear there.
Method Path Auth Purpose
POST /session none Create a kiosk session and its deep link
GET /session/{token} none Poll session status
GET /session/{token}/qr.svg none QR code with the deep link
POST /session/{token}/template none Choose an outfit (after consent)
POST /session/{token}/photo none Upload the selfie, queue the job
POST /session/{token}/expire none Kiosk abandons the session
GET /templates none Outfit cards for the kiosk
GET /templates/{id}.png none Card image
GET /branding none Optional footer logos
GET /kiosk-config none Kiosk timers
GET /kiosk-theme.js none Theme bootstrap script
GET /kiosk/light, /kiosk/light/ none Kiosk page in the light theme
GET /kiosk/… none Static kiosk files
GET / none Redirect to /kiosk/
GET /robots.txt none Disallow everything
GET /health none Liveness and version
POST /leads secret header Store a request from the landing page
GET /admin Basic Admin panel page
GET /admin/api/summary Basic Spend, balance, queue, errors
GET /admin/api/guests Basic Guest and job table
GET /admin/api/jobs/{job_id}/cost Basic Cost of one job by stage
GET /admin/api/jobs/{job_id}/anchor Basic Anchor portrait of a job
GET /admin/api/jobs/{job_id}/consistency Basic Consistency summary of a job
GET /admin/api/audit Basic Last 100 admin actions
POST /admin/api/balance Basic + action header Set the real provider balance
POST /admin/api/guests/{telegram_user_id}/grant Basic + action header Allow one more pack
POST /admin/api/guests/{telegram_user_id}/reset Basic + action header Delete a guest’s records
POST /admin/api/jobs/{job_id}/retry Basic + action header Re-queue a failed job

Called when a guest presses the start button. No body.

Success 200:

{ "session_token": "<token>", "deep_link": "https://t.me/<bot_username>?start=<token>" }
Status Body Meaning
503 {"detail": {"code": "maintenance", "message": "Сейчас идёт техобслуживание, загляни чуть позже 🛠"}} Maintenance mode. No session is created.

The kiosk polls this once a second.

{ "status": "consented", "template_id": null, "notice": null }
Field Values
status idle, linked, consented, template_chosen, photo_taken, expired
template_id outfit id or null
notice null, "pack_ready" or "pack_in_progress" — the session was closed by the pack limit and the kiosk shows a friendly screen instead of an error
Status Body Meaning
404 {"detail": "session not found"} Unknown token

Returns image/svg+xml: a QR code of the deep link, error correction level M, quiet zone of 4 modules, generated on the server so the kiosk needs no external library. Header Cache-Control: no-store.

Status Meaning
404 Unknown token

Request:

{ "template_id": "u1" }

Success 200:

{ "status": "template_chosen", "template_id": "u1" }

Allowed from consented and from template_chosen (the guest may change their mind until the photo is taken; the last choice wins).

Checks run in this order:

Status Body Meaning
422 {"detail": "неизвестный шаблон: <id>"} The id is not in the catalogue. Also the framework’s own 422 for a malformed body.
422 {"detail": "шаблон недоступен: <id>"} The id is a hidden placeholder.
503 maintenance body Maintenance mode
404 {"detail": "session not found"} Unknown token
409 {"detail": {"code": "pack_limit", "state": "ready" | "in_progress", "notice": "pack_ready" | "pack_in_progress"}} The guest already has a pack or one is being made. The session is closed with that notice, and the bot sends the guest the link to the existing pack or a “still in progress” message.
409 {"detail": "выбор шаблона сейчас невозможен, статус: <status>"} Wrong state — in particular, no consent yet.

multipart/form-data with one field, file.

Success 200:

{ "job_id": 42, "status": "queued" }

What the server does: reads at most 15 MB, decodes the image, applies the EXIF orientation, converts to RGB, downsizes so the longer side is at most 2048 px, re-encodes as JPEG (quality 92). Re-encoding removes all EXIF metadata, including location and device model. The file is stored under a random name, the session moves to photo_taken, a job is queued, and the response is returned. The “generating…” message to the guest is sent after the response, in a background task.

Status Body Meaning
503 maintenance body Maintenance. The photo is not stored and no job is created.
404 {"detail": "session not found"} Unknown token
409 {"detail": "сессия не готова к фото, статус: <status>"} The session is not in template_chosen. Also returned to the loser of a double tap; its file is deleted immediately.
413 {"detail": "фото слишком большое"} Upload larger than 15 MB
422 {"detail": "не картинка: …"} Not a decodable image
422 {"detail": "слишком маленькое фото: (w, h)"} Shorter side below 256 px
409 {"detail": {"code": "pack_limit", …}} Pack limit hit at the atomic check. The uploaded file is deleted immediately.

Limits: 15 MB per upload; minimum 256 px on the shorter side. There is no rate limit on kiosk endpoints.

Query parameter reason, default kiosk_reset. Accepted values: idle_timeout, qr_timeout, cancel, staff_reset, camera_error, kiosk_reset. Any other value is stored as kiosk_reset.

Always 200:

{ "expired": true }

expired is false when nothing changed: unknown token, or the session was already in photo_taken or expired. A session closed this way does not consume the guest’s one-pack allowance. This endpoint is not blocked by maintenance.

Outfits visible to guests, in catalogue order.

[
{ "id": "m1", "label": "Бирюзовый бомбер", "preview": "/templates/m1.png?v=3f2a9c0d1b7e" }
]

v is the first 12 hex characters of the file’s SHA-256. If the file is missing, preview has no v. Placeholders appear only with SHOW_PLACEHOLDER_TEMPLATES=1.

Returns the card image of a catalogue entry: its preview file if set, otherwise its file. The path always ends in .png, but the content type follows the real file (image/webp for preview cards, image/png for body templates).

Request Response headers
?v= equals the current version Cache-Control: public, max-age=31536000, immutable, ETag: "<version>"
no v, or an old one Cache-Control: public, no-cache, ETag: "<version>" — cache but revalidate
If-None-Match matches (weak or strong, or *) 304 with the same headers, no body
Status Body Meaning
404 {"detail": "template not found"} The id is not in the catalogue, or the file is missing. Files outside the catalogue are never served.
{ "logos": [ { "src": "/kiosk/static/<file>.png", "alt": "<label>" } ] }

Lists only the logo files that actually exist in bot/kiosk/static/ (two fixed slots), so the kiosk never requests a missing image. An empty list is normal.

{ "idle_sec": 90, "idle_prompt_sec": 15 }

From KIOSK_IDLE_SEC and KIOSK_IDLE_PROMPT_SEC.

A tiny synchronous script loaded in the page head. It sets data-theme on the root element before first paint: ?theme=light|dark in the page URL wins, then the /kiosk/light address, then KIOSK_THEME. Content type text/javascript, Cache-Control: no-store.

Path Behaviour
GET / Redirect (307) to /kiosk/
GET /kiosk/… Static files from bot/kiosk/ (HTML mode: /kiosk/ serves index.html)
GET /kiosk/light The same index.html, Cache-Control: no-cache; the theme script picks the light theme from the address
GET /kiosk/light/ Redirect to /kiosk/light, query string preserved (relative asset paths would break with the trailing slash)
GET /robots.txt User-agent: * / Disallow: /

No authentication, no secrets in the answer.

Terminal window
curl -s https://stickers.eventtalks.uz/health

200:

{
"ok": true,
"db": "ok",
"active_jobs": 0,
"maintenance": null,
"version": "b715418",
"render_mode": "figure",
"app_env": "prod"
}
Field Meaning
db The database really answered a query
active_jobs Jobs in queued, processing or delivering
maintenance null, or the source of the pause: "env" (MAINTENANCE_MODE=1), "auto" (after an “out of credit” answer), "admin" (/pause)
version Content of the VERSION file written at deploy (short commit hash); locally the output of git rev-parse --short HEAD; otherwise "unknown"
render_mode STICKER_RENDER_MODE as this process sees it
app_env prod or dev
Status Body Meaning
503 {"ok": false, "db": "error", "version": …, "render_mode": …, "app_env": …} The database does not answer

/health stays 200 during maintenance. It says nothing about the bot process; see Monitoring & alerts.

Source: bot/leads_api.py. Called by the landing page’s server action, never by a browser. The endpoint only validates and stores; the bot publishes the card to the requests group later, so a lead is not lost when the bot is down.

Auth: header X-Stickers-Leads-Secret: <LEADS_SECRET>, compared in constant time.

Terminal window
curl -i -X POST https://stickers.eventtalks.uz/leads \
-H "Content-Type: application/json" \
-H "X-Stickers-Leads-Secret: <LEADS_SECRET>" \
-d '{
"request_id": "3f0c1a52-6e1b-4a8e-9d1f-0b6c2a7e4d10",
"name": "Guest Name",
"telegram": "@example_user",
"phone": "+<digits>",
"date": "mid December",
"guests": "about 200",
"comment": "ТЕСТ",
"locale": "en",
"utm": "utm_source=docs utm_campaign=example",
"submitted_at": "2026-11-13T10:00:00Z",
"client_ip": ""
}'

Put the marker ТЕСТ (or test) into the name or comment of a test lead: only such leads can be deleted completely afterwards (see Bot commands).

Exact rules from bot/services/leads.py::validate. String fields are trimmed first. A field that is not a string counts as empty.

Field Required Rule Error code
request_id yes Must parse as a UUID; stored in canonical lower-case form. The landing page creates it when the form opens, so a retry carries the same id. requestIdInvalid
name yes 2 to 80 characters after trimming nameRequired (shorter than 2), tooLong (longer than 80)
telegram yes Normalised first: a leading https://, http://, www., t.me/ or telegram.me/ prefix is removed, then a trailing /, then a leading @; the result is prefixed with @. Must match ^@[A-Za-z][A-Za-z0-9_]{3,30}[A-Za-z0-9]$: 5–32 characters after @, starts with a letter, does not end with _. telegramInvalid
phone yes Spaces, parentheses and hyphens are removed. Must match ^\+\d{7,15}$. phoneInvalid
date no Free text, at most 60 characters. Stored as event_date. tooLong
guests no Free text, at most 40 characters tooLong
comment no At most 1200 characters tooLong
locale no ru, uz or en; anything else becomes en. Never an error. —
utm no String, cut to 300 characters. Parsed as space-separated key=value pairs; utm_source, utm_medium, utm_campaign are extracted (each cut to 100 characters). —
submitted_at no Cut to 40 characters, stored as is —
client_ip no The visitor’s address as seen by the landing page. A valid IPv4 or IPv6 address is hashed (HMAC-SHA-256 with LEADS_IP_SALT, first 32 hex characters) and only the hash is stored. Anything else is ignored, and the per-visitor limit then does not apply to this lead. —

Empty optional fields are stored as NULL. Lengths are counted in Unicode code points, which is never stricter than the landing page’s UTF-16 count. The rules and codes mirror the landing schema (src/lib/requestSchema.ts); a change must be made on both sides.

Status Body Meaning
201 {"ok": true, "lead": 42, "duplicate": false} Stored as a new lead
200 {"ok": true, "lead": 42, "duplicate": true} The same request_id was already stored: same number, nothing new. Checked before rate limits, so a retry never gets 429.
200 {"ok": true, "lead": 42, "duplicate": true, "repeat": true} Repeat request: the same Telegram handle (case-insensitive) or phone within 24 hours of an existing lead that is not spam and not anonymised. No new lead; the bot updates the existing card and pings the group.
401 {"ok": false, "error": "unauthorized"} Wrong or missing secret
404 {"detail": "Not Found"} The module is off: LEADS_ENABLED=0 or LEADS_SECRET empty. Indistinguishable from a missing route on purpose.
413 {"ok": false, "error": "too_large"} Body larger than 16 KB
422 {"ok": false, "error": "invalid", "errors": {"_": "badJson"}} Body is not a JSON object
422 {"ok": false, "error": "invalid", "errors": {"phone": "phoneInvalid", …}} Field errors, one code per field
429 {"ok": false, "error": "rate_limited", "why": "secret" | "visitor" | "hourly"} + Retry-After See limits below

How the landing page reacts to each status is described in Lead submission.

Limit Value Key Response
Wrong secret 5 failures in 600 s caller IP (the landing server) 429, why: "secret", until the window passes
Per visitor LEADS_IP_LIMIT (default 5) per LEADS_IP_WINDOW_SEC (default 600 s) hash of client_ip 429, why: "visitor"
Total LEADS_HOURLY_LIMIT (default 60) per hour all leads 429, why: "hourly", plus one alert to the admin chat per hour
Body size 16 KB — 413

Counters are sliding windows in process memory. Logs contain the lead number, request_id, locale and utm_source only — never the name, handle or phone.

Source: bot/admin_panel.py.

Mechanism Behaviour
HTTP Basic ADMIN_PANEL_USER / ADMIN_PANEL_PASSWORD, both compared in constant time.
Password not set Every admin route answers 503 (dev only; in prod the API does not start).
No Authorization header 401 with WWW-Authenticate: Basic realm="sticker-admin". Not counted as a failure — this is the browser’s first request.
Wrong credentials 401 after a 0.5 s delay; counted per client IP.
Lockout 5 failures within 10 minutes → that IP gets 429 with Retry-After for 10 minutes. Log markers ADMIN_LOGIN_FAILED, ADMIN_LOGIN_BLOCKED.
CSRF Every POST requires the header X-Admin-Action: 1; without it 403. A browser sends Basic credentials automatically, but another site cannot add a custom header without CORS.
Audit Every POST action is written to audit_log with the login, IP, action and details.
Response headers on /admin and /admin/*, including 401 and 429 Cache-Control: no-store, X-Robots-Tag: noindex, nofollow, noarchive, Referrer-Policy: no-referrer, X-Frame-Options: DENY, and a Content-Security-Policy restricted to 'self' (inline script and style allowed, no external origins, no framing, no form submission).

The panel never returns photos, file paths, the appearance card or debug sheets. Guest names are visible only after login.

The panel itself: one self-contained HTML page (bot/admin/index.html) that calls the endpoints below.

{
"maintenance": null,
"image_rpd": { "used": 0, "limit": 1000, "day_pt": "2026-11-13", "warn": false, "warn_frac": 0.7 },
"job_max_cost_usd": 1.5,
"spent_total_usd": 0.0,
"spent_today_usd": 0.0,
"balance": { "balance": null, "basis": null, "override": null, "spent_since": 0.0, "note": "…" },
"budget_usd": null,
"prices_missing": [],
"packs": { "ready": 0, "in_progress": 0, "failed": 0 },
"queue": { "queued": 0, "processing": 0, "delivering": 0 },
"avg_job_sec": null,
"errors_24h": { "rate_limited": 0, "errors": 0 },
"render_mode": "figure",
"version": "b715418",
"utc_offset_hours": 5.0
}
Field Meaning
maintenance null or {"source": "env" | "auto" | "admin", "reason": …, "since": …}
image_rpd Image requests in the provider’s current day (Pacific time) against IMAGE_RPD_LIMIT
spent_today_usd Estimated spend since local midnight (DISPLAY_UTC_OFFSET_HOURS)
balance.basis "override" (a real balance was typed in; spend since then is subtracted), "budget" (BUDGET_USD minus all spend) or null
prices_missing Names of price variables that are not set; a non-empty list means spend is underestimated

The same data feeds the bot command /admin_stats.

Query: q (search in name, username, Telegram user id, pack name; cut to 100 characters) and status (queued, processing, ready, failed, started, or empty). At most 500 rows.

{
"rows": [
{
"job_id": 42, "telegram_user_id": "<telegram_user_id>", "first_name": "Guest", "last_name": null,
"username": "example_user", "extra_packs_allowed": 0, "status": "ready",
"created_at": "…", "ready_at": "…", "duration_sec": 61, "attempts": 1,
"render_mode": "figure", "error": null, "cost_usd": 0.4,
"pack_name": "…", "pack_link": "https://t.me/addstickers/…", "has_anchor": true
}
]
}

telegram_user_id is an integer in real responses. Statuses are in panel terms: done is shown as ready, delivering as processing, and started marks a guest who began but has no job. error is cut to 200 characters. pack_link is built only from a pack name that passes a strict pattern.

Status Meaning
422 Unknown status value
{
"job_id": 42,
"stages": [ { "stage": "…", "model": "…", "resolution": "…", "calls": 1, "images": 1, "cost_usd": 0.07, "failed": 0 } ],
"total_usd": 0.4
}

Estimates from the usage log, grouped by stage, model and resolution. An unknown job returns an empty list.

image/png: the stylised anchor portrait of a job (a drawn character, not the photo). 404 when the job has no anchor, the file was removed by the TTL, or ADMIN_SHOW_ANCHOR=0.

{
"skin": [ { "sticker": 1, "de_before": 7.2, "de_after": 2.1, "applied": true, "regenerated": false } ],
"qa": {}, "regen": {}, "skin_de_max": 6.0,
"anchor_verify": { "mismatch": [], "retried": false },
"edge_kept": null
}

Colour distances per sticker, QA outliers and regenerations. The values above are illustrative; qa, regen and edge_kept are passed through from the job’s report file as written by the worker. anchor_verify.mismatch lists only check names, not the model’s remarks about a person’s appearance. 404 ({"detail": "нет данных"}) when there is no report or ADMIN_SHOW_ANCHOR=0.

{ "entries": [ { "id": 1, "ts": "…", "actor": "admin", "ip": "…", "action": "grant_extra_pack",
"telegram_user_id": null, "job_id": null, "details": "{…}" } ] }

The last 100 entries, newest first. action is one of grant_extra_pack, reset_user, retry_job, set_balance_override. details is a JSON string.

Body {"value": 12.5} — the real balance from the provider’s billing page, in USD. From then on the panel shows this value minus the estimated spend after that moment.

Status Body Meaning
200 {"ok": true, "balance": { … }} Saved; balance has the shape shown in the summary
422 {"detail": "остаток должен быть от 0 до 1 000 000 USD"} Value outside 0 ≤ value < 1 000 000

POST /admin/api/guests/{telegram_user_id}/grant

Section titled “POST /admin/api/guests/{telegram_user_id}/grant”

Allows the guest one more pack (users.extra_packs_allowed + 1). 200: {"ok": true, "extra_packs_allowed": 1}.

POST /admin/api/guests/{telegram_user_id}/reset

Section titled “POST /admin/api/guests/{telegram_user_id}/reset”

Deletes the guest’s users, sessions and jobs rows after backing up the database.

{ "ok": true, "backup": "app.db.bak-…", "jobs": 1, "sessions": 2, "users": 1, "old_packs": ["https://t.me/addstickers/…"] }

Packs in Telegram are not deleted; their links are returned and kept in the audit entry.

Re-queues a failed job. 200: {"ok": true}.

Status detail Meaning
409 джоб не найден или не в статусе failed Unknown job, or not failed
409 селфи уже удалено (TTL/после пака) — гостю нужно сфотографироваться заново The selfie is gone; the guest must be photographed again
409 лимит паков гостя исчерпан (… из …) — сначала «Разрешить ещё раз» Pack limit; grant first
Status Meaning
401 No or wrong credentials
403 Header X-Admin-Action: 1 missing
429 IP locked out after failed logins
503 ADMIN_PANEL_PASSWORD not set

Maintenance is on when MAINTENANCE_MODE=1, or when a pause is stored in the database (automatically after an “out of credit” answer from the provider, or by /pause).

Endpoint In maintenance
POST /session 503, code maintenance
POST /session/{token}/template 503 (after the 422 template checks)
POST /session/{token}/photo 503; nothing is stored
GET /session/{token}, qr.svg, POST …/expire Work normally
GET /templates, /branding, /kiosk-config, static kiosk Work normally
GET /health 200, maintenance names the source
POST /leads Works normally — leads do not depend on generation
/admin, /admin/api/* Work normally; the summary carries the maintenance state

The 503 body is always:

{ "detail": { "code": "maintenance", "message": "Сейчас идёт техобслуживание, загляни чуть позже 🛠" } }

Last verified: 2026-11-13 against ai-sticker-pack@b715418 and ai-stickers-landing@e350ff5.