Guides
Webhooks
Receive generation.succeeded and generation.failed webhooks and verify the CheaperGen-Signature HMAC-SHA256 header in Node.js or Python.
Instead of polling, pass a webhook_url when you submit a generation. When the job reaches a final state we send an HTTPS POST to that URL with the full generation object, signed with your account's secret.
Requesting a webhook#
Add webhook_url to the body of any POST /api/v1/run/{endpoint} request. It must be an HTTPS URL that is reachable from the internet.
curl -X POST https://cheapergen.com/api/v1/run/bytedance/seedance-2.0-fast/text-to-video \
-H "Authorization: Bearer $CHEAPERGEN_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"prompt": "A slow dolly-in on a ceramicist shaping a vase at golden hour, dust in the light, shallow depth of field",
"aspect_ratio": "16:9",
"resolution": "720p",
"duration": 5,
"webhook_url": "https://your-app.com/hooks/cheapergen"
}'Events#
generation.succeededevent- The generation finished.
data.outputcontains the media URLs anddata.costthe final charge. generation.failedevent- The generation failed.
data.errorexplains why; the hold has been refunded anddata.cost.amount_usdis 0.
Canceled generations don't trigger a webhook — you canceled them, so you already know.
Payload#
The body is JSON with the event type and the generation in data — the same object you get from GET /generations/{id}.
{
"type": "generation.succeeded",
"data": {
"id": "gen_8c2QmX1nq0k4Lw7Z",
"object": "generation",
"model": "bytedance/seedance-2.0-fast",
"endpoint": "bytedance/seedance-2.0-fast/text-to-video",
"mode": "text-to-video",
"status": "succeeded",
"input": {
"prompt": "A slow dolly-in on a ceramicist shaping a vase at golden hour, dust in the light, shallow depth of field",
"aspect_ratio": "16:9",
"resolution": "720p",
"duration": 5,
"generate_audio": true
},
"output": {
"video": {
"url": "https://cdn.cheapergen.ai/outputs/gen_8c2QmX1nq0k4Lw7Z.mp4",
"content_type": "video/mp4"
}
},
"error": null,
"cost": {
"amount_usd": 0.3278,
"currency": "usd",
"estimated": false
},
"source": "api",
"created_at": "2026-10-15T09:21:04.512Z",
"started_at": "2026-10-15T09:21:09.031Z",
"completed_at": "2026-10-15T09:22:31.870Z",
"retention": "1d",
"expires_at": "2026-10-16T09:22:31.870Z",
"purged_at": null
}
}Headers#
CheaperGen-Signaturestring- Timestamp and signature:
t=<unix seconds>,v1=<hex HMAC-SHA256>. Content-Typestringapplication/jsonUser-AgentstringCheaperGen-Webhooks/1.0
Verifying signatures#
Always verify the signature before trusting a webhook — anyone can POST to a public URL. The header looks like this:
CheaperGen-Signature: t=1760520151,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd- Split the header on
,and read thet(timestamp) andv1(signature) values. - Build the signed string: the timestamp, a literal
., then the raw request body exactly as received. - Compute HMAC-SHA256 of that string with your signing secret as the key, and hex-encode it.
- Compare it with
v1using a constant-time comparison, and reject timestamps more than a few minutes old to prevent replays.
Your signing secret starts with whsec_ and is shown in the dashboard next to your API keys. Keep it server-side, like an API key.
Use the raw body
Verify against the bytes you received. Parsing the JSON and serializing it again changes whitespace and key order, and the signature will not match.Node.js#
import crypto from "node:crypto";
const TOLERANCE_SECONDS = 300; // reject deliveries older than 5 minutes
/**
* Verify a Cheaper Gen. webhook.
* @param rawBody the request body exactly as received (string or Buffer) — not re-serialized JSON
* @param header the value of the "CheaperGen-Signature" header
* @param secret your signing secret (whsec_…)
*/
export function verifyCheaperGenSignature(rawBody, header, secret) {
const parts = Object.fromEntries(
String(header ?? "")
.split(",")
.map((kv) => kv.trim().split("=", 2)),
);
const t = Number(parts.t);
const v1 = parts.v1;
if (!Number.isFinite(t) || !v1) return false;
if (Math.abs(Date.now() / 1000 - t) > TOLERANCE_SECONDS) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${t}.${rawBody}`)
.digest("hex");
const a = Buffer.from(expected, "hex");
const b = Buffer.from(v1, "hex");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}Python#
import hashlib, hmac, time
TOLERANCE_SECONDS = 300 # reject deliveries older than 5 minutes
def verify_cheapergen_signature(raw_body: bytes, header: str, secret: str) -> bool:
"""Verify a Cheaper Gen. webhook. raw_body must be the exact bytes received."""
try:
parts = dict(kv.strip().split("=", 1) for kv in (header or "").split(","))
t, v1 = int(parts["t"]), parts["v1"]
except (KeyError, ValueError):
return False
if abs(time.time() - t) > TOLERANCE_SECONDS:
return False
signed = f"{t}.".encode() + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1)Delivery and retries#
- Respond with any 2xx status within 10 seconds to acknowledge the delivery. Redirects are not followed.
- If your endpoint times out or returns a non-2xx status, we retry. Each event is attempted up to 3 times with backoff (after about 2 and 8 seconds).
- Deliveries can arrive more than once and, rarely, out of order. Make your handler idempotent: key on
data.idand ignore events for generations you have already processed. - Webhooks are a notification, not the only source of truth. If you miss one, the generation is always available from
GET /api/v1/generations/{id}— a periodic reconcile of jobs still pending on your side covers every edge case.
Testing locally#
Webhook URLs must be publicly reachable over HTTPS. During development, expose your local server with a tunnel (for example cloudflared tunnel or ngrok http 3000) and use the tunnel's HTTPS URL as webhook_url.
Something unclear or missing? Email support@cheapergen.com — we read everything.