> ## Documentation Index
> Fetch the complete documentation index at: https://docs.moda.app/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Receive HTTP notifications when AI design tasks complete.

When you start a design task via `POST /tasks` or `POST /remix`, you can provide a `callback_url` to receive an HTTP notification when the task finishes. This lets you avoid polling `GET /tasks/{task_id}` and instead react to completion events asynchronously.

## Setting up a webhook

Include a `callback_url` when starting a task:

```bash theme={null}
curl -X POST https://api.moda.app/v1/tasks \
  -H "Authorization: Bearer moda_live_abc123..." \
  -H "Moda-Version: 2026-05-01" \
  -H "Content-Type: application/json" \
  -d '{
    "prompt": "Create a modern SaaS landing page",
    "callback_url": "https://your-server.com/webhooks/moda"
  }'
```

When the task reaches a terminal state (`succeeded`, `failed`, or `canceled`), Moda sends a `POST` request to your `callback_url`.

## Event types

Closed set. Moda only fires webhooks for the event types below. Anything else your integration sees is a bug and should be reported.

| Event              | When it fires                                                                  |
| ------------------ | ------------------------------------------------------------------------------ |
| `task.succeeded`   | A design task finished successfully                                            |
| `task.failed`      | A design task failed (transient or dead-lettered — see `data.error.retryable`) |
| `task.canceled`    | A design task was canceled                                                     |
| `export.succeeded` | An async canvas export finished successfully                                   |
| `export.failed`    | An async canvas export failed                                                  |

Non-terminal states (`queued`, `running`, `expired`) do **not** fire webhooks today. If you need running/progress beats, poll `GET /tasks/{task_id}` or open an issue — we'll expand the enum on concrete customer need.

## Webhook payload

The payload is a JSON event envelope wrapping the canonical `Task` object under a `data` key. Same shape for every event type.

```json theme={null}
{
  "id": "evt_01HT9WK8N3M2J4A5Z6P7Q8R9TV",
  "type": "task.succeeded",
  "created": "2026-04-15T12:01:00+00:00",
  "api_version": "2026-05-01",
  "data": {
    "id": "task_01HT9WK8N3M2J4A5Z6P7Q8R9TV",
    "kind": "design",
    "status": "succeeded",
    "created_at": "2026-04-15T12:00:00+00:00",
    "started_at": "2026-04-15T12:00:02+00:00",
    "completed_at": "2026-04-15T12:01:00+00:00",
    "progress": null,
    "attempt": 1,
    "max_attempts": 3,
    "input": { "prompt": "Create a modern SaaS landing page" },
    "result": {
      "canvas_id": "cvs_01HT9WK8N3M2J4A5Z6P7Q8R9TV",
      "canvas_url": "https://app.moda.com/canvas/..."
    },
    "error": null,
    "credits": { "credits_used": 5, "credits_remaining": 12 },
    "links": {
      "self": "/v1/tasks/task_01HT9WK8N3M2J4A5Z6P7Q8R9TV",
      "events": null,
      "cancel": null,
      "canvas": "https://app.moda.com/canvas/..."
    },
    "retry_after_ms": null
  }
}
```

### Envelope fields

| Field         | Type     | Description                                                                               |
| ------------- | -------- | ----------------------------------------------------------------------------------------- |
| `id`          | `string` | Unique event ID (prefixed `evt_…`). Stable across retries of this event                   |
| `type`        | `string` | One of the event types listed above                                                       |
| `created`     | `string` | ISO 8601 timestamp when the event was generated                                           |
| `api_version` | `string` | Canonical `Moda-Version` of the `data` payload (currently `2026-05-01`)                   |
| `data`        | `object` | Canonical `Task` object. See [Get Task](/api-reference/tasks/get-task-status) for details |

For `task.failed` / `export.failed` events, inspect `data.error.retryable` to distinguish transient failures (worth retrying your own downstream work) from dead-lettered ones.

## Verifying webhook signatures

Every webhook request includes two headers for signature verification:

| Header                | Description                                        |
| --------------------- | -------------------------------------------------- |
| `X-Webhook-Signature` | HMAC-SHA256 signature in the format `v1=<hex>`     |
| `X-Webhook-Timestamp` | Unix timestamp (seconds) when the webhook was sent |

