222 lines
7.5 KiB
Markdown
222 lines
7.5 KiB
Markdown
# 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
|