Runs and the interactive loop
https://terrads.com/api/v1Updated September 10, 2026A run is one request to produce a set of finished ads: several concepts,
each rendered natively in every format and language you asked for. It takes
minutes, not seconds, so POST /runs returns immediately and you follow the
run by polling GET /runs/{runId}. There is no streaming endpoint — polling
is the contract.
Lifecycle
| Status | Meaning |
|---|---|
queued | Accepted and charged; waiting to start. |
analyzing | Preparing the brief from your brand and the inputs you sent. |
awaiting_input | Paused. pendingInput tells you what it needs — see below. |
generating | Producing your creative. |
verifying | Final checks before delivery. |
completed | Done. Results are available. |
failed | Stopped on an error; errorMessage says what. Credits for work that did not complete are handled exactly as in the app. |
canceled | Stopped by you, or by us when it could not start. Credits for work that did not complete are handled exactly as in the app. |
completed, failed and canceled are terminal. Everything else means
keep polling.
Polling
curl -s "$BASE/runs/$RUN_ID?after=$LAST_SEQ" -H "Authorization: Bearer $TOKEN"
{
"run": { "id": "…", "status": "awaiting_input", "errorMessage": null, "createdAt": "…", "completedAt": null, "creditCost": 12 },
"pendingInput": { "gate": "questions", "questions": [ … ] },
"events": [ … ]
}
runis the six-field snapshot. It never includes your configuration or the run's internal working state.eventsis the run's log, in order, each with a monotonically increasingseq. Pass?after=<seq>to get only what is newer than the last event you have; without it you get the whole log.pendingInputisnullunless the run is waiting for you. It is derived from the run's entire history rather than from the events in this response, so a client that polls with?after=still sees an open gate it never received thequestionorcopy_reviewevent for.
Poll every few seconds. Faster does not make the run faster and spends your read budget.
Event kinds
kind | Payload | What it tells you |
|---|---|---|
work | itemId, role, label, state | A step started, finished or failed. state is running, done or failed. |
note | itemId, text, tone | Something worth knowing that is not a step — a warning about a reference, for example. |
question | itemId, q | A question was asked. q is the question object described below. |
answer | questionId, value | An answer was recorded — yours, from POST /runs/{runId}/answer. |
copy_review | itemId, drafts | Copy drafts are ready for review. |
copy_approved | kept | Copy was approved; kept lists the concepts that survived. |
result | ad | A creative finished. ad carries its id, concept, formatLabel, ratio, headline, status and generationId — never an image URL. generationId is the id of the matching row in GET /runs/{runId}/results, which is where the image is. |
Any other kind is omitted from the API response.
Gate 1 — questions
When a run is created with smartAsk: true and you attached references, the
analysis may need a decision from you about how to use one of them — keep a
mascot or drop it, match a layout or only borrow its mood. The run pauses in
awaiting_input with:
{
"gate": "questions",
"questions": [
{
"id": "q-…",
"refName": "Summer campaign hero",
"referenceId": "…",
"prompt": "The reference shows a person holding the product. Keep the person?",
"context": "…",
"options": [ { "id": "keep", "label": "Keep the person" }, { "id": "drop", "label": "Product only" } ],
"allowsImage": true
}
]
}
referenceId is the id of the reference asset the question is about; the
API returns the id, not the image. Answer every open question in one
request:
curl -s -X POST "$BASE/runs/$RUN_ID/answer" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{ "answers": [ { "questionId": "q-…", "value": "keep" } ] }'
value is one of:
- an option
idfrom the question; custom:followed by your own answer in plain text, when none of the options fit;asset:followed by the id of one of your uploaded assets, only on questions whereallowsImageistrue— use it to hand the run a photo of your own to use instead.
The response is 200 with { "runId": "…", "status": "generating" }. A
request that leaves a question unanswered is a 400 missing_answers; one
that answers a question the run did not ask, or gives a value it cannot use,
is a 400 invalid_input. Answering a run that is not waiting is a
409 not_awaiting; answering with the wrong endpoint for the gate it is on
(copy review vs questions) is a 409 wrong_gate.
A run created with smartAsk: false never reaches this gate.
Gate 2 — copy review
When a run is created with copyMode: "checkpoint", it pauses after writing
copy and before rendering anything, so you can edit or reject drafts before
they cost renders:
{
"gate": "copy",
"drafts": [
{ "concept": 0, "headline": "…", "body": "…", "cta": "…" },
{ "concept": 1, "headline": "…", "body": "…", "cta": "…" }
]
}
Approve with every draft, edited or not, keyed by its concept index as a
string, plus the indexes you are rejecting:
curl -s -X POST "$BASE/runs/$RUN_ID/approve-copy" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
--data @approve.json
{
"drafts": {
"0": { "headline": "Edited headline", "body": "…", "cta": "Shop now" },
"1": { "headline": "…", "body": "…", "cta": "…" }
},
"rejectedConcepts": [1]
}
Rejected concepts are not rendered and the credits they would have cost are
refunded. Some drafts carry a slots array instead of the three fixed fields
(when a reference's layout defines its own text slots); return slots as you
received them, edited or not. The response is 200 with
{ "runId": "…", "status": "generating" }. The same 409 rules as the
questions gate apply.
copyMode: "auto" skips this gate; copyMode: "manual" supplies the copy
in the create body (copyManual) and also skips it.
Cancelling
curl -s -X POST "$BASE/runs/$RUN_ID/cancel" -H "Authorization: Bearer $TOKEN"
200 with { "runId": "…", "status": "canceled" }. Credits for work that
had not started are handled exactly as when you cancel in the app. A run
that is already terminal answers 409 not_cancelable.
Results
curl -s "$BASE/runs/$RUN_ID/results" -H "Authorization: Bearer $TOKEN"
Returns every finished creative with a short-lived signed imageUrl and the
number of seconds those links stay valid. You may call it at any point in a
run's life — it returns whatever has finished so far, and an empty list while
nothing has — so a run that ended failed still hands back its partial
output. Call it again whenever you need fresh links. The
endpoint reference lists the fields.
Formats and languages
formatIds in the create body names the placements to render, each
natively composed for its own frame rather than cropped from one master:
| Id | Ratio | Placement |
|---|---|---|
feed | 1:1 | Square feed |
post | 4:5 | Portrait feed post |
story | 9:16 | Stories, Reels, vertical video covers |
land | 16:9 | Landscape |
pin | 2:3 | Pins |
languageIds takes ISO 639-1 codes (en, de, fr, …); each concept is
produced in every language listed. concepts is how many distinct ideas to
develop, so the number of creatives is concepts × formats × languages, and
the price scales with it — creditCost on the create response is the total.