Skip to content

Guides

Idempotency

Every operation on this surface that changes something takes an Idempotency-Key, and it is required rather than optional. A dropped answer to a call that started a video must be recoverable without starting a second one.

It is required, not optional

A mutating request over HTTP with no Idempotency-Key header is refused. The check runs before the body is parsed, so a caller who is missing both the header and a field is told about the header alone, and the body errors arrive on the next round trip. That order is deliberate: the promise this header makes is that a retry cannot spend twice, and a caller who never sends one has no such promise and would otherwise not find out until they had fixed everything else.

http
HTTP/1.1 400 Bad Request
RateLimit-Limit: 120
RateLimit-Remaining: 117
RateLimit-Reset: 60
Cache-Control: private, no-store

{
  "error": {
    "code": "idempotency_key_required",
    "message": "This operation needs an Idempotency-Key header: a string of up to 128 characters, unique to this request, so a retry cannot do the work twice.",
    "details": {
      "header": "Idempotency-Key",
      "maxLength": 128
    },
    "requestId": "3f2b0c1a-9d4e-4a61-b8c2-000000000019"
  }
}
POST /templates with no Idempotency-Key. Executed.
RuleValue
HeaderIdempotency-Key
Length1 to 128 characters. Longer is validation_failed with details.reason "too_long".
Sent twice on one requestRepeated headers are joined with a comma, so two keys arrive as one longer string and that joined value is the key. Send the header once.
Replay window24 hours from the first attempt.
ScopeThe credential and the operation. Two keys of one account never collide, and an agent cannot read back the answer the console was given.

Choosing a key

A key stands for one intent, not for one attempt. Mint it before the first send and reuse the same value on every retry of that send. A fresh key on a retry is a second request, and on a spending operation it is a second video.

A random UUID is the right default when the caller already remembers what it sent. When it does not, derive the key from the thing that makes the request unique in your own system, for example render:order-4821 or export:project-99:rev-7. Two different requests must never share one.

# One key for one intent. Mint it before the first attempt and reuse it on
# every retry of that attempt.
KEY=$(uuidgen)

curl -X POST https://api.gogoscreen.com/api/v1/renders \
  -H "Authorization: Bearer $GOGOSCREEN_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $KEY" \
  -d '{
    "url": "https://www.rfc-editor.org",
    "hint": "Show how to search for an RFC by its number",
    "targetSeconds": 30
  }'

What a replay looks like

The first request does the work. The second identical request gets the stored answer back: the same status, the same body, and the same Location on a 202, plus one header that says it was a replay. Nothing new was created and nothing new was charged.

http
HTTP/1.1 201 Created
RateLimit-Limit: 120
RateLimit-Remaining: 96
RateLimit-Reset: 3
Cache-Control: private, no-store

{
  "template": {
    "id": "3f2b0c1a-9d4e-4a61-b8c2-000000000012",
    "name": "Product tour, portrait",
    "description": "Phone view, 60 seconds, English.",
    "isDefault": false,
    "custom": true,
    "limits": {
      "editor": {},
      "executor": {},
      "render": {}
    },
    "voiceId": null,
    "lengthPreset": null,
    "orientationId": null,
    "captureId": null,
    "language": null,
    "background": null,
    "ttsSpeed": null,
    "sourceRenderId": null,
    "updatedAt": "2026-09-21T11:16:00.000Z"
  }
}
First send. 201, and no replay header. Executed.
http
HTTP/1.1 201 Created
Idempotent-Replayed: true
RateLimit-Limit: 120
RateLimit-Remaining: 95
RateLimit-Reset: 3
Cache-Control: private, no-store

{
  "template": {
    "id": "3f2b0c1a-9d4e-4a61-b8c2-000000000012",
    "name": "Product tour, portrait",
    "description": "Phone view, 60 seconds, English.",
    "isDefault": false,
    "custom": true,
    "limits": {
      "editor": {},
      "executor": {},
      "render": {}
    },
    "voiceId": null,
    "lengthPreset": null,
    "orientationId": null,
    "captureId": null,
    "language": null,
    "background": null,
    "ttsSpeed": null,
    "sourceRenderId": null,
    "updatedAt": "2026-09-21T11:16:00.000Z"
  }
}
The same key with the same body. The same 201, the same template id, and one more header. Executed.

