# Webhooks

> Receive a signed POST when a task settles instead of polling. Register an endpoint, verify it with Standard Webhooks, choose events and sources, and handle retries.

A webhook is an HTTP endpoint of yours that Mynth POSTs to when a task
completes or fails. Use one when the code that creates the task cannot wait
for it: serverless handlers, request/response paths where a user is waiting,
and long video renders. If your code can hold a connection, `await` the SDK
call or [poll](https://mynth.io/docs/guides/poll-for-results.md) instead.

> **Prompt to give a coding agent**
>
> Add a Mynth webhook receiver to this project. Read `npx @mynthio/cli docs get concepts/webhooks`
> first. Use the SDK helper for this stack if there is one, or a Standard Webhooks library, and fit
> the handler into the repo's existing routes. Read the secret from MYNTH_WEBHOOK_SECRET. Verify the
> signature against the raw request body, never re-serialized JSON. Return 2xx quickly and
> deduplicate on the webhook-id header. Ask me which events to subscribe to. The CLI (`npx
> @mynthio/cli webhook create`) can register the endpoint.

## How delivery works

```text
task settles
  │
  ▼
POST https://your-app.com/api/mynth-webhook
  headers: webhook-id, webhook-timestamp, webhook-signature
  body:    { id, type, timestamp, data: <the task> }
  │
  ├─ 2xx within 30 s ──▶ done
  └─ anything else ────▶ retried: 5 s, 10 s, 20 s … up to 1 h apart, for about 72 h
                          same webhook-id on every attempt
```

Deliveries follow [Standard Webhooks](https://www.standardwebhooks.com), and
`data` is the task exactly as `GET /tasks/{id}` returns it. The headers and
body are on [webhook payloads](https://mynth.io/docs/api-reference/webhook-payloads.md).

A task reaches two kinds of endpoint:

|            | Registered                                                                                       | Custom                                                  |
| ---------- | ------------------------------------------------------------------------------------------------ | ------------------------------------------------------- |
| Set up     | Once, in the dashboard, the CLI or `POST /webhooks`                                              | Per task, in the request's `webhook.custom`             |
| Receives   | Every task that matches its events and sources, unless the task sets `webhook.registered: false` | Only the task it was set on, completed or failed        |
| Signed     | Yes, with its `whsec_` secret                                                                    | No. Check a token you put in the URL                    |
| Shown back | In full, on the endpoint                                                                         | As `scheme://host` only, on the task and in every event |

Creating the task needs nothing extra: every registered endpoint that
subscribed to the event receives it. Each endpoint gets its own delivery, so
one failing endpoint never holds back another.

## Register an endpoint

**CLI**

```bash
npx @mynthio/cli webhook create \
  --url https://your-app.com/api/mynth-webhook \
  -e task.image.generate.completed \
  -e task.image.generate.failed
```

**REST**

```bash
curl https://api.mynth.io/webhooks \
  -X POST \
  -H "Authorization: Bearer $MYNTH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://your-app.com/api/mynth-webhook",
    "events": ["task.image.generate.completed", "task.image.generate.failed"]
  }'
```

The URL must be `https` on a public host. The response carries a `whsec_...`
signing secret. Store it where your app reads environment variables:

```bash
MYNTH_WEBHOOK_SECRET=whsec_...
```

The API and the CLI return the secret only on create. The endpoint's page in
the [dashboard](https://mynth.io/dashboard/webhooks) keeps showing it. Webhook endpoints need
a key with the `manage` scope. The key from `npx @mynthio/cli auth login` has
it.

`PUT /webhooks/{id}` replaces the whole configuration, so send `url`, `events`,
and `enabled` every time. An omitted `include_session_tasks` becomes `false`,
and an omitted `api_key_ids` becomes every key. `npx @mynthio/cli webhook update`
behaves the same way. `DELETE /webhooks/{id}` removes the endpoint and its
secret.

## Receive events

The SDK helpers verify the signature and call the handler for the event. They
answer `200` for an event with no handler, including event types newer than
the SDK, so subscribe only to what you handle. Every helper takes the handlers
below, image and video alike, plus `onEvent`, which receives every event
before its handler runs.

| Handler                              | Event                                    |
| ------------------------------------ | ---------------------------------------- |
| `imageTaskCompleted`                 | `task.image.generate.completed`          |
| `imageTaskFailed`                    | `task.image.generate.failed`             |
| `imageRateTaskCompleted`             | `task.image.rate.completed`              |
| `imageRateTaskFailed`                | `task.image.rate.failed`                 |
| `imageAltTaskCompleted`              | `task.image.alt.completed`               |
| `imageAltTaskFailed`                 | `task.image.alt.failed`                  |
| `imageReviewTaskCompleted`           | `task.image.review.completed`            |
| `imageReviewTaskFailed`              | `task.image.review.failed`               |
| `imageRemoveBackgroundTaskCompleted` | `task.image.remove_background.completed` |
| `imageRemoveBackgroundTaskFailed`    | `task.image.remove_background.failed`    |
| `imageUpscaleTaskCompleted`          | `task.image.upscale.completed`           |
| `imageUpscaleTaskFailed`             | `task.image.upscale.failed`              |
| `videoTaskCompleted`                 | `task.video.generate.completed`          |
| `videoTaskFailed`                    | `task.video.generate.failed`             |

### Next.js

```ts
// app/api/mynth-webhook/route.ts
import { mynthWebhookHandler } from "@mynthio/sdk/next";

export const POST = mynthWebhookHandler({
  imageTaskCompleted: async (result) => {
    for (const image of result.images) {
      console.log(result.taskId, image.url ?? image.mynthUrl);
    }
  },
  imageTaskFailed: async (failure) => {
    console.error(failure.taskId, failure.errors);
  },
});
```

The handler reads the secret from `MYNTH_WEBHOOK_SECRET` on each request, or
from `{ webhookSecret }` as the second argument. It answers `400` for a
missing or wrong signature, a `webhook-timestamp` more than five minutes off,
a missing `webhook-id`, and a body whose `id` is not the `webhook-id`.

Completed callbacks receive the same plain result objects as polling,
including `taskId`, `cost`, and successful media. Failed callbacks receive
`taskId`, `errors`, and `raw`, plus `metadata` when the request supports it.
The callback objects can be passed directly to server functions and Convex
mutations. The second argument carries the verified `event`; deduplicate on
`event.id`. See [generating media](https://mynth.io/docs/sdks/typescript/generating-media.md).

### TanStack Start

```ts
// src/routes/api/mynth-webhook.ts
import { createFileRoute } from "@tanstack/react-router";
import { mynthWebhookHandler } from "@mynthio/sdk/tanstack-start";

export const Route = createFileRoute("/api/mynth-webhook")({
  server: {
    handlers: {
      POST: mynthWebhookHandler({
        imageTaskCompleted: async (result) => {
          console.log(result.taskId, result.images);
        },
      }),
    },
  },
});
```

Same checks as the Next.js helper. [TanStack Start](https://mynth.io/docs/sdks/integrations/tanstack-start.md)
covers the callback context.

### Convex

```ts
// convex/http.ts
import { httpRouter } from "convex/server";
import { mynthWebhookAction } from "@mynthio/sdk/convex";

import { internal } from "./_generated/api";

const http = httpRouter();

export const mynthWebhook = mynthWebhookAction({
  imageTaskCompleted: async (result, { context }) => {
    await context.runMutation(internal.images.save, {
      taskId: result.taskId,
      images: result.images,
    });
  },
});

http.route({ path: "/mynth-webhook", method: "POST", handler: mynthWebhook });

export default http;
```

Same checks as the Next.js helper. [Convex](https://mynth.io/docs/sdks/integrations/convex.md)
covers the action context.

### Any other stack

Use a [Standard Webhooks library](https://github.com/standard-webhooks/standard-webhooks/tree/main/libraries).
There is one for most languages, and each takes the `whsec_` secret as it is.
Without the SDK, `verifyWebhook()` does the same in TypeScript:

```js
import http from "node:http";
import { Webhook } from "standardwebhooks";

const SECRET = process.env.MYNTH_WEBHOOK_SECRET;
if (!SECRET) throw new Error("MYNTH_WEBHOOK_SECRET is required");

const webhook = new Webhook(SECRET);

http
  .createServer((req, res) => {
    const chunks = [];
    req.on("data", (chunk) => chunks.push(chunk));
    req.on("end", () => {
      let event;
      try {
        event = webhook.verify(Buffer.concat(chunks).toString("utf8"), req.headers);
      } catch {
        res.writeHead(400).end("Bad signature");
        return;
      }

      res.writeHead(200).end("OK");
      // do the slow work after answering, once per event.id
      console.log(event.id, event.type, event.data.id);
    });
  })
  .listen(3000);
```

To verify by hand, the signing scheme is on
[webhook payloads](https://mynth.io/docs/api-reference/webhook-payloads.md#signature).

> **Warning**
>
> Verify the raw body exactly as received. Parsing the JSON and serializing it again changes the
> bytes, and the signature no longer matches. On Express, mount `express.raw({ type:
> "application/json" })` on the webhook route instead of `express.json()`.

## Events

Subscribe an endpoint to exact events, to a status across all task types, or
to everything:

| Subscription                                  | Receives                                                                                                                                                          |
| --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `task.<type>.completed`, `task.<type>.failed` | That task type settling. `<type>` is `image.generate`, `image.rate`, `image.alt`, `image.review`, `image.remove_background`, `image.upscale`, or `video.generate` |
| `task.completed`, `task.failed`               | Any task type settling that way                                                                                                                                   |
| `all`                                         | Every event, including future task types                                                                                                                          |

The event `type` is always the exact name, such as
`task.image.alt.completed`, never `all`. An endpoint that matches through
several of its subscriptions still gets one delivery. The body and an example
for every task type are on [webhook payloads](https://mynth.io/docs/api-reference/webhook-payloads.md).

## Sources

An endpoint receives only tasks from the sources it allows:

| Source                                         | Default       | Setting                                                                                      |
| ---------------------------------------------- | ------------- | -------------------------------------------------------------------------------------------- |
| Tasks from API keys                            | on, every key | `api_key_ids` (CLI: repeat `--api-key-id`) limits it to those keys. `[]` turns every key off |
| Tasks with no API key, such as playground runs | off           | `include_session_tasks: true` (CLI: `--include-session-tasks`)                               |

Tasks from the CLI carry the API key that `auth login` stored, so they deliver
on the default. You can also set sources in the endpoint's Sources section in
the [dashboard](https://mynth.io/dashboard/webhooks).

## Delivery behavior

| Behavior       | Value                                                                               |
| -------------- | ----------------------------------------------------------------------------------- |
| Success        | Any `2xx` within 30 seconds                                                         |
| Failure        | Any other status, a timeout, or a connection error                                  |
| Redirects      | Not followed. A `3xx` counts as a failure                                           |
| Retries        | 5 s apart at first, doubling up to 1 h apart, with jitter. For about 72 h in total  |
| Duplicates     | Possible. Every delivery of an event repeats its `webhook-id`, so deduplicate on it |
| Ordering       | Not guaranteed. Two tasks can arrive in either order                                |
| Config changes | Endpoint edits can take up to 5 minutes to reach deliveries                         |

Deliveries are at least once. Make the handler idempotent: store the
`webhook-id` and skip one you have already handled. The ID is the same on
every retry and to every endpoint, and the SDK helpers pass it as `event.id`.
Answer first and do slow work afterwards, because a handler that runs past 30
seconds counts as a failure and is retried.

A URL that resolves to a private, loopback or link-local address is refused
when the delivery connects, and is not retried.

## Per-request webhooks

`POST /image/generate`, `POST /image/remove-background`,
`POST /image/upscale`, and `POST /video/generate` take a `webhook` field for
one-off URLs that are not registered:

```json
{
  "model": "black-forest-labs/flux.2-pro",
  "prompt": "A lighthouse at dusk, film grain",
  "webhook": {
    "registered": false,
    "custom": [{ "url": "https://your-app.com/hooks/mynth?token=9f2c1e7b4a0d" }]
  }
}
```

`custom` takes up to five `https` URLs on public hosts. Each one receives this
task's event, completed or failed, whatever events your registered endpoints
subscribe to. `registered: false` skips your registered endpoints for this
task. Rate, alt text, and review requests do not take `webhook`.

> **Warning**
>
> Custom URLs get no `webhook-signature`: Mynth has no secret of yours to sign them with, so
> checking that a delivery is real is up to you. Put a long random token in the query string and
> compare it in constant time. The task, `GET /tasks/{id}` and every event show the URL only as
> `scheme://host`, so the token never reaches anyone else.

The SDK helpers reject unsigned deliveries unless you pass `unsigned`, which
replaces the signature check with yours:

```ts
// app/api/mynth-webhook/one-off/route.ts
import { timingSafeEqual } from "node:crypto";
import { mynthWebhookHandler } from "@mynthio/sdk/next";

const TOKEN = Buffer.from(process.env.MYNTH_CUSTOM_WEBHOOK_TOKEN ?? "");

export const POST = mynthWebhookHandler(
  {
    imageTaskCompleted: async (result) => {
      console.log(result.taskId, result.images);
    },
  },
  {
    unsigned: {
      verify: (request) => {
        const token = Buffer.from(new URL(request.url).searchParams.get("token") ?? "");
        return TOKEN.length > 0 && token.length === TOKEN.length && timingSafeEqual(token, TOKEN);
      },
    },
  },
);
```

`verify` returning `false` answers `401`. The event is still checked: its
body must be a Mynth event whose `id` is the `webhook-id`.

## Testing locally

There is no test-event generator. A real task is cheap enough. Expose your
local server with a tunnel, register the tunnel URL, and create a task:

```bash
ngrok http 3000
npx @mynthio/cli webhook create --url https://your-tunnel.ngrok.app/api/mynth-webhook -e all
npx @mynthio/cli image generate -m black-forest-labs/flux.2-klein-4b -p "A lighthouse at dusk"
```

Subscribing to `all` while testing means a refused or failed task still sends
you something.

## Next steps

- [Webhook payloads](https://mynth.io/docs/api-reference/webhook-payloads.md): headers, signature, and the body for each task type.
- [Tasks](https://mynth.io/docs/concepts/tasks.md): what `completed` and `failed` mean when the POST arrives.
- [Poll for results](https://mynth.io/docs/guides/poll-for-results.md): the alternative when something can wait.
