API authentication¶
XTV-SupportBot uses long-lived API keys with scopes. Keys are
minted from the Telegram bot (there is no HTTP signup / password flow),
stored SHA-256-hashed at rest, and carried as Authorization: Bearer.
Key format¶
xtv_<40 url-safe chars>
- Prefix
xtv_— quick visual identification. - 40 url-safe base64 characters — 240 bits of entropy.
- Total: 44 characters. Small enough for headers, large enough that brute-force is not the attack you should worry about.
Creating a key¶
Open a DM with the bot as an admin and run:
/apikey create <scope> [label]
Example:
/apikey create tickets:read reporting-service
The bot replies once with the plaintext key. After that it exists
only as a SHA-256 hash in the api_keys collection — there is no way
to re-read it. Store it immediately in a secrets manager (or at least
an .env file you can rotate).
Other admin commands:
| Command | Purpose |
|---|---|
/apikey list |
Show every key (label, scopes, last-used, revoked status) |
/apikey revoke <key_id> |
Immediately invalidate a key |
Scopes¶
A key carries a list of scope strings. Routes declare which scope they
need; the dependency resolver raises 401 / 403 with a structured
detail block so the caller knows exactly what was missing.
| Scope | Grants |
|---|---|
tickets:read |
GET /api/v1/tickets… |
tickets:write |
POST /api/v1/tickets/{id}/… (close, assign, tags, priority, notes, bulk-action) |
projects:read |
GET /api/v1/projects… |
projects:write |
POST /api/v1/projects, DELETE /api/v1/projects/{slug} |
users:read |
GET /api/v1/users… (reserved — lands in a later release) |
analytics:read |
GET /api/v1/analytics/summary |
rules:read |
GET /api/v1/rules / /api/v1/rules/{id} |
rules:write |
POST /api/v1/rules, PATCH /api/v1/rules/{id}/enabled, DELETE /api/v1/rules/{id} |
webhooks:write |
GET / POST / DELETE /api/v1/webhooks |
admin:full |
Every scope above — reserve for human admins or trusted services |
Assigning multiple scopes¶
Pass multiple scope strings separated by commas:
/apikey create tickets:read,analytics:read reporting
Philosophy¶
- Narrow keys per consumer. Your CRM sync doesn't need
admin:full;tickets:read,tickets:writeis enough. - Rotate on departure. When an integration is decommissioned, revoke its key rather than "soft-delete" the integration.
- One key per tool. If two services share a key, you can't revoke one without breaking the other.
Using a key¶
Authorization: Bearer xtv_<40-char-secret>
Example:
curl -sS $BASE/api/v1/tickets \
-H "Authorization: Bearer xtv_abcdef..."
Error semantics¶
| Status | Body | Meaning |
|---|---|---|
401 |
{"detail": "missing_bearer"} |
No Authorization header |
401 |
{"detail": "invalid_key"} |
Prefix wrong, key revoked, or hash mismatch |
403 |
{"detail": {"error": "insufficient_scope", "required": "X", "granted": [...]}} |
Key valid but lacks the required scope |
Rotation playbook¶
When a secret leaks or a team member rotates out:
- Mint the replacement first.
/apikey create <scope> label-v2. - Swap in-production config. Update the secret store / env var on every consumer; roll the deploy.
- Verify new key works.
curl $BASE/api/v1/versionwith the new key. - Revoke the old key.
/apikey revoke <old_id>— this is a hard cut-off; there is no grace period by design. - Audit the access log. Check the Mongo
audit_logcollection for any unexpected access using the old key's ID.
Leak response checklist¶
If a key is posted publicly (GitHub push, pastebin, Discord…):
- [ ] Revoke the key immediately with
/apikey revoke <id> - [ ] Rotate related credentials (Mongo, Telegram bot token if exposed together)
- [ ] Pull last 24h of
audit_logfiltered by that key's label - [ ] If write scopes were granted: check
ActionExecutedevents for unexpected close / assign / reply operations - [ ] If
webhooks:writewas granted: list allwebhook_subscriptionsand revoke any the caller didn't create - [ ] Post a short incident note in the admin topic (what was leaked, what was revoked, what was audited — a minute of writing now saves an afternoon of forensics later)
Storage model¶
Stored document shape:
{
"_id": ObjectId,
"hash": "sha256-hex",
"label": "reporting-service",
"scopes": ["tickets:read", "analytics:read"],
"created_by": 123,
"created_at": "2026-04-24T11:00:00Z",
"last_used_at": "2026-04-24T12:15:03Z",
"revoked_at": null
}
last_used_at is bumped best-effort on every successful lookup — it's
the fastest way to spot dead integrations on the next review.