# Webhooks

Альтернатива Kafka events для consumer'ов которые не хотят / не могут
поддерживать Kafka client. Ordinis отправляет HTTP POST на зарегистрированный
URL при изменениях справочников.

{% note info %}

Если у тебя есть Kafka access — используй [события](events.md). Webhooks
проще в развёртывании, но менее надёжны (HTTP retry vs Kafka durable log)
и медленнее (HTTP overhead vs Kafka batch).

{% endnote %}

## Регистрация подписки

```bash
curl -X POST -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  https://ordinis.k8s.264.nstart.cloud/api/v1/admin/webhooks/subscriptions \
  -d '{
    "name": "altum-cache-invalidation",
    "url": "https://altum-backend.altum.local/webhooks/ordinis",
    "secret": "<HMAC_SECRET_GENERATED_BY_YOU>",
    "scopes": ["PUBLIC", "INTERNAL"],
    "dictionaries": ["spacecraft", "satellite_type", "instrument"],
    "actions": ["created", "updated", "closed"],
    "active": true
  }'
```

### Поля

| Поле | Тип | Назначение |
|---|---|---|
| `name` | string | Человекочитаемое имя для admin UI. |
| `url` | string | HTTPS endpoint. HTTP блокируется SSRF guard'ом. |
| `secret` | string | HMAC-SHA256 секрет для подписи payload (см. ниже). |
| `scopes` | array | Фильтр по scope (subset из доступных consumer'у). |
| `dictionaries` | array | Фильтр по dictionary names. `null`/`[]` = все доступные. |
| `actions` | array | `created` / `updated` / `closed`. `null` = все. |
| `categories` | array | Event categories: `dict.record.*` / `dict.definition.*` / `dict.draft.*`. `null` = все. (v2.2.0+) |
| `active` | bool | Можно временно отключить без удаления. |

### Event categories (v2.2.0+)

С v2.2.0 webhook subscriber'ы могут фильтровать по семантическим категориям:

| Category | Маппит outbox events |
|---|---|
| `dict.record.*` | Record CRUD (default до v2.2.0): record.created / .updated / .closed |
| `dict.definition.*` | Schema-level: `DefinitionCreated`, `DefinitionUpdated` (inline PATCH) |
| `dict.draft.*` | Workflow drafts: `SchemaDraftCreated`, `…ReviewRequested`, `…Decided`, `…Published`, `…Withdrawn` |

Если `categories=null` подписчик получает все (legacy behaviour).
Frontend reviewer dashboards обычно подписываются на `dict.draft.*`
для notifications о новых drafts. Bundle consumers (Альтум, BI) —
на `dict.record.*`.

## SSRF guard

Webhook URLs валидируются перед отправкой:

- **Запрещено:** private CIDR (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16),
  localhost, link-local (169.254/16), metadata IPs.
- **Запрещено:** non-HTTPS scheme. Только `https://`.
- **Запрещено:** URL > 2048 chars.

При нарушении registration возвращает 422 + список причин.

## Payload

POST на зарегистрированный URL с body:

```json
{
  "deliveryId": "wd-abc123-xyz789",
  "subscriptionName": "altum-cache-invalidation",
  "occurredAt": "2026-05-07T15:23:45.123+03:00",
  "events": [
    {
      "schemaVersion": "1.0.0",
      "eventType": "dictionary.changed",
      "dictionary": "spacecraft",
      "businessKey": "RESURS-P-3",
      "action": "updated",
      "version": 3,
      "validFrom": "2026-05-07T00:00:00+03:00",
      "data": { ... }
    }
  ]
}
```

{% note tip %}

Payload может содержать **несколько events** (`events` array) если они
произошли в одной transaction (bulk close, bundle import). Не предполагай
single-event payload.

{% endnote %}

## HMAC подпись

Каждый POST имеет header:

```
X-Ordinis-Signature: sha256=<HEX>
X-Ordinis-Delivery: wd-abc123-xyz789
X-Ordinis-Subscription: altum-cache-invalidation
```

Где `<HEX>` = HMAC-SHA256 от raw body bytes с `secret` который ты задал
при регистрации.

### Verify (Python)

```python
import hmac, hashlib

def verify(body: bytes, signature_header: str, secret: str) -> bool:
    expected = "sha256=" + hmac.new(
        secret.encode(), body, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, signature_header)
```

### Verify (Node.js)

```javascript
import crypto from 'crypto'

function verify(body, signatureHeader, secret) {
  const expected = 'sha256=' + crypto
    .createHmac('sha256', secret)
    .update(body)
    .digest('hex')
  return crypto.timingSafeEqual(
    Buffer.from(expected),
    Buffer.from(signatureHeader)
  )
}
```

{% note warning %}

Verify подпись **до** парсинга JSON. Если invalid — `403 Forbidden`,
не трогай payload. Без verify ты уязвим к подделке events от любого, кто
знает твой URL.

{% endnote %}

## Response contract

Consumer должен ответить `2xx` в течение 10 секунд:

| Status | Семантика |
|---|---|
| `200` / `201` / `202` / `204` | Принято. Ordinis помечает delivered. |
| `4xx` | Permanent failure (твой bug). Ordinis НЕ retry. |
| `5xx` / timeout | Transient. Ordinis retry с exponential backoff. |

## Retry policy

| Попытка | Интервал |
|---|---|
| 1 | сразу |
| 2 | через 30s |
| 3 | через 2 мин |
| 4 | через 10 мин |
| 5 | через 1ч |
| 6-10 | каждые 6ч |
| После всех попыток | Ordinis team помечает delivery как failed для ручного разбора |

Если consumer недоступен > 1000 retry попыток — Ordinis team будет
расследовать. Долгосрочное unavailability стоит coordinate (planned
maintenance window) чтобы не накапливать orphan deliveries.

## Test ping

Endpoint для проверки доступности URL **до** регистрации:

```bash
curl -X POST -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  https://ordinis.k8s.264.nstart.cloud/api/v1/admin/webhooks/test-ping \
  -d '{
    "url": "https://altum-backend.altum.local/webhooks/ordinis",
    "secret": "<TEST_SECRET>"
  }'
```

Response:

```json
{
  "success": true,
  "statusCode": 200,
  "responseBody": "OK",
  "elapsedMs": 142
}
```

Либо details про failure (DNS issue, timeout, SSRF reject и т.д.).

## Best practices

1. **Idempotency** — webhook может прийти **дважды** (network retry, partial
   failure). Используй `deliveryId` как dedup key.
2. **Async обработка** — отвечай `202 Accepted` сразу, обрабатывай в
   background. 10s timeout жёсткий — если БД медленная, лучше принять и
   сложить в очередь.
3. **HMAC verify первым** — до парсинга JSON.
4. **Metrics** — track received / processed / errors для своего side.
5. **Whitelist Ordinis IP** на firewall если строгие правила. Точный
   адрес запросить у Ordinis team при registration.

## Дальше

- [События (Kafka)](events.md) — primary delivery channel
- [Caching](caching.md) — webhook invalidation pattern
