Skip to content
downpipes docs

Engine HTTP API overview and conventions

The engine is the in-account Cloudflare Worker that writes your backups. This page describes the HTTP surface it exposes, the conventions every endpoint follows, and the boundary of what is reachable, before you read the per-endpoint reference. It is for a developer integrating with the engine, an operator reading the audit feed into a SIEM, or anyone who needs to know exactly what the Worker answers and what it does not.

The single fact to hold first is that the engine runs entirely inside your own Cloudflare account. There is no public engine URL by default, and there is no vendor-facing API. The console reaches the engine through a service binding in the same account, and the console is the only intended client of the admin surface. Everything below is reached from your own infrastructure, never from the vendor side.

The path families

The Worker’s fetch entry routes by path prefix into seven top-level families, and nothing else (engine/src/index.ts). A request that matches none of them gets a plain 404.

Path family Purpose CORS Hardening
/admin/* The admin API the console calls: downpipe CRUD, runs, restore, identity, audit, posture, status Allowlisted to CONSOLE_ORIGIN only, except GET /admin/health (see below) Full set, including cache-control: no-store
/support/diagnostics Server-to-server pull of the signed and (where configured) sealed support bundle, under a minted pull credential None Base set (content-type, framing, referrer)
/support/audit-feed Server-to-server pull of the hash-chained audit feed for your own SIEM collector, under a separate scoped credential None Base set
GET /metrics Server-to-server Prometheus scrape, under its own minted, bearer-credentialed pull credential None Base set
GET / A static, unauthenticated banner string None Base set
GET /ready An unauthenticated readiness probe returning {status, service, version} JSON; it never reads account state None Base set
/scim/v2/* The SCIM 2.0 deprovision-only facade an IdP connector calls to offboard a leaver, under its own SCIM_BEARER_TOKEN; it deprovisions only and never provisions or reads users None Base set

The /scim/v2 facade is covered in full by SCIM and deprovisioning. GET /metrics is covered by the metrics endpoint.

GET /admin/health is the one deliberate exemption from the CONSOLE_ORIGIN allowlist inside /admin/*: it answers before the authorisation gate, reads no request state and returns only the constant {ok:true, service:"downpipe-engine"}, with a wildcard access-control-allow-origin and no credentials header, so a browser blocked by a misconfigured CONSOLE_ORIGIN can still tell a genuinely dead engine from a CORS misconfiguration.

Everything that is not one of those seven is a catch-all 404 returning the body not found. The /admin API has no DELETE, PUT or PATCH, by design: every admin mutation is a POST. The separate /scim/v2 deprovision facade is the only surface that handles DELETE or PATCH, and only to offboard a leaver (see conventions below). The admin family forwards almost every route inward to a per-account scheduler Durable Object, and those internal Durable Object paths are not part of the public surface and are not documented as endpoints you call.

The /support/* pair and GET /metrics are the inbound surfaces that are not the console. Each is a credentialed, read-only pull: vendor support fetches the diagnostic bundle during a live ticket, your own collector polls the audit feed, and your own monitoring scrapes metrics. All three present a minted bearer credential scoped to exactly that one read, with no admin route reachable from any of them. See the support and audit-feed endpoints, the metrics endpoint and pull credentials.

How the console reaches the engine

There is no public engine hostname in the default deployment. The console is a separate Worker in the same account, and it talks to the engine over an in-account binding, so the admin API never needs a public route to be useful. When this documentation shows a request against console.example.com, that is the console’s own custom domain, which then proxies inward to the engine. Substitute your own console domain.

Because the call crosses an origin boundary from the browser’s point of view (the console SPA on one origin calling the admin API), the engine emits CORS headers, but it allowlists exactly one origin. corsHeaders returns the access-control headers only when the request’s Origin header is an exact match for the configured CONSOLE_ORIGIN; there is no wildcard, and any other origin receives no CORS grant at all (engine/src/index.ts). The allowed methods are GET, POST, OPTIONS, which is the whole admin verb set. A browser preflight OPTIONS is answered with a 204 carrying the CORS and hardening headers.

The admin family also carries a standard set of hardening headers on every response, including the error responses and the preflight. The headers are applied uniformly by copying each response into a fresh one, so a route that forwards the Durable Object’s reply still ends up hardened (SECURITY_HEADERS, engine/src/index.ts).

Header Value Why
strict-transport-security max-age=63072000; includeSubDomains Transport safety on the admin host
x-content-type-options nosniff No content-type sniffing
referrer-policy no-referrer No referrer leakage from an authenticated page
x-frame-options DENY No framing of an authenticated surface
cache-control no-store Every admin response is authenticated and may carry account state, so nothing is cached
pragma no-cache Pairs with no-store for older intermediaries

The non-admin responses (the /support/* pulls, the root banner, and the 404) carry the base subset: x-content-type-options, referrer-policy and x-frame-options. They are unauthenticated and hold no account state, so they do not need no-store or the HSTS header, but the sniffing, framing and referrer guards still apply to every byte the Worker emits (BASE_SECURITY_HEADERS, engine/src/index.ts).

Conventions every route follows

A handful of conventions hold across the admin surface, so you can read any single endpoint against the same expectations.

Every mutation is a POST. On the /admin API there is no DELETE, PUT or PATCH. A read is a GET; a change of any kind, including a deletion such as removing a downpipe or a role mapping, is a POST to a named route (for example POST /admin/downpipes to add or update, and a .../delete route to remove). Do not write client code that issues DELETE or PUT against the admin API; no admin route answers those verbs.

Reads are open to any authenticated role; writes are gated by capability. A GET is permitted for any caller who authenticated at all. A mutating route first checks a specific capability and refuses with a 403 when the caller’s role does not hold it. The authentication precedence and the full capability matrix are documented in authentication and authorisation.

Bodies and replies are JSON. A mutating route reads a JSON request body and returns a JSON reply. A malformed body is caught at the route boundary and answered as a generic error rather than crashing the Worker.

A read needs only an authenticated session, so a GET is the simplest call to start with. As above, the request goes to your own console’s custom domain, which proxies inward to the engine; substitute your console domain and a session cookie or bootstrap token for $DOWNPIPES_TOKEN.

curl -sS https://console.example.com/admin/downpipes \
  -H "authorization: Bearer $DOWNPIPES_TOKEN"
const res = await fetch("https://console.example.com/admin/downpipes", {
  headers: { authorization: `Bearer ${process.env.DOWNPIPES_TOKEN}` },
});
const downpipes = await res.json(); // the configured downpipes with their state
req, _ := http.NewRequest("GET", "https://console.example.com/admin/downpipes", nil)
req.Header.Set("authorization", "Bearer "+os.Getenv("DOWNPIPES_TOKEN"))
res, err := http.DefaultClient.Do(req)
// res.Body lists the configured downpipes with their state.

This page shows the example once as the template. Adding curl, TypeScript and Go examples to every endpoint and prose API page is tracked as a follow-up; the admin API endpoint catalogue and the restore and recovery guide are the next to carry them.

The error channels

The engine answers a failed request on one of a small, fixed set of channels, and the status code plus the body shape tells the console which kind of failure it was. Distinguishing them on the status code is deliberate: the console shows a sign-in prompt for one and an “your role cannot do this” message for another.

Status Body When What to do
401 Plain text unauthorised No usable credential was presented, or a presented credential did not verify Authenticate, or re-authenticate. See the sign-in flows
403 JSON { "error": "forbidden", "required": "<capability>", "have": "<role>" } The caller authenticated but their role lacks the capability the route requires Use a role that holds the required capability, or have an Owner grant it
403 JSON { "error": "csrf origin check failed" } A cookie-session caller sent a state-changing request whose Origin did not match CONSOLE_ORIGIN Send the request from the console origin (a real browser fetch always does)
429 JSON { "error": "rate limited" } with a Retry-After header in whole seconds The caller exceeded the per-identity anti-automation window on a mutating route Wait the Retry-After interval, then retry
4xx JSON, route-specific, for example { "error": "..." } A bad request: a missing field, an invalid value, or a not-found target Fix the request per the route’s reference
500 JSON { "error": "internal error" } An unexpected fault Retry; the engine logs a coarse error id internally with no detail in the reply

Only the 429 and 500 bodies are RFC 9457 application/problem+json: each carries type, title and status alongside the legacy error field shown above (for example the 500 is { "type": "urn:downpipe:error:internal-error", "title": "Internal error", "status": 500, "error": "internal error", "requestId": "<cid>" }, with the correlation id from the response header always present as an extra member). The 429 also carries advisory RateLimit and RateLimit-Policy headers beside Retry-After. Every other channel, the two 403 bodies and the route-specific 4xx bodies, is plain application/json carrying only the fields shown in the table above, with no type, title or status. The 401 is the only plain-text channel.

The 401 is the only channel that is plain text. Everything else is JSON. The 403 forbidden body names the exact required capability and the caller’s have role, so a client can render a precise message without guessing (Forbidden, engine/src/admin/identity.ts). The 429 carries a Retry-After value rounded up to whole seconds, with a floor of one second, so a caller never retries a hair too early into a still-saturated window (rateLimited, engine/src/admin/router-core.ts).

A worked 403 body:

{
  "error": "forbidden",
  "required": "restore.apply",
  "have": "operator"
}
Field Meaning
error Always the literal forbidden for this channel, so the console discriminates it from a 401 sign-in failure
required The exact capability the route gates on (a capability, not a role rank)
have The caller’s resolved role, so the console can say which role would be needed

Honest provenance: the OpenAPI spec

No OpenAPI or Swagger document ships from the engine itself. The engine’s route surface is the switch over METHOD path cases, split across the admin router’s dispatch hub and the twelve route-group spokes it delegates to (for example router-identity.ts and router-ops.ts), plus the dynamic-path handlers matched ahead of the switch (engine/src/admin/router.ts and the sibling router-*.ts spokes it imports), and the human catalogue is authored from those cases, not from a machine specification the engine emits.

For this documentation site, an OpenAPI spec describing that route surface is committed and served at a static endpoint, and it powers the interactive API explorer here. It is a build-time artefact maintained alongside the docs (the same route catalogue the reference tables are authored from), not a document the engine emits and not a live scan of the running router.

The generated OpenAPI spec proves the docs artefact, not the running engine. Its digest attests that the spec the explorer loads is the spec the docs build produced. It does not connect to a live engine and it does not detect drift between this documentation and the engine you are running. Treat it as a faithful, build-time description of the route surface as documented, and treat the engine’s own source as the final authority when the two could disagree.

Where this fits

The rest of the API reference builds on the surface and conventions above.

Last updated .