Cheaper Gen.

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.output contains the media URLs and data.cost the final charge.
generation.failedevent
The generation failed. data.error explains why; the hold has been refunded and data.cost.amount_usd is 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}.

POST https://your-app.com/hooks/cheapergen
{
  "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-Typestring
application/json
User-Agentstring
CheaperGen-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
  1. Split the header on , and read the t (timestamp) and v1 (signature) values.
  2. Build the signed string: the timestamp, a literal ., then the raw request body exactly as received.
  3. Compute HMAC-SHA256 of that string with your signing secret as the key, and hex-encode it.
  4. Compare it with v1 using 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.id and 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.