# Webhooks

> Get a signed POST when a job finishes, instead of polling for it.

Source: https://developers.mnml.ai/docs/webhooks

## Set up in three steps

### Step 1: Add your endpoint

In the console’s [Webhooks](https://developers.mnml.ai/console/webhooks) page, add a public `https://` URL on your server. To hear about one job only, pass `webhook_url` on its create call instead.

### Step 2: Check each message is from mnml

Each endpoint has its own signing secret: open **Secret** on its row, copy it, and verify every delivery with it. A `webhook_url` passed on a create call is signed with the account secret on the same page. See [Verifying the signature](#verify).

### Step 3: Answer 2xx within 15 seconds

Reply first, then do the work. Use **Send test** in the console to try it.

## Events

| Event | When |
| --- | --- |
| `job.succeeded` | A job finished and has outputs. |
| `job.failed` | A job did not finish, and is refunded. |
| `job.canceled` | A job was cancelled before it delivered, and is refunded. |
| `credits.low` | A job settled with your balance under $4.95. At most once a day; `data` is `{ balance, threshold }`, counted as [GET /v2/account](https://developers.mnml.ai/docs/account#amounts) counts your balance. We email you too. |
| `webhook.test` | You pressed Send test in the console. |

An endpoint gets every event unless you pick some. The console playground’s own runs send nothing.

## The message

`data` is exactly what [GET /v2/jobs/{id}](https://developers.mnml.ai/docs/jobs) answers, so one parser serves polling and webhooks. Output links are signed and expire; download what you keep.

**POST to your endpoint**

```json
{
  "type": "job.succeeded",
  "timestamp": "2026-10-04T12:00:07.000Z",
  "data": {
    "id": "10482",
    "status": "succeeded",
    "kind": "render",
    "engine": "v4.6",
    "outputs": [
      {
        "url": "https://api.mnml.ai/v2/files/10482?exp=1759575600&sig=Q2h4…",
        "media": "image",
        "expires_at": "2026-10-04T11:00:00.000Z"
      }
    ],
    "charged_usd": 0.2475,
    "refunded_usd": 0,
    "credits_charged": 25,
    "credits_refunded": 0,
    "error": null,
    "created_at": "2026-10-04T09:00:00.000Z",
    "completed_at": "2026-10-04T09:00:41.000Z"
  }
}
```

## Verifying the signature

Each [SDK](https://developers.mnml.ai/docs/quickstart#sdk) verifies a delivery in one call, and rejects a bad signature or a stale timestamp. Deliveries follow the [Standard Webhooks](https://www.standardwebhooks.com) scheme, so its libraries work too, in any language. Verify against the raw body, before you parse it.

> [!TIP]
> Under the hood: each delivery carries `webhook-id`, `webhook-timestamp` and `webhook-signature`, an HMAC-SHA256 of `id.timestamp.body` under the endpoint’s signing secret. Reject a timestamp more than five minutes old. Resetting a secret in the console signs every later delivery with the new one at once.

**TypeScript**

```ts
import { verifyWebhook } from '@mnml-ai/sdk/webhooks';

// Next.js App Router
export async function POST(request: Request) {
  const event = verifyWebhook(
    await request.text(),
    request.headers,
    process.env.MNML_WEBHOOK_SECRET!,
  );
  if (event.type === 'job.succeeded') {
    // event.data is the job, as jobs.get returns it
  }
  return new Response(null, { status: 204 });
}
```

**Python**

```python
import os
from flask import Flask, request
from mnml_ai import WebhookVerificationError, verify_webhook

app = Flask(__name__)


@app.post("/webhooks/mnml")
def mnml_webhook():
    try:
        event = verify_webhook(request.get_data(), request.headers, os.environ["MNML_WEBHOOK_SECRET"])
    except WebhookVerificationError:
        return "", 400
    if event["type"] == "job.succeeded":
        job = event["data"]  # the job, as jobs.get returns it
    return "", 204
```

**PHP**

```php
$event = Mnml\Webhooks::verify(file_get_contents('php://input'), getallheaders(), $secret);
if ($event->type === 'job.succeeded') {
    echo $event->job()->outputs[0]->url;
}
```

**Go**

```go
http.HandleFunc("/mnml", func(w http.ResponseWriter, r *http.Request) {
	body, _ := io.ReadAll(r.Body)
	ev, err := mnml.VerifyWebhook(body, r.Header, secret, time.Now())
	if err != nil {
		http.Error(w, "bad signature", http.StatusBadRequest)
		return
	}
	if ev.Type == "job.succeeded" {
		job, _ := ev.Job()
		fmt.Println(job.Outputs[0].URL)
	}
})
```

**C#**

```csharp
// ASP.NET Core minimal API
app.MapPost("/mnml", async (HttpRequest req) =>
{
    var body = await new StreamReader(req.Body).ReadToEndAsync();
    var ev = Webhooks.Verify(body, name => req.Headers[name].ToString(), secret);
    if (ev.Type == "job.succeeded") Console.WriteLine(ev.AsJob()!.Outputs[0].Url);
    return Results.Ok();
});
```

## Retries

Answer with any 2xx within 15 seconds, then do the work. Anything else is retried after 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours and 10 hours, about a day in all. A message can arrive more than once: use `webhook-id` to ignore a repeat.

Every attempt is in the console’s delivery log, with the status your endpoint answered, for 30 days. A delivery that ran out of retries can be sent again with Replay.

An endpoint you switch off or delete gets nothing more, its waiting retries included; a test event from the console still reaches a switched-off one. An endpoint that has answered nothing but errors for three days is switched off for you, and the console says so: switch it back on once it answers, and deliveries start again.

## Endpoint URLs

- A public `https://` address. Private and local addresses are refused when you save, and again when we connect.
- Redirects are not followed: point at the final URL.
- Up to 10 endpoints per account.
