> ## Documentation Index
> Fetch the complete documentation index at: https://docs.platnova.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Travel

> Book flights and hotels for your customers, paid from your business wallet or a paid checkout, with refunds handled for you.

You search and price flights and hotel stays through Platnova, then book them. Each booking is paid from your NGN business wallet, or from a checkout your customer has already paid. If a booking cannot be completed, the money goes back to your wallet automatically.

> [!NOTE]
>
> "Pay with Platnova" is not yet available for travel (`422` `payment_method_not_available`).

Travel must be enabled for your business. Until it is, every travel route answers `403` `partner_product_disabled`. If the service is unavailable, routes answer `503` `travel_unavailable`.

## How a booking works

1. **Search.** `POST /v1/partner/travel/flights/search` returns offers.
2. **Quote.** `POST /v1/partner/travel/flights/quote` with `{ "offer_id", "currency": "NGN" }` returns the price you pay, fee included, as `amount_minor`, along with `expires_at`.
3. **Book.** `POST /v1/partner/travel/orders` with that exact `amount_minor`. Your wallet is debited at once and the answer is `202`, with the order `processing`.
4. **Wait for the outcome.** You receive the `travel.order.confirmed` webhook with the booking reference (PNR), or you can poll `GET /v1/partner/travel/orders/{partner_reference}`.

All amounts are integers in **minor units** (kobo for NGN). `100000` is ₦1,000.00.

A travel `partner_reference` is stricter than other partner references: **1 to 64 letters, digits, `.`, `_` or `-`, and never `..`**. Anything else is refused with `422` `partner_reference_invalid`.

## Search

`POST /v1/partner/travel/flights/search`

```json theme={null}
{
  "slices": [ { "origin": "LOS", "destination": "LHR", "departure_date": "2026-12-20" } ],
  "passengers": [ { "type": "adult" }, { "age": 1 } ],
  "cabin_class": "economy"
}
```

The answer is the offer request: its `offers`, each with an `id` and its `slices` and `passengers` (the passenger ids you book with). Prices in the search are the airline's own. **Always quote an offer before booking it.**

## Quote

`POST /v1/partner/travel/flights/quote`

```json theme={null}
{ "offer_id": "off_0000AbC…", "currency": "NGN" }
```

```json theme={null}
{ "quote_id": "off_0000AbC…", "amount_minor": 123456700, "currency": "NGN", "fee_minor": 150000, "expires_at": "2026-10-22T10:30:00Z", "cost_floor_applied": true }
```

**A booking is never priced below what it costs Platnova.** The quote's fee (`fee_minor`, included in `amount_minor`) must cover the airline's fare, the booking provider's order fee, the exchange cost and a minimum margin. When the normal fee falls short, the fee is raised and `cost_floor_applied: true` appears; otherwise the field is left out. The fare itself is never changed. If the cost can't be worked out, the offer is refused as `offer_unavailable`, never sold at a guess.

Book with exactly the quoted `amount_minor`. The order is priced the same way, so a quote and its order agree.

## Book

`POST /v1/partner/travel/orders` (needs an `Idempotency-Key`)

```json theme={null}
{
  "partner_reference": "SHL-123",
  "product": "flight",
  "offer_id": "off_0000AbC…",
  "amount_minor": 123456700,
  "currency": "NGN",
  "payment": { "method": "wallet" },
  "passengers": [
    {
      "id": "pas_0000…",
      "title": "ms",
      "given_name": "Ada",
      "family_name": "Obi",
      "gender": "f",
      "born_on": "1990-01-01",
      "email": "ada@example.com",
      "phone_number": "+2348012345678",
      "infant_passenger_id": "pas_0001…",
      "identity_documents": [
        { "unique_identifier": "A12345678", "type": "passport", "issuing_country_code": "NG", "expires_on": "2030-01-01" }
      ]
    },
    { "id": "pas_0001…", "given_name": "Tobi", "family_name": "Obi", "born_on": "2026-01-10", "gender": "m", "title": "mr" }
  ]
}
```

* **Infants** travel on an adult's lap: put the infant's passenger id in that adult's `infant_passenger_id`.
* **`payment.method`** is one of:
  * `wallet`: your default wallet in `currency` is debited.
  * `checkout`: send `payment.checkout_id` as well. The checkout must be one of yours, already paid, in the same currency, and for at least `amount_minor` less anything refunded from it. **A checkout pays for one booking only.** Your wallet is debited the booking amount; the checkout's payment is what funded it.

