Вебхуки

.md

Push-уведомления о завершении джобы с HMAC-подписью и повторами; polling остаётся обязательным fallback до устранения race callback_url.

В дополнение к опросу GET /v1/jobs/{id} СОЮЗ может прислать POST на твой URL, когда джоба завершится (успехом или ошибкой). До RSA-109 polling остаётся обязательным fallback.

Два способа:

  1. Постоянный вебхук — регистрируешь URL один раз, получаешь события по всем своим джобам.
  2. 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-Key callback и секрет коммитятся атомарно с 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}.