Video and image generation are asynchronous: you submit, get a job ID, and the result arrives a little later. Instead of polling, let us push the result to your server when the job ends, and warn you when your balance runs low.
Where webhooks fit and how to set them up in three steps: read the webhook guide →
Two ways to receive events
- Account endpoint: in the console go to Settings → Webhook, enter your endpoint URL and tick the events you want. Every job you submit through the API then reports back when it ends. Open webhook settings →
- Per-request callback_url: add a callback_url field when you submit a video or image job. That job's result (success or failure) goes only to that URL, whatever is ticked in Settings.
callback_url must be a publicly reachable http(s) URL of up to 2,000 characters; callBackUrl is accepted too. Both ways use the same signing secret, shown under Settings → Webhook.
curl https://nezhagate.com/v1/videos/generations -H 'Authorization: Bearer YOUR_API_KEY' -H 'Content-Type: application/json' -d '{"model": "seedance-2.5", "prompt": "a paper boat drifting down a rainy street", "duration": 5, "callback_url": "https://example.com/webhooks/nezhagate"}'Events
| type | Sent when | data contains |
|---|---|---|
video.completed | a video finished | exactly the JSON the job endpoints return (GET /v1/videos/jobs/{id}, GET /v1/images/jobs/{id}) |
video.failed | a video failed (fully refunded) | |
image.completed | an image finished | |
image.failed | an image failed (fully refunded) | |
balance.low | your balance drops below the number of credits you set (once per drop; it re-arms after a top-up) | balance_credits (the current balance, in credits) and threshold_credits (your alert level) |
webhook.test | you click "Send a test event" in Settings | a short message, to confirm that you receive and can verify events |
Request format
We send a POST request with a JSON body to your URL, with these headers:
webhook-id | The event ID. It stays the same on retries; use it to deduplicate. |
webhook-timestamp | When this attempt was sent (Unix seconds). New on every attempt. |
webhook-signature | The signature: v1, followed by a Base64 HMAC-SHA256. |
Content-Type | Always application/json. |
Body examples
A finished video:
{
"id": "evt_5f0c3d2e8a9b4c71d2e3f405",
"object": "event",
"type": "video.completed",
"created_at": 1791200000,
"data": {
"id": "img_3f9a...c2",
"object": "video.generation.job",
"status": "succeeded",
"created": 1791199400,
"model": "seedance-2.5",
"data": [{ "url": "https://img.nezhagate.com/i/9f86d081a8....mp4" }],
"usage": { "clips": 1, "model": "seedance-2.5" }
}
}A failed job carries error in data, as the job endpoints do; the charge has been fully refunded:
{
"id": "evt_8b1e47c09d3a2f6e5b7c1d90",
"object": "event",
"type": "image.failed",
"created_at": 1791200120,
"data": {
"id": "img_7c21...e4",
"object": "image.generation.job",
"status": "failed",
"created": 1791200050,
"model": "gpt-image-2",
"error": { "message": "...", "type": "upstream_error", "code": "upstream_error" }
}
}{
"id": "evt_1a2b3c4d5e6f708192a3b4c5",
"object": "event",
"type": "balance.low",
"created_at": 1791203000,
"data": { "balance_credits": 412.5, "threshold_credits": 500 }
}Verifying signatures
Events are signed per the Standard Webhooks spec (the one OpenAI's webhooks use). To verify:
- Read the webhook-id, webhook-timestamp and webhook-signature headers and the raw request body (don't parse it to JSON and serialize it again).
- Build the signed content: webhook-id + "." + webhook-timestamp + "." + raw body.
- The key is the part after whsec_, Base64-decoded. Compute HMAC-SHA256 with it and Base64-encode the result.
- webhook-signature may hold several signatures (space-separated, each starting with v1,); accept if any matches. Use a constant-time comparison.
- Reject timestamps more than 5 minutes away from your clock to block replays.
import base64, hashlib, hmac, time
from flask import Flask, abort, request
SECRET = "whsec_..." # Settings -> Webhook -> Signing secret
KEY = base64.b64decode(SECRET[len("whsec_"):])
def verify(headers, body: bytes) -> bool:
msg_id = headers.get("webhook-id", "")
ts = headers.get("webhook-timestamp", "")
if not msg_id or not ts.isdigit() or abs(time.time() - int(ts)) > 300:
return False
signed = f"{msg_id}.{ts}.".encode() + body
expected = base64.b64encode(hmac.new(KEY, signed, hashlib.sha256).digest()).decode()
return any(sig.startswith("v1,") and hmac.compare_digest(sig[3:], expected)
for sig in headers.get("webhook-signature", "").split())
app = Flask(__name__)
@app.post("/webhooks/nezhagate")
def webhook():
if not verify(request.headers, request.get_data()):
abort(401)
event = request.get_json()
if event["type"] == "video.completed":
print(event["data"]["id"], event["data"]["data"][0]["url"])
return "", 204import crypto from "node:crypto";
import express from "express";
const SECRET = "whsec_..."; // Settings -> Webhook -> Signing secret
const KEY = Buffer.from(SECRET.slice("whsec_".length), "base64");
const app = express();
app.post("/webhooks/nezhagate", express.raw({ type: "application/json" }), (req, res) => {
const id = req.header("webhook-id"), ts = req.header("webhook-timestamp");
if (!id || !ts || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return res.sendStatus(401);
const expected = Buffer.from(
crypto.createHmac("sha256", KEY).update(`${id}.${ts}.`).update(req.body).digest("base64"));
const ok = (req.header("webhook-signature") || "").split(" ").some((sig) => {
const got = Buffer.from(sig.slice(3));
return sig.startsWith("v1,") && got.length === expected.length && crypto.timingSafeEqual(got, expected);
});
if (!ok) return res.sendStatus(401);
const event = JSON.parse(req.body.toString("utf8"));
console.log(event.type, event.data.id);
res.sendStatus(204);
});
app.listen(3000);Or use the official Standard Webhooks library for your language (Python: pip install standardwebhooks; Node: npm install standardwebhooks).
Responses and retries
- Any 2xx within 10 seconds counts as delivered; the response body doesn't matter. Answer 2xx first, then do the work.
- Without a 2xx (timeout, connection error, 3xx, 4xx, 5xx) we retry after 1 min, 5 min, 30 min, 2 h and 6 h, then mark it failed.
- An event can arrive more than once and in any order: deduplicate by webhook-id and trust data.status.
- Settings → Webhook lists the last 30 days of deliveries with each result, and lets you resend one.
Security
- Public addresses only: private, local and reserved addresses are refused.
- Redirects are not followed: a 3xx answer counts as a failure.
- Keep the signing secret as private as an API key. If it leaks, roll it in Settings; the old one stops working at once.
Testing
Save your URL under Settings → Webhook and click "Send a test event". We send a webhook.test event right away and show the status code and time your server answered with.