### The answer

| HTTP | `data.order.status` | Meaning |
| - | - | - |
| `202` | `processing` | Accepted and being booked. Wait for the webhook. |
| `202` | `submitting` | We debited your wallet but did not hear back in time. We check within 30 minutes. If it was not booked, the money comes back. |
| `409` `price_changed` | `failed` | The price moved. Nothing was booked and your wallet is already refunded. `data.quote` is the new price, so book again with a new `partner_reference`. |
| `422` `offer_unavailable` (or another refusal code) | `failed` | Refused. Nothing was booked; already refunded. |

Before any money moves, these are refused:

| Refusal | When |
| - | - |
| `422` `insufficient_balance` | Your wallet does not hold `amount_minor`. |
| `422` `wallet_not_found` | You have no wallet in `currency`. |
| `422` `checkout_not_paid` | That checkout is not paid, or is not yours. |
| `422` `checkout_amount_short` | The checkout was paid in another currency, or for less. |
| `409` `checkout_already_used` | That checkout already paid for a booking. |
| `400` `travel_order_invalid` | A field is missing or malformed. `message` says which. |
| `422` `partner_per_transaction_cap` / `partner_daily_cap` / `partner_currency_not_enabled` | Your travel limits. |

The order object:

```json theme={null}
{
  "partner_reference": "SHL-123",
  "product": "flight",
  "status": "booked",
  "order_id": "…",
  "amount_minor": 123456700,
  "currency": "NGN",
  "transaction_ref": "BPTRVL…",
  "payment_method": "wallet",
  "booking_reference": "ABC123",
  "e_tickets": [ { "passenger_id": "pas_0000…", "ticket_number": "…" } ],
  "refund_minor": 0,
  "created_at": "…",
  "updated_at": "…"
}
```

`status` moves through:

* `submitting`, then `processing`, then `booked`
* or `failed` (refunded in full)
* or `cancelled` (refunded what the airline returns)
* or `attention`: something unexpected happened and a Platnova person is looking at it. Your money is held, not lost, and we contact you.

## Hotels (stays)

A stay follows the same steps and the same order routes as a flight: search, quote, book with `POST /v1/partner/travel/orders`, then read, cancel and receive webhooks by `partner_reference`.

### Search

`POST /v1/partner/travel/stays/search`

```json theme={null}
{
  "rooms": 1,
  "guests": [ { "type": "adult" }, { "type": "child", "age": 7 } ],
  "location": { "radius": 5, "geographic_coordinates": { "latitude": 6.4281, "longitude": 3.4219 } },
  "check_in_date": "2026-12-12",
  "check_out_date": "2026-12-14",
  "currency": "NGN"
}
```

The answer lists `results`, each a property with a search result `id`, its `accommodation` (name, address, photos, rating, amenities) and its cheapest rate. With `currency`, each result also carries `cheapest_amount_minor`: an indicative price in your currency, fee included. **The quote is the price you pay.**

### Rates

`POST /v1/partner/travel/stays/rates` with `{ "search_result_id": "ssr_…" }` returns every room and rate for that property. Each rate has an `id`, its `cancellation_timeline` and its `conditions`. Prices there are the property's own; quote a rate for yours.

### Quote

`POST /v1/partner/travel/stays/quote`

```json theme={null}
{ "rate_id": "rat_…", "currency": "NGN" }
```

```json theme={null}
{
  "quote_id": "quo_…",
  "amount_minor": 31500000,
  "currency": "NGN",
  "fee_minor": 1500000,
  "due_at_accommodation_amount": "12.00",
  "due_at_accommodation_currency": "USD",
  "check_in_date": "2026-12-12",
  "check_out_date": "2026-12-14",
  "rooms": 1,
  "accommodation_id": "acc_…",
  "accommodation_name": "…",
  "cancellation_timeline": [
    { "before": "2026-12-01T12:00:00Z", "refund_minor": 30000000 },
    { "before": "2026-12-10T12:00:00Z", "refund_minor": 15000000 }
  ],
  "expires_at": "2026-10-22T12:00:00Z"
}
```

