Skip to main content

Webhooks

When you start a task with POST /v1/tasks or POST /v1/remix, pass a callback_url to receive an HTTPS POST when the task reaches terminal state. Webhooks fire terminal-only — there is no progress stream.

Important auth restriction

callback_url is API-key-auth only. OAuth callers (MCP sessions) get:
Use polling from OAuth clients.

Event types

Closed set. If your handler sees anything outside this list, it’s a bug to report. Non-terminal states (queued, running, expired) do not fire webhooks today. If you need running/progress beats, poll GET /v1/tasks/{id}.

Payload

Fields: For task.failed / export.failed, inspect data.error.retryable to distinguish transient from dead-lettered failures. On a task.succeeded event for a programmatic design task, data.result.export carries the auto-exported artifact ({status, url, format, page_count}) — the task renders an export of its result in the canvas’s category-default format when it finishes. Use this URL directly instead of issuing a separate POST /v1/canvases/{id}/export. The field is absent when auto-export was disabled (export_on_complete: {enabled: false} on the task) or did not finish within the budget.

Signature verification

Every webhook POST includes two headers: Signature is computed over {timestamp}.{raw_body} using the webhook signing secret that was shown once in Settings → Developer → REST API when you created the API key.

Node.js

Python

Use timingSafeEqual / hmac.compare_digest — not raw == — to avoid timing attacks.

Replay protection

Reject any webhook with an X-Webhook-Timestamp older than 5 minutes. A valid recent signature could otherwise be replayed indefinitely.

Retry behavior

Moda makes up to 3 delivery attempts total (initial + 2 retries). If your endpoint returns a non-2xx status or doesn’t respond within 30 seconds, Moda retries with a short backoff: After the third attempt fails, the webhook is dropped. You can still fetch the task via GET /v1/tasks/{id}, or replay the dropped delivery from the delivery log (POST /v1/webhook_deliveries/{id}/redeliver) once your endpoint is fixed.

Deduplication

The event envelope id (evt_…) is stable across retries of the same event. Use it as your dedupe key:
Retries of the same event carry the same id. Different events (e.g. task.succeeded for two different tasks) have different ids — they’re not dedupe-collisions.

Handler best practices

  1. Return 200 fast. Process asynchronously — enqueue the event, don’t do the work inline. Moda’s 30s timeout will retry if you’re slow.
  2. Verify signature before trusting the payload. Even for non-sensitive work.
  3. Check the timestamp (>5 min old → reject).
  4. Use evt_… as your idempotency key.
  5. HTTPS only. Moda rejects non-HTTPS callback URLs up front.
  6. Log request_id from the task envelope inside data when reporting issues.

End-to-end handler (Python + FastAPI)

See ../recipes/webhook-receiver.md for both Node/Express and FastAPI worked examples with enqueue + retry plumbing.

Common wrong guesses

  • Using callback_url from an OAuth/MCP session. Rejected. API-key auth only.
  • Expecting task.running / task.queued events. Terminal-only today.
  • Using raw == to compare signatures. Timing-attack risk. Use hmac.compare_digest / crypto.timingSafeEqual.
  • Not verifying timestamp age. Replayable. Reject >5 min.
  • Doing work inside the handler. 30s timeout will re-fire retries. Enqueue, then 200.
  • Forgetting the signing secret. It’s shown once with the API key. Store it in your secret manager alongside the key.
  • Handling retries as new events. Same evt_… id → same event. Dedupe.

Upstream