Skip to content
downpipes docs

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. It sets out the request and result shapes and the 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 server-side, in this order. The second gate applies only when an owner has turned on restore approval, which is off by default.

  1. The apply capability

    The caller must hold restore.apply. An apply by a role without it is refused with a 403 and 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 the 403 and the audit entry, never a 429 that would hide a blocked production write.

  2. A dual-control approval bound to the plan, when required

    With restore approval on, 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 403 carrying the plan hash, which the console renders as an awaiting-approval state. With restore approval off, the Durable Object answers that no approval is required, and this gate passes.

  3. A two-phase integrity verify

    Only after the 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.

With restore approval on, 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. With restore approval on, 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>" }
}
FieldTypeRequiredMeaning
runIdstringyesThe sealed run to restore from. A 400 is returned when it is missing or empty.
confirmbooleannoDefaults to false (a dry-run that writes nothing). true is an apply that writes data back, behind the gates above.
targetobjectnoOverrides 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.
includestring[]noPrefix selectors. An empty include means all records; exclude wins over include. Every entry must be a non-empty string: an empty entry, or one that is not a string, is a 400.
excludestring[]noPrefix selectors removed from the set. Same entry rule as include, and an empty entry here is the one that would put the whole run out of scope.
maxRecordsnumbernoCaps a dry-run preview or a partial apply.
recordNamestringnoA 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.
destinationIdstringnoWhich archive destination to read the run back from. Absent means the default destination.
cfConfigobjectnoThe 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: the apply is refused before any write unless the field echoes the target id exactly. The two are not symmetric. An account is refused when the caller-supplied account differs from the archive’s signed origin or the origin cannot be verified; a zone is refused only on a provably different origin zone, and only when the plan writes a zone-scoped surface, so an unreadable origin zone warns on the dry run without refusing. surfaces is an optional allow-list, intersected with the surfaces that carry a write path; omitted resolves to the 60 default in-band surfaces only, so naming a surface explicitly is also how you reach a writer the default leaves out. Either way the resolved list is bound into the plan hash.
mediaRestoreobjectno{ 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, or cannot be proven to match it. The console drives this from a “Media restore (optional)” section on the restore plan form, which takes the token and the account together. The token is never stored, never logged, and never bound into the plan hash.
d1TablesobjectnoRestore 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.
onExistingstringnoFrom engine 0.3.6. What an apply does with a KV key or R2 object that already exists in the target under the same name. "overwrite", the default, replaces it with the archived value. "skip" keeps it: the apply checks each name just before its write, reports a kept name in skipped, and records a failure without writing when the check fails. "skip" is bound into the plan hash; "overwrite" hashes as an absent field. Any other value is refused before a write. A "skip" request has an in-account limit of 160 records, not 200, because the check is one more call per record. See live keys and objects an apply replaces.

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 matching no run record yields an empty plan and a “record not found in run” result, never an empty 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 below
body := 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,
  "plannedAt": "2026-08-09T02:14:07.318Z",
  "applyDeadline": "2026-08-10T02:44:07.318Z",
  "sample": [
    { "name": "user:42", "sourceType": "kv", "binding": "KV_MAIN", "namespace": "9a1f2c4e7b8d4a6f9c0e1b2d3f4a5c6e", "plaintextSize": 84 }
  ],
  "skipped": [
    { "name": "session-secret", "reason": "Secrets Store value, restore out of band: the binding is read-only at runtime, so there is no in-account write path; recover the value from this archive with the offline reader and your break-glass key, then re-create the secret through the Cloudflare API or wrangler" }
  ],
  "configChanges": [
    { "surface": "dns", "summary": "2 records would be updated", "willApply": true }
  ],
  "dependencyWarnings": [
    { "database": "app_db", "table": "orders", "missingParent": "users" }
  ],
  "fidelityWarnings": [
    { "name": "(kv expirations)", "reason": "3 Workers KV keys carry an expiration that has already passed, because this backup is older than the namespace's TTLs. They restore with the value and metadata intact and WITHOUT the expiration, so they will not expire until a new TTL is set. 2 further Workers KV keys carry an expiration that passes within the 24 hours this plan's approval stays valid plus the 30 minutes an apply may still be writing after it starts, so they are counted here rather than dropped later with nothing in this plan naming them. Applying sooner keeps those expirations. The receipt records what was actually dropped." }
  ]
}
FieldTypeMeaning
okbooleanTrue 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.
modestringAlways dry-run for this response.
recordsVerifiednumberRecords whose full chain and plaintext hash verified within the selector and maxRecords window.
isLatestbooleanWhether this run is the latest successful run for its downpipe (a blast-radius cue: restoring an older run is higher impact).
plannedWritesnumberHow many records an apply would write.
bytesnumberThe summed plaintext size of the planned writes, an upper bound.
plannedAtstringThe instant this plan was computed, RFC 3339 UTC to the millisecond. An approval raised against this plan counts its 24 hours from here rather than from the request, so this is the clock a requester and an approver should both be reading. See dual control.
applyDeadlinestringThe last instant at which an apply of an approval anchored to this plan can still be writing: plannedAt plus 24 hours and 30 minutes, being the approval’s own life plus the reservation lease an apply holds while it writes. fidelityWarnings is computed against this instant rather than against plannedAt, so what the plan warns about covers every instant the approval permits a write.
samplearrayUp 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. From engine 0.3.6 a KV or R2 row also carries targetExists: true when a key or object of that name is in the target now, false when it is not. It is absent when the row was not checked or the check failed.
skippedarrayRecords intentionally not written, each with a reason (a selector exclusion, a secrets record that has no runtime write path, or an out-of-band Cloudflare config record). The identity record of a Cloudflare config backup is listed here as informational. From engine 0.3.6 the coverage record is listed the same way.
configChangesarrayPresent 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.
dependencyWarningsarrayPresent 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.
fidelityWarningsarrayPresent only when the apply would write a record at less than full fidelity, known from the signed manifest before anything is written: each entry is a name and a reason. The one case is Workers KV expirations, reported as a single (kv expirations) row. It carries two counts, because they are different decisions for the reader: how many expirations had already passed when the plan was computed, and how many more pass between then and applyDeadline, which applying promptly still saves. Advisory, not a gate: the writes succeed and are counted in plannedWrites, and what was actually dropped is counted again on the receipt after the apply.
overwriteCheckobjectFrom engine 0.3.6. Present when the plan holds a KV or R2 record. The dry run asks the target whether each planned KV key and R2 object exists, without reading or returning a live value. checked counts the names it asked about, existing the names in the target now, absent the names an apply creates, and unknown the names whose check failed. existingSample lists up to 20 existing names. Advisory, not a gate.
onExistingstringFrom engine 0.3.6. The choice this plan was built with: "skip" when the request asked to keep live keys, else "overwrite". Engine 0.3.5 does not send it and ignores a "skip" request, so check for it before you send a "skip" apply.

