Skip to content
downpipes docs

Engine error and status codes

This page is the lookup for the HTTP outcomes the engine admin API returns: the status codes, the shape of each error body, what causes it, and what to do about it. It is for a developer integrating with the engine directly rather than through the console, authored from the response paths in the Worker entry and the admin router (engine/src/index.ts, engine/src/admin/router.ts).

The single thing to hold first is that the content type is part of the contract rather than an accident, and the place it decides an operator’s day is the 401. A plaintext body carrying unauthorised is a genuine authentication failure and the caller should sign in again. A JSON body carrying stepUpRequired at that same 401 status is a gated route asking a still-valid session for a fresh identity proof, and a client that reads it as a lapsed session signs out an operator who was never signed out. A failed capability check is a JSON 403. This page is the HTTP-channel view of the admin surface; for how a caller authenticates and the full role-by-capability matrix, the canonical home is authentication and authorisation, and for the offline reader’s process exit codes (a separate contract) see the CLI exit codes.

The status channels at a glance

The channels below carry the admin API. The /admin/support ingest surface and the SCIM endpoint answer 503 when their own backing store is unavailable, and that is the one status outside this table.

Status Body Content type What it means
200 JSON result application/json The request succeeded. Some routes that report rather than mutate are always 200 with an honest payload, even when the answer is a miss
202 { ownerActionQueued: true, id, status: "pending" } application/json An owner action was recorded for a second owner’s approval and has not been carried out yet. This is a not-yet-acted outcome, never a failure
204 empty none The CORS preflight (OPTIONS) answer. No body
400 { error: "<reason>" } application/json The request was malformed or the input was rejected. The reason names the problem
401 unauthorised text/plain Authentication failed or no usable credential was presented. This is the sign-in-again channel
401 { error: "step-up required", stepUpRequired: true } application/json A step-up gated route wants a fresh identity proof. The session is still valid, so this must never route the caller to a sign-in screen. It is the opening move of a re-verification, covered on step-up re-authentication
403 JSON, three shapes at the auth boundary (below) application/json The caller authenticated but is not allowed to proceed: a capability gate, a cross origin write rejection, or a failed double-submit token check
404 { error: "<reason>" } or not found mixed The resource or route does not exist on this engine
409 { error: "an attended-verification session is already active for this account" } application/json One attended-verification session may be live per account at a time. The body of the refusal carries the existing session’s id so a caller can resume it rather than start a second
429 RFC 9457 problem+json: { type, title, status, error: "rate limited" }, plus a Retry-After header application/problem+json A rate limit was met. Never an authentication failure: a 429 says to slow down, not to sign in again
500 RFC 9457 problem+json: { type, title, status, error: "internal error", requestId } application/problem+json An unhandled fault inside the engine’s own dispatch, not a refusal of anything you sent
501 { error: "passkey_not_configured" } application/json A passkey or step-up ceremony was asked for on an engine with no CONSOLE_ORIGIN set, so there is no origin to bind the ceremony to. A configuration gap, not a credential problem, and retrying never clears it

A request whose method and path match no route at all returns a plain not found with status 404 (engine/src/admin/router.ts). The CORS allowlist is exact: the engine reflects Access-Control-Allow-Origin only for the configured console origin, so a call from any other origin gets no CORS headers back rather than a wildcard (corsHeaders, engine/src/index.ts).

The 401 channel: two bodies that mean opposite things

A 401 is the one status on this surface that does not identify its own meaning. Read the body before you act on it.

The plaintext unauthorised is the authentication failure. It carries no JSON envelope, and the engine returns it from the auth boundary while logging a coarse failure line that never carries a token, an assertion, or an email (logAuthFailure, engine/src/admin/router-core.ts).

The JSON { "error": "step-up required", "stepUpRequired": true } at the same status is not an authentication failure at all (requireStepUp, engine/src/admin/router-core.ts). The caller is authenticated, their session is live, and a step-up gated route is asking for a fresh proof of presence before it acts. The two are covered separately below, because a client that handles them the same way has a bug that only shows up on the sensitive routes.

Status Code When Fix
401 unauthorised No usable credential, or a presented credential did not verify Present a valid credential: a Cloudflare Access assertion, a signed session cookie, or the ADMIN_TOKEN bearer
401 unauthorised A higher-assurance method was present but invalid (a bad Access JWT, or a tampered or expired session cookie) Fix the failing credential. The engine never downgrades a present-but-invalid higher method to a weaker one, so a broken cookie does not silently fall through to the token
401 unauthorised An Access assertion verified but carried no usable email or no stable subject (for example a Cloudflare Access service token) Use a credential that carries a verified email and subject. Only the bare ADMIN_TOKEN is exempt, because it is the email-less break-glass by design