* **A quote is yours alone** and lasts until `expires_at` (two hours). Book it before then.
* **`due_at_accommodation_amount`**, when present, is paid by the guest at the property, in its own currency. It is not in `amount_minor`. Tell your guest.
* **`cancellation_timeline`** is the refund if the booking is cancelled before each `before`, in your currency, at the price you pay now. Our booking fee is never refunded. An empty timeline means the room is non-refundable.

### Book

`POST /v1/partner/travel/orders` (needs an `Idempotency-Key`)

```json theme={null}
{
  "partner_reference": "SHL-STAY-1",
  "product": "stay",
  "quote_id": "quo_…",
  "amount_minor": 31500000,
  "currency": "NGN",
  "payment": { "method": "wallet" },
  "guests": [ { "given_name": "Ada", "family_name": "Obi" } ],
  "email": "ada@example.com",
  "phone_number": "+2348012345678",
  "special_requests": "Late check-in"
}
```

A stay needs `quote_id`, at least one guest, `email` and `phone_number`, and takes no `offer_id` or `passengers`. `payment` works as for flights. The answers are the flight answers, plus:

| HTTP | Meaning |
| - | - |
| `422` `quote_expired` | The quote is older than two hours, or was not made for your business. Nothing was booked; already refunded. Quote again. |
| `422` `rate_unavailable` | The room is no longer available. Nothing was booked; already refunded. |

`booking_reference` on the order and on `travel.order.confirmed` is the property's confirmation code.

### Cancel a stay

The same two calls as a flight. The refund comes from the quote's `cancellation_timeline`: the best step whose `before` is still more than five minutes away.

* The cancellation quote's `expires_at` is when that step runs out, or 15 minutes, whichever is sooner.
* Confirming after it answers `409` `cancellation_expired`. Ask for a new quote.
* `202` `cancellation_pending` means the property has not answered yet. Send the same call again.

## Cancel a booking

1. `POST /v1/partner/travel/orders/{partner_reference}/cancellation-quote` tells you what comes back:

   ```json theme={null}
   { "cancellation_id": "ore_…", "refund_minor": 98000000, "refund_currency": "NGN", "expires_at": "…" }
   ```

   For a flight, the refund is the airline's refund at the rate you booked at. Our booking fee is not returned. A refund that the airline makes as credit or a voucher, or in another currency, shows as `0`.
2. `POST /v1/partner/travel/orders/{partner_reference}/cancellation` with `{ "cancellation_id": "ore_…" }` (needs an `Idempotency-Key`) confirms it. The answer is the cancelled order, and `refund_minor` is already in your wallet.
   * `202` `cancellation_pending`: the airline has not answered yet. Send the same call again shortly.
   * `409` `cancellation_mismatch`: the id is not your latest quote.
   * `409` `travel_order_not_cancellable`: only a booked order can be cancelled.

## Webhooks

Subscribe to `travel.order.*`. Each event carries the order:

```json theme={null}
{
  "id": "…",
  "type": "travel.order.confirmed",
  "created_at": "…",
  "partner_reference": "SHL-123",
  "data": {
    "partner_reference": "SHL-123",
    "order_id": "…",
    "product": "flight",
    "status": "booked",
    "booking_reference": "ABC123",
    "amount_minor": 123456700,
    "currency": "NGN",
    "transaction_ref": "BPTRVL…",
    "details": { "booking_reference": "ABC123", "e_tickets": [ { "passenger_id": "pas_0000…", "ticket_number": "…" } ] }
  }
}
```

`details` holds, on `confirmed`, the airline order with its e-tickets, and on `failed` and `cancelled`, the reason and the refund.

| Event | When | Money |
| - | - | - |
| `travel.order.confirmed` | The airline or property confirmed. `booking_reference` is the PNR, or the property's confirmation code. | — |
| `travel.order.failed` | The airline or property refused. | `refund` is the full amount, already credited to your wallet. |
| `travel.order.cancelled` | Cancelled by you, the airline or the property. | `refund` is what came back, already credited. |
| `travel.order.changed` | The airline changed the schedule. Coming soon. | — |

`data.product` is `flight` or `stay`. Each order is refunded at most once, however many times an event is delivered.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.