Webhooks
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.
Two ways to receive events
Section titled “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. Passwebhook_urlonPOST /generationsto get that generation’sgeneration.*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
Section titled “What a delivery looks like”POST /webhooks/moanhot HTTP/1.1Content-Type: application/jsonUser-Agent: moanhot-api-webhooks/1webhook-id: evt_2mY8c4Kq0x9b1v7n3p5r6t0webhook-timestamp: 1792497731webhook-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
Section titled “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:
- Verify against the raw request body, before any JSON parsing.
- Reject timestamps more than 5 minutes from your clock.
- Compare in constant time.
- 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).
import base64, hashlib, hmac, json, time
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).
Responding
Section titled “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
Section titled “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
Section titled “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
Section titled “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
Section titled “price.changed”{ "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
Section titled “balance.low and balance.topped_up”{ "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.