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

StatusMeaning
queuedAccepted (an image run is charged now; a video run when its plan is approved); waiting to start.
analyzingPreparing the brief from your brand and the inputs you sent.
awaiting_inputPaused. pendingInput tells you what it needs — see below.
generatingProducing your creative.
verifyingFinal checks before delivery.
completedDone. Results are available.
failedStopped on an error; errorMessage says what. Credits for work that did not complete are handled exactly as in the app.
canceledStopped 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
}
  • run is the eight-field snapshot; its kind is image or video, and its spot is "audio" on an audio spot, else null. It never includes your configuration or the run's internal working state.
  • events is the run's log, in order, each with a monotonically increasing seq. Pass ?after=<seq> to get only what is newer than the last event you have; without it you get the whole log.
  • pendingInput is null unless 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 the question or copy_review event for.
  • webUrl is null for 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

kindPayloadWhat it tells you
workitemId, role, label, stateA step started, finished or failed. state is running, done or failed.
noteitemId, text, toneA message that is not a step, such as a warning about a reference.
questionitemId, qA question was asked. q is the question object described below.
answerquestionId, valueAn answer was recorded — yours, from POST /runs/{runId}/answer.
copy_reviewitemId, draftsCopy drafts are ready for review.
copy_draftitemId, draftOne 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_approvedkeptCopy was approved; kept lists the concepts that survived.
resultadA 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 id from 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 where allowsImage is true — 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 }
  }
}
  • formatIds takes story (9:16), post (4:5) and feed (1:1); concepts is the number of variations, up to 3; cta is at most 30 characters, a button's room.
  • Every key of video is optional and takes the app's default when left out. GET /video-options lists the ids each one takes: voices (or null for no voice), music moods (or null), caption styles, lengths, styles (or "mix", or null to 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 with 400 avatar_required.
  • The product must be one of the team's own: a shared system product from GET /products is refused with invalid_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_exhausted after 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 picture is photo, color, plate (an AI picture), clip (AI motion — start is "ai" when it starts from an AI picture, "asset" when it starts from the person's photo, picked in the app; null on every other scene), upload (a plan made from the person's clip) or video (the person's own clip on that scene, picked in the app). button is the scene's button text — its logo and a button are shown over its picture; null = none. The planner closes an ad on a color scene with onScreen and button.
  • AI motion is chosen per scene in the app. current says what a variation has now: none, open (the first scene with a product photo), all (every such scene), or mixed when its scenes were picked one by one. prices.current is the run as its plans stand; prices.base is the run with no AI motion. Sending a preset for a variation makes it cost that preset's credits instead of what it costs now. A preset with unavailable cannot 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) or voice_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, none too; shorten it first). prices is null and every credits is null when prices cannot be read right now; try again shortly.
  • lengthS is the length you asked for; playsS is what the plan will play once its voice is in. checked lists 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/retry with { "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" }
  • formatIds must be exactly ["story", "feed"], languageIds exactly ["en"] (an audio ad is written in English), and lengthS 15 or 30 (audioLengths in GET /video-options; 30 when left out). It needs a voice (null is refused) and takes no captions or style; a brief that breaks one of these is 400 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 adds script (what the voice says), headline and cta (its last scene's onScreen and button) and picture ({ "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, sending script, headline and/or cta instead of scenes; the scenes are laid out again from the script. A script longer than the ad holds is refused with 400 over_budget and details.variation — shorten it and send it again. The same answer comes from approve-plan for a stored script that runs long.
  • Approve with motion left out, null or none: AI motion is never offered (every choice but none is unavailable), and checkStills defaults to false. Sending AI motion or "checkStills": true is 400 motion_unavailable, and nothing is charged.
  • In the results, the 9:16 creative's audioUrl is the MP3 and the 1:1 creative's companionUrl is 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:

IdRatioPlacement
feed1:1Square feed
post4:5Portrait feed post
story9:16Stories, Reels, vertical video covers
land16:9Landscape
pin2:3Pins

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.