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

Agent prompt

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#

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#

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

The response carries a wbs_... signing secret. Store it where your app reads environment variables:

MYNTH_WEBHOOK_SECRET=wbs_...

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

HandlerEvent
imageTaskCompletedtask.image.generate.completed
imageTaskFailedtask.image.generate.failed
imageRateTaskCompletedtask.image.rate.completed
imageRateTaskFailedtask.image.rate.failed
imageAltTaskCompletedtask.image.alt.completed
imageAltTaskFailedtask.image.alt.failed
imageReviewTaskCompletedtask.image.review.completed
imageReviewTaskFailedtask.image.review.failed
imageRemoveBackgroundTaskCompletedtask.image.remove_background.completed
imageRemoveBackgroundTaskFailedtask.image.remove_background.failed
videoTaskCompletedtask.video.generate.completed
videoTaskFailedtask.video.generate.failed

Next.js#

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

// 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 covers the callback context.

Convex#

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

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:

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

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:

SubscriptionReceives
task.<type>.completed, task.<type>.failedThat task type settling. <type> is image.generate, image.rate, image.alt, image.review, image.remove_background, or video.generate
task.completed, task.failedAny task type settling that way
allEvery 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.

Sources#

An endpoint receives only tasks from the sources it allows:

SourceDefaultSetting
Tasks from API keyson, every keyapiKeyIds (CLI: repeat --api-key-id) limits it to those keys
Tasks with no API key, such as playground runsoffoauthEnabled: 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.

Delivery behavior#

BehaviorValue
SuccessAny 2xx within 30 seconds
FailureAny other status, a timeout, or a connection error
RedirectsNot followed. A 3xx counts as a failure
Retries12 attempts, 5 s apart at first, doubling up to 1 h apart. About 2.5 h in total
DuplicatesPossible. Every attempt repeats the same X-Mynth-Delivery, so deduplicate on it
OrderingNot guaranteed. Two tasks can arrive in either order
Config changesEndpoint 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:

{
  "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.

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:

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: headers, signature, and the body for each task type.
  • Tasks: what completed and failed mean when the POST arrives.
  • Poll for results: the alternative when something can wait.