---
title: "Book a rate: the price is held and captured only once the hotel confirms (asynchronous)"
method: POST
path: "/hotels/book"
tags: ["hotels"]
---

# Book a rate: the price is held and captured only once the hotel confirms (asynchronous)

`POST /hotels/book`

## Request

- Base URL: `https://letsfg.co/developers/api/v1`
- URL: `https://letsfg.co/developers/api/v1/hotels/book`
- Auth: API key in header `X-API-Key`

Returns 202 immediately with a booking_job_id; poll GET /hotels/booking/{booking_job_id} for the result. Guest details are checked first (400 invalid_details, before anything is held). Then the full price is HELD (authorised, not taken) on the connected Revolut payment method, LetsFG books the room, and the hold is captured only once the supplier has confirmed. A booking that fails for any reason releases the hold, and nothing is charged. There is no reservation fee, no deposit and no pay link.

Hotel bookings run one at a time; one that cannot start within 20 minutes fails and releases its hold. Do NOT re-POST while a job is running: poll it. A retry of the same booking (the same idempotency_key, or the same offer, e-mail and check-in) returns the existing job with duplicate: true and never holds the money twice. The guest is e-mailed at `email` however the booking ends. A successful booking resets the look-to-book search allowance back to 1,000.

## Request body

- HotelBookRequest: Book the exact rate the guest chose. Every identifier comes straight out of POST /hotels/search: session_id and combination_id_v2 from the chosen offer, hotel_code from its hotel. Copy the offer `price` as expected_price, and its expected_cost, currency and fx_rate, verbatim: a booking whose figures were not copied is refused (400 price_mismatch) before anything is held, and one whose supplier price has since moved fails with the hold released, so a guest is never charged a price they did not agree to. Otherwise the full price is HELD on the connected Revolut payment method and captured only once the supplier confirms.
  - `session_id` string, required: The `session_id` on the chosen offer.
  - `hotel_code` integer, required
  - `combination_id` integer, nullable
  - `combination_id_v2` string, required
  - `expected_price` number, required: The `price` from the chosen offer.
  - `expected_cost` number: The `expected_cost` from the chosen offer (0), sent back as returned. Optional.
  - `currency` string: The offer `currency`. Defaults to PLN, so an offer priced in USD (the search default) must send it.
  - `fx_rate` number, nullable: The offer `fx_rate`, copied verbatim.
  - `hotel_name` string, nullable
  - `city_id` integer, required
  - `city_name` string, required
  - `check_in` string, date, required
  - `check_out` string, date, required
  - `adults` integer
  - `guests` HotelGuest[], required: One entry per guest in the room, children included: adults first, then children in the order of child_ages. A booking with fewer names than guests is refused before anything is submitted, and the hold is released.
    - `title` string, required: Mr / Mrs / Ms
    - `first_name` string, required: Latin script, as on the guest documents.
    - `last_name` string, required: Latin script, as on the guest documents.
  - `email` string, email, required: The guest e-mail. The confirmation goes here, or word that the booking did not go through. A typo loses the booking.
  - `phone` string, required: A number valid for phone_country_code.
  - `phone_country_code` string
  - `special_requests` string[]
  - `idempotency_key` string, nullable: Optional. The same key on a retry returns the booking already opened instead of holding the money twice.

## Response `202`

Booking started; the price is held.

- HotelBookAcceptedResponse
  - `booking_job_id` string: Pass to GET /hotels/booking/{booking_job_id} to poll for the result.
  - `booking_id` string
  - `status` 'in_progress'
  - `held` object: What is now held (authorised, not taken) on the connected method. Absent on a duplicate.
    - `amount` number
    - `currency` string
    - `card` object
      - `brand` string
      - `last4` string
  - `duplicate` boolean: True when this booking was already under way; nothing new was held.
  - `poll` string
  - `poll_after_seconds` integer
  - `note` string

## Other responses

- `400`: invalid_details (with invalid_fields): a guest name, phone or e-mail the supplier would refuse. price_mismatch: expected_price, expected_cost, currency or fx_rate were not copied from the offer. Nothing is held.
- `402`: No payment method connected, or the connected method declined the hold. Nothing is booked.

---

[API](https://skmtc.dev/letsfg/apis/letsfg-public-developer-api.md) · [All operations](https://skmtc.dev/letsfg/apis/letsfg-public-developer-api/llms.txt) · [OpenAPI document](https://skmtc.dev/letsfg/apis/letsfg-public-developer-api/revisions/516ab6ba1d2e?raw)
