NurCore API
API Reference

Bookings

Создание, изменение, отмена и обмен бронирований.

Базовый префикс: /api/v1/bookings/

Создание

POST / — canonical quote-based

Создать бронирование. С 2026-06-01 канонический вход — только quote_id (без legacy fields). Handler резолвит flight_id + fare_* из quote snapshot.

Body (canonical):

{
  "quote_id": "abc123...",
  "contact_email": "user@example.com",
  "contact_name": "Иван Петров",
  "contact_phone": "+996555000000",
  "passengers": [{
    "first_name": "JOHN",
    "last_name": "DOE",
    "passenger_type": "adult",
    "date_of_birth": "1990-01-15",
    "gender": "male",
    "nationality": "KGZ",
    "document_type": "passport",
    "document_number": "AN1234567",
    "document_expiry": "2030-05-01",
    "seat_number": "12A"
  }]
}

Quote requirements: quote должен содержать flight_id (route-based quote без flight_id не подходит для booking — 400). Делайте quote с явным flight_id для бронирования.

Три валидных варианта входа:

ВариантПоляКогда
A. quote_id only (recommended)quote_id + passengers + contactCanonical путь — single source of truth pricing
B. segments[]segments[] + passengers + contactMulti-city explicit per-segment
C. Legacyflight_id + fare_plan_id + fare_rule_id + fare_price_id + passengersBackward-compat без quote

Если квоты нет — сначала POST /api/v1/fares/quote, потом сюда с quote_id.

Бизнес-правила:

  • Максимум 9 пассажиров на одну броню
  • expiry_date = created_at + 15 минут (для PENDING)
  • total_amount берётся из quote (а не пересчитывается локально)

Validation: passenger_type vs age + document_expiry

С 2026-06-01 NurCore валидирует IATA Resolution 728 + ICAO Annex 9:

ValidationRuleError при нарушении
passenger_type=infant vs ageВозраст < 2 лет на дату вылета«Тип 'infant' допустим только для детей до 2 лет на момент вылета (текущий возраст: X лет). Используйте 'child'.»
passenger_type=child vs ageВозраст 2-11 лет на дату вылета«Тип 'child' допустим для возраста 2-11 лет (текущий возраст: X лет)...»
passenger_type=adult vs ageВозраст ≥ 12 лет«Тип 'adult' требует возраст ≥ 12 лет...»
document_expiry> сегодня«Документ просрочен»
document_expiry vs departure_date> date_of_flight«Документ истекает 2026-08-01 — до даты вылета 2026-08-16. Обновите документ.»

Возраст считается на дату вылета (от quote.flight_id либо booking_data.departure_date).

Many international destinations require passport validity ≥ 6 месяцев после travel date — это soft check на стороне carrier, не enforced у нас.

  • Имена пассажиров — заглавными латинскими буквами (как в загранпаспорте)

Выбор места в салоне при бронировании

Каждый объект passengers[i] принимает опциональное поле seat_number (формат ^\d{1,2}[A-Z]$, например 12A). Если место указано — backend определяет его зону, списывает seat_fee из таблицы зон тарифа и сохраняет в booking_seats. Если seat_number не указан — место будет назначено автоматически при онлайн-регистрации.

Правило тарифа seat_selection

fare_rule.seat_selection управляет тем, можно ли выбрать место заранее.

  • seat_selection=true — выбор места при бронировании разрешён.
  • seat_selection=false (типично у LIGHT-тарифов) — seat_number в passengers[]HTTP 422 с сообщением «Тариф не позволяет выбор места заранее». Место присваивается автоматически при регистрации.

Проверить флаг можно через GET /api/v1/fares/rules/{fare_rule_id} → поле seat_selection.

Шаг 1. Карта салона (/checkin/flight/{id}/seat-map)

Тот же эндпоинт что и для онлайн-регистрации, но доступен и до бронирования через X-API-Key с client_type=consumer_app (без access_token/booking_id):

curl -H "X-API-Key: $KEY" \
     "https://api.nurcore.kg/api/v1/checkin/flight/$FLIGHT_ID/seat-map"

Response (сокращённо):

{
  "flight_id": "…",
  "aircraft_id": "…",
  "columns": ["A", "B", "C", "D"],
  "rows": [
    {
      "number": 1,
      "class": "economy",
      "seats": [
        {"seat_number": "1A", "type": "standard", "status": "free", "fee": 0},
        {"seat_number": "1B", "type": "standard", "status": "occupied", "fee": 0},

      ]
    }
  ],
  "stats": {"total": 80, "free": 78, "occupied": 2, "blocked": 0}
}

statusfree | occupied | blocked — выбирайте только free.

