Idempotency
A network can fail after your request arrives and before the response reaches you. When that happens on a read, you retry and nothing is lost. When it happens on a request that edits a canvas or queues a paid image job, retrying blindly can do the work twice.
idempotencyKey is how you make the second attempt indistinguishable from the
first. Gavana records the key with a fingerprint of what you asked for. Send the
same key with the same request and you get the original outcome back instead of
a second execution.
Where a key is required
Every operation that writes or queues work takes idempotencyKey as a required
body field, 8 to 200 characters.
| Operation | Notes |
|---|---|
POST /canvases/{canvasId}/operations | Required for mutation. Ignored and not consumed when validateOnly is true. |
POST /canvases/{canvasId}/workflows | Creating a private reusable workflow |
POST /images/generate · /images/edit · /images/variations | Paid provider work |
POST /images/import | Reuse the key only for the exact same import |
POST /videos/generate | Paid provider work |
POST /actions/{actionKey}/runs | Deterministic, credit-free, still idempotent |
POST /recipes/{recipeId}/fork | Creates a private Recipe instance; starts no generation |
POST /recipes/{recipeId}/runs | Paid provider work |
POST /canvases does not take one — creating a canvas with an explicit id is
already idempotent on that id, and the operation returns 409 conflict if the id
is taken.
Choosing a key
The key must be caller-stable: derived from the intent, computed once, and reused for every attempt at that same intent.
// Good — the same logical change always produces the same key.
const idempotencyKey = `sync-${jobRunId}-move-hero-node`
// Also good — a hash of the exact change you are about to request.
const idempotencyKey = createHash('sha256')
.update(JSON.stringify({ canvasId, operations }))
.digest('base64url')
.slice(0, 64)
// Wrong — a fresh value per attempt makes the key useless.
const idempotencyKey = crypto.randomUUID() // generated inside the retry loopA random UUID is a perfectly good key if you generate it once, before the first attempt, and persist it across retries. The failure mode is generating it inside the retry — at which point every attempt looks like new work and the mechanism silently does nothing.
Three practical rules:
- New intent, new key. Reusing a key for a different change is an error, not a shortcut (see below). If the user asks for something different, mint a new key.
- Do not put a timestamp in the key. It changes between attempts, which is exactly what you are trying to avoid.
- Keys are scoped, not global. They are namespaced per resource, so the same
key used against two different canvases, or against
/images/generateand/images/edit, refers to two independent records. You do not need globally unique keys — but you do need them stable.
What a replay returns
Canvas operation batches
The server keys the replay on your idempotency key and, separately, fingerprints
the operations array you sent. On a replay it returns HTTP 200 with the
current canvas and the stored result of the original apply:
{
"canvas": { "id": "...", "handle": "canvas:...", "revision": "...", "nodes": [], "connections": [] },
"replayed": true,
"resolvedIds": { "client:hero-image": "node:8f2a…" },
"changedNodeIds": ["node:8f2a…"],
"deletedNodeIds": [],
"changedConnectionIds": [],
"deletedConnectionIds": []
}replayed: true is the flag that tells you this response describes work that had
already happened. resolvedIds maps the clientId values you supplied on
node.create and connection.create operations to the server-assigned handles —
which is why a replay is genuinely useful and not merely harmless: you still
learn the ids of the nodes your first attempt created.
The canvas object in a replay is the canvas as it is now, not as it was
when the batch first applied. If other actors edited it since, you are seeing
their changes too. Only the replayed, resolvedIds, changedNodeIds,
deletedNodeIds, changedConnectionIds, and deletedConnectionIds fields
describe your original batch.
Image, Action, video, and Recipe starts
A replayed start returns the original job or Run record — same run: handle,
same job: alias, same pollUrl — with replayed: true. No second job is
queued and no additional provider credit is spent. Poll the returned handle
exactly as you would have polled the first one.
This is the property that makes a timeout on POST /images/generate recoverable.
You do not know whether the job was queued; you resend the identical request with
the identical key and find out, without risking a duplicate charge.
Reusing a key for different inputs is an error
Gavana fingerprints the request alongside the key. If the key matches but the
fingerprint does not, you get 409 conflict:
{
"error": {
"code": "conflict",
"message": "This idempotency key was already used for a different canvas batch. Use a new key for a new intended change."
}
}The image, Action, and Recipe surfaces enforce the same rule with their own message:
{
"error": {
"code": "conflict",
"message": "This idempotency key was already used for a different image or Action request. Use a new key for new intended work."
}
}Retrying will fail identically. There are exactly two correct responses: resend the original body under that key, or pick a new key for the new intent.
What counts as “different”
For a canvas batch, the fingerprint covers the operations array — canonicalised
so that key order in your JSON objects does not matter, but values do. Changing a
position, a title, a node id, or the order of operations makes it a different
batch. Notably, baseRevision is not part of the fingerprint: the same
operations replayed against a newer revision still count as the same intent.
For an image or Action start, the fingerprint covers the meaningful request intent — operation type, target nodes, requested count, prompt or prompt node, reference handles, model and connection selection, size, quality, and the webhook destination together with a hash of its secret. Change the prompt and it is new work; resend the identical request and it replays.
Idempotency and revisions interact in one surprising way
For POST /canvases/{canvasId}/operations, the server checks for a replay
before it checks baseRevision.
That means a replay succeeds even when your baseRevision has gone stale. If
your first attempt applied, another actor then edited the canvas, and your retry
arrives with the now-outdated baseRevision, you get the replay — not a 409.
This is the correct behaviour: the work you asked for already happened, so
there is nothing to conflict with.
The inverse does not hold. A first attempt with a stale baseRevision is
rejected with a revision conflict, and no key is consumed. So:
409withdetails.currentRevision→ a revision conflict, your batch never ran. Re-read and rebuild. See Revisions.409mentioning the idempotency key → a fingerprint mismatch, and re-reading will not help.
How long a key is remembered
Idempotency records for canvas batches live on the canvas document itself, which gives them a bounded, observable lifetime rather than a wall-clock TTL:
- The most recent 100 keys per canvas are retained for replay detection.
- The most recent 10 detailed results are retained.
Both windows are large enough for any realistic retry, which happens within seconds. They are not a general-purpose deduplication ledger. Two consequences worth knowing:
- After roughly 100 subsequent batches on the same canvas, an old key is no longer recognised and the same request would apply again as new work. Do not rely on a key from last week to protect you today.
- A key that is still within the 100-key window but has fallen out of the
10-result window replays successfully with
replayed: true, but theresolvedIdsand changed-id arrays come back empty. Capture those values from the first successful response rather than planning to recover them from a replay.
Job and Run records have their own retention, described on Jobs and Runs; a key whose job record has expired no longer replays.
Idempotency is not a licence to auto-retry paid work
The mechanism protects you against duplicate work. It does not decide whether work should be attempted again at all.
When an image, video, or Recipe job comes back terminal with a failure object,
that is a completed job that failed — not a lost request. Starting again means
spending credit again, whether you reuse the key or not. Read
failure.retryable, surface the failure and its cost implication, and get
explicit approval in the current turn before starting a new attempt with a new
key.
Related
- Revisions and baseRevision — the other source of
409 - Errors and X-Request-ID — what to retry and what not to
- Apply a revision-safe batch — the full sequence
- Canvases — the generated
POST /canvases/{canvasId}/operationscontract