The anti-downgrade rule is the load-bearing property here. Precedence is Access, then the session cookie, then the token, and a present-but-invalid step is rejected outright rather than retried at a lower tier (authorise, engine/src/admin/auth.ts). If you see a 401 with what you believe is a valid token, check that you did not also send an invalid Access header or a stale cookie, because the higher method is consulted first and its failure stops the request.

The JSON 401: step-up required

Status Code When Fix
401 step-up required (with stepUpRequired: true) A first-party cookie session called a step-up gated route without a fresh enough authentication and without a valid single-use step-up token Run the step-up ceremony at POST /admin/stepup/begin and POST /admin/stepup/finish, then re-send the original request carrying the returned token in the x-downpipes-stepup header. Do not sign the caller out

Two properties of this row matter more than the row. The first is that a bare ADMIN_TOKEN caller and a Cloudflare Access caller never see it, because both are exempt from step-up, so an integration built on either of those methods can be written as though this 401 does not exist and will still be correct. The second is that the gate fails closed: if the engine cannot reach the component that answers the freshness question, it returns this same body rather than admitting the request. A client cannot distinguish those two from the wire, which is why the fleet-wide version of the symptom is diagnosed by blast radius rather than by the response, on step-up re-authentication.

The 403 channel: three different rejections

A 403 is always JSON, and at the auth boundary it has three shapes that mean different things. Discriminate them by the error field, because no two of them share a remedy. Two further 403s carry a named dual-control payload and are documented under named JSON payloads rather than here.

The capability gate

When a caller authenticated but their role does not hold the capability a route requires, the engine returns the Forbidden body: { "error": "forbidden", "required": "<capability>", "have": "<role>" } (gate, and the Forbidden interface in engine/src/admin/identity.ts). The required field is the capability the route gates on, and have is the caller’s resolved role, so a client can say “your operator role cannot run a key ceremony” rather than showing a mystery failure.

Status Code When Fix
403 forbidden (with required and have) The caller’s role lacks the required capability for this route Grant the caller a role that holds the named capability, or have someone who already holds it perform the action. The owner-exclusive capabilities (keys.ceremony, posture.riskaccept) cannot be conferred to any other role

For a custom-role caller the have field reports the viewer floor while the real authority is the caller’s resolved capability set, so read the failure as “this caller does not hold required”, not as a literal statement about the floor role (gate, engine/src/admin/router-core.ts).

The cross origin write rejection (CSRF)

A separate 403 guards cookie-borne sessions against a forged cross origin write. A mutating request (anything that is not GET or OPTIONS) authenticated by an ambient session cookie must also carry an Origin that exactly matches the configured console origin; if it does not, the engine returns { "error": "csrf origin check failed" } before reading the body or touching state (engine/src/admin/router.ts).

Status Code When Fix
403 csrf origin check failed A cookie-session mutating request arrived with a missing or mismatched Origin Send the request from the configured console origin. The Access and token methods present an explicit header instead of an ambient cookie and are not subject to this check

The double-submit token rejection

The session-lifecycle and passkey-management routes carry a second, independent CSRF check on top of the Origin check above: a double-submit token. A cookie-borne request to one of those routes must present the x-downpipes-csrf header carrying the same value as its __Host-downpipes_csrf cookie, and one that does not is refused with { "error": "csrf token check failed" } before any rate-limit or storage work (csrfBlock, engine/src/admin/router-account-session.ts). It is a different string from the origin rejection deliberately, because it has a different cause and a different fix.

Status Code When Fix
403 csrf token check failed A cookie-borne request to a session-lifecycle or passkey-management route (sign-out, terminating sessions, listing or revoking credentials) presented no x-downpipes-csrf header, or one that did not match the paired cookie Call GET /admin/whoami on the cookie session first. It returns csrfToken in the body and sets the paired __Host-downpipes_csrf cookie in the same response, so echo the body value in the header on the request. A client that never called whoami, or a browser that blocked or partitioned the cookie, produces this without any cross-site attempt being involved

The way to tell the three apart is the error value, and a client must match all three rather than the first two. A forbidden body means change the caller’s role. A csrf origin check failed body means fix where the request is coming from. A csrf token check failed body means the request is coming from the right place but is not carrying the token that proves it. Only the cookie-borne methods (a passkey session, a native OIDC session, a native SAML session) can hit either CSRF 403; an Access or token caller never does, because their explicit header is not a cross-site-ridable ambient credential.

