Генерация видео и изображений выполняется асинхронно: вы отправляете запрос, получаете ID задачи, а результат приходит чуть позже. Вместо постоянного опроса API мы можем отправить результат на ваш сервер, как только задача завершится, и предупредить, когда баланс будет подходить к концу.
Где пригодятся вебхуки и как настроить их за три шага — читайте руководство →
Два способа получать события
- Адрес аккаунта по умолчанию: в консоли откройте «Настройки → Вебхуки», укажите URL эндпоинта и отметьте нужные события. После этого о завершении каждой задачи, отправленной через API, будет приходить уведомление. Открыть настройки вебхуков →
- callback_url в запросе: добавьте поле callback_url при отправке задачи на генерацию видео или изображения. Результат этой задачи (успех или ошибка) придёт только на этот URL, независимо от того, что отмечено в настройках.
callback_url должен быть общедоступным адресом http(s) длиной не более 2000 символов; написание callBackUrl тоже принимается. Оба способа используют один и тот же секрет подписи — он показан в разделе «Настройки → Вебхуки».
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"}'События
| type | Когда отправляется | Содержимое data |
|---|---|---|
video.completed | видео успешно создано | в точности тот же JSON, что возвращают эндпоинты задач (GET /v1/videos/jobs/{id}, GET /v1/images/jobs/{id}) |
video.failed | не удалось создать видео (кредиты возвращены полностью) | |
image.completed | изображение успешно создано | |
image.failed | не удалось создать изображение (кредиты возвращены полностью) | |
balance.low | баланс опустился ниже заданного вами числа кредитов (один раз при каждом снижении; после пополнения уведомление снова включается) | balance_credits (текущий баланс в кредитах) и threshold_credits (заданный вами порог) |
webhook.test | вы нажали «Отправить тестовое событие» в настройках | короткое сообщение — чтобы убедиться, что события доходят и подпись проверяется |
Формат запроса
Мы отправляем на ваш URL POST-запрос с телом в формате JSON и такими заголовками:
webhook-id | ID события. При повторных отправках не меняется — используйте его для дедупликации. |
webhook-timestamp | Время отправки этой попытки (секунды Unix). Обновляется при каждой попытке. |
webhook-signature | Подпись: префикс v1, и затем HMAC-SHA256 в кодировке Base64. |
Content-Type | Всегда application/json. |
Примеры тела запроса
Видео готово:
{
"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" }
}
}Если задача не удалась, в data передаётся error (как и в эндпоинтах задач), а списанные кредиты уже полностью возвращены:
{
"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 }
}Проверка подписи
События подписываются по спецификации Standard Webhooks (её же используют вебхуки OpenAI). Как проверить подпись:
- Считайте заголовки webhook-id, webhook-timestamp и webhook-signature и исходное тело запроса (не разбирайте его в JSON, чтобы потом сериализовать заново).
- Соберите подписываемую строку: webhook-id + "." + webhook-timestamp + "." + исходное тело.
- Ключ — это часть после whsec_, декодированная из Base64. Вычислите с ним HMAC-SHA256 и закодируйте результат в Base64.
- В webhook-signature может быть несколько подписей (через пробел, каждая начинается с v1,); проверка пройдена, если совпадает любая из них. Используйте сравнение за постоянное время.
- Чтобы защититься от атак повторного воспроизведения, отклоняйте запросы, метка времени которых расходится с вашими часами более чем на 5 минут.
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);Можно также использовать официальную библиотеку Standard Webhooks для вашего языка (Python: pip install standardwebhooks; Node: npm install standardwebhooks).
Ответы и повторные отправки
- Любой ответ 2xx в течение 10 секунд считается доставкой; тело ответа не важно. Сначала ответьте 2xx, а затем обрабатывайте событие.
- Если ответа 2xx нет (тайм-аут, ошибка соединения, 3xx, 4xx, 5xx), мы повторяем отправку через 1 мин, 5 мин, 30 мин, 2 ч и 6 ч, после чего помечаем её как неудачную.
- Одно и то же событие может прийти несколько раз и в любом порядке: убирайте дубликаты по webhook-id и ориентируйтесь на data.status.
- В разделе «Настройки → Вебхуки» показаны отправки за последние 30 дней с результатом каждой; любую из них можно отправить повторно.
Безопасность
- Только публичные адреса: частные, локальные и зарезервированные адреса отклоняются.
- Перенаправления не выполняются: ответ 3xx считается неудачей.
- Храните секрет подписи так же надёжно, как API Key. Если он утёк, смените его в настройках — старый сразу перестанет работать.
Тестирование
Сохраните URL в разделе «Настройки → Вебхуки» и нажмите «Отправить тестовое событие». Мы сразу отправим событие webhook.test и покажем код состояния, которым ответил ваш сервер, и время ответа.