Documentation menu
Getting started
New in v2.0
Feature deep dives
Reference
Responses
Response envelope
What every API response looks like: the three body shapes, the refusal codes, correlation ids, pagination bounds and the guard that produces a 429.
Verified against the checkout built for v2.2.4 · updated 20 Oct 2026
On this page
Responses are ordinary JSON with Content-Type: application/json. There is no wrapper object to unwrap: a successful call returns the resource, and a refusal returns an error object that names itself. That is a deliberate choice — a client can render error to a human and switch on error_code without knowing the endpoint.
Three body shapes
| Shape | When | Example keys |
|---|---|---|
| Resource | A successful read or a created object. | task, tasks, total, rules, services |
| Refusal | Anything the server refuses: malformed input, a missing token, a foreign row. | error plus error_code or code |
| Mutation result | Handlers that report success as a value rather than a status alone. | success, message, settings, effective |
{
"task": {
"id": "6f1c…",
"owner_user_id": 5094837833,
"kind": "dlq.replay",
"status": "RUNNING",
"attempt": 1,
"max_attempts": 3,
"created_at": 1790000000
}
}{
"error": "not_found",
"error_code": "not_found"
}{
"success": true,
"message": "settings updated",
"settings": { "clean_links": true },
"effective": {
"clean_links": { "value": true, "source": "rule" }
}
}{
"error": "rate limit exceeded",
"code": "rate_limited",
"retry_after_seconds": 12,
"limit": 120,
"scope": "u"
}The guard chain
The order is the order the handlers are wrapped in api/main.go, outermost first. It explains almost every refusal a client can meet: a 401 never carries rate-limit headers because the limiter for that class runs after authentication, and a 429 from the shield never reaches the core at all.
CorrelationID
Reads X-Correlation-ID, X-Request-ID or traceparent from the request, or generates one, and echoes it on the response so support can join a client call to its server log.
never refuses
AccessLogger
Records every request — success and refusal — with the verified actor and the correlation id. An unauthenticated attempt is recorded as a security-relevant warning.
never refuses
Per-address shield
The outer limit, keyed on the peer address, evaluated before token validation so a flood of forged tokens never reaches the core.
429 rate_limited · 503 rate_limit_unavailable (fail-closed)
API-key gate
Only installed when ATF_API_KEY is configured on the server. Accepts X-API-Key for service-to-service automation; it sits inside the access log so refused keys stay visible.
401 unauthorized (when configured)
RequireAuth
Validates the Bearer JWT with the core, loads the verified identity, and refuses an account with no Telegram link before any handler can scope by owner 0.
401 authentication required · 401 invalid or expired token · 403 account_not_linked
Rate class
Applies the class budget for the route (auth, read, write, admin), keyed on the verified user id, the address, or both.
429 rate_limited with Retry-After
Handler and core
The handler binds JSON, calls the core over gRPC, and maps a domain refusal onto an HTTP status without inventing a new vocabulary.
400 · 404 · 409 · 422 · 502 · 500
Status codes and bodies
| Status | Body | Produced by | What to do |
|---|---|---|---|
200 | The resource, or {"task": …} with already_existed: true. | Any successful read or an idempotent replay. | Read the body; nothing else is implied. |
201 | The created resource. | POST /api/rules, POST /api/tasks, POST /api/auth/register. | Store the id from the body. |
400 | {"error": "…", "code": "…"} — the code names the refusal. | Malformed JSON, an unknown task kind, an invalid state transition, a payload over 64 KiB. | Fix the request; the error text is meant to be shown to a human. |
401 | {"error": "authentication required"} or {"error": "invalid or expired token"}. | No Authorization: Bearer header, or a token the core rejected. | Log in again — tokens last 7 days. |
403 | {"error": "admin access required"} or {"error": "…", "code": "account_not_linked"}. | The administrator gate, or an account with no Telegram link. | Link the account from the bot, or use an administrator token. |
404 | {"error": "…"} — deliberately identical for foreign and missing rows. | A row that does not exist, or does not belong to the caller. | Treat it as missing; the API will not confirm that a foreign id exists. |
409 | The current resource with its version. | A stale write raced a newer one. | Re-read, re-apply, retry. |
422 | {"error": "…", "code": "…"} naming the refused field. | Schema or policy validation failed. | Read the field name in the message. |
429 | {"error": "rate limit exceeded", "code": "rate_limited", …} plus Retry-After. | The per-address shield or a route class budget. | Wait retry_after_seconds; do not spin. |
502 | {"error": "core unavailable: …"}. | The Go API could not reach the Python core or the logger. | Check /api/health/services, then atf doctor. |
503 | {"error": "rate limiting is unavailable…", "code": "rate_limit_unavailable"}, or session_not_connected. | A fail-closed limiter lost its store; a session has no live client. | Retry shortly; reconnect the session from the bot. |
Refusals that matter
A foreign row is a missing row
Reads never distinguish “no such task” from “not your task”. The ownership predicate is part of the query, so the server does not learn whether the id exists before it answers. That is what makes id probing useless rather than merely noisy.
An unlinked account is refused, not scoped to zero
A verified account with no Telegram id resolves to user_id = 0. Rather than serving the unattributed bucket, the authentication middleware answers 403 account_not_linked. Onboarding is unaffected: /api/auth/* is mounted outside that middleware, so /auth/me can still explain what is missing.
A missing limiter refuses instead of allowing
The authentication and shield classes fail closed. If their store cannot answer, the request is refused with 503 rate_limit_unavailable — because a brute-force budget that disappears with Redis is not a budget. Ordinary authenticated traffic falls back to the in-process limiter and keeps serving, with X-RateLimit-Degraded: 1 on the response.
A refused action still says why
Dead-letter replay, cancellation and retry all return their refusal body on the wire, with the same error / error_code shape. A client never has to infer failure from an empty 200.
Correlation and pacing
| Header | Direction | Meaning |
|---|---|---|
X-Correlation-ID | both | The request id, echoed on every response and stored with the log entry. |
X-Request-ID | both | Alias of the same value, so either name works on the way in. |
traceparent | in | Accepted as a correlation source when the caller already has a trace. |
X-RateLimit-Limit | out | The budget for the class that evaluated the call. |
X-RateLimit-Remaining | out | Units left in the current window. |
Retry-After | out | Seconds to wait — on a 429 and on a fail-closed 503. |
X-RateLimit-Reset | out | Same value as Retry-After, for clients that read it. |
X-RateLimit-Degraded | out | 1 when the in-process fallback enforced the limit. |
Pagination and limits
| Surface | Bound | Behaviour at the bound |
|---|---|---|
GET /api/tasks | limit 1–200, default 50; offset ≥ 0 | An out-of-range value is clamped before it is narrowed to an int — it cannot wrap. |
POST /api/tasks | payload and result ≤ 64 KiB each; error text ≤ 8 KiB | Over the cap answers 400 payload_too_large, never a truncated write. |
POST /api/tasks | max_attempts default 3, maximum 8 | Clamped server-side; retries back off 5s → 20s → 80s, capped at 5 minutes. |
GET /api/logs | limit plus a time window | Bounded reads only; unbounded history is not a query this API offers. |
POST /api/auth/bot-login-link | one live code per user, 60-second cooldown | 429 with Retry-After instead of invalidating the link already handed out. |
| Any request | header 10s, read 60s, write 120s, idle 120s | A wedged handler cannot hold a connection, and a pool checkout, forever. |
Worked exchanges
Log in, then read your own tasks
200 OKThe token is the only tenant key. Nothing in the second request names a user.
Request
# 1 — exchange credentials
curl -s -X POST https://your-host/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"username":"operator","password":"…"}'
# 2 — read the tasks that token owns
curl -s https://your-host/api/tasks?limit=5 \
-H "Authorization: Bearer $TOKEN"Response
{
"tasks": [
{ "id": "6f1c…", "kind": "session.backup", "status": "SUCCEEDED", "attempt": 1 }
],
"total": 1,
"limit": 5,
"offset": 0
}A request the guard chain refuses
401 UnauthorizedNo Bearer header: refused before any handler, any database read or any core call.
Request
curl -i https://your-host/api/rulesResponse
HTTP/1.1 401 Unauthorized
Content-Type: application/json
X-Correlation-ID: 4b7f…
{"error":"authentication required"}A task created twice with the same idempotency key
200 OKThe second call is answered from the store, not re-executed: 200 with already_existed:true, exactly one job.
Request
curl -s -X POST https://your-host/api/tasks \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"kind":"dlq.replay","payload":{"ids":["a1"]},"idempotency_key":"replay-42"}'Response
{
"task": { "id": "6f1c…", "status": "PENDING", "attempt": 0, "max_attempts": 3 },
"already_existed": true,
"id": "6f1c…",
"status": "PENDING"
}