Skip to main content

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.) Pin Moda-Version: 2026-05-01 to get this shape.

Full shape

Fields

Status state machine

Terminal: 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 in queued 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:
Notes:
  • >= 1, not >= 2. attempts_started is incremented when a worker claims the task, so a first attempt that is currently running already reads 1, and it stays 1 through that attempt’s backoff. Combine it with status — running + attempts_started == 1 is an ordinary first run, queued + attempts_started == 1 is a retry.
  • error on a non-terminal task describes the previous attempt, not the current one. Its attempt field says which attempt produced it. A task that is running again 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_at is the true queue wait. created_at → started_at spans every dead attempt and its backoff too, so it overstates queueing on any retried task.
  • first_started_at: null means unknown, never “never started” — tasks created before this field shipped have no value for it. Read attempts_started for the never-started case.
There is no 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: 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

Respect retry_after_ms. Don’t poll faster.

No sync-feel header

The API does not currently implement RFC 7240 Prefer: 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_id on a canonical response. It’s response.result.canvas_id. The flat field is legacy (2026-04-12).
  • Expecting export kind from POST /v1/canvases/{id}/export. That endpoint does not return a Task envelope — its task_id is polled via /canvases/{id}/export-status.
  • Reading a long queued as queue contention. Check attempts_started first — a retry after a dead attempt looks identical to a queue wait on status alone.
  • Treating expired as 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