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:

FieldTypeNotes
brandIduuidRequired. Must be one of your team's brands (GET /brands).
config.kindimage | videoimage (default). A video brief takes its own fields — see below.
config.productAssetIduuidRequired. From GET /products.
config.angleIduuidRequired. From GET /angles.
config.audienceIduuid or nullFrom GET /audiences.
config.referencesarrayReference assets to draw on: [{ "assetId": uuid, "mode": "vibe" | "remix" | "clone" }]. Send [] for none.
config.formatIdsarray of format idsAt least one. See formats.
config.languageIdsarray of language codesAt least one; en plus ISO 639-1 codes.
config.conceptsintegerHow many distinct ideas to develop.
config.modelSlugstringA slug from GET /models.
config.copyModeauto | checkpoint | manualcheckpoint pauses for copy review; manual uses config.copyManual.
config.copyTonestringEmpty string inherits the brand's tone.
config.ctastringEmpty string inherits the brand's call to action.
config.creativeDirectionstringFree text guidance for this run.
config.smartAskbooleantrue lets the run pause with questions about your references.
config.gemIdsarray of uuidOptional saved context to attach.
config.copyManualobjectRequired when copyMode is manual; drafts keyed by reference asset id, or __original for the run's own concept.
config.overlaysobjectOptional composited layers (logo, CTA pill) applied to the delivered image.
config.requiredProductImagesarrayAccepted 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.
maxCreditsinteger or nullOptional 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.