Each API key has its own unique webhook signing secret, shown once when the key is created. You can find it in the credentials banner alongside your API key in **Settings > Developer > REST API**. Store it securely -- it cannot be retrieved later.

The signature is computed over the string `{timestamp}.{request_body}` using HMAC-SHA256. To verify:

1. Extract the timestamp from the `X-Webhook-Timestamp` header
2. Concatenate: `{timestamp}.{raw_request_body}`
3. Compute `HMAC-SHA256(signing_secret, concatenated_string)`
4. Compare the result with the signature value after the `v1=` prefix

### Verification example

<CodeGroup>
  ```javascript Node.js theme={null}
  import crypto from 'node:crypto';

  function verifyWebhook(signingSecret, requestBody, signatureHeader, timestampHeader) {
    const message = `${timestampHeader}.${requestBody}`;
    const expected = crypto.createHmac('sha256', signingSecret).update(message).digest();
    const received = Buffer.from(signatureHeader.replace('v1=', ''), 'hex');

    // timingSafeEqual throws when buffer lengths differ; check first so a
    // malformed signature can't crash the handler.
    if (expected.length !== received.length) {
      return false;
    }
    return crypto.timingSafeEqual(expected, received);
  }
  ```

  ```python Python theme={null}
  import hashlib
  import hmac

  def verify_webhook(
      signing_secret: str,
      request_body: bytes,
      signature_header: str,
      timestamp_header: str,
  ) -> bool:
      message = f"{timestamp_header}.{request_body.decode()}"
      expected = hmac.new(
          signing_secret.encode(), message.encode(), hashlib.sha256
      ).hexdigest()
      received = signature_header.removeprefix("v1=")

      return hmac.compare_digest(expected, received)
  ```
</CodeGroup>

## Retry behavior

Moda makes up to **3 delivery attempts** total (the initial delivery plus 2 retries). If your endpoint returns a non-2xx status code or does not respond within 30 seconds, Moda retries with a short backoff:

| Attempt         | When                                               |
| --------------- | -------------------------------------------------- |
| 1 (initial)     | immediately when the task reaches a terminal state |
| 2 (retry)       | \~1 second after attempt 1 fails                   |
| 3 (final retry) | \~5 seconds after attempt 2 fails                  |

A persistent rejection (e.g., your endpoint returning `401` because signature verification is failing) exhausts all three attempts within a few seconds and the webhook is then dropped. You can still retrieve the task status via `GET /tasks/{task_id}` — the task itself completes independently of callback delivery — or replay the dropped delivery from the **delivery log** (see below) once your endpoint is fixed.

## Best practices

* **Return 200 quickly.** Process the webhook payload asynchronously to avoid timeouts. Acknowledge receipt first, then handle the event.
* **Verify signatures.** Always validate the `X-Webhook-Signature` header before trusting the payload.
* **Check timestamps.** Reject webhooks with timestamps more than 5 minutes old to prevent replay attacks.
* **Handle duplicates.** In rare cases, the same event may be delivered more than once. Use the envelope `id` (`evt_…`) as an idempotency key — it's stable across retries of the same event.
* **Use HTTPS.** Your `callback_url` must use HTTPS to protect the payload in transit.

## Delivery log & manual replay

Every webhook attempt is recorded in a **delivery log** you can query and replay. Useful when your endpoint was down, a signature-verification bug slipped through, or you just want to confirm "did this fire?"

* **List deliveries**: `GET /webhook_deliveries` ([reference](/api-reference/webhooks/list-deliveries)) — cursor-paginated, filterable by event type, status, API key, and date range. Response rows include the target URL, HTTP status code, response body excerpt (first 2KB), and attempt count.
* **Replay a delivery**: `POST /webhook_deliveries/{id}/redeliver` ([reference](/api-reference/webhooks/redeliver-webhook)) — queues a fresh attempt with the original payload to the original URL. Returns 202 with the new delivery's ID so you can follow it.

**Retention**: deliveries are kept for 90 days and then hard-deleted.

**Permission**: replay is restricted to the user who created the API key that dispatched the original delivery, or any team admin/owner. If the original API key was deleted, only admins can replay.

The log is also surfaced in the **Settings → API → View API usage** drawer with a Webhooks tab if you prefer a UI over the API.
