# Webhook payloads

> The HTTP request Mynth sends when a task settles. Headers, signature, event names, and the body for each task type.

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).

```text
POST https://your-app.com/api/mynth-webhook
Content-Type: application/json
User-Agent: Mynth-Webhook/1.0 (+https://mynth.io)
X-Mynth-Event: task.image.generate.completed
X-Mynth-Delivery: tsk_01KE7XWWEQ4MCGWKBQKJ1G47RP:dashboard:wbh_01KE7Y0C3D7XH2M9QK4VZ8TBN5:task.image.generate.completed
X-Mynth-Signature: t=1783159212,v1=5f0c6e...

{"event":"task.image.generate.completed","task":{"id":"tsk_01KE7XWWEQ4MCGWKBQKJ1G47RP"},"request":{...},"result":{...}}
```

## Headers

| Header              | Value                                                                                              |
| ------------------- | -------------------------------------------------------------------------------------------------- |
| `Content-Type`      | `application/json`                                                                                 |
| `User-Agent`        | `Mynth-Webhook/1.0 (+https://mynth.io)`                                                            |
| `X-Mynth-Event`     | The event name. Always equal to `event` in the body                                                |
| `X-Mynth-Delivery`  | A stable ID for this task, endpoint and event. Retries repeat it                                   |
| `X-Mynth-Signature` | `t=<unix seconds>,v1=<hex>`. Only on endpoints registered in the dashboard or with `POST /webhook` |

### `X-Mynth-Delivery`

The value has four parts separated by `:`:

```text
<task id>:<dashboard|custom>:<endpoint key>:<event>
```

The endpoint key is the `wbh_` id for a registered endpoint, or the first 16
hex characters of the URL's SHA-256 for a
[per-request URL](https://mynth.io/docs/concepts/webhooks.md#per-request-webhooks). Every retry
of one delivery sends the same value. Store it and skip any value you have
already handled. Treat it as an opaque string and do not parse it.

## Signature

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

```text
X-Mynth-Signature: t=<unix seconds>,v1=<hex>

v1 = hex( HMAC-SHA256( key = your full wbs_... secret,
                       message = "<t>." + raw request body ) )
```

- The key is the whole secret string, `wbs_` prefix included.
- The message is `t`, a literal `.`, 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 fresh `t`. A five-minute tolerance on `t`
  rejects replays and still accepts late retries.
- Compare in constant time. Mynth sends one `v1` today. If the header ever
  carries several, accept the delivery when any of them matches, as the SDK
  does.

Per-request `webhook.custom` URLs get no signature. Anyone who knows the URL can
post to it.

The SDK helpers verify all of this for you. Node.js code and a
language-neutral version are on [webhooks](https://mynth.io/docs/concepts/webhooks.md#any-other-stack).

## Events

The delivered event 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.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`          |

An endpoint can also subscribe to `task.completed`, `task.failed` or `all`.
Those decide what reaches the endpoint, not what it 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 still gets one delivery.

Branch on `event`, or on `X-Mynth-Event` if you want to route before reading
the body. `all` also covers task types added later, so handle unknown events by
answering `2xx` and ignoring them.

## Body

```json
{
  "event": "task.image.alt.completed",
  "task": { "id": "tsk_01KE7XWWEQ4MCGWKBQKJ1G47RP" },
  "request": { "url": "https://assets.example.com/lighthouse.webp" },
  "result": { "alt": "A white lighthouse on dark rocks, lit by an orange sunset sky" }
}
```

| Field     | Type                   | Present      | Notes                                                                                   |
| --------- | ---------------------- | ------------ | --------------------------------------------------------------------------------------- |
| `event`   | string                 | Always       | See [events](#events)                                                                   |
| `task.id` | string                 | Always       | The `tsk_` ID. The only field under `task`                                              |
| `request` | object                 | Always       | The task's `request`, defaults filled in, `metadata` unchanged when the type takes it   |
| `result`  | object                 | `.completed` | Same shape as `result` on [the task object](https://mynth.io/docs/api-reference/task-object.md#task-types) |
| `errors`  | `[{ code, message? }]` | `.failed`    | At least one entry. Codes are on [errors](https://mynth.io/docs/api-reference/errors.md#task-failures)     |

A completed payload has no `errors` key and a failed one has no `result` key.
The key is missing, not `null`.

The body has no `status`, `cost`, `apiKeyId`, or timestamps. If you need them,
call [`GET /tasks/{id}`](https://mynth.io/docs/api-reference/endpoints/tasks/get.md) with `task.id`.
To match the task to your own records, read `request.metadata`. You set it
when you created the task, and it comes back unchanged. Only image generate,
remove background, and video generate take `metadata`. For rate, alt, and
review, store the `taskId` from the create response instead.

URLs in `request.webhook.custom` are shortened to scheme, host and the first
12 characters of the path, the same as on `GET /tasks/{id}`. A token in a
per-request URL never reaches your other endpoints.

### Examples

**Image, partial**

```json
{
  "event": "task.image.generate.completed",
  "task": { "id": "tsk_01KE7XWWEQ4MCGWKBQKJ1G47RP" },
  "request": {
    "model": "black-forest-labs/flux.2-pro",
    "prompt": "A lighthouse at dusk, film grain",
    "count": 2
  },
  "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."
        }
      }
    ]
  }
}
```

**Image, failed**

```json
{
  "event": "task.image.generate.failed",
  "task": { "id": "tsk_01KE7XZ8B4N3QR5SJVDVAW6TME" },
  "request": {
    "model": "black-forest-labs/flux.2-pro",
    "prompt": "A lighthouse at dusk, film grain",
    "count": 1,
    "size": "16:9_4k"
  },
  "errors": [
    {
      "code": "CAPABILITY_NOT_SUPPORTED",
      "message": "The request uses options that are not supported for this model."
    }
  ]
}
```

**Remove background**

```json
{
  "event": "task.image.remove_background.completed",
  "task": { "id": "tsk_01KE7Y1R6WQ2J9TM4BXC8ZKD3F" },
  "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" }
    }
  }
}
```

**Rate**

```json
{
  "event": "task.image.rate.completed",
  "task": { "id": "tsk_01KE7Y3B0GZC5V7NHXA2RMQ8TP" },
  "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" }
}
```

**Review**

```json
{
  "event": "task.image.review.failed",
  "task": { "id": "tsk_01KE7Y4D8KM1P6WRZ3QXB9TJ2C" },
  "request": { "url": "https://assets.example.com/gone.webp", "effort": "high" },
  "errors": [{ "code": "FETCH_FAILED", "message": "The image could not be fetched." }]
}
```

**Video**

```json
{
  "event": "task.video.generate.completed",
  "task": { "id": "tsk_01KE7Y5T2HB9R4XQN7CZW3KM6D" },
  "request": {
    "model": "bytedance/seedance-2.0-mini",
    "prompt": "A cat surfing a wave at sunset",
    "duration": 5,
    "resolution": "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",
        "audio": true
      }
    ]
  }
}
```

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

## TypeScript types

The SDK exports the whole payload as a union discriminated on `event`:

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

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

Each variant also has its own name, such as
`MynthSDKTypes.WebhookTaskImageAltCompletedPayload`. The framework helpers in
`@mynthio/sdk/next`, `/tanstack-start` and `/convex` pass these types to
your handlers already.

## Responding

Answer any `2xx` within 30 seconds, then do the slow work. Mynth does not
follow redirects and ignores the response body. Anything other than a `2xx`
gets retried with the same `X-Mynth-Delivery`. The full schedule and ordering
rules are 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 `result`.
