NezhaGateNezhaGate
API Documentation

Webhooks: get notified when a job finishes

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

  1. 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 →
  2. 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 · Submitting a job with callback_url:
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

typeSent whendata contains
video.completeda video finishedexactly the JSON the job endpoints return (GET /v1/videos/jobs/{id}, GET /v1/images/jobs/{id})
video.faileda video failed (fully refunded)
image.completedan image finished
image.failedan image failed (fully refunded)
balance.lowyour 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.testyou click "Send a test event" in Settingsa 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-idThe event ID. It stays the same on retries; use it to deduplicate.
webhook-timestampWhen this attempt was sent (Unix seconds). New on every attempt.
webhook-signatureThe signature: v1, followed by a Base64 HMAC-SHA256.
Content-TypeAlways application/json.

Body examples

A finished video:

video.completed
{
  "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:

image.failed
{
  "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" }
  }
}
balance.low
{
  "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:

  1. 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).
  2. Build the signed content: webhook-id + "." + webhook-timestamp + "." + raw body.
  3. The key is the part after whsec_, Base64-decoded. Compute HMAC-SHA256 with it and Base64-encode the result.
  4. webhook-signature may hold several signatures (space-separated, each starting with v1,); accept if any matches. Use a constant-time comparison.
  5. Reject timestamps more than 5 minutes away from your clock to block replays.
Python · Flask
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 "", 204
Node.js · Express
import 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.

Open webhook settings