Named JSON payloads worth knowing

Beyond the bare channels, a few routes return a named JSON body that an integrator should match on. Each is documented here with the exact shape from the router.

restore not approved on an apply

A restore apply (POST /admin/restore with confirm: true) needs two things: the restore.apply capability, and a second authorised identity’s approval bound to the same plan hash, with maker not equal to checker. If the capability passes but no usable approval exists, the engine returns a 403 with { "error": "restore not approved", "planHash": "<hash>" } and records a denied apply (engine/src/admin/router-restore.ts).

Status Code When Fix
403 restore not approved (with planHash) An apply was attempted with the right capability but without a second identity’s approval for that exact plan Have a different authorised identity approve the request at POST /admin/restore/approve with this planHash, then re-submit the apply. A caller cannot approve their own request

The planHash in the body is the engine’s server-recomputed binding for the plan, so it is the value to pass to the approve route. This is a capability gate the console routes to “awaiting approval”, not an error to retry blindly. A changed plan produces a different hash and voids any prior approval, which is why dual control is covered in full on dual control.

prune not approved on a retention prune

Retention pruning has the same request, approve, apply shape as a restore, and the same dual-control gate on the apply. An apply with no usable approval bound to that exact plan is a 403 with { "error": "prune not approved", "downpipeId", "planHash", "mode": "not-approved", "retainedRuns", "supersededRuns" } (engine/src/admin/router-retention-prune.ts).

Status Code When Fix
403 prune not approved (with planHash and mode: "not-approved") A prune apply reached the dual-control gate with no approval armed for that plan Have a different authorised identity approve the plan, then re-submit. Match on this string separately from restore not approved: they are two different routes and a client that matches only the restore one falls through to a generic denial, which reads as a role problem and is not one

The two run counts in the body are the reason to parse this rather than treat it as a bare refusal. They tell the caller what the refused plan would have done, so an approver can be shown the consequence before they approve rather than after.

unknown report kind, unknown framework and unknown channel

A report request for a kind the engine does not recognise is a 404 with { "error": "unknown report kind" }, and an evidence pack asked for a ?framework= the engine does not map is a 404 with { "error": "unknown framework" } (both handleReport, engine/src/admin/router-posture.ts). The webhook routes return { "error": "unknown channel" } with the same 404 status for an unrecognised channel (engine/src/admin/router-ops.ts).

Status Code When Fix
404 unknown report kind GET /admin/reports/:kind named a report kind the engine does not produce Request one of the supported kinds. The kind is validated before any data is gathered, so a typo never produces a partial report
404 unknown framework GET /admin/reports/evidence-pack?framework= named a framework the engine does not map Request a framework the engine maps, or omit the parameter for the all default. Like the kind above this is checked before anything is gathered, so a typo never silently yields an empty pack
404 unknown channel A notification channel route named a channel the engine does not know Use a recognised channel identifier

report could not be rendered

One 500 on this surface is route-specific rather than the generic dispatch fault below, and it is worth knowing because of what it does not mean. A report request with ?format=pdf whose data was gathered and signed successfully but whose PDF render threw returns { "error": "report could not be rendered" } (handleReport, engine/src/admin/router-posture.ts).

Status Code When Fix
500 report could not be rendered The PDF renderer threw on a report that was otherwise assembled and signed Request the same report without ?format=pdf. The JSON body is unaffected, because the failure is in the rendering step alone, so an auditor waiting on the report is not blocked while the render fault is investigated

Do not read this as an unavailable report. The distinction is the whole value of the separate string: the generic internal error says the engine could not answer, and this one says it answered and could not draw the answer.

The 202 owner-action-queued response

Several owner operations are dual-control gated. When the gate is on and no armed approval exists yet, the route records a pending approval and returns 202 with { "ownerActionQueued": true, "id": "<id>", "status": "pending" } (ownerActionQueuedResponse, engine/src/admin/router-core.ts). This is the single most important non-failure to handle correctly.

Status Code When Fix
202 ownerActionQueued (with id) A gated owner action was submitted with dual control on and no second-owner approval armed Treat it as queued, not failed. A second owner approves the recorded action, then the original owner re-submits the same request, and the gate consumes the approval and proceeds

Do not retry a 202 as though it errored. The action has been recorded under id and is waiting for a second owner; the privileged step (for example minting a one-shot token) runs only on the approved re-submit, never on this first call (ownerActionGate, engine/src/admin/router-core.ts).

Rate limiting and the 429