plannedAt and applyDeadline are present on every plan that actually previewed the run. They are absent only from the refusals that preview nothing, such as a reserved target binding or a posture with no read-back key, where a deadline would be describing an apply that cannot happen. Publishing both is what makes the fidelity warning checkable from outside the engine: take applyDeadline, ask the source’s own expiry rule what it would drop at that instant, and compare that against what the plan disclosed.

The dry-run branch has its own capability, restore.dryrun, and it sits at the viewer floor: every built-in role holds it, and the read floor is folded into every custom role, so in practice any authenticated caller can read the full preview. restore.apply is the capability that actually separates readers from writers. 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": "Secrets Store value, restore out of band: the binding is read-only at runtime, so there is no in-account write path; recover the value from this archive with the offline reader and your break-glass key, then re-create the secret through the Cloudflare API or wrangler" }
  ],
  "configApplied": [
    { "surface": "dns", "applied": 2, "skipped": 0 }
  ]
}
FieldTypeMeaning
modestringAlways applied for this response.
recordsVerifiednumberRecords whose chain and plaintext hash verified in phase one.
recordsRestorednumberRecords actually written back.
bytesRestorednumberThe summed plaintext size actually written.
failuresarrayPer-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.
skippedarrayRecords intentionally not written for a known reason. One kind is a secrets record (Secrets Store bindings are read-only at runtime, so it restores out of band). A data record whose captured value is an incompleteness marker also reaches it: that marker is a sentinel written when the source was only partly available, and it must never be written back as live data. From engine 0.3.6, under onExisting: "skip", a KV key or R2 object the apply kept because a live one of the same name exists also reaches it, with a reason that starts kept the live key or object. 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.
configAppliedarrayPresent 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. 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

