# Webhooks

Вместо опроса вы можете попросить Accessful **отправлять POST-запросом подписанное событие**
на ваш сервер, как только задача меняет состояние.

## Как работает регистрация

**Нет ни панели управления, ни эндпоинта регистрации**. Вы прикрепляете обратный вызов **на
каждую загрузку** и **сами выбираете секрет для подписи** — Accessful его не выдаёт.

- **Multipart-загрузка** — отправьте поля формы `webhookUrl` и `secret`.
- **Загрузка по URL** — отправьте JSON-поля `callbackUrl` и `hmacSignature`.

```bash
curl -X POST "https://api.accessful.de/api/v1/upload-service/pdf/upload" \
  -H "X-API-Key: $ACCESSFUL_API_KEY" \
  -F "files=@document.pdf" \
  -F "webhookUrl=https://your-app.example.com/hooks/accessful" \
  -F "secret=$YOUR_WEBHOOK_SECRET"
```
**Caution:** Один URL обратного вызова на **кейс**, зафиксированный в момент загрузки. Если отсутствует
`webhookUrl` **или** `secret`, **вебхук не отправляется** — без вашего секрета подпись
вычислить нельзя.

## События

| `type` события | Срабатывает, когда |
| --- | --- |
| `case.running` | Задача начала обработку. Может срабатывать более одного раза (по одному на итерацию). |
| `case.completed` | Результат PDF/UA готов к скачиванию. |
| `case.failed` | Обработка не удалась. |
| `case.canceled` | Кейс был отменён. |
| `case.quota_exceeded` | Отклонено, потому что договорная квота исчерпана. |

Состояния `queued` и `quota_pending` **не** доставляются как вебхуки.

## Payload

Тело запроса — JSON. `Content-Type: application/json`.

```json
{
  "id": "f1d2c3b4-0000-4a1e-8f3c-2d6b5a9e1c40",
  "type": "case.completed",
  "apiVersion": "2026-06-05",
  "occurredAt": "2026-06-05T12:34:56Z",
  "data": {
    "caseId": "7c2f1e4a-9b0d-4a1e-8f3c-2d6b5a9e1c40",
    "fileName": "document.pdf",
    "jobStatus": "completed"
  }
}
```

| Поле | Тип | Примечания |
| --- | --- | --- |
| `id` | UUID | ID события. Используйте его как **ключ идемпотентности** (см. ниже). |
| `type` | string | Один из типов событий выше. |
| `apiVersion` | string | Версия контракта (`2026-06-05`). Зафиксируйте её, чтобы отслеживать изменения. |
| `occurredAt` | ISO-8601 | Когда событие создано; стабильно между повторными попытками. |
| `data.caseId` | UUID | Кейс, к которому относится это событие. |
| `data.fileName` | string | Имя файла. |
| `data.jobStatus` | string | Сырой статус задачи, напр. `completed`, `failed`, `canceled`, `quota_exceeded`. |

Null-поля опускаются в JSON.

### Заголовки доставки

| Заголовок | Пример | Назначение |
| --- | --- | --- |
| `X-Accessful-Signature` | `t=1749126896,v1=9f86d0…` | Подпись HMAC — **проверяйте именно её** |
| `X-Accessful-Webhook-Timestamp` | `1749126896` | Секунды Unix; то же, что `t=` выше |
| `X-Accessful-Event-Id` | `f1d2c3b4-…` | Равен `id`; ключ идемпотентности |
| `X-Accessful-Event-Type` | `case.completed` | Маршрутизация без разбора тела |
| `X-Accessful-Case-Id` | `7c2f1e4a-…` | ID кейса |
| `X-Accessful-Delivery-Attempt` | `1` | Счётчик попыток, начиная с 1 |
| `X-Signature` | `n4bQgY…` | legacy HMAC в base64 только по телу |

## Проверка подписи

Заголовок `X-Accessful-Signature` имеет вид `t=<unix>,v1=<hex>`. Пересчитайте её и сравните за
постоянное время:

1. Прочитайте `t` и `v1` из заголовка.
2. Вычислите `HMAC-SHA256(secret, "<t>.<сырое тело>")` и закодируйте в hex. Подписываемая
   строка — это временная метка, буквальная точка, затем **точное сырое тело запроса** —
   проверяйте *до* разбора JSON.
3. Сравните за постоянное время с `v1`.
4. При желании отклоняйте, если `t` старше нескольких минут (защита от повтора).

```js
import crypto from 'node:crypto';

// `rawBody` должен быть точными полученными байтами (напр. express.raw()).
function verify(rawBody, signatureHeader, secret) {
  const parts = Object.fromEntries(signatureHeader.split(',').map((p) => p.split('=')));
  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${parts.t}.${rawBody}`)
    .digest('hex');

  const valid = crypto.timingSafeEqual(Buffer.from(parts.v1), Buffer.from(expected));
  const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < 300; // 5 мин
  return valid && fresh;
}
```
```java
String[] kv = signatureHeader.split(",");           // ["t=...", "v1=..."]
long t = Long.parseLong(kv[0].substring(2));
String v1 = kv[1].substring(3);

Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
byte[] sig = mac.doFinal((t + "." + rawBody).getBytes(StandardCharsets.UTF_8));
String expected = HexFormat.of().formatHex(sig);

boolean valid = MessageDigest.isEqual(
    expected.getBytes(StandardCharsets.UTF_8), v1.getBytes(StandardCharsets.UTF_8));
```
**Note:** `X-Signature` — это устаревший запасной вариант: base64 от `HMAC-SHA256(secret, сырое тело)` —
только тело, без временной метки. Предпочитайте `X-Accessful-Signature`; используйте
устаревший заголовок, только если не можете прочитать новый.

## Повторные попытки и идемпотентность

- **Успех** = любой ответ **2xx** в пределах тайм-аута (5 с на подключение, 10 с на ответ).
  Отвечайте быстро, а свою работу выполняйте асинхронно.
- **Сбой** (не-2xx, тайм-аут или ошибка соединения) повторяется до **10 попыток** с
  экспоненциальной выдержкой и джиттером — примерно `10s → 30s → 1,5m → 4,5m → 13,5m → 40m → 2h`,
  затем ограничивается **6h**, минус до 20 % джиттера. Всё окно охватывает несколько часов.
- После 10 неудачных попыток событие **прекращают доставлять**. Обратитесь в поддержку для
  повторной доставки.
- **Идемпотентность:** одно и то же событие может прийти более одного раза (напр. повторная
  попытка после того, как ваш эндпоинт уже успешно ответил). Дедуплицируйте по
  `X-Accessful-Event-Id` — он стабилен при каждой повторной попытке.