# 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": "", "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= X-Ordinis-Delivery: wd-abc123-xyz789 X-Ordinis-Subscription: altum-cache-invalidation ``` Где `` = 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": "" }' ``` 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