Idempotent-Replayed: true is the only way to tell it worked from it worked twice. Location on a replayed 202 is rebuilt by the same function that built it the first time rather than stored, so it can never point at an older shape of the path.

What is stored and what is not

The first answer wasStored?What a retry gets
2xxYesThe same answer again, with Idempotent-Replayed: true.
4xxYesThe same refusal again. A caller told no must be told no again, not given a second chance against a state that has moved since.
5xxNo, the claim is deletedA fresh attempt. A transport failure is the one case where doing it again is what the caller wants, and a stored 500 would make the operation impossible under that key for 24 hours.

409 idempotency_conflict

The key has been seen on this operation before, with a different body. The server compares every later use with the first request exactly, so this is exact rather than heuristic. Nothing is done and nothing is changed.

What to do: use a fresh key. One key stands for one intent, and this one already belongs to a different one. It is a conflict whatever its age: a key reused for different work is a caller bug, and accepting it after a minute had passed would make the key mean nothing.

http
HTTP/1.1 409 Conflict
RateLimit-Limit: 120
RateLimit-Remaining: 94
RateLimit-Reset: 3
Cache-Control: private, no-store

{
  "error": {
    "code": "idempotency_conflict",
    "message": "That idempotency key was already used with a different request.",
    "details": {
      "operation": "templates.create"
    },
    "requestId": "3f2b0c1a-9d4e-4a61-b8c2-000000000013"
  }
}
POST /templates under a key already used for a different name. Executed.

409 idempotency_in_progress

The first request under this key is still running. It happens on a genuine retry that arrives before the first attempt finished, and it happens when two copies of your own code send the same request at the same moment. The claim is atomic, not a read followed by a write, so of any number of simultaneous identical requests exactly one is processed.

What to do: wait Retry-After seconds, which is always 2, and send exactly the same request again with the same key. It will either still be in progress, or it will replay the finished answer. Do not change the body and do not mint a new key: both turn a retry into a second piece of work.

http
HTTP/1.1 409 Conflict
Retry-After: 2

{
  "error": {
    "code": "idempotency_in_progress",
    "message": "That idempotency key is still being processed.",
    "details": { "retryAfterSeconds": 2 },
    "requestId": "1eceeb2c-4178-49bc-afb3-791e4a3e1c78"
  }
}
Executed: four identical POST /templates fired together. One answered 201, three answered this.

A claim cannot be stuck forever. If the first request never answers, the key is released after one minute by default, and the next request with it goes ahead. Of any number of callers arriving at that moment, exactly one takes it.

The operations that also deduplicate on the job itself

The replay store above remembers the answer. 9 operations additionally remember the key on the job they create, so the thing they must not do twice, which is make a second job, is prevented there as well.

OperationSpends seconds
marketing.exports.createYes
renders.createYes
renders.createFromStoryboardYes
assistant.handoffs.createNo
assistant.messages.createNo
marketing.generations.createNo
marketing.sources.createNo
marketing.sources.refreshNo
storyboards.createNo

The two rules agree rather than compete: both use the same key, so they can never disagree about which request a key belongs to. What you see as a caller is that these operations can answer a replay even after the 24 hour window has passed, because the job outlives the replay window. That is the 200 case on an async create: the work was already known, the key was recognised on the job itself, and nothing new was started.

Three of them have a refusal of their own worth knowing. renders.create, renders.createFromStoryboard and storyboards.create answer 400 idempotency_key_unscoped when a key arrives with no account to belong to.

Over MCP

MCP has no headers, so the key is an extra field on the tool arguments called idempotencyKey. It behaves the same way and it is the same store.

On the 3 tools that spend the account's seconds, marketing_exports_create, renders_create and renders_createFromStoryboard, the schema makes it required, and they are the only writing tools whose idempotentHint annotation is true. Every read tool carries the annotation too, because reading twice changes nothing. On every other mutating tool the field is optional and the annotation is false.

Omitting it where it is optional is not free of consequence: the server generates one per call and stores nothing, so every call is new and a retry does the work again. An answer that was replayed comes back with replayed: true in the structured content.