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/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"
}
}| Rule | Value |
|---|---|
| Header | Idempotency-Key |
| Length | 1 to 128 characters. Longer is validation_failed with details.reason "too_long". |
| Sent twice on one request | Repeated 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 window | 24 hours from the first attempt. |
| Scope | The 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/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"
}
}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"
}
}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 was | Stored? | What a retry gets |
|---|---|---|
| 2xx | Yes | The same answer again, with Idempotent-Replayed: true. |
| 4xx | Yes | The same refusal again. A caller told no must be told no again, not given a second chance against a state that has moved since. |
| 5xx | No, the claim is deleted | A 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/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"
}
}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/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"
}
}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.
| Operation | Spends seconds |
|---|---|
marketing.exports.create | Yes |
renders.create | Yes |
renders.createFromStoryboard | Yes |
assistant.handoffs.create | No |
assistant.messages.create | No |
marketing.generations.create | No |
marketing.sources.create | No |
marketing.sources.refresh | No |
storyboards.create | No |
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.