# Webhooks

> Receive a signed POST when a task settles instead of polling. Register an endpoint, verify the signature, 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, 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 X-Mynth-Delivery.
> 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: X-Mynth-Event, X-Mynth-Delivery, X-Mynth-Signature
  body:    { event, task: { id }, request, result | errors }
  │
  ├─ 2xx within 30 s ──▶ done
  └─ anything else ────▶ retried: 5 s, 10 s, 20 s … up to 1 h apart, 12 attempts
                          same X-Mynth-Delivery on every attempt
```

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/webhook \
  -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 response carries a `wbs_...` signing secret. Store it where your app reads
environment variables:

```bash
MYNTH_WEBHOOK_SECRET=wbs_...
```

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 /webhook/{id}` replaces the whole configuration, so send `url`, `events`,
and `enabled` every time. An omitted `oauthEnabled` becomes `false`, and an
omitted `apiKeyIds` becomes every key. `npx @mynthio/cli webhook update`
behaves the same way. `DELETE /webhook/{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, so subscribe only to what you
handle. Every helper takes the handlers below, image and video alike.

| 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`    |
| `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 (payload) => {
    for (const image of payload.result.images) {
      if (image.status === "success") console.log(payload.task.id, image.url ?? image.mynth_url);
    }
  },
  imageTaskFailed: async (payload) => {
    console.error(payload.task.id, payload.errors);
  },
});
```

The handler reads the secret from `MYNTH_WEBHOOK_SECRET` on each request, or
from `{ webhookSecret }` as the second argument. It rejects a bad or missing
signature, a timestamp more than five minutes off, a missing
`X-Mynth-Delivery`, and an `X-Mynth-Event` header that does not match the
body, all with `400`.

### 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 (payload) => {
          console.log(payload.task.id, payload.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 (payload, { context }) => {
    await context.runMutation(internal.images.save, {
      taskId: payload.task.id,
      images: payload.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

`X-Mynth-Signature` is `t=<unix seconds>,v1=<hex>`. `v1` is the hex
HMAC-SHA256 of `<t>.<raw body>`, keyed with the whole `wbs_...` secret.

```text
raw_body = the request body as bytes, before any JSON parsing
t, v1    = parse X-Mynth-Signature
reject unless |now_unix − t| <= 300
expected = hex(HMAC-SHA256(key = secret, message = t + "." + raw_body))
reject unless expected equals v1, compared in constant time
payload  = JSON.parse(raw_body)
handle payload.event, return 2xx
```

In Node.js:

```js
import http from "node:http";
import crypto from "node:crypto";

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

function verify(header, body) {
  const { t, v1 } = Object.fromEntries(header.split(",").map((part) => part.split("=", 2)));
  if (!t || !v1) return false;
  if (Math.abs(Math.floor(Date.now() / 1000) - Number(t)) > 300) return false;

  const expected = crypto.createHmac("sha256", SECRET).update(`${t}.`).update(body).digest("hex");
  return (
    v1.length === expected.length && crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(expected))
  );
}

http
  .createServer((req, res) => {
    const chunks = [];
    req.on("data", (chunk) => chunks.push(chunk));
    req.on("end", () => {
      const body = Buffer.concat(chunks);
      if (!verify(String(req.headers["x-mynth-signature"] ?? ""), body)) {
        res.writeHead(400).end("Bad signature");
        return;
      }

      const payload = JSON.parse(body.toString("utf8"));
      res.writeHead(200).end("OK");
      // do the slow work after answering
      console.log(payload.event, payload.task.id);
    });
  })
  .listen(3000);
```

> **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`, or `video.generate` |
| `task.completed`, `task.failed`               | Any task type settling that way                                                                                                                  |
| `all`                                         | Every event, including future task types                                                                                                         |

The delivered event 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 | `apiKeyIds` (CLI: repeat `--api-key-id`) limits it to those keys |
| Tasks with no API key, such as playground runs | off           | `oauthEnabled: true` (CLI: `--oauth-events`)                     |

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        | 12 attempts, 5 s apart at first, doubling up to 1 h apart. About 2.5 h in total   |
| Duplicates     | Possible. Every attempt repeats the same `X-Mynth-Delivery`, 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
`X-Mynth-Delivery` and skip a value you have already handled. The SDK helpers
pass it to every handler as `deliveryId`. Answer first and
do slow work afterwards, because a handler that runs past 30 seconds counts as
a failure and is retried.

## Per-request webhooks

`POST /image/generate`, `POST /image/remove-background`, 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": {
    "dashboard": false,
    "custom": [{ "url": "https://your-app.com/hooks/one-off/9f2c1e" }]
  }
}
```

`custom` takes up to five URLs. `dashboard: false` skips your registered
endpoints for this task. Rate, alt text, and review requests do not take
`webhook`.

> **Warning**
>
> Custom URLs get no `X-Mynth-Signature`, and the SDK helpers reject unsigned deliveries. Anyone who
> learns the URL can POST to it, so treat the payload as unverified, or put a hard-to-guess token in
> the URL path. The task record and other payloads show only the first 12 characters of that path.

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