Шаг 2. Цены зон мест (/fares/seat-prices/for-booking)

Зоны (стандарт / extra-legroom / front-row …) тарифицируются на уровне правила тарифа. Получить таблицу зон с ценами:

curl -H "X-API-Key: $KEY" \
     "https://api.nurcore.kg/api/v1/fares/seat-prices/for-booking?fare_rule_id=$RULE"

Response:

[
  {"zone_code": "STD",   "zone_name": "Стандартное место",       "price": 0,   "currency": "KGS", "is_included": true},
  {"zone_code": "XL",    "zone_name": "Место с увеличенным шагом", "price": 500, "currency": "KGS", "is_included": false},
  {"zone_code": "FRONT", "zone_name": "Место в передних рядах",   "price": 300, "currency": "KGS", "is_included": false}
]

Зона конкретного места определяется на сервере по seat_map воздушного судна (типы рядов: exit-row → XL, первые ряды → FRONT, остальное → STD). Клиент не передаёт zone_code — только seat_number; сервер сам сопоставляет место → зону → цену.

Шаг 3. POST /bookings/ с seat_number

{
  "flight_id": "…",
  "fare_price_id": "…",
  "quote_id": "…",
  "passengers": [
    {
      "first_name": "JOHN", "last_name": "DOE", "passenger_type": "adult",
      "date_of_birth": "1990-01-15", "gender": "male", "nationality": "KGZ",
      "document_type": "passport", "document_number": "AN1234567",
      "seat_number": "12A"
    }
  ],
  "contact_email": "user@example.com", "contact_phone": "+996555000000"
}

Возможные ответы:

КодСценарий
201Бронь создана; total_amount уже включает seat_fee из зоны места
409Место 12A уже занято другой бронью / зарезервировано
422Тариф не позволяет выбор места (fare_rule.seat_selection=false)
422Неверный формат (^\d{1,2}[A-Z]$) или место за пределами cabin

В GET /{booking_id} место отображается в passengers[i].seat_number и в массиве seats[] вместе с seat_fee.

Условия тарифа (обмен / возврат)

Перед оплатой пассажиру нужно показать, разрешены ли обмен/возврат, сколько они стоят и за какое время до вылета. Эта информация возвращается в QuoteResponse.fare_rules per leg (outbound + return при round-trip).

curl -X POST -H "X-API-Key: $KEY" \
  -H "Content-Type: application/json" \
  -d '{"origin":"BSZ","destination":"OSS","departure_date":"2026-05-22",
       "pax":[{"type":"adult","count":1}]}' \
  "https://api.nurcore.kg/api/v1/fares/quote"

В ответе:

{
  "net": "3900.00",
  "fare_rules": [
    {
      "leg": "outbound",
      "refundable": true,  "changeable": true,
      "cancellation_fee": "500.00", "change_fee": "500.00",
      "currency": "KGS",
      "exchange_threshold_hours": 24.0,
      "exchange_penalty_multiplier": 2.0,
      "notes": [
        "Обмен разрешён: штраф 500.00 KGS (до вылета > 24ч). Если < 24ч до вылета — штраф 1000.00 KGS.",
        "Возврат разрешён: штраф 500.00 KGS. Сумма возврата = total − штраф. Возможен до момента вылета."
      ]
    }
  ]
}

Готовые русские строки в notes[] уже учитывают глобальные пороги (fare.exchange_threshold_hours + fare.exchange_penalty_mult) — UI может выводить их «как есть».

После создания брони условия тарифа доступны через GET /api/v1/fares/rules/{fare_rule_id} (id находится в booking.segments[i].fare_rule_id), либо повторным quote с тем же route_id + fare_rule_id.

Получение

GET /{id}

Полная карточка брони (passengers, segments, payments, seats).

Authentication: X-API-Key. Каждый клиент видит только свои брони (B2B-агентство — свои бронирования; consumer_app — созданные через свой ключ).

GET /{id}/public?access_token=X

Публичная карточка через magic-link. Без X-API-Key — token bound к booking_id.

GET /lookup-consumer?pnr=X&email=Y

Find my trip — отправляет magic-link на email если PNR + email совпадают. Rate limit: 3/мин, 10/час на IP.

curl "https://api.nurcore.kg/api/v1/bookings/lookup-consumer?pnr=ABC123&email=user@example.com"

Подтверждение / отмена

POST /{id}/confirm

Перевод PENDING → CONFIRMED (после оплаты).

POST /{id}/cancel

Отмена B2B / staff. Авто-refund для оплаченных в течение fare-policy (зависит от тарифа).

POST /{id}/cancel-consumer

