Guides
Jobs
Planning a walkthrough, recording one, scanning a website, generating a marketing project and cutting an export all run as background jobs. Every one of them answers the same envelope and is polled the same way.
The 5 states
| state | Means | pollAfterMs |
|---|---|---|
| queued | Accepted and waiting to start. | 5000 |
| running | In progress. | 2500 |
| succeeded | A usable result exists. Read result, then fetch the file with the matching download operation. | null |
| failed | It could not be done. error carries a code. Nothing is charged. | null |
| cancelled | You stopped it. Not a failure: nothing went wrong and the reserved seconds went back whole. An integration reconciling its own records has to be able to tell we could not from you said not to. | null |
The 6 kinds
| kind | Started by | Polled at |
|---|---|---|
walkthrough.storyboard | POST /storyboards | GET /storyboards/{id} |
walkthrough.render | POST /renders or POST /storyboards/{id}/renders | GET /renders/{id} |
marketing.source_scan | POST /marketing/sources or its /refresh | GET /marketing/sources/{id} |
marketing.generation | POST /marketing/generations | GET /marketing/generations/{id} |
marketing.export | POST /marketing/projects/{id}/exports | GET /marketing/exports/{id} |
marketing.source_operation | An operation the scan runs on a source | Reported through the source |
202, Location, and the 200 replay
An operation that starts slow work answers 202 Accepted with a Location header pointing at the resource to poll, and a body of { resource, created } where resource is the envelope. Use the header rather than building the path: a replayed 202 carries the same one.
HTTP/1.1 202 Accepted
Location: /api/v1/renders/3f2b0c1a-9d4e-4a61-b8c2-000000000025
RateLimit-Limit: 20
RateLimit-Remaining: 19
RateLimit-Reset: 60
Cache-Control: private, no-store
{
"resource": {
"id": "3f2b0c1a-9d4e-4a61-b8c2-000000000025",
"kind": "walkthrough.render",
"state": "queued",
"internalStatus": "queued",
"stage": null,
"progress": null,
"error": null,
"result": null,
"cost": {
"secondsReserved": 30,
"secondsCharged": null,
"watermark": false
},
"artifactsExpired": false,
"cancellable": true,
"idempotencyKey": "render-1790012026461",
"requestId": null,
"createdAt": "2026-09-21T11:21:00.000Z",
"startedAt": null,
"finishedAt": null,
"expiresAt": null,
"pollAfterMs": 5000
},
"created": true
}The same request with the same Idempotency-Key answers the same 202 again, with Idempotent-Replayed: true and the same Location. Nothing new is started.
200 instead of 202 means the work was already known and nothing new was started: created is false. It happens on the operations that also remember the key on the job itself, when the 24 hour idempotency claim has expired but the job has not. Treat it exactly like a replay.
The envelope, field by field
| Name | Type | Description |
|---|---|---|
| id | string | The resource id. Also the last segment of Location. |
| kind | string | What sort of work this is. It never changes.one of the six kinds |
| state | string | The only field to branch on. Nothing outside this list is ever answered.queued | running | succeeded | failed | cancelled |
| internalStatus | string | The kind's own status word. Informational; useful in a log, never in a condition. |
| stage | string | null | A coarse sub state where the kind has one: an export's queued, preparing, rendering, mastering or checking; a generation's reading, planning or assembling. Null where the kind has none. |
| progress | object | null | How far the work has got, as last reported. Null until something is recorded, and null for every terminal state, because a finished job is 100 by definition and a failed one has no bar to draw. |
| stage | string | The current stage. For a render: starting, checking, recon, plan, dry_run, narration, recording, editing, encoding, uploading. Show it, do not branch on it. |
| percent | integer | The service's own reading. It never goes backwards. Draw exactly this number: nothing is interpolated, and a client that animates between readings is inventing progress.0 to 100 |
| at | string | null | When it was last updated, ISO 8601. A reading that has stopped moving means the work has, not that the bar is broken. |
| error | object | null | Present only while state is failed. |
| code | string | The job's own failure code, or "unknown" when none was recorded. url_private is the render specific one worth knowing: the target was private and nothing ran. |
| reason | string | null | A longer word where there is one. |
| retryable | boolean | Whether sending the same request again could plausibly work. |
| terminal | boolean | Always true. A failed job does not resume. |
| result | object | null | Present only while state is succeeded. |
| artifacts | array | What was produced, each with a type, a short lived signed url, an expiresAt, bytes and a sha256. Any of the last four may be null when the kind does not record it. Fetch the URL directly; the bytes are never proxied through this API. |
| durationSeconds | number | null | How long the finished video is. Null for a kind that does not produce one. |
| cost | object | What this job held and what it was charged, in whole seconds of video time. |
| secondsReserved | number | Held when the job was queued. 0 for work that spends nothing, such as planning a storyboard or scanning a site. |
| secondsCharged | number | null | Null until it settles. On success it is the finished file's measured length, which can be less than was reserved and the difference goes back. On a failure or a cancel it is 0. |
| watermark | boolean | True when any of the seconds came out of the free bucket, which means the file carries the free tier mark. Free seconds are spent last, so a video that paid time covers whole is never watermarked. |
| artifactsExpired | boolean | The work succeeded and its files have since been deleted at the end of the retention period. The outcome still reads succeeded, because it did; the download is gone. |
| cancellable | boolean | True while the state is not terminal, for every one of the six kinds. It is a truthful answer, not a constant: it means there is an operation that stops this and a settlement that hands the money back. |
| idempotencyKey | string | null | The key this job was created under, when it was created with one. |
| requestId | string | null | The request that created it, where one was kept. |
| createdAt | string | null | When it was queued, ISO 8601. |
| startedAt | string | null | When work started. Null while queued. |
| finishedAt | string | null | When it settled. Null until then. |
| expiresAt | string | null | When its files will be deleted at the end of the retention period. Download before this. |
| pollAfterMs | number | null | How long to wait before asking again: 5000 while queued, 2500 while running, null on every terminal state. Null is the signal to stop polling. |
The envelope is nested under a key named for the resource: storyboard, render, source, generation, export. Beside it the read may carry the resource's own detail, for example a storyboard's beats or a project's revision. The create answer nests it under resource.
Polling
Wait pollAfterMs, ask again, stop on a terminal state. That is the whole loop, and both timings come from the server so they can be tuned without a client change.
BASE=https://api.gogoscreen.com/api/v1
AUTH="Authorization: Bearer $GOGOSCREEN_API_KEY"
# 1. Start the work. Keep the Location header.
location=$(curl -sS -D - -o /tmp/created.json -X POST "$BASE/storyboards" \
-H "$AUTH" -H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{"url":"https://www.rfc-editor.org","hint":"Show how to search for an RFC by its number"}' \
| tr -d '\r' | awk 'tolower($1) == "location:" { print $2 }')
# 2. Poll it, waiting what the answer told you to wait.
while : ; do
body=$(curl -sS "https://api.gogoscreen.com$location" -H "$AUTH")
state=$(echo "$body" | jq -r '.storyboard.state')
wait_ms=$(echo "$body" | jq -r '.storyboard.pollAfterMs // 0')
echo "state: $state"
case "$state" in
succeeded|failed|cancelled) break ;;
esac
sleep "$(echo "$wait_ms / 1000" | bc -l)"
doneA failed job
error.code is the job's own failure code. error.retryable says whether the same request could plausibly work a second time. cost.secondsCharged is 0: nothing is charged for a video you cannot download.
HTTP/1.1 200 OK
{
"storyboard": {
"id": "3714fd13-9e14-42ab-b1f6-537a2b5b13f4",
"kind": "walkthrough.storyboard",
"state": "failed",
"internalStatus": "failed",
"stage": null,
"progress": null,
"error": {
"code": "unknown",
"reason": null,
"retryable": false,
"terminal": true
},
"result": null,
"cost": {
"secondsReserved": 0,
"secondsCharged": null,
"watermark": false
},
"artifactsExpired": false,
"cancellable": false,
"idempotencyKey": "docs-inflight-1",
"requestId": null,
"createdAt": "2026-09-21T16:17:16.000Z",
"startedAt": "2026-09-21T16:17:16.000Z",
"finishedAt": "2026-09-21T16:17:16.000Z",
"expiresAt": "2026-09-28T16:17:16.000Z",
"pollAfterMs": null
},
"title": null,
"summary": null,
"hint": "Show the protocol registries",
"targetOrigin": "https://www.iana.org",
"targetPath": "/",
"appEntryUrl": null,
"hadCredentials": false,
"credentialsHeld": false,
"beats": [],
"stuckSteps": [],
"coverage": { "uncovered": [], "wider": [] },
"lengthFit": null
}Cancelling, and what happens to the seconds
Every kind has a cancel operation. What it does depends on how far the work has got, and each one says so plainly.
| Cancel | Queued | Running | What happens to the money |
|---|---|---|---|
renders.cancel | Cancelled before it starts. | Stops at its next checkpoint. | The reservation is released whole. secondsCharged is 0. |
storyboards.cancel | Cancelled before it starts. | Stops at the next step boundary. | Nothing to refund. Planning holds no video time. |
marketing.exports.cancel | Cancelled before it starts. | Stopped. | The reserved time is handed back whole. A cancel that races a finish never moves the money twice. |
marketing.generations.cancel | Stopped. | A step already under way finishes first. A late cancel is a 200 with the generation unchanged. | Generating holds no video time. |
marketing.sources.cancel | Stopped. | Stopped. The source itself stays: with a published manifest it goes back to succeeded, without one it is failed. | Scanning holds no video time. |
HTTP/1.1 200 OK
RateLimit-Limit: 120
RateLimit-Remaining: 115
RateLimit-Reset: 60
Cache-Control: private, no-store
{
"resource": {
"id": "3f2b0c1a-9d4e-4a61-b8c2-000000000025",
"kind": "walkthrough.render",
"state": "cancelled",
"internalStatus": "cancelled",
"stage": null,
"progress": null,
"error": null,
"result": null,
"cost": {
"secondsReserved": 30,
"secondsCharged": 0,
"watermark": false
},
"artifactsExpired": false,
"cancellable": false,
"idempotencyKey": "render-1790012026461",
"requestId": null,
"createdAt": "2026-09-21T11:21:00.000Z",
"startedAt": "2026-09-21T11:21:00.000Z",
"finishedAt": "2026-09-21T11:21:00.000Z",
"expiresAt": null,
"pollAfterMs": null
},
"released": true,
"removedFromQueue": false
}Cancelling something that has already settled is 409 already_settled and changes nothing. Read the resource to see what it actually did.
HTTP/1.1 409 Conflict
{
"error": {
"code": "already_settled",
"message": "That operation has already finished.",
"details": { "status": "failed" },
"requestId": "bd17a468-a784-400c-98b3-46693bbdffbf"
}
}A cancel is a mutating operation, so it takes an Idempotency-Key like every other one. Sending the same cancel twice under one key replays the first answer rather than producing the 409.
Or stop polling entirely
Register a webhook endpoint and every terminal transition is posted to you with the same envelope as its data. There is no event for queued or running on purpose: an event per stage would be a stream, not a notification, and it would save you no polls at all. Webhooks has the details.