Runs and the interactive loop
- Base URL
https://terrads.com/api/v1 - Updated October 15, 2026
A 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 (an image run is charged now; a video run when its plan is approved); 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 had not started are handled exactly as in the app. |
completed, failed and canceled are terminal. Everything else means
keep polling. A run that is awaiting_input moves again once you answer
pendingInput with its endpoint — answer, approve-copy, or for a run
whose kind is video, approve-plan and approve-stills. Polling stops at
a gate only when you hand it to a person instead: stop polling and send them
to webUrl, a video run's page in the app, where they can answer it.
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, "kind": "image", "spot": null },
"pendingInput": { "gate": "questions", "questions": [ … ] },
"events": [ … ],
"webUrl": null
}
runis the eight-field snapshot; itskindisimageorvideo, and itsspotis"audio"on an audio spot, elsenull. 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 an answer you can give through the API. 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.webUrlisnullfor an image run. For a video run it is the run's page in the app, in every status: a person can follow the run there, or answer a gate you hand to them. See video runs below.
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 | A message that is not a step, such as a warning about a reference. |
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_draft | itemId, draft | One draft was rewritten at the copy gate and now supersedes the one copy_review carried. draft has the same shape as one entry of drafts. pendingInput already reflects it. |
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.
A video run asks its questions here too, with the same answer call. One of
them can be about footage: a question with allowsClip: true lists the
team's usable clips as its own options — up to three, each labelled by its
file name and length, its id already asset: followed by the clip's id —
after a first option that builds the ad from photos instead. Answer with one
of those option ids, exactly as given; no other endpoint lists clips, and
custom: text is refused there because it names no clip. With no clip on
file, the first option is the only answer.
A video run created with smartAsk: false asks nothing, so a style made
from a clip uses the newest usable clip for the run's brand (or one shared
across brands), when the ad has room for one of its lines. To use another one,
create the run with smartAsk: true and answer the footage question.
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": "…" }
],
"basedOnSeq": 12
}
basedOnSeq is the event seq these drafts are current as of. Send it back
with the approval (below) and the approval is refused, rather than applied, if
a draft was rewritten after you read it — by someone in the app, or another
client.
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],
"basedOnSeq": 12
}
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, plus one: with basedOnSeq set, drafts that changed
since that read answer 409 copy_changed, whose details carry the current
drafts, the changedConcepts and the basedOnSeq to send next — nothing is
approved or refunded. basedOnSeq is optional; without it the approval is
applied as sent.
copyMode: "auto" skips this gate; copyMode: "manual" supplies the copy
in the create body (copyManual) and also skips it. A video run has no copy
review: approve-copy on one answers 400 unsupported_kind.
Video runs
A video run ("kind": "video" in config) makes short video ads: one ad per
variation, in every size you ask for. Creating one is free — creditCost is
0 on the create response and maxCredits there is refused with
400 invalid_input (details.field is "maxCredits"). The run is charged
once, when you approve its plan.
{
"brandId": "…",
"config": {
"kind": "video",
"productAssetId": "…",
"angleId": "…",
"audienceId": null,
"languageIds": ["en"],
"formatIds": ["story", "feed"],
"concepts": 2,
"cta": "Shop now",
"creativeDirection": "",
"smartAsk": true,
"video": { "lengthS": 15, "voice": "calm", "music": "warm", "captions": "auto", "style": null }
}
}
formatIdstakesstory(9:16),post(4:5) andfeed(1:1);conceptsis the number of variations, up to 3;ctais at most 30 characters, a button's room.- Every key of
videois optional and takes the app's default when left out.GET /video-optionslists the ids each one takes: voices (ornullfor no voice), music moods (ornull), caption styles, lengths, styles (or"mix", ornullto let us pick) and the default still model. A style that needs the person's own clip works only once a video is uploaded in the app. A style that needs a character to present it (presenter) is made in the app only: a brief that names it is refused with400 avatar_required. - The product must be one of the team's own: a shared system product from
GET /productsis refused withinvalid_product. - Plans are free but not unlimited: a fair-use daily limit answers
429 plan_cap_reached, and a team without a plan has a few free plans in all (402 free_quota_exhaustedafter them).
A video run can pause three times: at its questions (as above), at its plan,
and — with AI motion and checkStills — at its stills.
The plan gate
{
"gate": "plan",
"basedOnSeq": 31,
"boardUrl": "…",
"prices": { "base": 40, "current": 70 },
"variations": [
{
"variation": 0,
"status": "ready",
"label": "Problem → solution",
"formatId": "problem-solution",
"lengthS": 15,
"playsS": 16,
"fromClip": false,
"checked": ["…"],
"scenes": [
{ "index": 0, "purpose": "hook", "lengthS": 3, "picture": "photo", "onScreen": "Lying awake?", "voice": "Lying awake again?", "button": null }
],
"current": "open",
"motion": {
"none": { "credits": 20 },
"open": { "credits": 50 },
"all": { "unavailable": "voice_too_long" }
}
}
]
}
- Each scene's
pictureisphoto,color,plate(an AI picture),clip(AI motion —startis"ai"when it starts from an AI picture,"asset"when it starts from the person's photo, picked in the app;nullon every other scene),upload(a plan made from the person's clip) orvideo(the person's own clip on that scene, picked in the app).buttonis the scene's button text — its logo and a button are shown over its picture;null= none. The planner closes an ad on acolorscene withonScreenandbutton. - AI motion is chosen per scene in the app.
currentsays what a variation has now:none,open(the first scene with a product photo),all(every such scene), ormixedwhen its scenes were picked one by one.prices.currentis the run as its plans stand;prices.baseis the run with no AI motion. Sending a preset for a variation makes it cost that preset'screditsinstead of what it costs now. A preset withunavailablecannot be sent:not_offered(nothing to animate or a plan made from the person's clip),describe_missing(a scene it would animate has no description yet — a scene added in the app starts empty; describe it there first) orvoice_too_long(a voice line longer than an AI motion scene holds, or than the person's own clip on its scene has room for — any preset,nonetoo; shorten it first).pricesisnulland everycreditsisnullwhen prices cannot be read right now; try again shortly. lengthSis the length you asked for;playsSis what the plan will play once its voice is in.checkedlists what was changed to keep the plan within what your brief backs.- A variation with
status: "failed"could not be planned:POST /runs/{runId}/plan/retrywith{ "variation": 1 }plans it again, free.
Change the words of a scene before you approve — on screen, voice and
button, by scene index; null removes a line (or the button):
{ "variation": 0, "basedOnSeq": 31, "scenes": [ { "index": 0, "onScreen": "Sleep, sorted" } ] }
POST /runs/{runId}/plan answers 200 with the basedOnSeq to send next. A
line too long for its scene, or an empty button, is 400 invalid_input with
details.variation, details.scene and details.field; a voice line on a
plan made from the person's clip is 400 clip_voice.
Approve with POST /runs/{runId}/approve-plan — this is the charge:
{ "basedOnSeq": 32, "motion": ["open", "none"], "checkStills": true, "maxCredits": 70 }
maxCredits is required: the most you agree to pay. Above it the call is
409 quote_changed with details.creditCost, and nothing is charged. A
basedOnSeq that is not the latest plans' is 409 plan_changed with the
details.basedOnSeq to send — checked before the price, so read the run
again before you judge a price. motion lists one preset per variation in
order (none, open or all); a preset replaces that variation's AI
motion, and a variation left out, or null, keeps what it has (current).
checkStills (default true) pauses a run with AI motion at its stills
before any AI motion is made. A scene that starts from the person's photo,
when the photo is too wide or too tall for the ad's shape, is
400 photo_shape with details.variation and details.scene, and nothing
is charged: pick another photo in the app, or send a preset for that
variation (a preset starts its scenes from an AI picture). The response is 200 with the run's status,
the creditCost it was charged, each variation's motion as approved (in
the same words, mixed included) and the checkStills it was approved
with. Sending it again never charges twice: it answers the
run as approved.
The stills gate
{ "gate": "stills", "stills": 4, "ready": 4, "boardUrl": "…" }
GET /runs/{runId}/stills lists them: each still's variation, scene,
role (start, end or plate), status, whether it was checked for
stray words (read) and kept with words in it (textWarning), a signed
imageUrl (null until made), its attempt and rerollCredits. To make one
again, POST /runs/{runId}/stills/{stillId}/reroll with
{ "expectedAttempt": 0, "maxCredits": 10 } — it is charged at the still's own
price and never twice: if a call fails, list the stills again, and an
attempt one higher means it happened (a repeat answers
409 retry_in_flight). POST /runs/{runId}/approve-stills (free) moves on
once ready equals stills; before that it is 409 stills_not_ready.
Audio spots
An audio spot is a video run made from a spoken script over one product
photo (or your brand colour when the product has none), with music under the
voice. It is delivered as an MP3 for audio platforms and as 9:16 and 1:1
videos with the same sound. Ask for one with "spot": "audio" in video:
"video": { "spot": "audio", "lengthS": 30, "voice": "calm", "music": "warm" }
formatIdsmust be exactly["story", "feed"],languageIdsexactly["en"](an audio ad is written in English), andlengthS15 or 30 (audioLengthsinGET /video-options; 30 when left out). It needs a voice (nullis refused) and takes nocaptionsorstyle; a brief that breaks one of these is400 invalid_input.- It runs like any video run and is charged once, when you approve its plan.
Its plan gate carries
"spot": "audio", and each variation addsscript(what the voice says),headlineandcta(its last scene'sonScreenandbutton) andpicture({ "imageIndex": 0 }, one of the product's images, or"color"):
{ "variation": 0, "status": "ready", "script": "Meet Veya. …", "headline": "Skin that drinks it in", "cta": "Try it", "picture": { "imageIndex": 0 } }
- Change the words with
POST /runs/{runId}/plan, sendingscript,headlineand/orctainstead ofscenes; the scenes are laid out again from the script. A script longer than the ad holds is refused with400 over_budgetanddetails.variation— shorten it and send it again. The same answer comes fromapprove-planfor a stored script that runs long. - Approve with
motionleft out,nullornone: AI motion is never offered (every choice butnoneisunavailable), andcheckStillsdefaults tofalse. Sending AI motion or"checkStills": trueis400 motion_unavailable, and nothing is charged. - In the results, the 9:16 creative's
audioUrlis the MP3 and the 1:1 creative'scompanionUrlis a 640×640 image to go with it.
In the app only
Some of a video run stays in the app, at webUrl: changing a plan's
pictures (an audio spot's picture too), scene lengths, order or camera,
removing music under a person's voice, using an Ad Library video as the
style, a character presenting the ad (the presenter style), uploading a
clip, and every edit after the run — new hooks,
translations, changes to a finished ad.
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. For a video run (kind: "video")
each result also carries videoUrl; its imageUrl is the poster frame,
never the ad. A video ad translated in the app lists each of its other
languages in translations, each with its own signed videoUrl. An audio
spot adds audioUrl (the MP3, on its 9:16 result) and companionUrl (its
1:1 result's 640×640 image). 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.