Self-cancel пассажиром. Два варианта авторизации:

  • Magic-link?access_token=X (из письма Find My Trip);
  • Passenger-JWTAuthorization: Bearer <passenger_access_token> (из POST /auth/passenger/login). Бронь должна принадлежать владельцу токена (по user_id или contact_email).
# Вариант A: magic-link
curl -X POST "https://api.nurcore.kg/api/v1/bookings/$BOOKING_ID/cancel-consumer?access_token=$ACCESS_TOKEN"

# Вариант B: passenger-JWT
curl -X POST \
  -H "Authorization: Bearer $PASSENGER_JWT" \
  "https://api.nurcore.kg/api/v1/bookings/$BOOKING_ID/cancel-consumer"

Поведение:

  • PENDING → place становится свободным (нет рефанда — деньги ещё не списаны).
  • PAID → бронь переходит в CANCELLED, refund инициируется по тарифной политике в течение 5–7 рабочих дней.

Response 200:

{
  "booking_id": "uuid",
  "booking_reference": "ABC123",
  "status": "cancelled",
  "payment_status": "refund_pending",
  "refund_initiated": true,
  "refund_message": "Refund will be processed within 5–7 business days"
}

POST /{id}/refund-consumer

Запрос возврата по оплаченной броне. Двухвариантная авторизация — такая же как у cancel-consumer (magic-link ?access_token=X либо passenger-JWT).

Проверяет fare_rule.refundable из связанного тарифа:

СемействоПоведение
flex / businessFull refund (refund_amount = total_amount)
classicRefund − cancellation_fee
light400 Non-refundable (только cancellation без рефанда)
curl -X POST \
  -H "Authorization: Bearer $PASSENGER_JWT" \
  "https://api.nurcore.kg/api/v1/bookings/$BOOKING_ID/refund-consumer"

Response 200:

{
  "booking_id": "uuid",
  "booking_reference": "ABC123",
  "status": "cancelled",
  "payment_status": "refunded",
  "refund_amount": 4500.00,
  "currency": "KGS",
  "fare_family": "classic",
  "refundable": true
}

Errors:

КодСценарий
400Бронь не оплачена / уже отменена / тариф non-refundable
401 / 403Невалидный токен / бронь принадлежит другому пассажиру
404Бронь не найдена

Обмен (exchange) — только B2B

GET  /{id}/exchange/dates              — календарь альтернативных дат
GET  /{id}/exchange/flights?date=...    — рейсы на дату
POST /{id}/exchange/preview             — расчёт + price lock 15 мин
POST /{id}/exchange/execute             — выполнить обмен

Доступно только для agency ключей. Consumer-flow обмена пока не поддерживается — пассажир обращается к агенту или авиакомпании.

E-ticket

GET /{id}/eticket

PDF посадочного билета. Поддерживает оба способа:

  • X-API-Key (B2B / Mobile с правами на эту броню)
  • access_token=X (consumer magic-link)

POST /{id}/send-access-link

Перевыпустить magic-link на contact_email. Используется когда пассажир потерял оригинальное письмо.

Платежи

Есть три способа оплаты — выбирайте под client_type.

СпособКомуEndpoint
Свой PSP (Kaspi/MBank/...)consumer-appPOST /{id}/initiate-payment + наш external webhook
Wallet B2B-партнёраagencyPOST /{id}/pay-with-balance
Pre-integrated PSP (Stripe / Freedom Pay)consumer-appPOST /{id}/initiate-payment (без external webhook)

POST /{id}/initiate-payment

Старт оплаты через интегрированный платёжный провайдер. Возвращает payment_url (для Freedom Pay / других redirect-PSP) либо client_secret (для Stripe.js). Авторизация — consumer-app key (X-API-Key).

Body:

{
  "amount": 5000.00,
  "currency": "KGS",
  "return_url": "https://your-site.com/payment-success",
  "cancel_url": "https://your-site.com/payment-cancelled"
}
ПолеТипОписание
amountfloatСумма к оплате; если 0 — берётся booking.total_amount
currencystringВалюта (3-letter ISO). По умолчанию — валюта брони
return_urlstringURL редиректа после успешной оплаты
cancel_urlstringURL редиректа при отмене

Response 200:

{
  "payment_id": "uuid",
  "payment_url": "https://provider.com/checkout/...",
  "redirect_url": "https://provider.com/checkout/...",
  "expires_at": "2026-05-12T12:30:00Z",
  "provider": "freedompay",
  "client_secret": null,
  "stripe_publishable_key": null
}

Для Stripe-flow вместо payment_url приходит client_secret — используйте его на frontend через Stripe.js (stripe.confirmCardPayment(client_secret)). После успешной оплаты Stripe сам шлёт webhook в payment-service — никаких дополнительных вызовов от вас не требуется.

