Webhooks
Real-time события от NurCore — оплата прошла, рейс задержался, пассажир зарегистрировался.
NurCore отправляет webhooks в ваш backend, когда происходят важные события (бронь подтвердилась, рейс задержался, пассажир зарегистрировался). Это критично для интеграции — без webhooks вам пришлось бы постоянно опрашивать API ("polling каждые 60 секунд: что нового?").
Архитектура
[Пассажир оплачивает на сайте авиакомпании]
│
▼
[Your backend] → POST /bookings/{id}/initiate-payment → NurCore
│
▼
[Freedom Pay] → платёж проходит → webhook → NurCore
│
▼
[NurCore] → booking.confirmed → POST на ваш webhook URL
│ (HMAC-SHA256 подпись)
▼
[Ваш backend]
│ Парсит payload
│ Начисляет миль в loyalty programme
│ Шлёт push пользователю через ваш FCM
▼
[Возвращает 200 OK]Регистрация webhook subscription
POST /api/v1/notifications/outgoing-webhooks/
Создаёт подписку и возвращает secret (показывается ОДИН раз — сохраните его, потом будете только rotate).
curl -X POST \
-H "Authorization: Bearer $STAFF_JWT" \
-H "Content-Type: application/json" \
-d '{
"name": "Your backend integration",
"url": "https://api.example-airline.com/internal/nurcore-webhooks",
"events": [
"booking.confirmed",
"booking.cancelled",
"booking.refunded",
"flight.delayed",
"flight.cancelled",
"passenger.checked_in"
],
"max_retries": 3,
"timeout_seconds": 10
}' \
"https://api.nurcore.kg/api/v1/notifications/outgoing-webhooks/"Response 201:
{
"id": "uuid",
"name": "Your backend integration",
"url": "https://api.example-airline.com/internal/nurcore-webhooks",
"secret": "abc123XyZ...48-char-secret",
"events": ["booking.confirmed", "..."],
"is_active": true,
"max_retries": 3,
"timeout_seconds": 10,
"created_at": "2026-05-12T02:00:00Z"
}⚠️ Сохраните
secretсразу — он показывается только при создании. Используется для проверки HMAC подписей входящих webhooks.
Управление подписками
| Endpoint | Назначение |
|---|---|
GET /outgoing-webhooks/ | Все подписки (без secrets) |
GET /outgoing-webhooks/{id} | Деталь подписки |
PATCH /outgoing-webhooks/{id} | Обновить (url, events, is_active) |
DELETE /outgoing-webhooks/{id} | Удалить |
POST /outgoing-webhooks/{id}/rotate-secret | Новый secret (старый перестаёт работать) |
GET /outgoing-webhooks/{id}/deliveries | История доставок (debug) |
GET /outgoing-webhooks/event-types | Список всех поддерживаемых events |
Поддерживаемые события
| Event Type | Когда | Payload содержит |
|---|---|---|
booking.created | После POST /bookings/ → status=PENDING | booking_id, booking_reference, total, expiry |
booking.confirmed | После успешной оплаты → CONFIRMED | + payment_status, confirmed_at |
booking.cancelled | При cancel | + cancelled_at, refund_amount (если оплачено) |
booking.refunded | После refund (отдельно от cancel) | + refund_amount, currency, refunded_at |
booking.expired | PENDING → EXPIRED (15 минут истекли) | booking_id, expired_at |
flight.delayed | Задержка ≥30 мин | flight_id, original_dep, new_dep, delay_minutes |
flight.cancelled | Отмена рейса | flight_id, reason, affected_bookings[] |
flight.diverted | Diversion | flight_id, new_destination, reason |
flight.departed | После takeoff | flight_id, actual_departure |
flight.arrived | После landing | flight_id, actual_arrival |
passenger.checked_in | OCI или counter check-in | booking_id, passenger_id, seat, terminal |
passenger.boarded | На gate | booking_id, passenger_id, gate, boarded_at |
passenger.no_show | Не явился | booking_id, passenger_id, flight_id |
Пример payload — booking.confirmed
{
"booking_id": "uuid",
"booking_reference": "ABC123",
"status": "confirmed",
"total_amount": 5000.00,
"currency": "KGS",
"passenger_count": 2,
"contact_email": "user@example.com",
"flight_id": "uuid",
"confirmed_at": "2026-05-12T10:00:00Z"
}Пример payload — flight.delayed
{
"flight_id": "uuid",
"flight_number": "ZM-202",
"origin_iata": "FRU",
"destination_iata": "IST",
"original_departure": "2026-05-15T07:30:00Z",
"new_departure": "2026-05-15T09:00:00Z",
"delay_minutes": 90,
"reason": "ATC delay",
"affected_passengers": 78
}HTTP Headers вебхука
Каждый POST на ваш URL содержит:
POST /your/webhook/endpoint HTTP/1.1
Host: api.example-airline.com
Content-Type: application/json
User-Agent: NurCore-Webhooks/1.0
X-NurCore-Event: booking.confirmed
X-NurCore-Event-Id: 550e8400-e29b-41d4-a716-446655440000
X-NurCore-Timestamp: 1747031600
X-NurCore-Signature: sha256=abc123def456...
X-NurCore-Delivery-Id: 660e8400-e29b-41d4-a716-446655440001
{"booking_id":"uuid","booking_reference":"ABC123",...}| Header | Назначение |
|---|---|
X-NurCore-Event | Тип события (booking.confirmed) |
X-NurCore-Event-Id | UUID события — используйте для идемпотентности на вашей стороне |
X-NurCore-Timestamp | Unix timestamp — нужен для signature verification |
X-NurCore-Signature | HMAC-SHA256, sha256=<hex> — обязательно проверяйте |
X-NurCore-Delivery-Id | UUID попытки доставки — для debug |
Проверка подписи (CRITICAL!)
NurCore подписывает каждый webhook HMAC-SHA256. Всегда проверяйте подпись — иначе злоумышленник может прислать поддельные события.
Алгоритм
signed_payload = "{timestamp}.{event_id}.{request_body}"
expected = HMAC-SHA256(secret, signed_payload).hex()
expected_signature = "sha256=" + expected
# Сравните с X-NurCore-Signature (constant-time comparison!)Python (FastAPI)
import hmac
import hashlib
WEBHOOK_SECRET = "abc123XyZ..." # из POST /outgoing-webhooks/ response
@app.post("/internal/nurcore-webhooks")
async def receive_webhook(request: Request):
body = await request.body()
timestamp = request.headers["X-NurCore-Timestamp"]
event_id = request.headers["X-NurCore-Event-Id"]
signature = request.headers["X-NurCore-Signature"]
# 1. Compute expected
signed_payload = f"{timestamp}.{event_id}.{body.decode()}"
expected = "sha256=" + hmac.new(
WEBHOOK_SECRET.encode(),
signed_payload.encode(),
hashlib.sha256
).hexdigest()
# 2. Constant-time compare
if not hmac.compare_digest(expected, signature):
raise HTTPException(403, "Invalid signature")
# 3. Anti-replay: timestamp не старше 5 минут
import time
if abs(time.time() - int(timestamp)) > 300:
raise HTTPException(400, "Replay attack: timestamp too old")
# 4. Idempotency: уже обработали этот event_id?
if await already_processed(event_id):
return {"status": "duplicate"}
# 5. Обработка
event_type = request.headers["X-NurCore-Event"]
data = await request.json()
await handle_event(event_type, data)
await mark_processed(event_id)
return {"status": "ok"}TypeScript (Node.js)
import crypto from "crypto";
const WEBHOOK_SECRET = process.env.NURCORE_WEBHOOK_SECRET!;
app.post("/internal/nurcore-webhooks", async (req, res) => {
const body = JSON.stringify(req.body); // или raw body
const timestamp = req.headers["x-nurcore-timestamp"] as string;
const eventId = req.headers["x-nurcore-event-id"] as string;
const receivedSig = req.headers["x-nurcore-signature"] as string;
const signedPayload = `${timestamp}.${eventId}.${body}`;
const expected = "sha256=" + crypto
.createHmac("sha256", WEBHOOK_SECRET)
.update(signedPayload)
.digest("hex");
// Constant-time comparison
const a = Buffer.from(expected);
const b = Buffer.from(receivedSig);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.status(403).json({ error: "Invalid signature" });
}
if (Math.abs(Date.now() / 1000 - parseInt(timestamp)) > 300) {
return res.status(400).json({ error: "Replay attack" });
}
// Process event...
res.json({ status: "ok" });
});Retry Policy
Если ваш endpoint вернул не 2xx — NurCore попробует снова:
| Попытка | Задержка |
|---|---|
| 1 (немедленно) | 0s |
| 2 | +1 минута |
| 3 | +5 минут (от начала) |
| 4 | +30 минут (от начала) |
После max_retries (default 3) → статус dead_letter. Дальше повторов
нет — нужно вручную через GET /outgoing-webhooks/{id}/deliveries найти
и переотправить (admin feature).
Что считается transient vs permanent
| Статус код | Поведение |
|---|---|
| 2xx | ✅ delivered, retry прекращается |
| 408, 425, 429 | 🔄 retry (transient — rate-limit, timeout) |
| 400, 401, 403, 404, 410, 422 | ❌ dead_letter сразу (ваш bug, retry бесполезен) |
| 5xx | 🔄 retry |
| Timeout | 🔄 retry (default 10s — настраивается через timeout_seconds) |
| Connection error | 🔄 retry |
Ваш endpoint должен
- Быстро отвечать (≤ 10 секунд) — иначе timeout → retry
- Идемпотентно обрабатывать (
X-NurCore-Event-Id— primary key) - Не делать heavy work синхронно — положите в очередь, ответьте 200
- Возвращать 4xx только при настоящих client bugs (malformed payload, missing required field)
Best practice — async обработка
@app.post("/internal/nurcore-webhooks")
async def receive_webhook(request: Request):
# 1. Проверить подпись (быстро)
verify_signature(request)
# 2. Сохранить в очередь
await queue.publish("nurcore_event", await request.json())
# 3. Ответить 200 сразу
return {"status": "queued"}Дальше worker'ы парсят очередь асинхронно. Это позволяет принять 1000+ webhooks/sec без блокировки.
Идемпотентность на вашей стороне
NurCore гарантирует at-least-once delivery — webhook может прилететь
2+ раз (timeout на одном retry → следующая попытка делает duplicate). Ваш
обработчик должен дедуплицировать по X-NurCore-Event-Id.
-- В вашей БД:
CREATE TABLE processed_webhook_events (
event_id UUID PRIMARY KEY,
processed_at TIMESTAMPTZ DEFAULT now()
);
-- В обработчике:
INSERT INTO processed_webhook_events (event_id) VALUES ($1)
ON CONFLICT DO NOTHING
RETURNING event_id;
-- Если RETURNING пустой → уже обработали, skipПартнёрский фильтр
Если partner_id указан при создании subscription — webhook'и приходят
только для броней этого партнёра:
{
"name": "Astana Travel agency webhooks",
"url": "https://crm.astana-travel.kz/nurcore",
"events": ["booking.confirmed", "booking.cancelled"],
"partner_id": "uuid-of-astana-travel"
}При booking.confirmed от бронирования другого партнёра — этой
подписке не доставится. Это позволяет настроить multiple integrations
без cross-talk.
Тестирование
Локально с ngrok / Cloudflare tunnel
# Localhost listener (Python)
python3 -m http.server 8080
# Tunnel через ngrok
ngrok http 8080
# Создаёте subscription на полученный URL
curl -X POST .../outgoing-webhooks/ -d '{
"url": "https://abc123.ngrok.io/webhook",
"events": ["booking.confirmed"],
...
}'Replay прошлых событий
# История всех попыток для подписки
curl -H "Authorization: Bearer $JWT" \
"https://api.nurcore.kg/api/v1/notifications/outgoing-webhooks/{sub_id}/deliveries"
# Видите failed/dead_letter deliveries для debugFAQ
Что если мой backend упал и не отвечал час?
NurCore сделает 3 retry с backoff (1m / 5m / 30m). Если все не прошли —
событие в dead_letter. Можно вручную переотправить через admin API
(в дашборде или curl).
Можно ли получать webhooks через webhook.site для теста?
Да, для разработки. webhook.site принимает любой POST, секрет можно поставить любой (но проверьте signature код).
Поддерживается ли webhook batch (несколько событий в одном request)?
Пока нет — каждое событие отправляется отдельным HTTP POST. Это упрощает retry и idempotency. Если объём станет проблемой — добавим batch.
Есть ли rate-limit на доставку?
Нет — webhooks доставляются как только событие происходит, без задержек. Если ваш endpoint вернул 429 → NurCore делает retry с backoff.
Связанные документы
- Bookings API — события
booking.* - Schedules API — события
flight.* - Check-in API — события
passenger.* - Versioning policy — backward-compat правила