When an owner has turned on restore approval, an apply needs a usable approval bound to the exact plan. The request and approve routes build that approval; the apply route consumes it. With restore approval off, the apply needs no approval.

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"
}
FieldTypeRequiredMeaning
runIdstringyesThe run the request is bound to.
reasonstringyesA free-text justification, validated non-empty by the Durable Object and recorded for the trail.
target, include, exclude, maxRecords, recordName, destinationId, cfConfig, mediaRestore, d1Tables, onExistingvariousnoThe same decision fields as a restore request; they define the plan the approval keys on. Send exactly what the apply will send: the hash is computed over all of them, so a field omitted here and supplied at apply time yields a different hash and no matching approval.

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.

The approval’s window is anchored to the dry run rather than to this call, so a dry run for this plan must precede the request. Run POST /admin/restore without confirm first and raise the request from the plan it returns. The dry run is read-only and writes nothing, so this costs one extra call and no risk. Interactive users never meet this, because the console previews before it offers the request.

A request the engine cannot anchor to a recorded dry run is refused 400, and the two refusals name the same remedy:

Conditionerror
No dry run for this plan hash was recordedthis request is not raised against a dry run this engine recorded; run the dry run for this restore and request from the plan it returns
The recorded dry run is older than 24 hours and 30 minutesthe plan this request is raised against is older than this engine will vouch for; run the dry run again and request from the plan it returns

The refusal exists so a request cannot outlive the preview it was read from. An approval authorises writes against a blast radius a human read, and the plan hash carries no instant of its own, so without the recorded anchor the same day-old plan could be re-requested for a fresh window each time.

Where the anchor is present and current, the approval’s expiresAt is 24 hours from that plan’s plannedAt, so a request raised hours after the preview inherits only what is left of the window. Read expiresAt back rather than assuming a full day: the request is still accepted in the half hour between the approval’s own 24 hours and the 24-hour-30-minute refusal above, and an approval minted there is already expired when it is returned.

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" }
FieldTypeRequiredMeaning
planHashstringyesThe 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 window with the dry run it is counted from are documented on dual control.

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.

MethodPathWhat it provesWrites anything?Needs a key?
POST/admin/restore/verifyThe 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/attestThe keyless Tier-0 attestation: signature, completeness and anti-rollback.nono
POST/admin/drillA read-only recovery drill over a run.noyes

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.

From engine 0.3.6 every restore open pins the run log of the destination it reads. That covers the dry run, the apply, the request’s recomputed plan, both proofs and the drill. The pin is the highest run-log index the engine confirmed it appended there. A run log put back to an older, validly signed copy is below that pin, so the open refuses with a freshness reason rather than reading it.

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, 85 carry a write path. 60 of those are the default set that a restore resolves to when surfaces is omitted; the other 25 carry a generated, off-by-default writer and run only when the request names them in surfaces. The remaining 228 have no write path at all and are backup-and-preview only. With a read-scoped token, a dry-run returns a per-surface diff preview in configChanges for the resolved set only; everything outside it appears in skipped with a reason naming its tier-specific guidance (dependency order, or a re-provision checklist), not a diff. With an edit-scoped token, an apply re-applies the resolved 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.

The config restore checks the target account

The console drives the Cloudflare config restore. 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. 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 a 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 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 floor, the boundary beyond which the calendar can no longer show a day’s runs. Choosing a day and a time resolves to one of your retained runs, never an arbitrary instant. 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.

StatusBodyWhenFix
400{ "error": "runId required" }The request body has no runId.Supply the run id you are restoring from.
400{ "error": "exclude contains an empty prefix ..." }An include or exclude entry is the empty string, or is not a string. Checked on /admin/restore and /admin/restore/request before the role gate. On /admin/restore/verify the role gate runs first, so a caller without restore.verify gets a 403 instead.Send the prefix you meant, or send an empty list. Selectors and scope carries the rule.
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:..." }With restore approval on, 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.
429rate-limit responseA 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.
200RestorePlan or RestoreResult with ok: false and a reasonAn 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 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. For what restores in place versus out of band, see what restore can and cannot write back. The full route catalogue is the admin API endpoint catalogue, the route-level view is the API explorer, and unexpected statuses are explained on troubleshooting.

Last updated .