# Webhooks

Receive signed events for generations, the catalogue, prices and your balance. Verification in Node and Python, retries, event types and payloads.

Webhooks tell your server when something happens, so you do not have to poll. They follow the [Standard Webhooks](https://www.standardwebhooks.com/) specification, so any of its verifier libraries works out of the box.

## Two ways to receive events

- **Registered endpoints.** Add an https URL in the dashboard under **Webhooks**, pick the events it receives, and copy its signing secret. Each endpoint has its own secret and its own delivery log.
- **Per-request `webhook_url`.** Pass `webhook_url` on `POST /generations` to get that generation's `generation.*` events at a URL of your choice. These are signed with your account's **ad-hoc secret**, one per mode, shown on the same dashboard page.

Both can be used together; registered endpoints still receive their subscribed events. The `webhook_url` must be public `https`; anything else answers `422 webhook_url_rejected`.

Webhook endpoints are managed only in the dashboard, so a leaked API key cannot redirect your events.

## What a delivery looks like

```http
POST /webhooks/moanhot HTTP/1.1
Content-Type: application/json
User-Agent: moanhot-api-webhooks/1
webhook-id: evt_2mY8c4Kq0x9b1v7n3p5r6t0
webhook-timestamp: 1792497731
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=

{
  "type": "generation.succeeded",
  "timestamp": "2026-10-20T12:02:11Z",
  "mode": "live",
  "sequence": 3,
  "data": { "id": "gen_7c2x9q0k3m1z8b4v6n5p2r0t", "object": "generation", "status": "succeeded", "...": "..." }
}
```

| Field | Meaning |
|---|---|
| `type` | The event type, below |
| `timestamp` | When the event happened (not when this attempt was sent) |
| `mode` | `live` or `test` |
| `sequence` | Per object, increases with every event. Use it to drop stale events |
| `data` | The full current object: a Generation, an Effect, a price change or balances |

## Verify the signature

The signature is `v1,` plus the base64 HMAC-SHA256 of `{webhook-id}.{webhook-timestamp}.{raw body}`, keyed with the base64-decoded part of your `whsec_` secret. During a secret rotation the header carries two signatures separated by a space, one per secret, for 24 hours; accept either.

Always:

1. Verify against the **raw** request body, before any JSON parsing.
2. Reject timestamps more than 5 minutes from your clock.
3. Compare in constant time.
4. Deduplicate on `webhook-id`; it is the same on every retry of one event.

<Tabs syncKey="lang">
<TabItem label="Node">

```js

/** Returns the parsed event, or throws when the delivery is not authentic. */
export function verifyWebhook(rawBody, headers, secret) {
  const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
  const id = headers['webhook-id'];
  const timestamp = headers['webhook-timestamp'];
  const signatures = String(headers['webhook-signature'] ?? '').split(' ');

  if (!id || !timestamp) throw new Error('missing webhook headers');
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) throw new Error('timestamp too old');

  const expected = crypto.createHmac('sha256', key).update(`${id}.${timestamp}.${rawBody}`).digest();
  const ok = signatures.some((entry) => {
    const [version, sig] = entry.split(',');
    if (version !== 'v1' || !sig) return false;
    const given = Buffer.from(sig, 'base64');
    return given.length === expected.length && crypto.timingSafeEqual(given, expected);
  });
  if (!ok) throw new Error('bad signature');
  return JSON.parse(rawBody);
}
```

Or use the official library: `npm install standardwebhooks`, then `new Webhook(secret).verify(rawBody, headers)`.

</TabItem>
<TabItem label="Python">

```python

def verify_webhook(raw_body: bytes, headers, secret: str) -> dict:
    """Returns the parsed event, or raises when the delivery is not authentic."""
    key = base64.b64decode(secret.removeprefix("whsec_"))
    msg_id = headers["webhook-id"]
    timestamp = headers["webhook-timestamp"]
    if abs(time.time() - int(timestamp)) > 300:
        raise ValueError("timestamp too old")

    expected = hmac.new(key, f"{msg_id}.{timestamp}.".encode() + raw_body, hashlib.sha256).digest()
    for entry in headers.get("webhook-signature", "").split():
        version, _, sig = entry.partition(",")
        if version == "v1" and hmac.compare_digest(base64.b64decode(sig), expected):
            return json.loads(raw_body)
    raise ValueError("bad signature")
```

Or use the official library: `pip install standardwebhooks`, then `Webhook(secret).verify(raw_body, headers)`.

</TabItem>
</Tabs>

## Responding

Answer any `2xx` within 15 seconds to acknowledge. Do the real work after you respond, for example by putting the event on a queue. We do not follow redirects. Answering `410 Gone` disables the endpoint at once.

## Retries

A delivery that does not get a `2xx` is retried with exponential backoff, each delay with up to 10% jitter:

| Attempt | Delay after the previous one |
|---|---|
| 1 | Immediately |
| 2 | 5 seconds |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 2 hours |
| 6 | 5 hours |
| 7 | 10 hours |
| 8 | 14 hours |
| 9 | 20 hours |
| 10 | 20 hours |

The last attempt comes about 3 days after the first. After that the delivery is marked failed. An endpoint with no successful delivery for 5 days in a row is disabled and you are emailed. Once you fix and re-enable it, you can replay failed deliveries from the dashboard's delivery log, which also has **Resend** and **Send test event**. A test event has type `test.ping`, is signed like any other delivery, and never touches your balance.

## Ordering

Delivery is **at least once and unordered**. A retried `generation.queued` can arrive after `generation.succeeded`. Keep the highest `sequence` you have seen per object and ignore lower ones. The source of truth is always `GET /generations/{id}`. Output URLs in a late event may have expired; read the generation again for fresh ones.

## Event types

| Type | When | `data` |
|---|---|---|
| `generation.queued` | A create was accepted | Generation |
| `generation.started` | A GPU worker started it | Generation |
| `generation.succeeded` | Outputs are stored and the price charged | Generation, with fresh signed output URLs |
| `generation.failed` | It failed and the hold was released | Generation, with `error` |
| `generation.canceled` | A cancel completed | Generation |
| `effect.published` | A new effect is live for your account | Effect |
| `effect.retired` | An effect is scheduled to retire, at least 14 days ahead | Effect, with `retires_at` |
| `price.changed` | A new price list is scheduled, at least 7 days ahead | `version`, `effective_at`, `changes[]` |
| `balance.low` | Your available balance fell below your alert threshold | Balances and `threshold_micros` |
| `balance.topped_up` | A top-up was credited | Balances and `topup` |

### price.changed

```json
{
  "type": "price.changed",
  "timestamp": "2026-10-20T09:00:00Z",
  "mode": "live",
  "data": {
    "version": 4,
    "effective_at": "2026-10-27T00:00:00Z",
    "changes": [
      { "effect": "EFFECT_SLUG", "variant_key": "10s_640p", "old_price_micros": 180000, "new_price_micros": 160000 }
    ]
  }
}
```

A `null` old price means a new variant; a `null` new price means the variant is going away.

### balance.low and balance.topped_up

```json
{
  "type": "balance.topped_up",
  "timestamp": "2026-10-20T10:15:00Z",
  "mode": "live",
  "data": {
    "balance_micros": 250000000,
    "available_micros": 249600000,
    "threshold_micros": 20000000,
    "topup": { "invoice_id": "inv_...", "amount_micros": 200000000, "currency_paid": "USDTTRC20" }
  }
}
```

`balance.low` fires once when the available balance crosses below the threshold, and re-arms when it rises back above. Set the threshold in the dashboard.

Every payload schema is also in the [API reference](/docs/api/).
