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 instead.
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.
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 attemptDeliveries follow Standard Webhooks, and
data is the task exactly as GET /tasks/{id} returns it. The headers and
body are on webhook payloads.
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.
npx @mynthio/cli webhook create \
--url https://your-app.com/api/mynth-webhook \
-e task.image.generate.completed \
-e task.image.generate.failedThe URL must be https on a public host. The response carries a whsec_...
signing secret. Store it where your app reads environment variables:
MYNTH_WEBHOOK_SECRET=whsec_...The API and the CLI return the secret only on create. The endpoint's page in
the dashboard 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.
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 |
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.
Same checks as the Next.js helper. TanStack Start covers the callback context.
Same checks as the Next.js helper. Convex covers the action context.
Use a Standard Webhooks library.
There is one for most languages, and each takes the whsec_ secret as it is.
Without the SDK, verifyWebhook() does the same in TypeScript:
To verify by hand, the signing scheme is on webhook payloads.
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().
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.
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.
| 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.
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:
{
"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.
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:
// 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.
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:
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.
completed and failed mean when the POST arrives.