Task envelope
Every task-shaped operation — design, remix, brand-kit extraction — returns the canonical Task envelope. (Canvas export is not one of them; it has its own two-shape payload — see below.) PinModa-Version: 2026-05-01 to get this shape.
Full shape
Fields
Status state machine
succeeded, failed, canceled, expired. Non-terminal: queued, running.
Derive is_terminal = status in {"succeeded","failed","canceled","expired"}. Derive can_export = status == "succeeded" && result && result.canvas_id.
Distinguishing a retry from a queue wait
A task whose attempt died (execution-cap kill, worker timeout, lost heartbeat) is retried automatically, and it sits inqueued for the whole backoff. That is not queue
contention, and it is the single most-misread thing about this envelope: teams see a
10-minute queued and buy parallelism they do not need.
Tell them apart:
>= 1, not>= 2.attempts_startedis incremented when a worker claims the task, so a first attempt that is currentlyrunningalready reads1, and it stays1through that attempt’s backoff. Combine it withstatus—running+attempts_started == 1is an ordinary first run,queued+attempts_started == 1is a retry.erroron a non-terminal task describes the previous attempt, not the current one. Itsattemptfield says which attempt produced it. A task that isrunningagain after a failure deliberately keeps the failing reason for triage — do not render it as “this attempt failed”.- Wall-clock attribution.
created_at → first_started_atis the true queue wait.created_at → started_atspans every dead attempt and its backoff too, so it overstates queueing on any retried task. first_started_at: nullmeans unknown, never “never started” — tasks created before this field shipped have no value for it. Readattempts_startedfor the never-started case.
retrying status. The public status taxonomy is frozen, and consumers derive
is_terminal from it, so a new value would break existing pollers.
Discriminator — kind
Task shape is polymorphic on kind:
Always check
kind before reading result fields.
On a succeeded design task, result.export carries the finished design already rendered to a file — {url, format, status, page_count}, exported in the canvas’s category-default format. Read it directly instead of issuing a separate POST /v1/canvases/{id}/export. It is absent when auto-export was disabled (export_on_complete: {enabled: false}) or did not finish within the budget.
Note: POST /v1/canvases/{id}/export never returns a Task envelope. It returns its own payload — {status: "completed", url, format, source} when the render lands inside the ~20s wait budget, or {status: "in_progress", task_id, retry_after_seconds} when it doesn’t. Poll GET /v1/canvases/{id}/export-status?task_id=… for the async case, not /v1/tasks/{id}. The export task kind and the export.succeeded / export.failed webhook events are declared in the public taxonomy but nothing emits them today. See canvases-and-exports.md.
Delivery patterns
Pick one per task — don’t stack:1. Webhook (callback_url) — recommended for async backends
Pass callback_url in the task body. Webhook fires on terminal state only. Requires moda_live_… API key auth (OAuth callers get 400). See webhooks.md.
2. Polling — simplest for short-lived callers
retry_after_ms. Don’t poll faster.
No sync-feel header
The API does not currently implement RFC 7240Prefer: wait — the header is ignored on every endpoint. Operations that are fast (brand-kit creation, remix without a prompt) are simply synchronous; everything else is webhook or polling.
Timing expectations
Set user expectations up front. Don’t block callers on multi-minute operations.
Common wrong guesses
- Polling faster than
retry_after_ms. Burns your rate budget for no latency benefit. - Reading
response.canvas_idon a canonical response. It’sresponse.result.canvas_id. The flat field is legacy (2026-04-12). - Expecting
exportkind fromPOST /v1/canvases/{id}/export. That endpoint does not return a Task envelope — itstask_idis polled via/canvases/{id}/export-status. - Reading a long
queuedas queue contention. Checkattempts_startedfirst — a retry after a dead attempt looks identical to a queue wait onstatusalone. - Treating
expiredas a failure. It is terminal (same as the others) but the cause is queue-depth or worker-starvation, not a user-actionable error — retry the task fresh.
Upstream
docs.moda.app/api/tasks/startTaskdocs.moda.app/api/tasks/getTaskdocs.moda.app/api/versioning— for the legacy → canonical migration map