# Webhook payloads

> The HTTP request Mynth sends when a task settles. Standard Webhooks headers and signature, event names, and the event body.

When a task settles, Mynth sends one `POST` to each endpoint that subscribed.
This page describes that request exactly. Registering endpoints, receiver
code for each framework, and retry behavior are on
[webhooks](https://mynth.io/docs/concepts/webhooks.md).

Deliveries follow [Standard Webhooks](https://www.standardwebhooks.com), so
any of its libraries verifies them.

```text
POST https://your-app.com/api/mynth-webhook
Content-Type: application/json
User-Agent: Mynth-Webhook/1.0 (+https://mynth.io)
webhook-id: evt_01KE7Y9M3R8T2QXN5BVC4WJ6HD
webhook-timestamp: 1791108012
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=

{"id":"evt_01KE7Y9M3R8T2QXN5BVC4WJ6HD","type":"task.image.generate.completed","timestamp":"2026-10-04T10:00:12.000Z","data":{...}}
```

## Headers

| Header              | Value                                                                                         |
| ------------------- | --------------------------------------------------------------------------------------------- |
| `Content-Type`      | `application/json`                                                                            |
| `User-Agent`        | `Mynth-Webhook/1.0 (+https://mynth.io)`                                                       |
| `webhook-id`        | The event ID, `evt_...`. The same for every delivery of the event, and equal to the body `id` |
| `webhook-timestamp` | When this attempt was sent, in Unix seconds. Each retry sends a new one                       |
| `webhook-signature` | `v1,<base64>`. Only on endpoints registered in the dashboard or with `POST /webhooks`         |

### `webhook-id`

Every delivery of one event carries the same ID: to each of your endpoints,
on every retry, and after Mynth recovers from an outage. Store it and skip any
ID you have already handled. Treat it as an opaque string: do not parse it.

An endpoint registered twice with the same URL gets the event twice, with the
same ID. Deduplicating drops the second one, which is correct.

## Signature

A registered endpoint gets a `whsec_` secret when it is created. Every delivery
to it is signed with that secret:

```text
webhook-signature: v1,<base64>

signature = base64( HMAC-SHA256( key     = base64-decode( secret without "whsec_" ),
                                 message = "<webhook-id>.<webhook-timestamp>.<raw body>" ) )
```

- The key is the bytes the secret encodes, not the secret string.
- The message is the ID, a `.`, the timestamp, a `.`, then the body bytes
  exactly as received. Parsing and re-serializing the JSON changes the bytes
  and breaks the match.
- Each retry is signed again with a new `webhook-timestamp`. Reject a
  timestamp more than five minutes from your clock: that stops replays and
  still accepts late retries.
- The ID is signed, so a replay with a fresh `webhook-id` to get past your
  deduplication fails verification.
- The header is a space-separated list. Mynth sends one `v1` today; accept the
  delivery when any `v1` entry matches. Compare in constant time.

Per-request `webhook.custom` URLs get no signature. They carry `webhook-id`
and `webhook-timestamp` but anyone who knows the URL can post to them: put a
secret token in the URL and check it. See
[per-request webhooks](https://mynth.io/docs/concepts/webhooks.md#per-request-webhooks).

`verifyWebhook()` in the SDK, the SDK's framework helpers, and every
[Standard Webhooks library](https://github.com/standard-webhooks/standard-webhooks/tree/main/libraries)
verify all of this for you.

## Events

The event `type` is always the exact name, `task.<type>.<status>`:

| Task type                 | Completed                                | Failed                                |
| ------------------------- | ---------------------------------------- | ------------------------------------- |
| `image.generate`          | `task.image.generate.completed`          | `task.image.generate.failed`          |
| `image.remove_background` | `task.image.remove_background.completed` | `task.image.remove_background.failed` |
| `image.upscale`           | `task.image.upscale.completed`           | `task.image.upscale.failed`           |
| `image.rate`              | `task.image.rate.completed`              | `task.image.rate.failed`              |
| `image.alt`               | `task.image.alt.completed`               | `task.image.alt.failed`               |
| `image.review`            | `task.image.review.completed`            | `task.image.review.failed`            |
| `video.generate`          | `task.video.generate.completed`          | `task.video.generate.failed`          |

`completed` means the task finished with results. Items in the result can
still have failed: check each one's `status`. `failed` means the task failed
as a whole and has no results; `data.errors` says why.

An endpoint can also subscribe to `task.completed`, `task.failed` or `all`.
Those decide what reaches the endpoint, not what an event is called. An
endpoint on `all` still receives `task.image.alt.completed`, never `all`. An
endpoint that matches through more than one of its events gets one delivery.

`all` also covers task types added later, so answer an unknown `type` with a
`2xx` and ignore it.

## Body

```json
{
  "id": "evt_01KE7Y9M3R8T2QXN5BVC4WJ6HD",
  "type": "task.image.alt.completed",
  "timestamp": "2026-10-04T10:00:12.000Z",
  "data": {
    "id": "tsk_01KE7XWWEQ4MCGWKBQKJ1G47RP",
    "type": "image.alt",
    "status": "completed",
    "request": { "url": "https://assets.example.com/lighthouse.webp" },
    "result": { "alt": "A white lighthouse on dark rocks, lit by an orange sunset sky" },
    "errors": null,
    "cost": "0.00040000",
    "user_id": "user_01KE7XS3J0V5N8QF2PZ6W4MBRC",
    "api_key_id": "ak_01KE7XT9K2C6M4RX8NBV1QZJ5W",
    "created_at": "2026-10-04T10:00:00.000Z",
    "updated_at": "2026-10-04T10:00:12.000Z"
  }
}
```

| Field       | Type   | Notes                                                                                                                  |
| ----------- | ------ | ---------------------------------------------------------------------------------------------------------------------- |
| `id`        | string | The event ID, equal to the `webhook-id` header                                                                         |
| `type`      | string | See [events](#events)                                                                                                  |
| `timestamp` | string | ISO 8601. When the event happened: the task's `updated_at`, not when this attempt was sent                             |
| `data`      | object | The task exactly as [`GET /tasks/{id}`](https://mynth.io/docs/api-reference/task-object.md) returns it: same fields, same nulls, redacted |

`data.request` comes back with defaults filled in and `metadata` unchanged, so
match the task to your own records with `data.request.metadata`. Only image
generate, remove background, upscale and video generate take `metadata`. For
rate, alt and review, store the `task_id` from the create response instead.

URLs in `data.request.webhook.custom` are cut down to `scheme://host`, the same
as on `GET /tasks/{id}`, so a token in a per-request URL never reaches your
other endpoints.

### Examples

These show `data` only; the envelope around it is the same for every event.

**Image, partial**

```json
{
  "id": "tsk_01KE7XWWEQ4MCGWKBQKJ1G47RP",
  "type": "image.generate",
  "status": "completed",
  "request": {
    "model": "black-forest-labs/flux.2-pro",
    "prompt": "A lighthouse at dusk, film grain",
    "count": 2,
    "metadata": { "order_id": "ord_42" }
  },
  "result": {
    "model": "black-forest-labs/flux.2-pro",
    "images": [
      {
        "status": "success",
        "id": "img_V1StGXR8Z5jdHi6BmyT0sC1pQ2rN4wLk",
        "url": "https://cdn.mynth.io/images/img_V1StGXR8Z5jdHi6BmyT0sC1pQ2rN4wLk.webp",
        "mynth_url": "https://cdn.mynth.io/images/img_V1StGXR8Z5jdHi6BmyT0sC1pQ2rN4wLk.webp",
        "size": "1536x1024",
        "format": "webp"
      },
      {
        "status": "failed",
        "error": {
          "code": "provider_error",
          "message": "The provider failed to process the request. Please try again."
        }
      }
    ]
  },
  "errors": null,
  "cost": "0.03000000",
  "user_id": "user_01KE7XS3J0V5N8QF2PZ6W4MBRC",
  "api_key_id": "ak_01KE7XT9K2C6M4RX8NBV1QZJ5W",
  "created_at": "2026-10-04T10:00:00.000Z",
  "updated_at": "2026-10-04T10:00:12.000Z"
}
```

**Image, failed**

```json
{
  "id": "tsk_01KE7XZ8B4N3QR5SJVDVAW6TME",
  "type": "image.generate",
  "status": "failed",
  "request": {
    "model": "black-forest-labs/flux.2-pro",
    "prompt": "A lighthouse at dusk, film grain",
    "count": 1,
    "size": "16:9_4k"
  },
  "result": null,
  "errors": [
    {
      "code": "capability_not_supported",
      "message": "The request uses options that are not supported for this model."
    }
  ],
  "cost": null,
  "user_id": "user_01KE7XS3J0V5N8QF2PZ6W4MBRC",
  "api_key_id": "ak_01KE7XT9K2C6M4RX8NBV1QZJ5W",
  "created_at": "2026-10-04T10:00:00.000Z",
  "updated_at": "2026-10-04T10:00:03.000Z"
}
```

**Remove background**

```json
{
  "id": "tsk_01KE7Y1R6WQ2J9TM4BXC8ZKD3F",
  "type": "image.remove_background",
  "status": "completed",
  "request": {
    "url": "https://assets.example.com/product-042.jpg",
    "output": { "format": "png" },
    "destination": "r2-prod"
  },
  "result": {
    "image": {
      "id": "img_Q8mZr2LkT0vWc5NhY7pD3xFa9GsJ1bEu",
      "url": "https://assets.example.com/cutouts/img_Q8mZr2LkT0vWc5NhY7pD3xFa9GsJ1bEu.png",
      "mynth_url": "https://cdn.mynth.io/images/img_Q8mZr2LkT0vWc5NhY7pD3xFa9GsJ1bEu.png",
      "size": "1024x1024",
      "format": "png",
      "destination": { "status": "success", "name": "r2-prod" }
    }
  },
  "errors": null,
  "cost": "0.02000000",
  "user_id": "user_01KE7XS3J0V5N8QF2PZ6W4MBRC",
  "api_key_id": "ak_01KE7XT9K2C6M4RX8NBV1QZJ5W",
  "created_at": "2026-10-04T10:00:00.000Z",
  "updated_at": "2026-10-04T10:00:09.000Z"
}
```

**Upscale, failed**

```json
{
  "id": "tsk_01KE7Y2C9DF4M8QW1RXN6TBZ5H",
  "type": "image.upscale",
  "status": "failed",
  "request": {
    "url": "https://assets.example.com/poster-6000.png",
    "size": "4x",
    "effort": "high",
    "metadata": { "poster_id": "pst_19" }
  },
  "result": null,
  "errors": [
    {
      "code": "output_too_large",
      "message": "The upscaled image would be larger than 4096x4096 pixels. Send a smaller image or a lower factor."
    }
  ],
  "cost": null,
  "user_id": "user_01KE7XS3J0V5N8QF2PZ6W4MBRC",
  "api_key_id": "ak_01KE7XT9K2C6M4RX8NBV1QZJ5W",
  "created_at": "2026-10-04T10:00:00.000Z",
  "updated_at": "2026-10-04T10:00:02.000Z"
}
```

**Rate**

```json
{
  "id": "tsk_01KE7Y3B0GZC5V7NHXA2RMQ8TP",
  "type": "image.rate",
  "status": "completed",
  "request": {
    "url": "https://assets.example.com/upload-7731.jpg",
    "mode": "custom",
    "levels": [
      { "value": "<13", "description": "Safe for Children" },
      { "value": "<18", "description": "Safe for Teens" },
      { "value": "18+", "description": "Adults Only" }
    ]
  },
  "result": { "level": "<13" },
  "errors": null,
  "cost": "0.00020000",
  "user_id": "user_01KE7XS3J0V5N8QF2PZ6W4MBRC",
  "api_key_id": "ak_01KE7XT9K2C6M4RX8NBV1QZJ5W",
  "created_at": "2026-10-04T10:00:00.000Z",
  "updated_at": "2026-10-04T10:00:02.000Z"
}
```

**Review**

```json
{
  "id": "tsk_01KE7Y4D8KM1P6WRZ3QXB9TJ2C",
  "type": "image.review",
  "status": "failed",
  "request": { "url": "https://assets.example.com/gone.webp", "effort": "high" },
  "result": null,
  "errors": [{ "code": "fetch_failed", "message": "The image could not be fetched." }],
  "cost": null,
  "user_id": "user_01KE7XS3J0V5N8QF2PZ6W4MBRC",
  "api_key_id": "ak_01KE7XT9K2C6M4RX8NBV1QZJ5W",
  "created_at": "2026-10-04T10:00:00.000Z",
  "updated_at": "2026-10-04T10:00:04.000Z"
}
```

**Video**

```json
{
  "id": "tsk_01KE7Y5T2HB9R4XQN7CZW3KM6D",
  "type": "video.generate",
  "status": "completed",
  "request": {
    "model": "bytedance/seedance-2.0-mini",
    "prompt": "A cat surfing a wave at sunset",
    "duration": 5,
    "size": "9:16_720p"
  },
  "result": {
    "model": "bytedance/seedance-2.0-mini",
    "videos": [
      {
        "status": "success",
        "id": "vid_3HkP9wQz7LmR2tXc8VbN5dFy0JsA4gUe",
        "url": "https://cdn.mynth.io/videos/vid_3HkP9wQz7LmR2tXc8VbN5dFy0JsA4gUe.mp4",
        "mynth_url": "https://cdn.mynth.io/videos/vid_3HkP9wQz7LmR2tXc8VbN5dFy0JsA4gUe.mp4",
        "cost": "0.40500000",
        "duration": 5,
        "resolution": "720p",
        "aspect_ratio": "9:16",
        "audio": true
      }
    ]
  },
  "errors": null,
  "cost": "0.40500000",
  "user_id": "user_01KE7XS3J0V5N8QF2PZ6W4MBRC",
  "api_key_id": null,
  "created_at": "2026-10-04T10:00:00.000Z",
  "updated_at": "2026-10-04T10:02:41.000Z"
}
```

> **Warning**
>
> `task.image.generate.completed` and `task.video.generate.completed` do not mean the media exists.
> Walk `data.result.images` or `data.result.videos` and check each `status`. A completed task can
> hold only failed items.

## TypeScript types

`verifyWebhook()` returns the event typed as a union discriminated on `type`:

```ts
import { verifyWebhook } from "@mynthio/sdk";

const event = await verifyWebhook({ body: await request.text(), headers: request.headers });

switch (event.type) {
  case "task.image.generate.completed":
    for (const image of event.data.result.images) {
      if (image.status === "success") console.log(image.url ?? image.mynth_url);
    }
    break;
  case "task.image.generate.failed":
    console.error(event.data.id, event.data.errors);
    break;
}
```

The type is `MynthSDKTypes.WebhookEvent`, and one variant is
`MynthSDKTypes.WebhookEventOf<"image.alt", "completed">`. The framework helpers
in `@mynthio/sdk/next`, `/tanstack-start` and `/convex` map completed events to
the same results as polling. See
[receive events](https://mynth.io/docs/concepts/webhooks.md#receive-events).

## Responding

Answer any `2xx` within 30 seconds, then do the slow work. Mynth does not
follow redirects and ignores the response body. Anything else gets retried
with the same `webhook-id`. The full schedule is in
[delivery behavior](https://mynth.io/docs/concepts/webhooks.md#delivery-behavior).

## Next steps

- [Webhooks](https://mynth.io/docs/concepts/webhooks.md): register an endpoint and verify deliveries.
- [The task object](https://mynth.io/docs/api-reference/task-object.md): every field inside `data`.
