Skip to content

Webhooks

View .md

Webhooks tell your server when something happens, so you do not have to poll. They follow the Standard Webhooks specification, so any of its verifier libraries works out of the box.

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

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

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.
import crypto from 'node:crypto';
/** 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).

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.

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.

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.

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

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