Endpoint reference
- Base URL
https://terrads.com/api/v1 - Updated October 15, 2026
All paths are relative to the base URL shown at the top of this page. Every
request needs Authorization: Bearer <key> (authentication).
Request and response bodies are JSON. Errors share one envelope
(errors). Each route lists the scope it belongs to and the
rate-limit tier it spends.
The same reference is available as an OpenAPI 3.1 document at
/api/v1/openapi.json, generated from the schemas the routes validate with.
Runs
POST /runs
Scope runs:write · tier writes · header Idempotency-Key required
(idempotency).
Creates a run and returns as soon as it is accepted. The full walkthrough is on the quickstart.
Body:
| Field | Type | Notes |
|---|---|---|
brandId | uuid | Required. Must be one of your team's brands (GET /brands). |
config.kind | image | video | image (default). A video brief takes its own fields — see below. |
config.productAssetId | uuid | Required. From GET /products. |
config.angleId | uuid | Required. From GET /angles. |
config.audienceId | uuid or null | From GET /audiences. |
config.references | array | Reference assets to draw on: [{ "assetId": uuid, "mode": "vibe" | "remix" | "clone" }]. Send [] for none. |
config.formatIds | array of format ids | At least one. See formats. |
config.languageIds | array of language codes | At least one; en plus ISO 639-1 codes. |
config.concepts | integer | How many distinct ideas to develop. |
config.modelSlug | string | A slug from GET /models. |
config.copyMode | auto | checkpoint | manual | checkpoint pauses for copy review; manual uses config.copyManual. |
config.copyTone | string | Empty string inherits the brand's tone. |
config.cta | string | Empty string inherits the brand's call to action. |
config.creativeDirection | string | Free text guidance for this run. |
config.smartAsk | boolean | true lets the run pause with questions about your references. |
config.gemIds | array of uuid | Optional saved context to attach. |
config.copyManual | object | Required when copyMode is manual; drafts keyed by reference asset id, or __original for the run's own concept. |
config.overlays | object | Optional composited layers (logo, CTA pill) applied to the delivered image. |
config.requiredProductImages | array | Accepted for parity with the app, but not usable through the API today: its values are app-internal image paths that no API endpoint returns (GET /products exposes only imageCount). Omit it. |
maxCredits | integer or null | Optional ceiling. Above it, 409 quote_changed and nothing is charged. A number on a video brief is 400 invalid_input: a video run is charged at approve-plan. |
Unknown top-level fields are rejected (400 invalid_input).
A video brief (config.kind video, video runs)
takes productAssetId (one of your team's own products), angleId,
audienceId, languageIds, formatIds (story, post, feed),
concepts (1–3), cta (at most 30 characters), creativeDirection,
smartAsk and an optional video object — lengthS, voice, music,
captions, style, stillModel — whose ids come from GET /video-options,
and quality: best (the default) or fast, the model the AI motion is made
on. It takes no other field. Its creditCost is 0. The presenter style
needs a character to present it, which only the app can pick: it is 400 avatar_required.
An audio spot is a video brief with
"spot": "audio" in video: formatIds exactly ["story", "feed"], one
language, lengthS 15 or 30 (default 30), a voice (not null), and no
captions, style or quality.
Response 201 (or 200 on an idempotent replay):
{ "runId": "…", "status": "queued", "creditCost": 12 }
GET /runs
Scope runs:read · tier reads.
Your team's runs, newest first. Query: limit (1–50, default 20), cursor
(from a previous page's nextCursor).
{
"runs": [ { "id": "…", "status": "completed", "errorMessage": null, "createdAt": "…", "completedAt": "…", "creditCost": 12, "kind": "image", "spot": null } ],
"nextCursor": "…"
}
kind is image or video; runs made in the app are listed with those made
through this API.
nextCursor is null on the last page. Cursors are opaque; do not construct
them.
GET /runs/{runId}
Scope runs:read · tier reads.
The run, what it is waiting for, and its event log. Query: after (a seq;
only events newer than it are returned). Explained in full on the
runs page.
{
"run": { "id": "…", "status": "generating", "errorMessage": null, "createdAt": "…", "completedAt": null, "creditCost": 12, "kind": "image", "spot": null },
"pendingInput": null,
"events": [ { "seq": 1, "kind": "work", "payload": { "…": "…" } } ],
"webUrl": null
}
run.kind is image or video; run.spot is "audio" on an audio spot,
else null. pendingInput.gate is questions or
copy on an image run, and questions, plan or stills on a video run.
webUrl is null for an image run; for a video run it is the run's page in
the app, where a person can answer a gate you hand to them.
A run that belongs to another team is a 404, exactly like a run that does
not exist.
POST /runs/{runId}/answer
Scope runs:write · tier writes.
Answers every open question on a run paused at the questions gate.
{ "answers": [ { "questionId": "q-…", "value": "keep" } ] }
Response 200: { "runId": "…", "status": "generating" }. Errors:
400 missing_answers, 400 invalid_input, 409 wrong_gate,
409 not_awaiting. A video run's questions are answered here too.
POST /runs/{runId}/approve-copy
Scope runs:write · tier writes.
Approves (and optionally edits or rejects) the drafts of a run paused at the copy-review gate.
{
"drafts": { "0": { "headline": "…", "body": "…", "cta": "…" } },
"rejectedConcepts": [],
"basedOnSeq": 12
}
basedOnSeq is optional: pendingInput.basedOnSeq from the read you edited.
Response 200: { "runId": "…", "status": "generating" }. Errors as for
answer, plus 409 copy_changed when basedOnSeq is set and a draft was
rewritten after that read, and 400 unsupported_kind for a video run, which
has no copy review.
POST /runs/{runId}/approve-plan
Scope runs:write · tier writes. A video run's plan gate — the charge of
a video run.
{ "basedOnSeq": 32, "motion": ["open", "none"], "checkStills": true, "maxCredits": 70 }
basedOnSeq and maxCredits are required; motion (one of none, open,
all or null per variation, up to 3) and checkStills (default true) are
optional. A preset replaces that variation's AI motion; null or a variation
left out keeps what its plan has (pendingInput.variations[].current). On an audio spot, motion takes none only and checkStills
defaults to false; asking for either is 400 motion_unavailable. Response 200:
{ "runId": "…", "status": "generating", "creditCost": 70, "motion": ["open", "none"], "checkStills": true }
— what the run holds (motion is mixed for a variation whose scenes were
picked one by one in the app), also when the call is sent again (never charged
twice). Errors: 409 quote_changed (details.creditCost), 409 plan_changed
(details.basedOnSeq), 409 plan_not_ready, 409 not_awaiting,
409 wrong_gate, 400 over_budget (details.variation, and
details.scene except on an audio spot, whose script runs too long),
400 describe_missing (details.variation, details.scene: a scene it would
make with AI has no description yet), 400 photo_shape (details.variation,
details.scene: a scene that starts from the person's photo, and the photo is
too wide or too tall for the ad's shape), 400 motion_unavailable, 400 clip_voice,
402 insufficient_credits, 429 too_many_active_runs,
503 cost_table_unavailable, 500 approve_failed.
A plan edited in the app can carry a delivery on a voice line (how it is said). It is priced as part of the voice, and the plan this API reads doesn't show it.
POST /runs/{runId}/plan
Scope runs:write · tier writes. Free.
The words of one variation's scenes, by scene index: onScreen, voice and
button (the scene's button text, up to 30 characters — its logo and a
button are shown over its picture), each a string or null (null removes
it; an empty button is 400 invalid_input with details.field: "button").
{ "variation": 0, "basedOnSeq": 31, "scenes": [ { "index": 0, "onScreen": "Sleep, sorted", "voice": null } ] }
On an audio spot send script, headline and/or cta instead of
scenes (one or the other, never both); the scenes are laid out again from
the script.
{ "variation": 0, "basedOnSeq": 31, "script": "Meet Veya. …", "cta": "Try it" }
Response 200: { "basedOnSeq": 32 }. Errors: 400 invalid_input (with
details.variation, details.scene and details.field for a line too long
for its scene, or an empty button), 400 over_budget (details.variation: an audio spot's
script runs longer than the ad), 400 clip_voice, 409 plan_changed
(details.basedOnSeq), 409 plan_not_ready, 409 not_awaiting.
POST /runs/{runId}/plan/retry
Scope runs:write · tier writes. Free.
{ "variation": 1 } plans again a variation whose status is failed.
Response 200: { "runId": "…", "status": "generating" }. A run gets five
free retries. Errors: 409 not_awaiting, 409 plan_retry_limit,
400 invalid_input, 503 llm_unavailable, 500 retry_failed.
GET /runs/{runId}/stills
Scope runs:read · tier reads. Cache-Control: private, no-store.
A video run's stills: { "stills": [ … ], "expiresInSeconds": 300 }, each
{ id, variation, scene, role, status, read, textWarning, imageUrl, attempt, rerollCredits }.
imageUrl is signed, and null until the still is made.
POST /runs/{runId}/stills/{stillId}/reroll
Scope runs:write · tier writes. Paid, at the still's rerollCredits.
{ "expectedAttempt": 0, "maxCredits": 10 }
Both required. Response 200: { "id": "…", "attempt": 1 }. Errors:
409 retry_in_flight, 409 quote_changed (details.creditCost),
409 not_awaiting, 400 not_a_still, 402 insufficient_credits.
POST /runs/{runId}/approve-stills
Scope runs:write · tier writes. Free; no body.
Response 200: { "runId": "…", "status": "generating" }, also when sent
again. Errors: 409 stills_not_ready, 409 not_awaiting.
POST /runs/{runId}/cancel
Scope runs:write · tier writes. No body.
Response 200: { "runId": "…", "status": "canceled" }. A terminal run is a
409 not_cancelable.
GET /runs/{runId}/results
Scope runs:read · tier reads.
Every finished creative, with a short-lived signed link to the delivered
image. Cache-Control: private, no-store.
{
"results": [
{
"id": "g-base",
"conceptIndex": 0,
"sourceGenerationId": null,
"format": null,
"kind": "image",
"width": 1080,
"height": 1080,
"imageUrl": "https://…",
"videoUrl": null,
"translations": [],
"audioUrl": null,
"companionUrl": null,
"notes": [],
"createdAt": "…"
},
{
"id": "g-story",
"conceptIndex": null,
"sourceGenerationId": "g-base",
"format": "story",
"kind": "image",
"width": 1080,
"height": 1920,
"imageUrl": "https://…",
"videoUrl": null,
"translations": [],
"audioUrl": null,
"companionUrl": null,
"notes": [],
"createdAt": "…"
}
],
"expiresInSeconds": 300
}
A video run's results have kind: "video": videoUrl is the finished MP4,
signed like imageUrl, and imageUrl is its poster frame (for an image,
videoUrl is null). A video ad you translated in the app also lists each
other language in translations: one entry a language, with its language
code, width, height, and its own signed videoUrl (the MP4 in that
language) and imageUrl (its poster). The top-level videoUrl is always the
ad in the run's own language; translations is empty for an image and for
an ad with no translation.
An audio spot adds two links, signed like
imageUrl: audioUrl on its 9:16 result is the MP3, and companionUrl on
its 1:1 result is the 640×640 companion image. Both are null on every
other result.
notes lists what needs a look on a creative, in plain sentences — today a
video ad whose music couldn't be made ("Music couldn't be made for this ad —
it plays with the voice only. Those credits came back."), on each of its
sizes. It is empty otherwise.
Two kinds of row. A base render carries conceptIndex and has format
and sourceGenerationId null — identify its frame from width and
height. Each additional format you asked for is a child row:
sourceGenerationId is the base it was composed from, format names the
placement, and conceptIndex is null on an image run (a video run's sizes
carry their variation's conceptIndex). Links expire after
expiresInSeconds; call again for fresh ones.
Catalog
Scope catalog:read · tier catalog. Read-only and cached for a minute
(Cache-Control: private, max-age=60). Brands, products, angles and
audiences are your team's; models and video options are the same for
everyone. Each
response is an object with one key named after the resource, holding an
array of the fields listed and nothing else:
{ "brands": [ { "id": "…", "name": "…", "slug": "…", "description": null, "createdAt": "…" } ] }
GET /brands
Response key brands: { id, name, slug, description, createdAt } for each
of your team's active brands.
GET /products
Query: brandId (optional uuid — omit for every product the team can use).
Response key products: { id, name, description, brandId, imageCount, isSystem }.
isSystem marks products provided by us rather than uploaded by your team.
GET /angles
Query: brandId (required uuid). Response key angles:
{ id, brandId, category, title, hook, painPoints, desiredOutcome }.
GET /audiences
Query: brandId (required uuid). Response key audiences:
{ id, brandId, name, gender, ageMin, ageMax, interests }.
GET /models
Response key models:
{ slug, name, description, creditCost, maxCreditCost, createTier, isDefault, estimatedTime }
for every model currently available for runs. creditCost is what one render
costs on that model today when the render sends the model no image. Some
models also charge for the images a render sends — the product's photos and
your reference — so on those a render costs more the more photos the product
has (imageCount on GET /products). maxCreditCost is the most one render
can cost on that model, whatever the product; it equals creditCost where
the photo count changes nothing. createTier is quality or
standard — the app's two-way split between its best renderer and its
fastest, cheapest one; isDefault marks the one the app picks when you do
not; estimatedTime is a rough, human-readable render time when we have one.
The list, its names and its prices change over time — read it, do not
hard-code it.
A simple way to set maxCredits: create a run without one, read
creditCost from the response, and use that figure (plus whatever headroom
you accept) as the ceiling for runs of the same shape and the same product.
A ceiling worked out from creditCost alone can be too low for a model whose
maxCreditCost is higher; work it out from maxCreditCost instead and the
price per render can never be the reason for a 409 quote_changed.
GET /video-options
What a video brief can name, each by the id config.video takes:
{ voices, music, captions, lengths, audioLengths, sizes, styles, motion, defaultStillModel }.
voices, music and captions are { id, label } (a voice also has
a gender (female, male or neutral) and a description); lengths are seconds, and audioLengths the seconds an
audio spot takes; sizes are { id, ratio }; styles
are { id, name, description, needs } — a style that needs a clip works
once the person has uploaded one in the app; motion says what none,
open and all make and whether each is available; defaultStillModel is
the GET /models slug a brief uses when it names none.