Restore and recovery API guide
This guide is the developer-facing deep-dive into the restore family, the most consequential endpoints the engine serves. It is written for an engineer calling the restore API directly, or building a tool on top of it, who needs the exact request and result shapes and the precise order in which the engine gates an apply. The restore route is the one path that writes archived data back into your live account, and the engine does not roll back what it has written, so the preconditions come first.
A restore opens a sealed run read-only, verifies the whole chain and each record’s plaintext hash, and only on an explicit confirm writes the verified plaintext back into live in-account resources. A dry-run is the default and writes nothing. The dual-control mechanics, the maker-is-not-checker rule and the plan-hash binding are owned by dual control; this page describes the API surface and points there for the mechanics rather than re-deriving them.
The apply preconditions, first
POST /admin/restore pivots on a single field, confirm. Omitted or false, it is a dry-run that plans and writes nothing, open to any authenticated role. Set to true, it is an apply that writes data back, and the engine gates it three ways, server-side, in this order.
The apply capability
The caller must hold
restore.apply. An apply by a role without it is refused with a403and recorded as a denied apply. This check runs before the rate limiter on purpose, so an unauthorised apply that is also over its window still gets the403and the audit entry, never a429that would hide a blocked production write.A dual-control approval bound to the plan
The apply additionally needs a separate approval bound to the server-recomputed plan hash, with the approver not equal to the requester on stable identity. The engine asks the Durable Object, read-only, whether a usable approval exists before touching any data. With none, the apply is a
403carrying the plan hash, which the console renders as an awaiting-approval state.A two-phase integrity verify
Only after the two gates pass does the engine verify. In phase one it verifies every in-scope record’s plaintext hash read-only, with nothing written; a single failure aborts the whole apply with zero records restored, so a tampered archive can never half-overwrite a live resource. In phase two it re-verifies each record immediately before writing that record.
So an apply is never a single call. The full sequence is request, approve or reject, then apply, with the approval consumed only after the apply succeeds. The bare shared admin token can neither raise nor approve a request, because it has no stable subject and dual control needs an attributable identity; an apply attempted with it alone returns the same 403 restore not approved.
A restore apply cannot be undone
Once an apply begins writing, there is no rollback. A cancel stops further writes but does not undo records already written. Read the dry-run plan and the blast-radius figures before you arm an apply, and prefer restoring into a fresh empty target.
The request shape
Every restore route takes a RestoreRequest body. The same shape drives the dry-run, the apply, the verify, the attest and the request routes; the route and the confirm flag decide what happens.
{
"runId": "run_01HViewExample",
"confirm": false,
"target": { "binding": "KV_MAIN", "namespaceId": "9a1f2c4e7b8d4a6f9c0e1b2d3f4a5c6e", "bucketName": "my-bucket" },
"include": ["user:"],
"exclude": ["user:test:"],
"maxRecords": 100,
"recordName": "user:42",
"destinationId": "dest_secondary",
"cfConfig": { "token": "<edit-or-read-scoped-cf-token>", "accountId": "<account-id>", "zoneId": "<zone-id>" }
}
| Field | Type | Required | Meaning |
|---|---|---|---|
| runId | string | yes | The sealed run to restore from. A 400 is returned when it is missing or empty. |
| confirm | boolean | no | Defaults to false (a dry-run that writes nothing). true is an apply that writes data back, behind the three gates above. |
| target | object | no | Overrides where records are written. binding is the env binding to write through; namespaceId and bucketName override the recorded names. When omitted, each record restores to the source binding recovered from the manifest. |
| include | string[] | no | Prefix selectors. An empty include means all records; exclude wins over include. |
| exclude | string[] | no | Prefix selectors removed from the set. |
| maxRecords | number | no | Caps a dry-run preview or a partial apply. |
| recordName | string | no | A first-class single-record restore: an exact name match, not a prefix. When set, the restore scopes to exactly the one record whose source name equals it, and every other record is reported as not the selected record. |
| destinationId | string | no | Which archive destination to read the run back from. Absent means the default destination. |
| cfConfig | object | no | The Cloudflare config restore context: { token, accountId, zoneId?, confirmDifferentAccountId?, confirmDifferentZoneId?, surfaces? }. The token is never stored, never logged, and never bound into the plan hash. confirmDifferentAccountId/confirmDifferentZoneId are the confused-deputy guard: when the caller-supplied account or zone differs from the archive’s own signed origin (or the origin cannot be verified), the apply is refused before any write unless the field echoes the target id exactly. surfaces is an optional allow-list that only ever narrows the in-band surface set; omitted means every in-band surface, and either way the resolved list is bound into the plan hash. |
| mediaRestore | object | no | { token, accountId, confirmDifferentAccountId? }. An edit-scoped Cloudflare token plus the account. Additive and create-only, gated on confirm: true. Images restore to their original id; a video gets a new uid (reported as remapped). confirmDifferentAccountId is the same cross-account confirmation as cfConfig’s, required only when the target account differs from the archive’s signed origin. This is an engine-API capability with no console screen in v1. The token is never stored, never logged, and never bound into the plan hash. |
| d1Tables | object | no | Restore a chosen subset of one D1 database’s tables into a fresh database. database names the database and tables the table names, matched case-insensitively to the backup; createOnly: true creates only those tables (a minimal extract) instead of the full schema. It supersedes the prefix selectors, cannot combine with recordName, and is bound into the plan hash. |
recordName and the prefix selectors do not combine: when recordName is set, the more specific single-record intent wins and the include and exclude prefixes are ignored. A recordName that matches no record in the run yields an empty plan and an honest “record not found in run” result, never a vacuous success or a silent whole-run restore. The granular and destination-targeted behaviour is covered on granular and targeted restores.
A dry-run is the safe default, so it is the call to start with. The request goes to your own console’s custom domain, which proxies inward to the engine over an in-account binding; there is no public engine hostname to call. Substitute your console domain and a session cookie or bootstrap token for $DOWNPIPES_TOKEN.
curl -sS https://console.example.com/admin/restore \
-H "authorization: Bearer $DOWNPIPES_TOKEN" \
-H "content-type: application/json" \
-d '{
"runId": "run_01HViewExample",
"confirm": false,
"include": ["user:"],
"maxRecords": 100
}'const res = await fetch("https://console.example.com/admin/restore", {
method: "POST",
headers: {
authorization: `Bearer ${process.env.DOWNPIPES_TOKEN}`,
"content-type": "application/json",
},
body: JSON.stringify({
runId: "run_01HViewExample",
confirm: false, // dry-run: plans and writes nothing
include: ["user:"],
maxRecords: 100,
}),
});
const plan = await res.json(); // a RestorePlan, see belowbody := strings.NewReader(`{"runId":"run_01HViewExample","confirm":false,"include":["user:"],"maxRecords":100}`)
req, _ := http.NewRequest("POST", "https://console.example.com/admin/restore", body)
req.Header.Set("authorization", "Bearer "+os.Getenv("DOWNPIPES_TOKEN"))
req.Header.Set("content-type", "application/json")
res, err := http.DefaultClient.Do(req)
// res.Body is a RestorePlan when confirm is false.Dry-run: the RestorePlan
With confirm omitted or false, POST /admin/restore returns a RestorePlan. It verifies read-only and reports what an apply would do.
{
"ok": true,
"runId": "run_01HViewExample",
"mode": "dry-run",
"recordsVerified": 128,
"isLatest": true,
"plannedWrites": 128,
"bytes": 491520,
"sample": [
{ "name": "user:42", "sourceType": "kv", "binding": "KV_MAIN", "namespace": "9a1f2c4e7b8d4a6f9c0e1b2d3f4a5c6e", "plaintextSize": 84 }
],
"skipped": [
{ "name": "session-secret", "reason": "restore out of band" }
],
"configChanges": [
{ "surface": "dns", "summary": "2 records would be updated", "willApply": true }
],
"dependencyWarnings": [
{ "database": "app_db", "table": "orders", "missingParent": "users" }
]
}
| Field | Type | Meaning |
|---|---|---|
| ok | boolean | True when the plan resolved. A posture that cannot read back (no operational key, a missing run) returns ok: false with a reason, never an error. |
| mode | string | Always dry-run for this response. |
| recordsVerified | number | Records whose full chain and plaintext hash verified within the selector and maxRecords window. |
| isLatest | boolean | Whether this run is the latest successful run for its downpipe (a blast-radius cue: restoring an older run is higher impact). |
| plannedWrites | number | How many records an apply would write. |
| bytes | number | The summed plaintext size of the planned writes, an upper bound. |
| sample | array | Up to fifty preview rows by default (or up to maxRecords rows when maxRecords is set), each with the resolved destination binding and the recovered namespace or bucket. |
| skipped | array | Records intentionally not written, each with an honest reason (a selector exclusion, a secrets record that has no runtime write path, or an out-of-band Cloudflare config surface). |
| configChanges | array | Present only when a cfConfig context was supplied: the per-surface diff preview for the Cloudflare config records an apply would write back, each with whether it would apply any change. |
| dependencyWarnings | array | Present only when a D1 table-subset selection leaves a selected child table’s foreign-key parent out of scope: each entry names the database, the selected table and the missingParent. Advisory, not a gate, since a D1 restore inserts with foreign-key enforcement off. |
The dry-run branch is open to any authenticated role, including a viewer, so anyone can read the full preview; only confirm: true is capability-gated. The blast-radius figures here are the same ones the engine recomputes server-side when a request is raised, so an approver always sees the engine’s view of the run rather than whatever a requester claimed.
Apply: the RestoreResult
With confirm: true, and once the gates pass, POST /admin/restore returns a RestoreResult.
{
"ok": true,
"runId": "run_01HViewExample",
"mode": "applied",
"recordsVerified": 128,
"recordsRestored": 127,
"bytesRestored": 491520,
"isLatest": true,
"failures": [],
"skipped": [
{ "name": "session-secret", "reason": "restore out of band" }
],
"configApplied": [
{ "surface": "dns", "applied": 2, "skipped": 0 }
]
}
| Field | Type | Meaning |
|---|---|---|
| mode | string | Always applied for this response. |
| recordsVerified | number | Records whose chain and plaintext hash verified in phase one. |
| recordsRestored | number | Records actually written back. |
| bytesRestored | number | The summed plaintext size actually written. |
| failures | array | Per-record coarse reasons for records that verified but could not be written, drained without aborting the whole restore. A D1 write fault names the partial-load risk plainly. |
| skipped | array | Records intentionally not written for a known platform reason. Two kinds reach it: a secrets record (Secrets Store bindings are read-only at runtime, so it restores out of band) and a data record whose captured value is an incompleteness marker, a sentinel written when the source was only partly available, which must never be written back as live data. A non-empty skipped list does not set ok: false on its own, because nothing failed. It does mean the apply is not a clean full restore: those records are in the archive and are not in the account. |
| configApplied | array | Present only with a cfConfig context: the per-surface result of re-applying idempotent surfaces to the live account, each with the applied and skipped counts. |
ok reflects FAILURES, not shortfall. Any record that verified but could not be written leaves ok false with the per-record reasons. It says nothing about skipped, which is deliberate, since a skipped record is not a failure. So do not read ok: true as “everything landed”: compare recordsRestored against recordsVerified and read skipped when the two differ. The console does exactly that, reporting such an apply as having records outstanding rather than as a clean restore, and the applied restore’s signed receipt carries summary.recordsSkipped so the same shortfall is visible in the audit chain. A reserved target binding refuses the whole restore before any write, so a confirm run never writes a single byte when a reserved binding is in play. The full account of what restores in place and what is out of band is on what restore can and cannot write back.
When a mediaRestore context is supplied, the dry-run plan additionally carries mediaPlanned (the stream and images media a confirm run would re-upload), each entry { name, type } where type is images or stream. The apply result additionally carries mediaRestored, each entry { name, restoredId, remapped }, where remapped is true for a video that took a new uid on re-upload.
The dual-control flow: request, approve, apply
An apply needs a usable approval bound to the exact plan. The request and approve routes build that approval; the apply route consumes it.
Raise a request
POST /admin/restore/request raises a request bound to the plan hash, with a mandatory reason for the trail. It needs restore.request.
{
"runId": "run_01HViewExample",
"target": { "binding": "KV_MAIN" },
"include": ["user:"],
"reason": "Restoring the user namespace after the 14:00 incident"
}
| Field | Type | Required | Meaning |
|---|---|---|---|
| runId | string | yes | The run the request is bound to. |
| reason | string | yes | A free-text justification, validated non-empty by the Durable Object and recorded for the trail. |
| target, include, exclude, maxRecords, recordName, destinationId, cfConfig | various | no | The same decision fields as a restore request; they define the plan the approval keys on. |
The engine recomputes the plan hash server-side from the submitted request, so the binding the approval keys on is the engine’s, not a value the client chose. The blast-radius cues stored on the request (isLatest, plannedWrites, bytes) are recomputed by running the same dry-run plan with confirm forced off; the client-supplied cues are ignored for what is stored, and a divergence is logged as a misuse signal. The bare admin token cannot raise a request.
Approve or reject
POST /admin/restore/approve lets a different authorised identity approve a pending request for a plan hash. POST /admin/restore/reject rejects one. Both need restore.approve.
{ "planHash": "sha384:5f3c...e0" }
| Field | Type | Required | Meaning |
|---|---|---|---|
| planHash | string | yes | The sha384:-prefixed binding key of the request being approved or rejected. |
The maker-is-not-checker rule compares the stable subject of each person, not their email. A self-approval is refused at approve time, and re-checked at apply time, so the apply gate cannot be satisfied by approving your own request. A reject does not require a distinct identity, because a requester may withdraw their own request. The approval is single-use and expires; the exact binding, the subject comparison and the twenty-four-hour expiry are documented on dual control, which is the canonical home.
Read the inbox
GET /admin/restore/approvals returns the pending-approval inbox. A caller who can approve sees every request; a requester who cannot approve still sees their own, because the Durable Object filters on the stable subject. The inbox shows a lapsed record as expired without mutating storage on a read.
Write-nothing proofs
Three routes prove a sealed archive is recoverable without writing a byte back and without ever surfacing plaintext. The two restorability proofs, POST /admin/restore/verify and POST /admin/restore/attest, sit behind restore.verify, granted from the viewer floor up, because proving recoverability is as safe as a read. The read-only POST /admin/drill instead needs drill.run, held by operator, restore-operator, approver and owner but not by viewer or access-admin, because a drill opens the run and measures recovery time rather than only checking it.
| Method | Path | What it proves | Writes anything? | Needs a key? |
|---|---|---|---|---|
| POST | /admin/restore/verify | The blind restore test: every in-scope record decrypts and its plaintext hash verifies. | no (a discard sink) | yes (the operational read-back key) |
| POST | /admin/restore/attest | The keyless Tier-0 attestation: signature, completeness and anti-rollback. | no | no |
| POST | /admin/drill | A read-only recovery drill over a run. | no | yes |
POST /admin/restore/verify returns a BlindRestoreTest: the records and bytes verified, per-record failures for any record that did not verify, and a restoreDigest. The digest is a sha384:-prefixed hash that stands in for the verified plaintext without the plaintext ever entering it: it folds each verified record’s id together with the per-record plaintext hash, in id order, so the same data restoring yields the same digest, while the digest input is never a plaintext byte. It is null when no record verified. POST /admin/restore/attest returns a KeylessAttestationResult whose ok is the conjunction of the three flags, and it runs even in a break-glass-only posture because it decrypts nothing. The recoverability proofs are covered on prove recoverability.
The blind-test digest is not the offline reader's digest
The blind restore test digest and the Go offline reader’s discard digest are independent recoverability proofs of the same data, not a shared value to compare across the two. Both fold a per-record plaintext hash and never the plaintext, and both are deterministic and content-sensitive, but their constructions differ. Do not assert they are byte-identical.
The Cloudflare config restore context
A restore can carry a cfConfig context to drive a Cloudflare configuration restore. The token is supplied in the request body for the operation only: the engine acts on cfConfig without binding or storing the token, and only the non-secret account and zone are folded into the plan hash. The engine does not persist Cloudflare or deploy credentials.
Of the 313 Cloudflare config surfaces in the registry, 60 auto-restore in-band and 253 are backup-and-preview only. With a read-scoped token, a dry-run returns a per-surface diff preview in configChanges, scoped to the 60 in-band surfaces only; the other 253 appear in skipped with a reason naming their tier-specific guidance (dependency order, or a re-provision checklist), not a diff. With an edit-scoped token, an apply re-applies the in-band surfaces to the live account, additively and diff-driven, after every data record has verified, so a config write never lands before the data set is proven. Ordered and reprovision surfaces stay out of band with tier guidance, and a surface write that throws becomes a per-surface failure rather than a silent drop.
Engine capability, console surface and deployment are separate facts
The Cloudflare config restore is built and console-wired on main, but it is not deployed to the live demo. The account scope is guarded: an apply into an account that is not provably the archive’s recorded origin is refused unless the request echoes the target account id in confirmDifferentAccountId. Treat live use as supervised-first. Describe the engine capability and the console surface as distinct facts. The surface registry and tiers are on the Cloudflare config surface reference, and the restore walkthrough on Cloudflare config backup and restore.
Point-in-time resolution and the restore calendar
GET /admin/runs/at?downpipe=&at=<rfc3339> resolves the latest successful run completed at or before a timestamp, returning the run id and the instant it completed, or an honest miss with the retained-window bounds. The resolved run id then flows through the normal restore path unchanged. The restore screen’s calendar (Browse by date, beside the existing run-id picker) is built on this route: it groups a downpipe’s run history by day and reads this route directly for its own honest floor, the boundary beyond which the calendar can no longer show a day’s runs. Choosing a day and a time still resolves to one of your retained runs, never an arbitrary instant, and recovery freshness is shown as the newest good run either way. The caveat and the reasoning are owned by recovery objectives.
Errors
The restore routes return a small set of shapes. A capability gate is JSON, distinct from the plaintext 401 a sign-in failure returns.
| Status | Body | When | Fix |
|---|---|---|---|
| 400 | { "error": "runId required" } |
The request body has no runId. |
Supply the run id you are restoring from. |
| 403 | { "error": "forbidden", "required": "restore.apply", "have": <role> } |
An apply by a role without the apply capability. | Use a role holding restore.apply, or restore via the dual-control flow. |
| 403 | { "error": "restore not approved", "planHash": "sha384:..." } |
An authorised apply with no usable dual-control approval bound to this plan. | Raise a request and have a distinct approver approve this exact plan hash, then apply. |
| 429 | rate-limit response | A caller already past the per-window cap (the apply role gate runs first, so an unauthorised apply is the 403, never this). |
Retry after the window; the limiter fails open if it is unavailable. |
| 200 | RestorePlan or RestoreResult with ok: false and a reason |
An in-flow failure: a break-glass-only posture with no read-back key, an integrity check, a missing object, a freshness check. | Read the coarse reason; fix the underlying posture or run, then retry. |
A dry-run that cannot be planned, and an apply that fails verification, both return ok: false with a coarse, enumerated reason rather than a 500, so a caller always gets a structured answer. The full status-code catalogue is on the error and status codes reference.
Where this fits
For the canonical dual-control mechanics, the subject comparison, the plan-hash binding and the expiry, read dual control. For the human walkthrough of a real restore, read the restore flow. For granular and destination-targeted restores, see granular and targeted restores, and for what restores in place versus out of band, what restore can and cannot write back. The full route catalogue is the admin API endpoint catalogue, the generated route-level view is the API explorer, and unexpected statuses are explained on troubleshooting.
Last updated .