Documentation menu

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

The three shapes a control-plane response can take
ShapeWhenExample keys
ResourceA successful read or a created object.task, tasks, total, rules, services
RefusalAnything the server refuses: malformed input, a missing token, a foreign row.error plus error_code or code
Mutation resultHandlers that report success as a value rather than a status alone.success, message, settings, effective
A resource: GET /api/tasks/{id}
{
  "task": {
    "id": "6f1c…",
    "owner_user_id": 5094837833,
    "kind": "dlq.replay",
    "status": "RUNNING",
    "attempt": 1,
    "max_attempts": 3,
    "created_at": 1790000000
  }
}
A refusal: 404 on somebody else's task
{
  "error": "not_found",
  "error_code": "not_found"
}
A mutation result: PATCH settings
{
  "success": true,
  "message": "settings updated",
  "settings": { "clean_links": true },
  "effective": {
    "clean_links": { "value": true, "source": "rule" }
  }
}
A rate-limit refusal: 429
{
  "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.

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

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

  3. 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)

  4. 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)

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

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

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

Statuses the control plane returns, and the body each one carries
StatusBodyProduced byWhat to do
200The resource, or {"task": …} with already_existed: true.Any successful read or an idempotent replay.Read the body; nothing else is implied.
201The 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.
409The 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

Headers worth reading on every call
HeaderDirectionMeaning
X-Correlation-IDbothThe request id, echoed on every response and stored with the log entry.
X-Request-IDbothAlias of the same value, so either name works on the way in.
traceparentinAccepted as a correlation source when the caller already has a trace.
X-RateLimit-LimitoutThe budget for the class that evaluated the call.
X-RateLimit-RemainingoutUnits left in the current window.
Retry-AfteroutSeconds to wait — on a 429 and on a fail-closed 503.
X-RateLimit-ResetoutSame value as Retry-After, for clients that read it.
X-RateLimit-Degradedout1 when the in-process fallback enforced the limit.

Pagination and limits

Every bound a client can actually hit
SurfaceBoundBehaviour at the bound
GET /api/taskslimit 1–200, default 50; offset ≥ 0An out-of-range value is clamped before it is narrowed to an int — it cannot wrap.
POST /api/taskspayload and result ≤ 64 KiB each; error text ≤ 8 KiBOver the cap answers 400 payload_too_large, never a truncated write.
POST /api/tasksmax_attempts default 3, maximum 8Clamped server-side; retries back off 5s → 20s → 80s, capped at 5 minutes.
GET /api/logslimit plus a time windowBounded reads only; unbounded history is not a query this API offers.
POST /api/auth/bot-login-linkone live code per user, 60-second cooldown429 with Retry-After instead of invalidating the link already handed out.
Any requestheader 10s, read 60s, write 120s, idle 120sA wedged handler cannot hold a connection, and a pool checkout, forever.

Worked exchanges

Log in, then read your own tasks

200 OK

The token is the only tenant key. Nothing in the second request names a user.

Request

Log in, then read your own tasks
# 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

Response body
{
  "tasks": [
    { "id": "6f1c…", "kind": "session.backup", "status": "SUCCEEDED", "attempt": 1 }
  ],
  "total": 1,
  "limit": 5,
  "offset": 0
}

A request the guard chain refuses

401 Unauthorized

No Bearer header: refused before any handler, any database read or any core call.

Request

A request the guard chain refuses
curl -i https://your-host/api/rules

Response

Response body
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 OK

The second call is answered from the store, not re-executed: 200 with already_existed:true, exactly one job.

Request

A task created twice with the same idempotency key
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

Response body
{
  "task": { "id": "6f1c…", "status": "PENDING", "attempt": 0, "max_attempts": 3 },
  "already_existed": true,
  "id": "6f1c…",
  "status": "PENDING"
}