Every 429 the engine returns carries the same body: an RFC 9457 problem+json document (application/problem+json) with type, title, status and an error field that a client matches on (error === "rate limited"), plus a Retry-After header in whole seconds, rounded up so a client never retries a hair early into a still-saturated window, and the advisory IETF RateLimit and RateLimit-Policy headers beside it. One builder produces all of them (rateLimitedResponse, engine/src/admin/router-core.ts), so the shape does not vary by which limiter refused.

There is more than one limiter, and they differ in what they are keyed on and how they behave when their own backing store is unavailable.

Status Code When Fix
429 rate limited (with Retry-After) The caller exceeded the per-caller cap for the fixed window. This is the limiter on the mutating routes: every admin write is a POST, and the GET reads are exempt from this one Back off for the Retry-After seconds, then retry. The bucket is per verified caller, not per source IP, so a shared egress does not pool everyone into one limit
429 rate limited (with Retry-After) The source address exceeded the per-IP cap on the bare ADMIN_TOKEN break-glass compare. This limiter sits inside the auth gate, so unlike the per-caller one it applies to GET as well Back off for the Retry-After seconds. A correct token does not exempt you, because the throttle is checked before the token is compared at all
429 rate limit unavailable (with Retry-After) The per-IP limiter on the unauthenticated sign-in ceremony could not reach its own backing store, and that surface fails closed Retry after the advertised window. This is an engine-side availability fault, not a statement about your credential

Two properties matter for an integrator. First, the per-caller limiter is keyed on the verified caller (an Access caller’s email, or one shared bucket for the bare token) rather than the source IP, so a shared NAT does not lock out legitimate callers; the two per-IP limiters above are keyed the other way, in their own separate namespaces, because they guard credential-guessing surfaces where the caller is not yet established. Second, the per-caller limiter fails open: if its backing store is unavailable the request is admitted rather than denied, because this is an authenticated admin and recovery surface where availability beats strict limiting (rateLimited, engine/src/admin/router-core.ts). The two per-IP limiters fail closed for the opposite reason: a brute-force gate that opens when its counter breaks is not a gate. The windows and caps are constants in the scheduler Durable Object (RATE_LIMIT_WINDOW_MS, RATE_LIMIT_MAX_PER_WINDOW and the per-IP caps beside them, engine/src/sched/scheduler-do.ts); read them there rather than hard-coding a number, since they are an operational tuning, not a wire contract.

A 429 is never a session loss. The break-glass throttle in particular refuses before any credential is compared, so it establishes nothing about the token presented. A client that treats a rate limit as an expired session and signs the operator out is reading it wrongly, which is why the engine answers 429 for it rather than the 401 every genuine authentication failure returns.

Ordering of the gate, the limiter and dual control on an apply

On a restore apply the engine runs the checks in a deliberate order so a security-relevant denial is never masked by a rate limit (engine/src/admin/router.ts). The capability gate runs first, so an unauthorised apply always returns the forbidden 403 and a denied-apply audit entry, even if the caller is also over their rate limit. The per-caller limiter runs next, so an authorised caller hammering the apply surface still meets a 429. The dual-control approval check runs last, before any byte is written, and a missing approval is the restore not approved 403. The approval is consumed only after a successful apply, so a failed apply leaves the approval usable for a retry without a fresh round of dual control.

The 500 channel: an unhandled fault

A 500 is the engine’s own last-resort handler, not a route-specific refusal. Both the admin dispatch and the outer Worker entry run under a single top-level catch, so a route handler that throws unexpectedly lands here rather than escaping the response unlogged (engine/src/index.ts). The body is the same RFC 9457 problem+json shape the 429s use, with error: "internal error" and a requestId that also rides on the response as the x-downpipe-request-id header, so a client and the engine’s own log line can be matched by the one value.

Status Code When Fix
500 internal error (with requestId) An unhandled error inside the engine’s dispatch, not caused by anything identifiable in the request itself Retry once. If it persists, quote the requestId (also the x-downpipe-request-id response header) when raising it, since it is the join key to the engine’s own log line

What this reference does not cover

Two things are deliberately out of scope here. The first is the reader’s process exit codes: the offline downpipe tool returns numeric exit statuses (0 verified, 2 unverified, and so on) that are a different contract from these HTTP codes, documented on the CLI exit codes. The second is the DELETE, PUT and PATCH verbs: the admin API mutates exclusively through POST and reads through GET, so those verbs are not part of the surface and are not documented as error rows.

Where this fits

These are the wire-level outcomes; the pages below give the surrounding model.

Last updated .