Вебхуки
Push-уведомления о завершении джобы с HMAC-подписью и повторами; polling остаётся обязательным fallback до устранения race callback_url.
В дополнение к опросу GET /v1/jobs/{id} СОЮЗ может прислать POST на твой URL, когда джоба
завершится (успехом или ошибкой). До RSA-109 polling остаётся обязательным fallback.
Два способа:
- Постоянный вебхук — регистрируешь URL один раз, получаешь события по всем своим джобам.
callback_urlна запрос — указываешь URL прямо в телеPOST .../generationsдля доставки по одной джобе. ⚠️ Сейчас attachment выполняется после enqueue и имеет редкую race с очень быстрым завершением; polling остаётся обязательным fallback доRSA-109.
Требования к URL: только https, публичный адрес (не локальная сеть), без редиректов.
Постоянный вебхук
curl https://api.souz.ai/v1/webhooks \
-H "Authorization: Bearer sk-souz-..." \
-H "Content-Type: application/json" \
-d '{ "url": "https://example.com/souz/hook" }'
{ "id": "wh_1a2b3c", "object": "webhook", "url": "https://example.com/souz/hook", "secret": "whsec_..." }
secret показывается один раз — сохрани, им подписывается каждая доставка.
Список и удаление:
GET /v1/webhooks
DELETE /v1/webhooks/{id}
callback_url на один запрос
curl https://api.souz.ai/v1/images/generations \
-H "Authorization: Bearer sk-souz-..." \
-H "Content-Type: application/json" \
-d '{
"model": "souz/nano-banana-2",
"prompt": "...",
"callback_url": "https://example.com/hooks/job-42"
}'
В ответе 202 появится webhook_secret — секрет подписи для этой джобы. Если запрос был с
Idempotency-Key, replay возвращает тот же секрет; без ключа он показывается один раз.
⚠️ С
Idempotency-Keycallback и секрет коммитятся атомарно с claim/reserve/job, а replay возвращает тот же durable-секрет. Без ключа пока остаётся отдельный UPDATE после создания job: в редкой гонке job может завершиться раньше и callback не получит событие. Кроме того, немедленно отклонённая keyed-job ещё может не создать ad-hoc delivery. ДоRSA-109сохраняйjob_idи перепроверяй polling.
Что приходит
POST с JSON, близким к объекту джобы, плюс event. price берётся из той же durable job
(после refund это 0), но serializer пока отдельный: created_at — ISO-время формирования
terminal payload, не unix-время создания job. Полная унификация с polling — RSA-109.
{
"event": "job.completed",
"id": "job_a1b2c3",
"object": "image",
"model": "souz/nano-banana-pro",
"status": "completed",
"price": 20,
"created_at": "2026-06-12T10:00:00.000Z",
"data": [{ "url": "https://api.souz.ai/v1/files/res_....png?exp=...&sig=..." }],
"error": null
}
События: job.completed и job.failed (при сбое data: null, причина в error.code). Заголовки: Souz-Event (тип события), Souz-Delivery-Id (уникальный ID доставки — для дедупликации), Souz-Signature (подпись).
Проверка подписи
Подпись в стиле Stripe. Заголовок:
Souz-Signature: t=1781250000,v1=9f86d081884c7d659a2feaa0c55ad015...
где v1 = HMAC_SHA256(secret, "{t}.{сырое_тело}") (hex). Проверяй по сырому телу запроса (до парсинга JSON) и отклоняй старые t (защита от повтора):
import hmac, hashlib, time
def verify(secret: str, signature_header: str, raw_body: bytes) -> bool:
parts = dict(p.split("=", 1) for p in signature_header.split(","))
t, v1 = parts["t"], parts["v1"]
if abs(time.time() - int(t)) > 300: # старше 5 минут — отказ
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, v1)
import { createHmac, timingSafeEqual } from "node:crypto";
function verify(secret, signatureHeader, rawBody) {
const parts = Object.fromEntries(signatureHeader.split(",").map((p) => p.split("=")));
if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) return false;
const expected = createHmac("sha256", secret).update(`${parts.t}.${rawBody}`).digest("hex");
return expected.length === parts.v1.length &&
timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}
Доставка и повторы
- Ответь
2xxбыстро (до 10 секунд) — лучше принять и обработать асинхронно. - Не
2xxили таймаут → до 12 попыток с нарастающей паузой; базовая задержка ограничена 24 часами, затем применяется jitter ±20%, поэтому вся серия может занять несколько суток. - Дедуплицируй по
Souz-Delivery-Id: при повторах одно событие может прийти больше одного раза. - Вебхук — это уведомление, а не источник истины: при сомнениях перепроверь статус через
GET /v1/jobs/{id}.