# Book a hotel

> Book a pre-booked rate with the guest details: the booking confirms at once, with no hold or pay-later. Requires Read+Write permission.

Endpoint: `POST /api/v1/hotels/book_hotel`

Source: https://fx-port.com/docs/hotels/book

Finish the booking of a rate you locked with [`pre_book`](https://fx-port.com/docs/hotels/pre-book.md). **Booking a hotel confirms it: there is no hold or pay-later option for API keys, and your agency is debited as part of the booking, when it is confirmed** (the sandbox debits nothing). The balance is checked before anything is booked (live only: `402 insufficient_balance`). You send the **booking id** (the `partnerOrderId` of `pre_book`), the **guests of each room** and the **contact of the booker**. The price, the hotel, the dates and the residency come from the offer that `pre_book` stored, not from this request.

**Permission:** Read+Write (a read key answers `403 read_write_permission_required`)


> [!CAUTION]
> **In the sandbox only the supplier test hotels can be booked**
>
> Book `test_hotel_do_not_book` (every residency) or `test_hotel` (residency `dz`, `ru` or `us`). For any other hotel `pre_book` answers `409 hotel_not_bookable_in_sandbox`, and a `book_hotel` that reaches the supplier anyway fails with `order_not_found`. **Every sandbox booking is a real order on the supplier's test contract: cancel it** (free until 21:00 UTC on the check-in day). [Sandbox testing](https://fx-port.com/docs/hotels/sandbox-testing.md) has a script that books and cancels.

> [!WARNING]
> **Do not retry book_hotel blindly**
>
> The answer is `pending`: the supplier confirms in the background. If a call times out or fails with a `5xx` (except `503 supplier_busy`, which is refused before anything is sent), **read the booking before you do anything else** ([how](#when-something-goes-wrong)). A second `book_hotel` for the same booking id answers `409 duplicate_booking` and does not reach the supplier.


## Request body

`POST /api/v1/hotels/book_hotel`

| Field | Type | Required | Default | Description |
|---|---|---|---|---|
| `booking_id` | string | Yes | | The `partnerOrderId` of [`pre_book`](https://fx-port.com/docs/hotels/pre-book.md). A booking id is used once. |
| `rooms` | array | Yes | | One object per room, **in the order you searched**. See [Guests and rooms](https://fx-port.com/docs/hotels/guests-and-rooms.md#guest-names-at-booking-time). |
| `rooms[].guests` | array | Yes | | One `{ first_name, last_name }` per guest of the room, adults and children. A child also carries `is_child: true` and `age`. `is_child` is a boolean: `1` and `0`, and texts such as `"true"`, `"false"`, `"yes"` or `"no"`, are read as one; anything else is `400 invalid_is_child`. |
| `user` | object | Yes | | The booker: `email` (required), `phone` (with country code) and an optional `comment` for the hotel. FX-Port sends its confirmation e-mail to `email`. |
| `language` | string | No | `en` | Language of the confirmation e-mail and of the voucher. |
| `send_email` | boolean | No | `true` | `false` to stop FX-Port from sending its confirmation e-mail. Use it when you test. Read like `is_child` (`"false"`, `"no"` and `0` work); anything that is not a boolean is `400 invalid_send_email`. |
| `commission_override` | object | No | | Your commission for this booking: see [Commission override](#commission-override). |

The body does not need the hotel, the rate or the dates: FX-Port keeps them under the booking id and **always books the pre-booked rate and hotel**. Do not send `book_hash` or `hotel_id`. If you do, a hash the offer was made from (the `bookHash` of `pre_book` or of `get_rates`, a match hash) and the offer's own hotel (in any case, its numeric `hid`, the id `get_rates` was called with) are accepted silently; any other value is **ignored**, never refused: the answer carries an `ignored_field` warning (`the pre-booked rate was booked`, see [Conventions](https://fx-port.com/docs/conventions.md#a-previous-spelling-still-works-and-says-so)).


**Two adults**

**Request**

```bash
curl https://api.fx-port.com/api/v1/hotels/book_hotel \
  -H "Authorization: Bearer fxp_test_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "booking_id": "HMVXX4ZQ3",
  "rooms": [
    {
      "guests": [
        {
          "first_name": "Jane",
          "last_name": "Smith"
        },
        {
          "first_name": "John",
          "last_name": "Smith"
        }
      ]
    }
  ],
  "user": {
    "email": "traveler@example.com",
    "phone": "+15555550123"
  },
  "send_email": false
}'
```

**Two rooms, with a child**

**Request**

```bash
curl https://api.fx-port.com/api/v1/hotels/book_hotel \
  -H "Authorization: Bearer fxp_test_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "booking_id": "HMVXX4ZQ3",
  "rooms": [
    {
      "guests": [
        {
          "first_name": "Anna",
          "last_name": "Smith"
        },
        {
          "first_name": "Ben",
          "last_name": "Smith"
        },
        {
          "first_name": "Cleo",
          "last_name": "Smith",
          "is_child": true,
          "age": 8
        }
      ]
    },
    {
      "guests": [
        {
          "first_name": "Dan",
          "last_name": "Jones"
        }
      ]
    }
  ],
  "user": {
    "email": "traveler@example.com",
    "phone": "+15555550123",
    "comment": "Late arrival"
  },
  "send_email": false
}'
```

**With my own commission**

**Request**

```bash
curl https://api.fx-port.com/api/v1/hotels/book_hotel \
  -H "Authorization: Bearer fxp_test_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "booking_id": "HMVXX4ZQ3",
  "rooms": [
    {
      "guests": [
        {
          "first_name": "Jane",
          "last_name": "Smith"
        },
        {
          "first_name": "John",
          "last_name": "Smith"
        }
      ]
    }
  ],
  "user": {
    "email": "traveler@example.com",
    "phone": "+15555550123"
  },
  "commission_override": {
    "type": "fixed",
    "value": 50
  },
  "send_email": false
}'
```


### Legacy names

These older spellings still work. The answer carries a `deprecated_field` warning that names the spelling to use (see [Legacy names](https://fx-port.com/docs/legacy-names.md)).

| Older name | Use instead |
|---|---|
| `partner_order_id`, `partnerOrderId`, `bookingId` | `booking_id` |
| `bookHash` | `book_hash` (do not send it: FX-Port books the pre-booked rate) |
| `hotelId` | `hotel_id` (do not send it: FX-Port books the pre-booked hotel) |

## Response: the booking is `pending`

A real answer of the sandbox, in 3 to 5 seconds:

~~~json
{
  "environment": "sandbox",
  "success": true,
  "status": "pending",
  "bookingId": "HMVXX4ZQ3",
  "partnerOrderId": "HMVXX4ZQ3",
  "hotelId": "test_hotel_do_not_book",
  "checkinDate": "2026-12-01",
  "checkoutDate": "2026-12-04",
  "pricing": {
    "totalPrice": 1484.97,
    "agencyCommission": 2.97,
    "currency": "DZD",
    "supplierCurrency": "EUR",
    "originalPrice": 3
  },
  "postProcessing": {
    "bookingDbId": "24fec1e5-a09f-48ec-8050-1adaab0a8de1",
    "webhookSent": false,
    "bucketLink": null
  },
  "processingTime": 5.449,
  "balanceDebited": false,
  "transactionCreated": false,
  "payoutCreated": false
}
~~~

| Field | Description |
|---|---|
| `status` | **Always `pending`.** The supplier confirms in the background. |
| `bookingId` | The booking id: the same as `partnerOrderId` and as the `booking_id` you sent. Use it for the booking detail, the voucher and the cancellation. |
| `hotelId`, `checkinDate`, `checkoutDate` | The hotel and the dates, from the locked offer. |
| `pricing` | The price as locked: `totalPrice` is what the traveler pays, `agencyCommission` is yours, and you are debited `totalPrice - agencyCommission`. There is no `b2bPrice` here. See [Hotel pricing](https://fx-port.com/docs/hotels/pricing.md). |
| `postProcessing.bucketLink` | `null` here: the voucher is made after the call. Read it with [`get_voucher`](https://fx-port.com/docs/hotels/voucher.md). The other keys of `postProcessing` are internal. |
| `balanceDebited`, `transactionCreated`, `payoutCreated` | **Sandbox only**, always `false`: nothing is debited. They are absent on live. |
| `processingTime` | Seconds the call took. |

The keys also come as snake_case twins (`booking_id`, `partner_order_id`, `hotel_id`, `checkin`, `checkout`, `pricing.total_price`, `pricing.agency_commission`, `pricing.agency_currency`, `pricing.supplier_currency`, `voucher_url`, `processing_time`, `balance_debited` ...). See [Legacy names](https://fx-port.com/docs/legacy-names.md).

## From pending to confirmed

`pending` becomes `confirmed` or `failed` a few seconds later: **4 to 14 seconds** in our measurements (7 to 11 seconds in the last runs). There are **no webhooks**: you poll.

~~~text
book_hotel → pending → (4 to 14 s) → confirmed → voucher links (at the same moment, or up to 10 s later)
                                   → failed
~~~

Call [`GET /api/v1/bookings/{booking_id}?product=hotels`](https://fx-port.com/docs/hotels/get-booking.md) **every 2 seconds** (the booking changes within seconds: polling faster only uses your [rate limits](https://fx-port.com/docs/rate-limits.md)) until `status` is no longer `pending`, and stop after about 90 seconds.


**JavaScript**

~~~js
async function waitForBooking(bookingId, headers) {
  const started = Date.now();
  while (Date.now() - started < 90_000) {
    const url = `https://api.fx-port.com/api/v1/bookings/${bookingId}?product=hotels`;
    const { data } = await (await fetch(url, { headers })).json();
    if (data.status !== "pending") return data; // "confirmed" or "failed"
    await new Promise((resolve) => setTimeout(resolve, 2000));
  }
  throw new Error(`Booking ${bookingId} is still pending: keep polling it, do not book again`);
}
~~~

**Python**

~~~python
import time
import requests


def wait_for_booking(booking_id, headers, timeout=90):
    started = time.time()
    while time.time() - started < timeout:
        response = requests.get(
            f"https://api.fx-port.com/api/v1/bookings/{booking_id}",
            params={"product": "hotels"},
            headers=headers,
            timeout=30,
        )
        record = response.json()["data"]
        if record["status"] != "pending":
            return record  # "confirmed" or "failed"
        time.sleep(2)
    raise TimeoutError(f"Booking {booking_id} is still pending: keep polling it, do not book again")
~~~


| Status | Meaning |
|---|---|
| `pending` | Accepted. The supplier has not confirmed yet. |
| `confirmed` | Booked. The voucher links follow within seconds. The booking can be cancelled. |
| `failed` | The booking did not go through. Lock a rate again with `pre_book` (new booking id) and book again. |
| `refunded`, `partial_refund`, `cancelled` | After a cancellation: [Cancel a hotel booking](https://fx-port.com/docs/hotels/cancel.md). |

## When something goes wrong

The supplier documents that a timeout or a `5xx` during a booking **does not mean that the booking failed**: it may exist. So the rule is: **never call `book_hotel` again with the same booking id to see what happens. Read it.**

| What you got | What to do |
|---|---|
| `200` `pending` | Poll the booking. |
| A timeout, a network error, `500` (`post_booking_failed`, `internal_error`) or `502` `supplier_invalid_response` | Read `GET /bookings/{booking_id}?product=hotels`. `pending` or `confirmed`: carry on, the booking exists. `404 booking_not_found`: the booking never started, you may call `book_hotel` once more. `failed`: `pre_book` again. |
| `503` `supplier_busy` or `balance_lock_timeout` | **Refused before anything was sent**: nothing was booked at the supplier, no booking exists, nothing is debited. Call `book_hotel` again with the same booking id after the seconds in `Retry-After` (a few seconds for `balance_lock_timeout`, which means that another booking of your agency in the same currency is being processed). |
| `409` `duplicate_booking` | A booking with this id exists already (`errors[0].details` has its `booking_id` and, for your own booking, its `status`). **No second call reached the supplier.** Poll it. |
| `409` `cached_offer_required` | The offer of `pre_book` was not found: it is older than one hour (live), or another agency locked it. `pre_book` again with your own key, then book the new booking id. |
| `400` `invalid_request` | The body names two booking ids: `partner.partner_order_id` differs from `booking_id`. Send the booking id once, as `booking_id`. |
| `400` `incorrect_guests_number` | A child without `is_child` and `age`, or guests that do not match the searched rooms. Fix the list and book again. |
| `400` `order_not_found` | The supplier has no order for this booking id: the hotel was not bookable (in the sandbox, not a test hotel) or the order expired. `pre_book` again. |
| `402` `insufficient_balance` | Live: top up the balance of your agency, then book again. |

## Commission override

`commission_override` sets **your commission** for this booking instead of the default one of your agency:

| Field | Description |
|---|---|
| `type` | `fixed`: `value` is your commission **per room**, in your agency currency. `percentage`: `value` is a percentage of what you pay (`totalPrice - agencyCommission` before the override). |
| `value` | `0` or more. A negative value, or a `type` that is not one of the two, is ignored. |

The traveler price becomes **what you pay plus the new commission**. What you are debited does not change. The answer shows the new `pricing.totalPrice` and `pricing.agencyCommission`. Sandbox example: `{ "type": "fixed", "value": 50 }` on a one-room booking costing 1,185.60 DZD gives `totalPrice` 1,235.60.

## Errors

| HTTP | Code | Cause |
|---|---|---|
| `400` | `missing_field` | A required field is missing: `rooms` or `user.email`. `errors[0].field` names it (`Missing required field: rooms`), and `errors[0].details.missing_fields` lists all of them when several are missing. |
| `400` | `invalid_rooms`, `too_many_rooms`, `invalid_child_age`, `invalid_is_child`, `invalid_send_email`, `invalid_booking_id`, `invalid_language` | The body breaks a rule: `rooms` must be a non-empty list of at most 9 rooms with 1 to 10 guests each, names at most 100 characters, `booking_id` 1 to 256 characters, `is_child` and `send_email` booleans. |
| `400` | `invalid_request` | The body names two booking ids: `partner.partner_order_id` differs from `booking_id`. A `book_hash` or `hotel_id` that is not the offer's is not an error: it is ignored. |
| `400` | `booking_mode_not_available` | The body has a `booking_mode` other than `standard`. A booking made with an API key always books and confirms the rate: do not send the field. |
| `400` | `incorrect_guests_number`, `order_not_found` | See [above](#when-something-goes-wrong). Both are refusals of the supplier: `errors[0].type` is `supplier_error`. |
| `402` | `insufficient_balance` | Live only. |
| `403` | `read_write_permission_required`, `agency_not_active` | A read key, or an agency that is not approved for this environment. |
| `409` | `duplicate_booking`, `cached_offer_required`, `trusted_offer_pricing_missing` | See [above](#when-something-goes-wrong). |
| `500` | `post_booking_failed`, `internal_error` | **Read the booking before you repeat the call.** |
| `502` | `supplier_invalid_response` | The supplier's answer to the booking could not be read. **Read the booking before you repeat the call.** |
| `503` | `supplier_busy`, `balance_lock_timeout` | Refused before anything was sent: retry after the seconds in `Retry-After`. |

The `409 hotel_not_bookable_in_sandbox` belongs to `pre_book`; this is what it carries:

~~~json
{
  "test_hotels": [
    {
      "id": "test_hotel_do_not_book",
      "hid": 8473727,
      "residency": "any"
    },
    {
      "id": "test_hotel",
      "hid": 8526976,
      "residency": [
        "dz",
        "ru",
        "us"
      ]
    }
  ]
}
~~~

## Next step

Poll [the booking](https://fx-port.com/docs/hotels/get-booking.md) until it is `confirmed`, then fetch the [voucher](https://fx-port.com/docs/hotels/voucher.md). To cancel, see [Cancel a hotel booking](https://fx-port.com/docs/hotels/cancel.md).

## Related


- [Pre-book a rate](https://fx-port.com/docs/hotels/pre-book.md): Where the booking id comes from.
- [Get a hotel booking](https://fx-port.com/docs/hotels/get-booking.md): Poll the status, with the price and the voucher links.
- [Get the hotel voucher](https://fx-port.com/docs/hotels/voucher.md): The PDF to hand to the traveler.
- [Cancel a hotel booking](https://fx-port.com/docs/hotels/cancel.md): Free until 21:00 UTC on the check-in day in the sandbox.
- [API reference: Book a locked rate](https://fx-port.com/docs/api/book-hotel.md): Every field and error.
