# Вебхуки

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

Два способа:

1. **Постоянный вебхук** — регистрируешь URL один раз, получаешь события по всем своим джобам.
2. **`callback_url` на запрос** — указываешь URL прямо в теле `POST .../generations`, событие придёт только по этой джобе.

Требования к URL: только `https`, публичный адрес (не локальная сеть), без редиректов.

## Постоянный вебхук

```bash
curl https://api.souz.ai/v1/webhooks \
  -H "Authorization: Bearer sk-souz-..." \
  -H "Content-Type: application/json" \
  -d '{ "url": "https://example.com/souz/hook" }'
```

```json
{ "id": "wh_1a2b3c", "object": "webhook", "url": "https://example.com/souz/hook", "secret": "whsec_..." }
```

`secret` показывается **один раз** — сохрани, им подписывается каждая доставка.

Список и удаление:

```text
GET    /v1/webhooks
DELETE /v1/webhooks/{id}
```

## callback_url на один запрос

```bash
curl https://api.souz.ai/v1/images/generations \
  -H "Authorization: Bearer sk-souz-..." \
  -H "Content-Type: application/json" \
  -d '{
    "model": "souz/nano-banana",
    "prompt": "...",
    "callback_url": "https://example.com/hooks/job-42"
  }'
```

В ответе `202` появится `webhook_secret` (один раз) — секрет подписи для этой джобы.

## Что приходит

`POST` с JSON — тот же объект джобы плюс `event`:

```json
{
  "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. Заголовок:

```text
Souz-Signature: t=1781250000,v1=9f86d081884c7d659a2feaa0c55ad015...
```

где `v1 = HMAC_SHA256(secret, "{t}.{сырое_тело}")` (hex). Проверяй по **сырому** телу запроса (до парсинга JSON) и отклоняй старые `t` (защита от повтора):

```python
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)
```

```javascript
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 попыток на протяжении ~суток.
- Дедуплицируй по `Souz-Delivery-Id`: при повторах одно событие может прийти больше одного раза.
- Вебхук — это уведомление, а не источник истины: при сомнениях перепроверь статус через `GET /v1/jobs/{id}`.