Idempotency: поддерживается через заголовок Idempotency-Key (TTL 24h). Повторный POST с тем же ключом вернёт исходный payment_id без двойной инициации.

Errors:

КодСценарий
400Бронь уже оплачена / бронь в статусе CANCELLED / EXPIRED
404Бронь не найдена
409Параллельная инициация платежа (повторите через 1–2с)

POST /{id}/pay-with-balance — только B2B

Списание с partner wallet — для турагентств. Авторизация — agency ключ (X-API-Key) с привязанным partner_id и достаточным балансом в валюте брони.

Body: пустой (всё необходимое — total_amount и currency — берётся из самой брони).

curl -X POST \
  -H "X-API-Key: $AGENCY_KEY" \
  "https://api.nurcore.kg/api/v1/bookings/$BOOKING_ID/pay-with-balance"

Response 200: полная карточка брони (BookingResponse) с payment_status="paid", status="confirmed".

Поведение:

  • Atomic списание с partner wallet (FOR UPDATE на строке кошелька);
  • Создаётся запись в wallet.transactions (type=booking_payment);
  • Начисляется комиссия партнёра по applicable commission rule (видно в booking.commission_amount);
  • При недостатке баланса в нужной валюте → 422 «Insufficient balance for currency XYZ» (для multi-currency wallet нужно top-up через авиакомпанию).

Drafts (черновики броней)

Черновик — это холд мест без указания пассажиров. Используется для многошаговых UX-сценариев (пользователь выбрал рейс, ищет промокод, советуется с друзьями) — места зарезервированы, но бронь не создаётся до подтверждения.

POST /drafts/

Создать черновик.

curl -X POST \
  -H "X-API-Key: $SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "flight_id": "uuid",
    "fare_plan_id": "uuid",
    "fare_price_id": "uuid",
    "seat_count": 2,
    "contact_email": "user@example.com",
    "expires_hours": 24
  }' \
  "https://api.nurcore.kg/api/v1/bookings/drafts/"

Body:

ПолеТипRequiredОписание
flight_idUUIDID рейса
return_flight_idUUIDДля round-trip
fare_plan_idUUIDТарифный план
fare_price_idUUIDКонкретная цена
seat_countint (1-9)Количество мест (default 1)
contact_emailstringEmail для связи
contact_phonestringТелефон
expires_hoursint (1-8760)TTL в часах (default null = бессрочно)

Response 201:

{
  "id": "uuid",
  "flight_id": "uuid",
  "fare_plan_id": "uuid",
  "fare_price_id": "uuid",
  "seat_count": 2,
  "user_id": "uuid",
  "contact_email": "user@example.com",
  "status": "active",
  "expires_at": "2026-05-13T10:00:00Z",
  "created_at": "2026-05-12T10:00:00Z"
}

GET /drafts/

Список черновиков текущего пользователя.

Query:

  • flight_id — фильтр по рейсу
  • statusactive (default) / released / expired
  • page, page_size — пагинация

GET /drafts/{draft_id}

Получить один черновик. Доступ — только владельцу (user_id совпадает).

DELETE /drafts/{draft_id}

Освободить черновик (снять холд). Статус → released. Hard delete не используется — для аудита.

Когда использовать drafts vs PENDING booking?

СценарийРешение
Пользователь не уверен в датах / попутчикахDraft (без пассажиров, легко изменить)
Готовы данные пассажиров, осталась оплатаPENDING booking (15 минут на оплату)
Долгий процесс согласования с командой / клиентомDraft с expires_hours=24

Важно: drafts не блокируют availability так же жёстко как PENDING. В overbooked сценариях preference имеет PENDING. См. Inventory M4.

Resume PENDING booking (Sprint 2.6)

POST /{id}/resume-pending?access_token=...

Продлить 15-минутное окно PENDING-брони. Use case: пользователь начал бронирование, закрыл вкладку перед оплатой — через email открыл magic-link → видит "осталось 30 секунд" → нажимает «Возобновить».

Условия:

  • Только для status=PENDING (CONFIRMED/CANCELLED/EXPIRED → 400)
  • Требует валидный access_token (как /public)
  • Не более 3 продлений на бронь (anti-abuse → 429)

Query:

ParamRequired
access_token✓ — bound к этому booking_id

Response 200:

{
  "booking_reference": "ABC123",
  "status": "pending",
  "expiry_date": "2026-05-12T11:15:00Z",
  "extended_by_minutes": 15,
  "payment_url": "/booking/pay?bid=uuid&access_token=..."
}

Errors:

CodeСценарий
400Не PENDING статус
401 / 403Invalid / mismatched access_token
429Превышен лимит продлений (3)
404Бронь не найдена

On this page