Endpoint reference
https://terrads.com/api/v1Updated September 10, 2026All 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.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. |
Unknown top-level fields are rejected (400 invalid_input).
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 } ],
"nextCursor": "…"
}
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 },
"pendingInput": null,
"events": [ { "seq": 1, "kind": "work", "payload": { "…": "…" } } ]
}
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.
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": []
}
Response 200: { "runId": "…", "status": "generating" }. Errors as for
answer.
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,
"width": 1080,
"height": 1080,
"imageUrl": "https://…",
"createdAt": "…"
},
{
"id": "g-story",
"conceptIndex": null,
"sourceGenerationId": "g-base",
"format": "story",
"width": 1080,
"height": 1920,
"imageUrl": "https://…",
"createdAt": "…"
}
],
"expiresInSeconds": 300
}
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. 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 are the same list 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, createTier, isDefault, estimatedTime }
for every model currently available for runs. creditCost is the credit cost
the pricing uses for that model today; 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.