# Booking flow

Every product in API v2 is sold with the same steps. Only the way you get the catalog and the availability changes. This guide describes the shared steps. It uses a transfer as the example item. What is specific to each product is in [Transfers](/developers/guides/v2-transfers), [Tickets](/developers/guides/v2-tickets) and [Hotels](/developers/guides/v2-hotels). For the idea behind the flow, read the [API v2 overview](/developers/guides/v2-overview). Field-by-field detail is in the [Booking flow reference](/developers/reference/v2/bookings).

| Step | Call | What carries to the next step |
|---|---|---|
| 1 | The catalog and availability calls of the product | `offer_id` |
| 2 (optional) | `POST /api/v2/offers/check` | A new `offer_id` and the cancellation policy |
| 3 | `POST /api/v2/bookings` | `booking_id` |
| 4 | `GET`, `cancel`, `voucher` on `/api/v2/bookings/{booking_id}` | |

The smallest integration is the availability call and step 3.

## 1. The offer

Availability answers with an `offer_id` for each thing you can sell: a transfer day, a ticket day or time slot, a hotel room. The offer is signed. It carries the product, the date, the quantities and the price. You never send those again: the booking takes the `offer_id` and nothing else about the product.

- An `offer_id` is valid for your credential only. An offer that is malformed, altered or issued to another company answers `400 OFFER_INVALID`.
- Most offers have no `expires_at`. An offer without `expires_at` has no expiry time of its own, but `null` does not mean valid forever. A hotel offer works while its stored search exists, and that search is deleted after about a day. After that, the check and the booking answer `OFFER_EXPIRED` and you search again. The source can also drop the rate earlier, with the same answer.
- To know whether the price still holds, call the check, which re-quotes live. The booking always re-quotes too.
- When the source gives a real expiry, the offer carries `expires_at`. Today that is the Disney resort hotels, after the check. Book before that time.
- The price you saw is not a promise until the booking. See [Book](#3-book) for what happens when it moved.

## 2. Check the offer (optional)

```bash
curl -X POST "https://highstartravel.com/api/v2/offers/check" \
  -H "Authorization: Bearer YOUR_CREDENTIAL" \
  -H "Content-Type: application/json" \
  -d '{"offer_id": "OFFER_ID"}'
```

The `offer_id` goes in the JSON body, not in the URL, like in `POST /bookings`. Without it the call answers `400 VALIDATION_ERROR` with `offer_id` in `details.errors[]`. The call re-quotes the offer live and returns it with `cancellation_policy` and `conditions`. The check returns a new offer (`stage: "checked"`). The original `offer_id` stays valid, and you can book with either.

Most products do not need the check. Call it when you want a live price or the final cancellation policy before you show the offer to your customer. Disney resort hotels need it: their search rate cannot be reserved as it is, and the check returns the offer with a real `expires_at` from the source. See [Hotels](/developers/guides/v2-hotels).

- `alternatives` holds other offers for the same item, each one complete and already checked. It is always empty for transfers and tickets.
- If the price moved, the call answers `409 PRICE_CHANGED` with the current offer in `details.current_offer`.
- `409 OFFER_EXPIRED` means the source no longer has the rate. `409 NO_AVAILABILITY` means it cannot be sold any more. In both cases ask availability again.

## 3. Book

```bash
curl -X POST "https://highstartravel.com/api/v2/bookings" \
  -H "Authorization: Bearer YOUR_CREDENTIAL" \
  -H "Content-Type: application/json" \
  -d '{
    "external_reference": "MY-SYSTEM-9876",
    "items": [
      {
        "offer_id": "OFFER_ID",
        "travelers": [
          { "first_name": "John", "last_name": "Doe", "phone": "+15555550123", "nationality": "US" }
        ],
        "logistics": {
          "airport": "MCO",
          "airline": "AA",
          "flight_time": "13:45",
          "hotel": "Hotel name"
        }
      }
    ]
  }'
```

- `external_reference` is your own booking id, up to 50 characters. It is required, and it makes the request safe to repeat.
- Each item has the `offer_id` and the data of the product: `travelers`, and depending on the product `logistics` or `options`. The product catalog or the offer lists what to send in `booking_requirements` (for tickets, the brand lists it in `GET /tickets/brands`, and the product adds `booking_options`). Build your form from it. A key the product does not list is rejected or ignored, as the product guide says.
- `nationality` is an ISO 3166-1 alpha-2 code; alpha-3 codes and country names are accepted and converted. `phone` is in international format.
- A booking takes the items the product allows. Items of different products in one booking answer `400 VALIDATION_ERROR` with `UNSUPPORTED_COMBINATION`.
- A booking that cannot be made in full is rejected as a whole. If the answer is not 2xx, nothing was booked, with the one exception described under [If the result is unknown](#if-the-result-is-unknown).

### The price is checked again

The booking always re-quotes the offer live. You get one of these:

| Answer | Meaning | What to do |
|---|---|---|
| `409 PRICE_CHANGED` | The live price differs from the offer by 0.01 or more. `details.current_offer` is the current offer. Nothing was created. | Show the new price and book again with the `offer_id` of `details.current_offer`. |
| `409 NO_AVAILABILITY` | It can no longer be sold. | Ask availability again. |
| `409 OFFER_EXPIRED` | The source no longer has the rate. | Search again. |

### Validation errors

A validation failure answers `400 VALIDATION_ERROR` and lists every problem at once in `details.errors[]`. Each entry has the `field` path, for example `items[0].travelers[1].birth_date`, and a `code` such as `REQUIRED`. Fix them all and send again.

### The answer

A `201` returns the booking:

```json
{
  "booking": {
    "booking_id": 245117,
    "status": "confirmed",
    "external_reference": "MY-SYSTEM-9876",
    "created_at": "2026-09-30T17:58:12Z",
    "currency": "USD",
    "total": 120.0,
    "items": [
      {
        "item_id": 1,
        "product": { "product_type": "transfer", "product_id": 301, "name": "MCO to Disney Area" },
        "date": "2026-11-17",
        "time_slot": null,
        "travelers": { "adult": 2, "child": 1, "infant": 0 },
        "total": 120.0,
        "status": "confirmed",
        "cancellation": { "allowed": true, "deadline": null, "penalty": null },
        "lead_traveler": { "first_name": "John", "last_name": "Doe", "phone": "+15555550123", "nationality": "US" },
        "logistics": { "airport": "MCO", "airline": "AA", "flight_time": "13:45", "hotel": "Hotel name" }
      }
    ],
    "voucher": { "url": "https://highstartravel.com/api/v2/bookings/245117/voucher", "status": "ready" },
    "test_mode": false
  },
  "request_id": "3f8a9b7c1d2e4f5a6b7c8d9e0f1a2b3c"
}
```

Keep `booking_id`: reading, cancelling and the voucher use it. An item is shaped by its product type, so ignore the shapes and fields you do not know.

### If the request times out

Repeat the request with the same `external_reference`. A confirmed reference answers `200` with the original booking and `idempotent_replay: true`. Nothing new is booked, even if the offer expired or the price moved since. You can also send an `Idempotency-Key` header; `external_reference` has precedence. Never retry with a new reference.

To learn whether a booking exists without repeating the request:

```bash
curl "https://highstartravel.com/api/v2/bookings?external_reference=MY-SYSTEM-9876" \
  -H "Authorization: Bearer YOUR_CREDENTIAL"
```

`external_reference` is unique per company, so the answer is one booking or `404 BOOKING_NOT_FOUND`.

### If the result is unknown

When the source does not answer and we cannot know whether it booked, the answer is `502 PROVIDER_ERROR` with a message that says the booking is being verified. The reference stays tied to that attempt.

- A retry with the same reference answers `409 BOOKING_IN_PROGRESS`. The same code answers while the first attempt is still running.
- `GET /api/v2/bookings?external_reference=...` answers `409 BOOKING_IN_PROGRESS` too while we check.
- Do not book again with a new reference: it can create a second reservation. Retry the same reference in a few seconds, or read the booking by reference until it appears.
- If the read answers `404 BOOKING_NOT_FOUND`, there is no booking for that reference.

## 4. Read, cancel, voucher

### Read

```bash
curl "https://highstartravel.com/api/v2/bookings/245117" \
  -H "Authorization: Bearer YOUR_CREDENTIAL"
```

Reading returns the same shape as the booking answer, with the current `status` (`confirmed`, `cancelled`, `partially_cancelled` or `error`). It is not cached. To find a booking by your own id, use `GET /api/v2/bookings?external_reference=...`.

### Cancel

```bash
curl -X POST "https://highstartravel.com/api/v2/bookings/245117/cancel" \
  -H "Authorization: Bearer YOUR_CREDENTIAL" \
  -H "Content-Type: application/json" \
  -d '{ "reason": "Customer changed plans" }'
```

The call cancels the whole booking. The body is optional. `reason` can be up to 200 characters. Cancelling an already cancelled booking answers `200` with `already_cancelled: true`, so it is safe to retry. The `cancellation` of each item tells what applies: `allowed`, `deadline` and `penalty`. Failures are:

| Answer | Meaning |
|---|---|
| `409 CANCELLATION_REJECTED` | The source refused. The booking is unchanged. |
| `409 CANCELLATION_PARTIAL` | Some items were cancelled and others were not. `details.items[]` gives the state of each item. An item in `error` needs attention: read the booking and act on it. |
| `409 NOT_CANCELLABLE` | The booking cannot be cancelled through the API: the deadline passed, or there is no reservation we can cancel. Nothing was changed. Stop retrying and contact support. |

### Voucher

```bash
curl "https://highstartravel.com/api/v2/bookings/245117/voucher" \
  -H "Authorization: Bearer YOUR_CREDENTIAL" \
  -o voucher.pdf
```

`voucher.status` in the booking is one of:

- `ready`: download it now.
- `pending`: it is not published yet. The download answers `409 VOUCHER_NOT_READY`. Retry later.
- `none`: voucher delivery is off for your account. The download answers `409 VOUCHER_NOT_READY` and the booking is still valid.

The voucher is built again on each call, in the language set on your credential, and is sent inline with `Cache-Control: private, no-store`. A cancelled booking answers `410 BOOKING_CANCELLED`. The download needs your Bearer credential.

### Your company only

Reading, cancelling and the voucher only see bookings of your company. A booking that does not exist and one that belongs to someone else both answer `404 BOOKING_NOT_FOUND`.

## Test credentials

A test credential runs the whole flow on the real catalog and real prices, and every validation runs for real, but no real booking is created. The booking answers `test_mode: true`. It is stored as a snapshot, and reading, cancelling and the voucher work on it. See [Sandbox](/developers/guides/sandbox).

## Errors to handle

| Code | Action |
|---|---|
| `VALIDATION_ERROR` | Fix the fields in `details.errors[]`. Check `booking_requirements` of the product (tickets: of the brand). |
| `OFFER_INVALID` | Ask availability again. |
| `PRICE_CHANGED` | Show `details.current_offer` and book again with its `offer_id`. |
| `OFFER_EXPIRED`, `NO_AVAILABILITY` | Ask availability again. |
| `BOOKING_IN_PROGRESS` | Retry the same reference in a few seconds. If the message says it is being verified, read the booking by reference and do not book again. |
| `PROVIDER_ERROR`, `PROVIDER_UNAVAILABLE` | Retry later with the same `external_reference`. |
| `CANCELLATION_PARTIAL` | Read the booking and act on the items in `error`. |
| `VOUCHER_NOT_READY` | Retry later. |
| `BOOKING_NOT_FOUND` | Check the id or the reference. |

The full list is in [v2 errors](/developers/guides/v2-errors).
