Dokumentasi
Webhook
Events
Register an HTTPS endpoint on the developer page (up to 5 endpoints per account). CheapVids sends a POST with a JSON body when a job:
| Event | Sent when |
|---|---|
generation.started | the generator starts the job |
generation.completed | the job finished and the result can be downloaded |
generation.failed | the job failed and was refunded |
The body contains the same job object as GET /v1/generations/{id}, with the content URL valid for one hour:
{
"id": "0190a1b2-0000-7000-8000-000000000001",
"type": "generation.completed",
"created_at": "2026-10-03T08:00:00Z",
"data": {
"generation": {
"id": "0190a1b2-0000-7000-8000-0000000000aa",
"status": "completed",
"output": { "url": "https://api.cheapvids.com/v1/generations/0190a1b2-0000-7000-8000-0000000000aa/content?exp=1760003600&sig=..." }
}
}
}
id identifies the delivery. Use it to ignore duplicates: the same event can arrive more than once.
Requirements for your endpoint
- HTTPS only, port 443 or 8443, a public address. Endpoints on private networks,
localhostor CheapVids domains are rejected. - Answer with any
2xxwithin 10 seconds. Anything else, or no answer, counts as a failed send. - Do the slow work after answering.
- Webhooks come from Cloudflare addresses, not from a fixed IP range of CheapVids: authenticate with the signature, not with the IP address.
Headers
| Header | Value |
|---|---|
Content-Type | application/json |
User-Agent | CheapVids-Webhooks/1.0 |
X-CheapVids-Event | the event name |
X-CheapVids-Delivery | the delivery id |
X-CheapVids-Signature | t=<unix seconds>,v1=<hex> |
Verify the signature
The signature is hex(HMAC_SHA256(secret, "<t>." + raw body)), where secret is the whsec_... string shown once when you create the endpoint (use the whole string, including whsec_) and t is the timestamp in the header. Compute it over the raw bytes you received, before parsing the JSON, compare in constant time and reject timestamps older than 5 minutes.
Python:
import hashlib
import hmac
import time
def verify_webhook(raw_body: bytes, signature_header: str, secret: str, tolerance: int = 300) -> bool:
# The header comes from the sender: any odd value must give False, never an exception.
try:
timestamp, signatures = None, []
for part in signature_header.split(","):
key, _, value = part.strip().partition("=")
if key == "t" and value.isascii() and value.isdigit():
timestamp = int(value)
elif key == "v1":
signatures.append(value)
if timestamp is None or not signatures or abs(time.time() - timestamp) > tolerance:
return False
message = str(timestamp).encode() + b"." + raw_body
expected = hmac.new(secret.encode(), message, hashlib.sha256).hexdigest().encode()
# Compare as bytes: compare_digest raises TypeError on a non-ASCII str.
return any(hmac.compare_digest(expected, signature.encode()) for signature in signatures)
except (ValueError, TypeError, OverflowError):
return False
Node.js:
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyWebhook(rawBody, signatureHeader, secret, toleranceSec = 300) {
let timestamp;
const signatures = [];
for (const part of signatureHeader.split(",")) {
const [key, value = ""] = part.trim().split("=");
if (key === "t" && /^\d+$/.test(value)) timestamp = Number(value);
else if (key === "v1") signatures.push(value);
}
if (timestamp === undefined || signatures.length === 0) return false;
if (Math.abs(Date.now() / 1000 - timestamp) > toleranceSec) return false;
const expected = Buffer.from(createHmac("sha256", secret).update(`${timestamp}.`).update(rawBody).digest("hex"));
// Compare the bytes: timingSafeEqual throws when the lengths differ, which a non-ASCII value can cause.
return signatures.some((s) => {
const got = Buffer.from(s);
return got.length === expected.length && timingSafeEqual(got, expected);
});
}
Run this check on the raw request body before you parse it, and reject the request when it fails.
Delivery schedule
Each event is sent at most 4 times: the first send and 3 retries after 10, 60 and 300 seconds. After the fourth failed send the delivery is marked as failed. The developer page shows the last 20 sends of each endpoint with the HTTP status and a short reason.
A failed webhook never loses the result: you can always read the job with GET /v1/generations/{id} and download it within the download window.
Rotating the secret
You can issue a new secret for an endpoint at any time; it is shown once. Events sent after the change are signed with the new secret.

