# Travelgenix Contracting API

**Version:** v1 · **Status:** Live. Eight product families: accommodation, car hire, transfers, attractions, flights, tours, coaches and trains

> The base URL is `https://contracts.travelify.io/api/v1`. Send every request there over HTTPS, with your API key as a Bearer token.

The Contracting API is how you sell a Travelgenix client's directly-contracted product. A client loads their own hotels, villas, apartments, car hire, transfers, attractions, flights, tours, coaches and trains into the contracting engine, along with rates, availability, stop-sales and closures. You connect to this API to browse that product, price it for real dates and travellers, and book it. You are the consumer. The engine is the source of truth for product, price and availability.

Accommodation is covered first: how to read product and prices, then the three-step booking flow (quote, hold, confirm). Every other family has its own section and rides the same auth, conventions, errors and booking flow. Each family also has a bulk priced search that returns complete one-call results (see **Search priced availability across products**), so an integration never needs follow-up calls per result. The machine-readable OpenAPI spec, the sandbox and end-to-end walkthroughs are at the end.

---

## Quick start

Five minutes to your first priced result.

1. **Get a key.** You are issued a sandbox key (`sk_test_...`) and, once live, a production key (`sk_live_...`). Keys are tied to an access account and scoped to the client product you are entitled to sell.

2. **Call the sandbox.** Every example below runs against the sandbox with seeded demo product, so you can build before going live.

```bash
curl "https://contracts.travelify.io/api/v1/properties?property_type=hotel&country=Portugal" \
  -H "Authorization: Bearer sk_test_example"
```

3. **Price a stay.** Pick a property id from the list, then ask for real dates and occupancy:

```bash
curl -X POST https://contracts.travelify.io/api/v1/properties/PROPERTY_ID/availability \
  -H "Authorization: Bearer sk_test_example" \
  -H "Content-Type: application/json" \
  -d '{
    "check_in": "2026-07-10",
    "check_out": "2026-07-17",
    "occupancy": { "adults": 2, "children": [6] }
  }'
```

You get back priced, bookable options. The `rate_reference` on each option is what you carry into the booking flow. The shape is identical for the other products: browse `/v1/cars`, `/v1/transfers`, `/v1/tickets`, `/v1/flights`, `/v1/tours`, `/v1/coaches` or `/v1/trains`, then price with the family's availability call. Each family has its own section below.

That is the whole read loop: browse, then price. Better still, each family has a bulk priced search that does both in one call and returns complete, bookable results; see **Search priced availability across products**.

---

## Base URL and versioning

| | |
|---|---|
| Production base URL | `https://contracts.travelify.io/api/v1` |
| Sandbox base URL | `https://contracts.travelify.io/api/v1` with a `sk_test_` key |
| Version | Pinned in the path (`/v1`). All eight product families ride the same path; additive changes never break `/v1`. Breaking changes ship as `/v2`. |

We publish a changelog and give advance notice before any breaking version is retired.

### Changelog

Everything here is additive or a correction within `/v1`. Nothing was removed or renamed.

**Guide only — 9 September 2026**

Added **"Return journeys end to end: quote, hold, confirm"** to the Coaches
section: the full developer walkthrough for a round trip, with the exact request
and response bodies at each step, what the engine does on the hold and in what
order, the errors specific to the inbound leg, seat selection rules, cancelling,
and the things that catch people out. No API change.

**1.8.0 — 9 September 2026**

Cruise feeders can be sold by the sailing rather than by the coach date. No new
endpoints: this is two optional fields on calls you already make.

- **`POST /v1/coaches/availability` accepts `sailing_id`.** Send it instead of a
  date and the answer is exactly two results: that sailing's outbound coach and
  its inbound coach, each priced separately with its own `rate_reference` and
  labelled `leg: "outbound" | "inbound"`. No other cruises, no other dates, no
  other routes. The response carries a `sailing` object, and an
  `inbound_unavailable` when there is no coach home.
- **The same call accepts `sailing`** to narrow an ordinary date search to one
  cruise, matched on the sailing name, ship or voyage reference.
- **Every sailing on a departure now carries a `sailing_id`**, in
  `GET /v1/coaches/{id}`, in `GET /v1/coaches?include=all` and in every coach
  availability response.
- **`POST /v1/bookings/quote` accepts `sailing_id`** on a coach. The inbound leg
  is then taken from the sailing's own return date instead of from a date the
  caller supplies, so a coach feeding two cruises books the right return for
  each. It takes precedence over `return_date`, and it must belong to the
  departure the `rate_reference` pins.

Why: `return_date` was the only way to add an inbound leg, and it identifies a
**coach**, not a **cruise**. Where one run feeds two cruises with different
return dates, the caller had no way to say which one the customer had booked, and
a date search returned every route running that day in both directions. It is
live: one Oceania run on 2027-05-27 feeds *Celtic Dreams* (home 2027-06-08) and
*The Crowning Jewels* (home 2027-06-22). `return_date` and date searches still
work and are unchanged, but on a cruise feeder use `sailing_id`.

**1.7.0 — 7 September 2026**

A full audit of the coach family, and the corrections that came out of it. Read
this one if you integrate coaches.

Behaviour that changed, all of it making the API tell the truth rather than
guess:

- **Coach and train bookings and quotes now carry the route's cancellation
  bands.** They
  were previously always empty, whatever the contract said, so a cancellation
  charge could never be calculated. `cancellation_policy` is now populated on
  the quote, on the booking and on `GET /v1/coaches/{coach_id}`, which also
  gained `cancellation_notes`.
- **A departure that has already left is no longer bookable.** The check was by
  date alone, so a coach that pulled out at 07:00 stayed on sale until midnight.
  A run now stops selling once its **last** boarding point has been passed, and
  each earlier stop drops out of `pick_up_points` as the coach leaves it, so the
  towns still ahead of it stay sellable. The booking cut-off is measured from
  that last boarding rather than from midnight, and the reason is `departed`.
  Board times are read as UK local time. Where no stop yields a readable time —
  a route with none, or a return-direction route, where the times against the
  stops are drop-offs rather than departures — only the date is known, so the
  booking cut-off is measured from midnight on it and a route with no cut-off
  has no time guard at all.
- **`POST /v1/bookings/quote` now refuses a coach or train departure that is
  not bookable** (closed, cancelled, stop-saled, past or full) with
  `availability_changed`, instead of pricing it and leaving the hold to reject
  it. Tours and attractions are unchanged.
- **A search for a party the route cannot seat now returns nothing for that
  route.** A route selling Adult only used to answer a search for two adults and
  two children with a price for two seats while echoing the party of four back.
  Infants are never a reason to drop a route: they travel on a lap and no coach
  sells an infant fare.
- **A closure scoped to one pick-up point now closes that point only.** It
  previously closed the whole route.
- **Round trips work.** Passing `return_date` always failed with "the return
  coach could not be priced for that pick-up", because the return leg was priced
  with the outbound route's passenger type ids. The basket is now matched across
  the pair by passenger type name.
- **Repeated passenger lines add up.** Sending one line per passenger priced a
  single fare while claiming a seat per line.
- **The breakdown sums to the total.** A compulsory extra is inside the quoted
  price and now has its own "Compulsory extras (prepaid, included)" line. That
  line was only on the attraction hold before; it is now on every quote and
  every hold for coaches, trains, tours and attractions alike.

Documentation that was wrong, now corrected:

- `Price.per` did not allow `seat`, `person` or `admission`, so a generated
  client failed validation on the first coach, train, tour or attraction price.
  A quote also never set `per` on any ticket-family product (attractions,
  tours, coaches, trains, flights) or on a multi-room accommodation basket,
  although it is a required property. Every quote now carries it. Accommodation,
  car hire and transfers already did.
- `Booking` and `Quote` documented `passengers` as an object and said "flights
  only". Coaches and trains return an array of priced passenger types. Both
  schemas also now document `departure_date`, `pick_up` and `destination`.
  `pick_up` is coaches only: a train booking names it `boarding_station`.
- `TicketSeatType` documented a `fee` that is never returned, and omitted
  `rows`, `columns`, `aisles_after` and `seat_count`, without which a seat plan
  cannot be drawn. The surcharge is on the seat. `rows` is the list of row
  numbers and `aisles_after` holds column letters, not positions.
- `round_trip` and `return_route_id` are documented on both `CoachSummary` and
  `Coach`, so a caller can tell whether a return can be offered before trying.
  `Coach` also gained `cancellation_policy` and `cancellation_notes`.
- `sailings[].return_date` is documented: the paired inbound date for a
  round-trip search box.
- `cancelled` was missing from the coach departure reasons.
- The bulk-search section claimed every family returns `product_id` and an
  entry-level `rate_reference`. Coaches return neither.
- The hold's `seats` array requires exactly one seat per passenger, or none.
  That rule was enforced and undocumented.

**1.7.1 — 7 September 2026**

A second audit of the 1.7.0 work found two of its fixes shipped broken and
several claims stated more broadly than the code delivered. Corrected here.

- **Round trips really do work now.** 1.7.0 shipped a crash: the quote threw and
  answered 500. The booking step had not been given the name-matching at all, so
  it failed there too. Both fixed and exercised end to end against a live paired
  route.
- **`boarding_station` is defined.** 1.7.0 said trains return it instead of
  `pick_up` and never added the property to `Quote` or `Booking`.
- **Trains carry their cancellation bands**, and a train quote refuses a
  departure that is not bookable. 1.7.0 did both for coaches only.
- **`cancelled` added to the train departure reasons**, as it was for coaches.
- **The coach `departed` description** still described the rule 1.7.0 replaced.
  Sale stops at the LAST boarding point, not the earliest; a stop already passed
  or inside the cut-off drops out of `pick_up_points` on its own. Board times are
  read as UK local time.
- **The breakdown really does sum.** Coach, tour and train totals accumulated
  unrounded fares while each line was rounded, so two fares with sub-penny
  fractions left a penny unaccounted for. It was then reported as a compulsory
  extra on contracts that have none, or silently dropped. Totals are now built
  from the rounded lines.
- **A `Price` with no `per`** still shipped inside a multi-room accommodation
  basket. Fixed.
- Corrections to the 1.7.0 changelog itself: the missing `per` affected the
  ticket families and multi-room baskets, not "any product"; the bulk-search
  note applies to coaches **and** trains, and flights belong with the first
  group; trains key their boarding points `stations`, not `pick_up_points`.

**1.6.0 — 4 September 2026**

*Transfer search now answers in the direction you searched.* A route is stored one way round but runs both, and the reply used to echo the stored order back whichever way you asked. Now, when a search names both an `origin` and a `destination`:

- `route.point_a` is where the passenger is picked up on leg 1 and `route.point_b` where they are dropped off, so the airport code and the coordinates sit on the right end of the journey
- `route.from`, `route.to` and `route.label` read the same way round
- `direction` resolves to `arrival` (airport to resort) or `departure` (resort to airport) instead of echoing `any`
- each option carries `journey`, the trip leg by leg, so a return reads as the outbound leg and then the way back, always starting from the origin you searched

Options sharing a route are oriented independently, and a search that does not name both ends keeps the stored order. A **booking** is unchanged: its `route` is the snapshot taken at booking time, in stored order, with `direction` saying which way it runs.

*Transfers carry more of the journey.* `transfer_details` accepts `return_flight_number`, `child_seats`, `extra_luggage` and `agent_email` alongside the existing flight and accommodation fields, and the top-level `guests` array records the named party. The account manager in `agent_email` is copied on the supplier letter and is its reply-to.

*Hotels can sell extras.* A property advertises them in `extras` on `GET /v1/properties/{id}`, and they are sent at `hold` exactly as car hire extras are. See Accommodation extras under Booking.

*Vouchers carry more.* Bookings snapshot more of the product when they are made, so vouchers now show hotel check-in and check-out times, tourist tax payable at the property, car hire fuel policy and excess, coach boarding times, flight baggage allowances and named travellers. `RoomRef` gained `bedding`, `max_occupancy` and `size_sqm`. Bookings made before a field was captured simply do not carry it.

---

## Authentication

All requests use a bearer token in the `Authorization` header over HTTPS. There is no other transport; plain HTTP is rejected.

```
Authorization: Bearer sk_live_xxxxxxxxxxxxxxxxxxxx
```

- **Test vs live.** `sk_test_` keys are read-only: they can search, read and price your real product, but any attempt to create or change a booking is refused with `403 forbidden`, so a test key can never consume your real inventory. `sk_live_` keys operate on real product and can book. The read surface is identical, so you build and verify your integration with a test key, then swap to a live key to book for real.
- **Scope.** A key is bound to an access account and authorised for specific client product only. Requests for product you are not entitled to return `404` (not `403`), so the existence of other clients' product is never disclosed.
- **Secrecy.** Keys are secrets. Never embed a key in a browser, mobile app, or any client-side code. All calls are server to server. If a key is exposed, rotate it immediately from your account; the old key stops working at once.

---

## Conventions

- **Format.** JSON in, JSON out. Send `Content-Type: application/json` on any request with a body.
- **Identifiers.** Resource ids are opaque strings. Do not parse them; store and reuse them as given.
- **Dates and times.** Stay dates are calendar dates, `YYYY-MM-DD`. `check_in` is the arrival night, `check_out` is the departure morning, so a 10th to 17th stay is seven nights. Car pickup and dropoff are full timestamps, because the time of day matters; they and all other timestamps are ISO 8601 UTC (`2026-07-10T10:00:00Z`).
- **Money.** Every amount is an object: `{ "amount": 1234.50, "currency": "GBP" }`. A product prices in its contract currency, so different products may return different currencies. We never convert; you handle any display conversion.
- **Sell price only.** The API exposes the **sell price** the traveller pays. Cost, margin, markup and commission never leave the engine.
- **Pagination.** List endpoints take `page` (from 1) and `page_size` (default 20, max 100) and return a `pagination` block with `page`, `page_size`, `total` and `has_more`.
- **Rate limits.** Each key has a request budget per minute. Responses carry `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset`. Over budget returns `429`; back off using `Retry-After`.
- **Idempotency.** Write calls (the booking flow) accept an `Idempotency-Key` header so retries never double-book. Read calls are naturally safe to retry.

---

## Searching the catalogue

Every list endpoint is a search, and the same grammar works across accommodation, cars and transfers, so once you have built one results grid the others follow.

- **One filter, many values.** Every text filter accepts a comma-separated list, so `country=ES,PT` means "Spain or Portugal". Combine filters freely; a product must match all of them.
- **Country by name or code.** The `country` filter takes the full country name or its ISO 3166-1 alpha-2 code, in any case, so `country=Turkey`, `country=turkey` and `country=TR` all match the same place. You can mix the two forms in a list, e.g. `country=Spain,PT`. This works on accommodation, cars and transfers. Every result also returns a `country_code` (the ISO alpha-2 code) alongside the country name.
- **Fetch a basket by id.** `ids=id1,id2,id3` returns just those products in one call, so you never loop one by one. Use it with other filters or on its own.
- **Search near a point.** `near=lat,lon,radius_km` (the radius defaults to 25km) returns everything within that radius, nearest first, and each result carries a `distance_km`. For example `near=37.02,-7.93,40`.
- **Sort.** `sort=name` (default), `sort=distance` (the default when `near` is set), or `sort=rating` (accommodation).
- **GET or POST.** Use `GET` with query parameters for everyday calls. For long id baskets or large combinations, send the identical filters as a JSON body to the matching `POST .../search`; there a multi-value field takes a JSON array (`"country": ["ES","PT"]`) or the same comma string.

### Accommodation filters

`GET /v1/properties` and `POST /v1/properties/search`:

| Filter | Meaning |
|---|---|
| `ids` | A basket of property ids |
| `country` | Country (name or code) |
| `region` | Region or area; also matches the resort |
| `city` | City or town |
| `resort` | Resort |
| `airport_code` | Nearest airport (IATA) |
| `property_type` | `hotel`, `villa`, `apartment` |
| `board_basis` | Offers any of `RO,BB,HB,FB,AI` |
| `amenities` | Must-have amenities, e.g. `pool,wifi` (all must be present) |
| `star_min`, `star_max` | Star rating range |
| `near` | Map radius |

```bash
curl "https://contracts.travelify.io/api/v1/properties?region=Algarve&property_type=villa,apartment&board_basis=HB,AI&star_min=4" \
  -H "Authorization: Bearer sk_test_example"
```

```bash
# the same as a POST body — handy for a long id basket
curl -X POST "https://contracts.travelify.io/api/v1/properties/search" \
  -H "Authorization: Bearer sk_test_example" -H "Content-Type: application/json" \
  -d '{ "ids": ["d865cac7-...", "a1b2c3d4-..."], "near": { "lat": 37.02, "lon": -7.93, "radius_km": 40 } }'
```

Results carry the property's offered `board_basis`, and a `distance_km` when you search by `near`.

### Car hire filters

`GET /v1/cars` (no prices) and `POST /v1/cars/search` (priced; that one also takes `pickup_at`, `dropoff_at`, `driver_age`):

| Filter | Meaning |
|---|---|
| `ids` | A basket of car product ids |
| `country`, `region`, `city`, `airport_code` | Pickup station location |
| `category` | Vehicle category, label or ACRISS code, e.g. `SUV` |
| `transmission` | `manual`, `automatic` |
| `fuel_type` | Fuel type |
| `seats_min` | Minimum seats |
| `air_con` | `true` for an air-conditioned vehicle |
| `supplier` | Supplier name contains |
| `near` | Map radius around the pickup station |

```bash
curl "https://contracts.travelify.io/api/v1/cars?region=Algarve&category=suv&transmission=automatic&seats_min=5&air_con=true" \
  -H "Authorization: Bearer sk_test_example"
```

To price that same search, send the filters plus the dates to `POST /v1/cars/search`.

### Transfer filters

`GET /v1/transfers` and `POST /v1/transfers/search`:

| Filter | Meaning |
|---|---|
| `ids` | A basket of transfer product ids |
| `country`, `region`, `airport_code` | A served location |
| `point_type` | `airport`, `city`, `resort` |
| `transfer_mode` | `private`, `shared` |
| `vehicle_class` | Vehicle class |
| `pax` | Minimum passenger capacity needed |
| `meet_and_greet` | `true` for contracts that include it |
| `supplier` | Supplier name contains |
| `near` | Map radius around a served point |

```bash
curl "https://contracts.travelify.io/api/v1/transfers?region=Costa%20del%20Sol&transfer_mode=private&pax=6&meet_and_greet=true" \
  -H "Authorization: Bearer sk_test_example"
```

---

## Search priced availability across products

The catalogue search above finds products. To **price** many of them for a stay or journey in one call, instead of listing then calling per-product availability N times, use the bulk availability search. Every family has one:

| Family | Bulk priced search |
|---|---|
| Accommodation | `POST /v1/properties/availability/search` |
| Transfers | `POST /v1/transfers/availability/search` |
| Car hire | `POST /v1/cars/search` |
| Attractions and tickets | `POST /v1/tickets/availability/search` |
| Flights | `POST /v1/flights/availability/search` |
| Tours | `POST /v1/tours/availability/search` |
| Coaches | `POST /v1/coaches/availability` |
| Trains | `POST /v1/trains/availability` |

Each takes the family's catalogue filters **plus** the pricing inputs (dates, occupancy, driver age, pax). It returns one entry per product that has at least one **bookable** option, and every entry is a **one-call result**:

- for most families a **lead** (cheapest) option whose `rate_reference` feeds the booking flow directly; coaches and trains have no lead option, so take the reference from the boarding point you want,
- **`options`**: every bookable option priced, each with its own `rate_reference` (for coaches these are the priced `pick_up_points`, for trains the priced `stations`; for tours the priced `departures`, each with its `room_bases`),
- **`extras`**: the bookable add-ons priced, on the families that sell them (transfers, attractions and tours). For transfers and attractions, book one by sending its id in `extras` at `hold`,
- **`product`**: the complete detail the family's `GET /{id}` endpoint would return (descriptions, images, policies, rooms, vehicles, routes and so on).

So one search request replaces the whole search, detail and per-product availability chain: you can render a results grid, open a detail page and go straight into `quote` with nothing else to fetch. The per-product detail and availability endpoints remain available when you want a single product refreshed.

All families share one envelope: `data[]` plus `pagination`. Accommodation, cars, transfers, attractions, tours and flights carry `product_id`, `price_from` and the lead option's `rate_reference`. **Coaches and trains are shaped differently**: an entry is keyed `id`, not `product_id`, and has no lead option and no entry-level `rate_reference`, because they are priced per boarding point. Take the reference from the `pick_up_points[]` entry (coaches) or `stations[]` entry (trains) you want.

### Accommodation

Required: `check_in`, `check_out`, `occupancy`. Plus any `POST /v1/properties/search` filter, and at least one scoping filter (`ids`, `near`, or a location filter).

```bash
curl -X POST "https://contracts.travelify.io/api/v1/properties/availability/search" \
  -H "Authorization: Bearer sk_test_example" -H "Content-Type: application/json" \
  -d '{
    "check_in": "2026-07-10", "check_out": "2026-07-17",
    "occupancy": { "adults": 2, "children": [6] },
    "region": "Algarve", "board_basis": ["BB", "HB"], "star_min": 4,
    "sort": "price", "page": 1, "page_size": 20
  }'
```

```json
{
  "data": [
    {
      "product_id": "d865cac7-...",
      "type": "accommodation",
      "name": "Vale do Sol Beach Hotel",
      "star_rating": 4,
      "currency": "EUR",
      "nights": 7,
      "location": { "country": "Portugal", "country_code": "PT", "region": "Algarve", "city": "Albufeira", "distance_km": 12.4 },
      "price_from": { "amount": 1043.00, "currency": "EUR", "per": "stay" },
      "option_count": 4,
      "lead_option": {
        "rate_reference": "acc_…",
        "room_id": "d10834f6-...", "room_name": "Twin Garden View",
        "board_basis": "BB",
        "price": { "amount": 1043.00, "currency": "EUR", "per": "stay" },
        "available_units": 3,
        "cancellation_policy": [
          { "from_days_before": 30, "charge_pct": 0 },
          { "from_days_before": 7, "charge_pct": 50 }
        ]
      },
      "options": [
        {
          "rate_reference": "acc_…",
          "room_id": "d10834f6-...", "room_name": "Twin Garden View",
          "board_basis": "BB",
          "price": { "amount": 1043.00, "currency": "EUR", "per": "stay" },
          "price_per_night": { "amount": 149.00, "currency": "EUR" },
          "available_units": 3,
          "cancellation_policy": [
            { "from_days_before": 30, "charge_pct": 0 },
            { "from_days_before": 7, "charge_pct": 50 }
          ]
        }
      ]
    }
  ],
  "pagination": { "page": 1, "page_size": 20, "total": 1, "has_more": false }
}
```

Each option carries its `cancellation_policy`, the bands a booking is held under: `from_days_before` is the deadline and `charge_pct` the fee from that point. Options that fail a stay rule (minimum or maximum nights, or a not-permitted arrival day) for the searched dates, or whose room cannot take the occupancy you sent (its `max_adults`, `max_children` and `max_occupancy` caps), are not returned, so everything in `options` is genuinely bookable. Villas and apartments return their single whole-unit option as the lead, with `security_deposit` and `stay_rules` on it when present, so a grid can flag a deposit or a Saturday changeover without a detail call. `sort=price` orders by `price_from`.

Each entry also carries `product`, the full content `GET /v1/properties/{property_id}` would return (description, facilities, images, policies and room details), so a search result can render the whole detail page without a second call.

### Transfers

Required: `date`, `pax`. Plus any `POST /v1/transfers/search` filter, and at least one scoping filter. `route_id` is not accepted — every qualifying route on the product is priced. `pricing_basis` (`per_vehicle` or `per_person`) and `legs` ride on the lead option so the grid shows the right unit. For a return, add `return_date` and each leg prices on its own date (see **Price a transfer** in the Transfers section).

This is a **one-call search for transfers**: besides `price_from` and the `lead_option`, each entry carries `options` (every priced route-and-vehicle combination for the journey, each with its own `rate_reference`, per-leg breakdown and cancellation policy), `extras` (the bookable add-ons, priced; book one by sending its id in `extras` at `hold`), and `product` (the full detail `GET /v1/transfers/{transfer_id}` would return: points, routes with their `vehicle_ids`, vehicles with images, extras and policies). One request takes you from search to `quote` with nothing else to fetch.

```bash
curl -X POST "https://contracts.travelify.io/api/v1/transfers/availability/search" \
  -H "Authorization: Bearer sk_test_example" -H "Content-Type: application/json" \
  -d '{ "date": "2026-07-10", "pax": 3, "direction": "arrival", "is_return": true, "return_date": "2026-07-17", "airport_code": "FAO", "transfer_mode": "private" }'
```

**Point to point (from a pickup to a dropoff).** To search a specific journey rather than everything around one place, send an `origin` and a `destination`, each an object with the same location fields as the top level (`near` as `{ "lat", "lon", "radius_km" }`, or `country` / `region` / `airport_code` / `point_type`). Only routes that run between the two ends are returned: one endpoint must match `origin` and the other `destination`. The pair is unordered — a route is bidirectional, so the same route answers "airport to resort" and "resort to airport"; choose the leg with `direction`. `origin` accepts the aliases `from`, `pickup` and `pick_up`; `destination` accepts `to`, `dropoff`, `drop_off` and `dest`. An `origin`/`destination` counts as the required scoping filter on its own.

Each end names a **specific place**, so the match is tight: a `near` on an origin or destination is treated as "this place" (capped at a small radius), not an area, so a drop-off of Courmayeur returns Courmayeur routes and not the neighbouring resorts a wide circle would sweep in. Airports match exactly by `airport_code`. If you want a broad "everything around here" search instead, use the top-level `near` / location filters rather than `origin`/`destination`.

```bash
curl -X POST "https://contracts.travelify.io/api/v1/transfers/availability/search" \
  -H "Authorization: Bearer sk_test_example" -H "Content-Type: application/json" \
  -d '{ "date": "2026-07-10", "pax": 3, "direction": "arrival",
        "origin": { "airport_code": "GVA" },
        "destination": { "near": { "lat": 45.9237, "lon": 6.8694, "radius_km": 15 } } }'
```

### Car hire

`POST /v1/cars/search` already does this for cars (it also takes `pickup_at`, `dropoff_at` and `driver_age`). Each entry carries the shared envelope (`product_id`, `price_from`, `option_count`, `lead_option`) plus `options` (the whole priced fleet, each vehicle with its own `rate_reference`) and `product` (the full detail `GET /v1/cars/{car_id}` would return), alongside its original fields, so existing integrations keep working unchanged.

### Limits

Because this prices many products, it is bounded:

- **A scoping filter is required.** A dates-only request (no `ids`, `near` or location) returns `400` — it would otherwise price the whole catalogue.
- At most **60 products are priced per request**, after the filter. Narrow with `near`, `region` or `ids` for predictable results.
- Products are priced concurrently under a per-product timeout. A slow product is dropped rather than stalling the call, and the response carries `"partial": true` so you can refine or retry.
- An empty match is `200` with `"data": []` (sold out for those dates), not an error. A bad date range is `422`; a missing scoping filter is `400`; an `ids` basket naming a product you cannot see is `404`.

### From a bulk search to a booking

Four calls, not N+1: one bulk search, then the standard three booking steps using the lead option's `rate_reference`.

1. `POST /v1/properties/availability/search` → read `data[0].lead_option.rate_reference`.
2. `POST /v1/bookings/quote` with that `rate_reference` → a `quote_id`.
3. `POST /v1/bookings/hold` with the `quote_id` and the guest → a held booking.
4. `POST /v1/bookings/{booking_id}/confirm` after payment → a confirmed booking (carrying its `voucher_url`).

No second availability call is needed to book the cheapest room.

---

## Errors

Errors use standard HTTP status codes and a consistent body. The message is for your logs; branch your logic on `code`, never on the prose.

```json
{
  "error": {
    "code": "availability_changed",
    "message": "The selected option is no longer available for these dates.",
    "request_id": "req_8f2c1a"
  }
}
```

| Status | `code` | Meaning |
|---|---|---|
| 400 | `invalid_request` | Malformed body, missing field, or a bad date range. |
| 401 | `unauthenticated` | Missing or invalid key. |
| 403 | `forbidden` | The action is not allowed for this key. A `sk_test_` key is read-only: use a `sk_live_` key to create or change bookings. |
| 404 | `not_found` | The resource does not exist, or your key is not entitled to it. |
| 409 | `availability_changed` | Price or availability moved since you read it. Re-price and retry. |
| 422 | `unprocessable` | Well-formed but cannot be fulfilled (for example check_out is not after check_in, or a driver is below the minimum age). |
| 429 | `rate_limited` | Over your request budget. Honour `Retry-After`. |
| 500 | `server_error` | Our side. Safe to retry idempotent calls with backoff. |

Always quote `request_id` when contacting support; it pinpoints the exact call.

---

## Core concepts

A quick glossary, then the detail.

- **Client.** A Travelgenix customer (a tour operator, agency or hotelier) who has loaded their contracted product. Your key is entitled to one or more clients' product.
- **Product.** A single bookable thing. Its `type` is one of three families: `accommodation` (the `property_type` field then carries `hotel`, `villa` or `apartment`), `car_hire`, or `transfer`. The type changes how it is priced and booked, so handle each kind you sell.
- **Room (unit).** Within a hotel, a sellable room type (Deluxe King, Sea View Suite). A villa or apartment is itself a single unit.
- **Option.** A specific priced, bookable result for your search: a room, unit or vehicle, for an occupancy or a driver, for your dates, at a sell price. Each option carries a `rate_reference` you use to book.
- **Availability.** What is genuinely sellable. For accommodation the engine nets off existing reservations, stop-sales and room closures before it answers, so anything you are offered can be booked.

### The three accommodation types

**Hotel.** Priced per room type. A search returns one or more options per available room type, each with a board basis (room only, B&B, half board, full board, all inclusive), an occupancy it is priced for, and the total sell price for the stay. Child pricing and extra-bed rules are applied for you based on the occupancy you send.

**Villa.** Priced as a whole unit, by default **per week**, with **per night** also available where the client allows it. Villas usually carry a **refundable security deposit** and may enforce a fixed changeover day and a minimum number of nights. A search returns the whole unit as a single option with the period price, the deposit, and any changeover or minimum-stay rule that applies.

**Apartment.** Treated like a villa: a single whole unit, per week or per night, with optional deposit and stay rules.

Car hire and transfers are the other two product families and have their own sections below.

### Net and gross, resolved

Client contracts mix net and gross rates. You never see that. The engine resolves every rate to a single sell price and returns only that. A `breakdown` is provided so you can show the traveller how the total is composed (nightly or weekly rate, supplements, any offer applied, or for cars the per-day rate and prepaid extras), but it is all sell-side.

### Availability semantics

For a hotel room type, the sellable count for your dates is the room type's physical inventory minus overlapping reservations, minus any stop-sale on those dates, minus any room closure. If that is zero, the room type is simply not returned. A room must also fit the occupancy you send: a party that exceeds the room's `max_adults`, `max_children` or `max_occupancy` caps (shown on the room in the product detail) is never offered that room, so a family search will not surface an adults-only or too-small room. A null cap means no cap; `max_children` of 0 means adults only. For a villa or apartment, the unit is either available for the exact dates or it is not. This means you can trust a returned option: if we offered it, it was bookable at the moment we answered. Prices and availability can still move between reading and booking, which is exactly why the booking flow re-checks at hold. Car availability is lighter and is explained in the car hire section.

---

## Accommodation

> The authoritative request and response schemas are in the OpenAPI reference; the JSON below is illustrative. A property's `type` is always `accommodation`; the `property_type` field carries `hotel`, `villa` or `apartment`.

### List accommodation

Browse the accommodation your key can sell (properties with at least one active contract). No prices here; this is for building listings.

```
GET /v1/properties
```

Supports the full filter grammar — see **Searching the catalogue**. Parameters (all optional): `ids`, `country`, `region`, `city`, `resort`, `airport_code`, `property_type`, `board_basis`, `amenities`, `star_min`/`star_max`, `near`, `q`, `sort`, `page`, `page_size`. The same filters work as a JSON body on `POST /v1/properties/search`.

```bash
curl "https://contracts.travelify.io/api/v1/properties?property_type=hotel&country=Portugal&page=1&page_size=20" \
  -H "Authorization: Bearer sk_test_example"
```

```json
{
  "data": [
    {
      "id": "d865cac7-...",
      "type": "accommodation",
      "name": "Vale do Sol Beach Hotel",
      "property_type": "hotel",
      "star_rating": 4,
      "currency": "EUR",
      "location": {
        "country": "Portugal", "country_code": "PT", "region": "Algarve", "city": "Albufeira", "resort": "Albufeira",
        "coordinates": { "latitude": 37.0891, "longitude": -8.2475 },
        "nearest_airport": { "code": "FAO", "name": "Faro", "distance_km": 38 }
      },
      "room_count": 5,
      "board_basis": ["BB", "HB"],
      "hero_image": { "url": "https://cdn.travelify.io/.../vale-do-sol-1.jpg" }
    }
  ],
  "pagination": { "page": 1, "page_size": 20, "total": 1, "has_more": false }
}
```

### Get a property

Full content for one property: location, amenities, images, policies and room types. Use this to build the detail page.

```
GET /v1/properties/{property_id}
```

```bash
curl https://contracts.travelify.io/api/v1/properties/d865cac7-... \
  -H "Authorization: Bearer sk_test_example"
```

```json
{
  "id": "d865cac7-...",
  "type": "hotel",
  "name": "Vale do Sol Beach Hotel",
  "description": "A relaxed beachfront hotel a short walk from the old town...",
  "star_rating": 4,
  "currency": "EUR",
  "location": {
    "latitude": 37.0891, "longitude": -8.2476,
    "address": "Avenida do Mar, Albufeira",
    "nearest_airport": { "code": "FAO", "name": "Faro", "distance_km": 38 },
    "beach_distance_m": 120
  },
  "facilities": ["outdoor_pool", "wifi", "restaurant", "spa", "parking"],
  "images": ["https://cdn.travelify.io/.../1.jpg", "https://cdn.travelify.io/.../2.jpg"],
  "policies": {
    "check_in_time": "15:00", "check_out_time": "11:00",
    "min_checkin_age": 18, "tourist_tax": { "amount": 2.00, "currency": "EUR", "per": "person_per_night", "max_nights": 7 }
  },
  "rooms": [
    {
      "id": "d10834f6-...",
      "name": "Twin Garden View",
      "max_occupancy": 3, "max_adults": 2, "max_children": 2,
      "size_sqm": 24, "bedding": "2 single or 1 double",
      "facilities": ["air_conditioning", "balcony", "safe"],
      "images": ["https://cdn.travelify.io/.../twin-1.jpg"]
    }
  ]
}
```

For a villa or apartment, the property has a single room type that represents the whole unit (with its `bedrooms` and `bathrooms`).

### Price a property

Return bookable, priced room options for one property, dates and occupancy. This is the core read call and the entry to booking. A room is returned only when it prices for the stay, is not on stop sale, has a unit free every night, and can take the occupancy you sent (its `max_adults`, `max_children` and `max_occupancy` caps). Quoting a room the occupancy does not fit returns `availability_changed`.

```
POST /v1/properties/{property_id}/availability
```

Body (`occupancy.children` is one age per child; `board_basis` and `room_id` are optional filters):

```json
{
  "check_in": "2026-07-10",
  "check_out": "2026-07-17",
  "occupancy": { "adults": 2, "children": [6] }
}
```

**Hotel response.** One option per available room type and board:

```json
{
  "product_id": "d865cac7-...",
  "type": "hotel",
  "currency": "EUR",
  "nights": 7,
  "options": [
    {
      "rate_reference": "rate_ref_01J...redacted",
      "room_id": "d10834f6-...",
      "room_name": "Twin Garden View",
      "board_basis": "BB",
      "occupancy": { "adults": 2, "children": 1, "child_ages": [6] },
      "price": { "amount": 1043.00, "currency": "EUR", "per": "stay" },
      "breakdown": [
        { "label": "Room, 7 nights B&B", "amount": 980.00 },
        { "label": "Child supplement", "amount": 63.00 }
      ],
      "offer": { "name": "Early booking 10%", "applied": true, "saving": 116.00 },
      "cancellation_policy": [
        { "from_days_before": 22, "charge_pct": 0 },
        { "from_days_before": 7, "charge_pct": 50 },
        { "from_days_before": 0, "charge_pct": 100 }
      ],
      "available_units": 3
    }
  ]
}
```

**Villa or apartment response.** A single whole-unit option:

```json
{
  "product_id": "1b0e4a3b-...",
  "type": "villa",
  "currency": "EUR",
  "nights": 7,
  "options": [
    {
      "rate_reference": "rate_ref_01J...redacted",
      "unit_name": "Casa del Sol",
      "price": { "amount": 1850.00, "currency": "EUR", "per": "week" },
      "breakdown": [ { "label": "1 week", "amount": 1850.00 } ],
      "security_deposit": { "amount": 500.00, "currency": "EUR", "refundable": true },
      "stay_rules": { "changeover_day": "Saturday", "min_nights": 7 },
      "cancellation_policy": [
        { "from_days_before": 56, "charge_pct": 0 },
        { "from_days_before": 14, "charge_pct": 50 },
        { "from_days_before": 0, "charge_pct": 100 }
      ],
      "available": true
    }
  ]
}
```

If nothing is available for those dates, `options` is an empty array. That includes dates that break a villa's changeover or minimum-stay rule, and rooms the occupancy does not fit: the option is simply not offered. The product detail carries the `stay_rules` and each room's occupancy caps, so you can guide the traveller to valid dates or a room that fits.

### More than one room

The same endpoint prices a group. Send `rooms` when you know who is in each room, or `party` when you only know how many people there are. Send exactly one of `occupancy`, `rooms` or `party`; sending two is refused rather than guessed at.

**You know the rooms.** One entry per room, each with its own guests. Every room needs at least one adult, children are ages rather than a count, and a basket holds at most 9 rooms.

```json
{
  "check_in": "2026-07-10",
  "check_out": "2026-07-17",
  "rooms": [
    { "adults": 2, "children": [6] },
    { "adults": 2, "children": [] }
  ]
}
```

**You only know the group.** The reply suggests the combinations that fit, cheapest first, mixing room types where that is cheaper. Add `max_rooms` to cap how many rooms a suggestion may use.

```json
{
  "check_in": "2026-07-10",
  "check_out": "2026-07-17",
  "party": { "adults": 8, "children": [] }
}
```

**The response.** Each option is a whole basket rather than a single room. It carries one `price` for the group, one `rate_reference` that quotes and books all of it, and a `rooms` list showing what each room costs and why.

```json
{
  "property_id": "d865cac7-...",
  "check_in": "2026-07-10",
  "check_out": "2026-07-17",
  "nights": 7,
  "currency": "EUR",
  "options": [
    {
      "rate_reference": "accb_eyJ2IjoxLCJrIjoi...redacted",
      "board_basis": "bed_breakfast",
      "rooms_count": 2,
      "nights": 7,
      "guests": { "adults": 4, "children": [6] },
      "price": { "amount": 2086.00, "currency": "EUR", "per": "stay" },
      "rooms": [
        {
          "room": { "id": "d10834f6-...", "name": "Twin Garden View" },
          "occupancy": { "adults": 2, "children": [6] },
          "price": { "amount": 1043.00, "currency": "EUR" },
          "breakdown": [ { "label": "Room rate, All year", "amount": 980.00 } ],
          "cancellation_policy": [ { "from_days_before": 7, "charge_pct": 50 } ]
        },
        {
          "room": { "id": "a41f77b2-...", "name": "Twin Garden View" },
          "occupancy": { "adults": 2, "children": [] },
          "price": { "amount": 1043.00, "currency": "EUR" },
          "breakdown": [ { "label": "Room rate, All year", "amount": 980.00 } ],
          "cancellation_policy": [ { "from_days_before": 7, "charge_pct": 50 } ]
        }
      ],
      "cancellation_policy": [ { "from_days_before": 7, "charge_pct": 50 } ],
      "available": true
    }
  ]
}
```

An empty `options` array means the property cannot house that group for those dates. It never comes back part-housed.

**What to know.**

- Every room is priced by the same engine a one-room search uses, so seasons, child age bands, supplements, offers and cancellation bands behave identically.
- The basket's own `cancellation_policy` states the **strictest** charge at each notice, because where rooms differ that is what the booker is held to. Each room also carries its own.
- An offer written per room, such as "first child free", applies **in each room**. A family split across two rooms gets it twice, which is how hotels contract it.
- The basket is the cheapest workable mix, not merely the first that fits.

### Booking a group

Take the basket's `rate_reference` (it starts `accb_`) through the same three steps as a single room. `POST /bookings/quote` returns a `quote_id` starting `qtb_`; `POST /bookings/hold` turns that into the booking.

**Holding is all or nothing.** If any room has gone since the quote, the whole basket is refused with `availability_changed` naming the room, and everything already claimed for it is released. A group is never part booked.

The hold returns the group rather than one booking:

```json
{
  "group_reference": "TGC-7HX317HX",
  "status": "held",
  "rooms_count": 2,
  "price": { "amount": 2086.00, "currency": "EUR", "per": "stay" },
  "expires_at": "2026-07-01T10:30:00Z",
  "bookings": [
    { "id": "...", "reference": "TGC-A1B2C3D4", "group_reference": "TGC-7HX317HX", "status": "held" },
    { "id": "...", "reference": "TGC-E5F6G7H8", "group_reference": "TGC-7HX317HX", "status": "held" }
  ]
}
```

Each room is a booking in its own right, sharing a `group_reference`. Confirm, amend, cancel, retrieve and voucher all work per room exactly as they do for a single-room booking, so you can cancel one room of a group without touching the rest. Retrieving any room of a group tells you its `group_reference`, and a retried hold carrying the same `Idempotency-Key` returns the whole group rather than one room of it.

### Searching

See **Searching the catalogue** above for the full filter grammar. In short: narrow the list with `GET /v1/properties` (or `POST /v1/properties/search` for a long id basket), then call `POST /v1/properties/{property_id}/availability` for each property the traveller is interested in to get priced, bookable rooms.

---

## Booking

Booking is three steps, the same shape as the platform's own checkout (select a result, build the basket, take payment), expressed as three API calls. The flow is shared by accommodation, car hire and transfers; only the request and response details differ by product type.

1. **Quote** turns a `rate_reference` from availability into a firm, time-limited price.
2. **Hold** reserves the actual inventory for a short window and attaches the traveller. This is the equivalent of the platform basket.
3. **Confirm** commits the hold once you have taken payment. This is the equivalent of a completed order, and it is the moment the booking becomes a reservation on the client's calendar.

You never process money through this API. You are the merchant of record; you take payment your way and pass a reference at confirm for reconciliation.

### The booking object

Every booking call returns the same object. For accommodation it carries `room_name`, board and stay dates; for car hire it carries the `vehicle`, the pickup and dropoff and the car policies. Fields not relevant to the product type are omitted.

```json
{
  "id": "bkg_01J8...",
  "reference": "TGC-7F3K9Q",
  "status": "confirmed",
  "product": { "id": "d865cac7-...", "type": "hotel", "name": "Vale do Sol Beach Hotel" },
  "room_name": "Twin Garden View",
  "board_basis": "BB",
  "check_in": "2026-07-10",
  "check_out": "2026-07-17",
  "nights": 7,
  "occupancy": { "adults": 2, "children": 1, "child_ages": [6] },
  "lead_guest": { "name": "Sarah Hughes", "email": "s.hughes@example.com" },
  "price": { "amount": 1043.00, "currency": "EUR", "per": "stay" },
  "security_deposit": null,
  "cancellation_policy": [
    { "from_days_before": 22, "charge_pct": 0 },
    { "from_days_before": 7, "charge_pct": 50 },
    { "from_days_before": 0, "charge_pct": 100 }
  ],
  "source": "your-account",
  "external_ref": "YOUR-REF-10482",
  "created_at": "2026-06-06T14:20:00Z",
  "confirmed_at": "2026-06-06T14:22:10Z"
}
```

`security_deposit` is populated for villas, apartments and car hire and null for hotels. `external_ref` is your own reference; set it so re-sends are deduplicated (see safe retries).

### Statuses

| Status | Meaning |
|---|---|
| `held` | Reserved, awaiting confirmation. Expires if not confirmed in time. |
| `confirmed` | Committed. Live on the client's calendar. |
| `cancelled` | Cancelled. Cancellation charges applied per policy. |
| `expired` | A hold that was not confirmed in its window. Inventory released. |

A quote is not a booking and has no status; it is a short-lived price you turn into a hold.

### Step 1: Quote

Lock a firm price from a `rate_reference`. No inventory is reserved yet, so a quote never blocks a room or a car. The same call serves accommodation and car hire; the engine reads the product from the `rate_reference`.

```
POST /v1/bookings/quote
```

```bash
curl -X POST https://contracts.travelify.io/api/v1/bookings/quote \
  -H "Authorization: Bearer sk_test_example" \
  -H "Content-Type: application/json" \
  -d '{ "rate_reference": "rate_ref_01J...redacted" }'
```

```json
{
  "quote_id": "qte_01J8...",
  "expires_at": "2026-06-06T14:35:00Z",
  "product": { "id": "d865cac7-...", "type": "hotel", "name": "Vale do Sol Beach Hotel" },
  "room_name": "Twin Garden View",
  "board_basis": "BB",
  "check_in": "2026-07-10",
  "check_out": "2026-07-17",
  "nights": 7,
  "price": { "amount": 1043.00, "currency": "EUR", "per": "stay" },
  "breakdown": [
    { "label": "Room, 7 nights B&B", "amount": 980.00 },
    { "label": "Child supplement", "amount": 63.00 }
  ],
  "security_deposit": null,
  "cancellation_policy": [
    { "from_days_before": 22, "charge_pct": 0 },
    { "from_days_before": 7, "charge_pct": 50 },
    { "from_days_before": 0, "charge_pct": 100 }
  ]
}
```

If the option has sold out or the price has moved since you read availability, you get `409 availability_changed`. Re-price from a fresh availability call and quote again.

### Step 2: Hold

Reserve the inventory and attach the traveller. This creates a held booking, the basket equivalent, and starts the hold clock. For accommodation you send `lead_guest`, `guests` and any prepaid `extras`. For car hire you send `lead_guest`, a `driver` and any prepaid `extras` (see the car hire section).

```
POST /v1/bookings/hold
```

Send an `Idempotency-Key` so a retried hold never creates two reservations.

```bash
curl -X POST https://contracts.travelify.io/api/v1/bookings/hold \
  -H "Authorization: Bearer sk_test_example" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 6a1f9c2e-..." \
  -d '{
    "quote_id": "qte_01J8...",
    "external_ref": "YOUR-REF-10482",
    "lead_guest": {
      "title": "Ms", "first_name": "Sarah", "last_name": "Hughes",
      "email": "s.hughes@example.com", "phone": "+447700900123"
    },
    "guests": [
      { "type": "adult", "first_name": "Sarah", "last_name": "Hughes" },
      { "type": "adult", "first_name": "Tom", "last_name": "Hughes" },
      { "type": "child", "first_name": "Mia", "last_name": "Hughes", "age": 6 }
    ],
    "extras": [ { "extra_id": "…", "quantity": 1 } ],
    "notes": "Late arrival, approx 22:00"
  }'
```

**Accommodation extras.** A property advertises what it sells alongside the room in the `extras` array on `GET /v1/properties/{id}`: a cot, an extra bed, a late check-out, a board upgrade, parking. Each entry carries an `id`, a `charge_basis` and a `price`. Send the ones the traveller wants at `hold` as `extras: [{ "extra_id": …, "quantity": … }]`, and they are priced, added to `price_total`, listed in the `breakdown` and printed on the traveller's voucher.

The charge basis decides how the amount multiplies: `per_stay` and `per_unit` charge once, `per_night` and `per_unit_per_night` multiply by the nights, `per_person` by the adults plus children, and `per_person_per_night` by both. So a 12.00 breakfast on `per_person_per_night` for three guests over seven nights is 252.00. `quantity` multiplies on top of all of them and defaults to 1.

Two kinds are rejected rather than silently charged, so read `payable` and `mandatory` before sending:

- `mandatory: true` is already inside the quoted room price and cannot be added again.
- `payable: "local"` (or a `pay_locally` basis) is quoted for information and settled at the property, so it cannot be prepaid.

A `max_qty` on an extra is the most that may be booked. Exceeding it, sending an unknown `extra_id`, or sending either of the two rejected kinds returns `422 unprocessable` naming the extra. On a multi-room hold the extras belong to the group: they are priced once for the whole party and carried on the first room of the group.

```json
{
  "id": "bkg_01J8...",
  "reference": "TGC-7F3K9Q",
  "status": "held",
  "expires_at": "2026-06-06T14:37:00Z",
  "hold_window_seconds": 900,
  "product": { "id": "d865cac7-...", "type": "hotel", "name": "Vale do Sol Beach Hotel" },
  "room_name": "Twin Garden View",
  "board_basis": "BB",
  "check_in": "2026-07-10",
  "check_out": "2026-07-17",
  "nights": 7,
  "lead_guest": { "name": "Sarah Hughes", "email": "s.hughes@example.com" },
  "price": { "amount": 1043.00, "currency": "EUR", "per": "stay" },
  "security_deposit": null,
  "external_ref": "YOUR-REF-10482"
}
```

Read `expires_at` and `hold_window_seconds` from the response rather than assuming a fixed window. If the quote has expired you get `409 availability_changed`; start again from quote. A room the travellers do not fit (for example three adults in a twin) is never offered in the first place: availability and quote both exclude it, so by hold the occupancy always fits.

For a villa, apartment or car the hold response includes the `security_deposit`, which the traveller pays you and you reconcile at confirm.

### Step 3: Confirm

Once you have taken payment, commit the hold. This is the order-complete moment.

```
POST /v1/bookings/{booking_id}/confirm
```

Reuse the same `Idempotency-Key` you used for the hold, or send a new one; either way a repeat of the same confirm returns the same booking and never double-books.

```bash
curl -X POST https://contracts.travelify.io/api/v1/bookings/bkg_01J8.../confirm \
  -H "Authorization: Bearer sk_test_example" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 6a1f9c2e-..." \
  -d '{
    "payment_reference": "stripe_pi_3Q...",
    "payment_status": "paid",
    "amount_paid": { "amount": 1043.00, "currency": "EUR" }
  }'
```

The response is the full booking object with `status: "confirmed"` and a `confirmed_at` timestamp. If the hold expired before you confirmed, you get `409 availability_changed` and must re-quote and re-hold.

### The voucher

Every booking carries a `voucher_url`: a signed, shareable link to a branded PDF voucher you can hand to the traveller. It is white-labelled to the agent's own brand and covers every product. Open the link to view or print it, or fetch the PDF programmatically:

```
GET /v1/bookings/{booking_id}/voucher
```

The voucher is grouped into titled sections rather than one flat list, and it carries more than the booking object returns, because it is drawn from the snapshot taken at booking time:

| Product | What the voucher shows |
| --- | --- |
| Accommodation | Property, address, resort and rating, room and bedding, board, check-in and check-out **with the property's own times**, nights, the named party, and a "Good to know" section with any tourist tax payable at the property, the minimum check-in age and the property's policies |
| Transfers | The route read the way the journey runs, trip type, vehicle with private or shared and its capacity, the **luggage allowance**, additional luggage, child seats, the outbound and inbound flights, and the named passengers |
| Car hire | Vehicle, seats and transmission, pick-up and drop-off with the station address and how to collect, plus the fuel policy, mileage, damage excess, security deposit and named drivers |
| Flights | Airline, cabin, each segment, the baggage allowance, whether the ticket is refundable, the party and the seats |
| Coaches and trains | Service and operator, pick-up or boarding station, destination, departure date and **boarding time**, tickets and seats |
| Tours | Tour and operator, start point, duration, room basis, departure and return, tickets, and what is and is not included |

Everything on it is snapshotted when the booking is made, so editing a product later never rewrites a voucher already issued. A booking made before a field was captured simply does not show that line.

Add `?download=1` to force a download rather than inline display. The shareable link needs no API key, so it is safe to forward straight to the client.

### Idempotency and safe retries

Network calls fail and get retried, so writes are protected two ways. Send an `Idempotency-Key` (any unique string, a UUID is ideal) on hold and confirm: replaying the same key with the same body returns the original result rather than acting twice; reusing a key with a different body returns `422`. Keys are remembered per account for 24 hours. Independently, set your own `external_ref` on the hold: the engine treats a confirmed `(source, external_ref)` pair as unique, so a booking you have already placed cannot be created a second time even from a fresh key.

### Hold expiry

A hold reserves real inventory, so it cannot live forever. If you do not confirm before `expires_at`, the hold flips to `expired` and the room or car returns to sale automatically. There is nothing to clean up; just quote again if the traveller is still deciding.

### Retrieve a booking

```
GET /v1/bookings/{booking_id}
GET /v1/bookings?reference=TGC-7F3K9Q
GET /v1/bookings?external_ref=YOUR-REF-10482
```

Returns the current booking object. Full guest or driver details are visible only to the key that owns the booking.

### Amend a booking

```
PATCH /v1/bookings/{booking_id}
```

In v1 you can amend the lead contact and notes directly, plus guest details for accommodation or the driver for car hire. Changing dates, occupancy, room or vehicle is a price-and-availability change, so for v1 you do that by cancelling and rebooking through a fresh quote. A richer in-place amend is on the roadmap.

### Cancel a booking

```
POST /v1/bookings/{booking_id}/cancel
```

```bash
curl -X POST https://contracts.travelify.io/api/v1/bookings/bkg_01J8.../cancel \
  -H "Authorization: Bearer sk_test_example" \
  -H "Content-Type: application/json" \
  -d '{ "reason": "Customer cancelled" }'
```

The engine applies the cancellation policy as at today and returns the booking with `status: "cancelled"` plus a sell-side breakdown of any charge and refund. The slot returns to sale subject to that policy.

### How a confirmed booking appears to the client

The instant you confirm, the engine assigns the actual inventory (a specific physical room for a hotel, the whole unit for a villa or apartment, or a vehicle from the contracted fleet for car hire) and writes a booking tagged with your account as the `source` and your `external_ref`. It appears immediately on the client's calendar as read-only, since the client does not edit bookings that came from you. For accommodation, because availability had already netted off reservations, stop-sales and closures before you ever saw the option, a confirmed booking can never oversell the room.

---

## Car hire

From v1.1 the same API sells a client's contracted car hire, with the same key, conventions, errors and booking flow. A **car product** is one supplier's fleet at one pickup station. You browse cars, price a vehicle for real pickup and dropoff datetimes and a driver age, then book it through the same quote, hold and confirm steps.

### Car concepts

- **Car product.** One contracted fleet at one pickup station. Its `type` is `car_hire`. One pickup station per product today.
- **Vehicle.** A bookable group (Compact, Estate and so on), shown with an example model "or similar" rather than a guaranteed car. Priced per day.
- **Collection.** How the traveller gets the car: at a desk then the vehicle, met at the vehicle, or via a shuttle to the depot.
- **Rental days.** Whole 24-hour periods from pickup to dropoff, rounded up, minimum one. A pickup at 10:00 and a dropoff at 14:00 seven days later is eight rental days, not seven.
- **Driver age.** The lead driver's age decides whether a vehicle is bookable and whether a young-driver charge applies.

### Car-specific conventions

- **Datetimes, not dates.** `pickup_at` and `dropoff_at` are ISO 8601 timestamps, because the time of day sets the rental length. Accommodation stay dates remain calendar dates.
- **Per day with length tiers.** A vehicle is priced per day, and the per-day rate can change with the length of the rental, for example a lower daily rate over two weeks than over three days. You always get back the rental total and a breakdown.
- **Fuel policy.** How fuel is handled: `full_full` (collect and return full), `same_to_same`, `quarter_to_empty`, `full_empty` or `prepaid`. Returned on every option.
- **Mileage policy.** `unlimited`, or `limited` with a `mileage_limit`.
- **Excess and cover.** The damage and theft excess, and what cover is included. Surfaced so the traveller knows their liability.
- **Security deposit.** A refundable card pre-authorisation taken at the desk against damage or loss. We never charge it. Surfaced so you can tell the traveller what to bring.
- **Extras.** Child seats, additional drivers, sat nav and the like. Each is `prepaid` (paid to you and included in the total) or `local` (settled at the desk). Only prepaid extras enter the booking total.

### Availability semantics for cars

Car availability is lighter than hotel availability. There is no physical-inventory grid and no stop-sale. A vehicle is bookable when the contract is active, the pickup date falls inside the contract's validity and sell windows, the rental length maps to a tier that has a rate for that season, and the driver meets the age policy. If any of those fail, the vehicle is simply not returned. An option that comes back was bookable at the moment we answered; price can still move between reading and booking, which is why hold re-checks.

### List car products

```
GET /v1/cars
```

Supports the full filter grammar — see **Searching the catalogue**. Parameters (all optional): `ids`, `country`, `region`, `city`, `airport_code`, `category`, `transmission`, `fuel_type`, `seats_min`, `air_con`, `supplier`, `near`, `q`, `sort`, `page`, `page_size`. The priced `POST /v1/cars/search` accepts the same filters plus the dates.

```bash
curl "https://contracts.travelify.io/api/v1/cars?airport_code=FAO" \
  -H "Authorization: Bearer sk_test_example"
```

```json
{
  "data": [
    {
      "id": "7c2a9f10-...",
      "type": "car_hire",
      "supplier_name": "Algarve Auto",
      "country": "Portugal",
      "country_code": "PT",
      "city": "Faro",
      "currency": "EUR",
      "station": {
        "name": "Faro Airport",
        "collection_type": "shuttle",
        "airport_code": "FAO",
        "location": { "latitude": 37.0144, "longitude": -7.9659 }
      },
      "vehicle_count": 4
    }
  ],
  "pagination": { "page": 1, "page_size": 20, "total": 1, "has_more": false }
}
```

### Get a car product

Full content for one car product: the pickup station, the fleet, the extras and the policies. Use this to build the detail page.

```
GET /v1/cars/{car_id}
```

```bash
curl https://contracts.travelify.io/api/v1/cars/7c2a9f10-... \
  -H "Authorization: Bearer sk_test_example"
```

```json
{
  "id": "7c2a9f10-...",
  "type": "car_hire",
  "supplier_name": "Algarve Auto",
  "country": "Portugal",
  "country_code": "PT",
  "city": "Faro",
  "currency": "EUR",
  "station": {
    "name": "Faro Airport",
    "collection_type": "shuttle",
    "address": "Aeroporto de Faro, 8005-226 Faro",
    "city": "Faro", "country": "Portugal", "country_code": "PT", "airport_code": "FAO",
    "location": { "latitude": 37.0144, "longitude": -7.9659 }
  },
  "age_policy": { "min_age": 21, "max_age": 75, "young_driver_age": 25, "notes": "Drivers 21 to 24 pay a young-driver surcharge." },
  "security_deposit": { "amount": 1200.00, "currency": "EUR", "refundable": true, "method": "card_pre_authorisation" },
  "excess": { "amount": 1200.00, "currency": "EUR" },
  "cover_note": "Collision damage waiver and theft protection included, subject to the excess.",
  "cancellation_notes": "Free cancellation up to 48 hours before pickup.",
  "cancellation_policy": [
    { "from_days_before": 2, "charge_pct": 0 },
    { "from_days_before": 0, "charge_pct": 100 }
  ],
  "licence_notes": "Full licence held for at least one year. Physical or digital licence accepted.",
  "cross_border_notes": "Travel into Spain permitted with prior notice.",
  "vehicles": [
    {
      "id": "v_91a2...",
      "category": "Compact",
      "example_model": "Volkswagen Polo or similar",
      "acriss_code": "CDMR",
      "transmission": "manual",
      "fuel_type": "petrol",
      "seats": 5, "doors": 5, "large_bags": 1, "small_bags": 2,
      "air_con": true,
      "min_driver_age": 21,
      "fuel_policy": "full_full",
      "mileage_policy": "unlimited",
      "excess": { "amount": 1200.00, "currency": "EUR" },
      "features": ["bluetooth", "usb"],
      "images": ["https://cdn.travelify.io/.../polo-1.jpg"]
    }
  ],
  "extras": [
    { "id": "x_child", "type": "child_seat", "name": "Child seat", "charge_basis": "per_rental", "price": { "amount": 35.00, "currency": "EUR" }, "max_qty": 3, "payable": "prepaid" },
    { "id": "x_addl", "type": "additional_driver", "name": "Additional driver", "charge_basis": "per_day", "price": { "amount": 7.00, "currency": "EUR" }, "payable": "local" }
  ]
}
```

### Price a car product

Return bookable, priced vehicle options for one car product, a pickup and dropoff datetime and a driver age.

```
POST /v1/cars/{car_id}/availability
```

Body:

```json
{
  "pickup_at": "2026-07-10T10:00:00Z",
  "dropoff_at": "2026-07-17T10:00:00Z",
  "driver_age": 35,
  "driver_licence_country": "GB"
}
```

`pickup_station_id` and `dropoff_station_id` are optional and default to the product's pickup station.

```json
{
  "car_id": "7c2a9f10-...",
  "type": "car_hire",
  "currency": "EUR",
  "pickup_at": "2026-07-10T10:00:00Z",
  "dropoff_at": "2026-07-17T10:00:00Z",
  "rental_days": 7,
  "options": [
    {
      "rate_reference": "rate_ref_01J...redacted",
      "vehicle": {
        "id": "v_91a2...",
        "category": "Compact",
        "example_model": "Volkswagen Polo or similar",
        "transmission": "manual", "fuel_type": "petrol",
        "seats": 5, "doors": 5, "large_bags": 1, "small_bags": 2, "air_con": true
      },
      "price": { "amount": 287.00, "currency": "EUR", "per": "rental" },
      "breakdown": [
        { "label": "7 days at 41.00 per day", "amount": 287.00 }
      ],
      "fuel_policy": "full_full",
      "mileage_policy": "unlimited",
      "mileage_limit": null,
      "excess": { "amount": 1200.00, "currency": "EUR" },
      "security_deposit": { "amount": 1200.00, "currency": "EUR", "refundable": true, "method": "card_pre_authorisation" },
      "cover_note": "Collision damage waiver and theft protection included, subject to the excess.",
      "extras": [
        { "id": "x_child", "type": "child_seat", "name": "Child seat", "charge_basis": "per_rental", "price": { "amount": 35.00, "currency": "EUR" }, "max_qty": 3, "payable": "prepaid" }
      ],
      "cancellation_policy": [
        { "from_days_before": 2, "charge_pct": 0 },
        { "from_days_before": 0, "charge_pct": 100 }
      ],
      "available": true
    }
  ]
}
```

The per-day rate in the breakdown is the rate for the tier the rental length falls in. Prepaid extras are listed on the option so you can offer them; you add the ones the traveller wants at hold. Pay-local extras are shown for information and are settled at the desk.

If the driver is below a vehicle's minimum age, or the rental length maps to no tier, that vehicle is not returned. If the contract is not active for those dates, `options` is an empty array. A `422 unprocessable` comes back if `pickup_at` is not before `dropoff_at`, or `driver_age` is missing.

### Search across car products

Find available, priced car hire for a location, pickup and dropoff datetimes and a driver age in one call.

```
POST /v1/cars/search
```

```json
{
  "airport_code": "FAO",
  "pickup_at": "2026-07-10T10:00:00Z",
  "dropoff_at": "2026-07-17T10:00:00Z",
  "driver_age": 35,
  "page": 1,
  "page_size": 20
}
```

You can search by `airport_code`, or by `city` and `country`. The response is a paginated list of car products, each with its lead vehicle option, `rate_reference` and `price`, plus `options` (the whole priced fleet, each vehicle with its own `rate_reference`) and `product` (the full detail `GET /v1/cars/{car_id}` would return), so you can render a results grid and a detail page and go straight to `quote` from this one call.

### Booking a car

Booking reuses the same three steps. The `rate_reference` from a car option already carries the car context, so **quote** and **confirm** are exactly the same calls as for accommodation. Only **hold** differs: you send a `driver` instead of `guests`, plus any prepaid `extras`.

**Quote a car.** Same call, car-shaped response:

```bash
curl -X POST https://contracts.travelify.io/api/v1/bookings/quote \
  -H "Authorization: Bearer sk_test_example" \
  -H "Content-Type: application/json" \
  -d '{ "rate_reference": "rate_ref_01J...redacted" }'
```

```json
{
  "quote_id": "qte_01J8...",
  "expires_at": "2026-06-06T14:35:00Z",
  "product": { "id": "7c2a9f10-...", "type": "car_hire", "name": "Algarve Auto, Faro Airport" },
  "vehicle": { "id": "v_91a2...", "category": "Compact", "example_model": "Volkswagen Polo or similar" },
  "pickup_at": "2026-07-10T10:00:00Z",
  "dropoff_at": "2026-07-17T10:00:00Z",
  "rental_days": 7,
  "fuel_policy": "full_full",
  "mileage_policy": "unlimited",
  "excess": { "amount": 1200.00, "currency": "EUR" },
  "price": { "amount": 322.00, "currency": "EUR", "per": "rental" },
  "breakdown": [
    { "label": "7 days at 41.00 per day", "amount": 287.00 },
    { "label": "Child seat", "amount": 35.00 }
  ],
  "security_deposit": { "amount": 1200.00, "currency": "EUR", "refundable": true, "method": "card_pre_authorisation" },
  "extras": [
    { "id": "x_child", "type": "child_seat", "name": "Child seat", "price": { "amount": 35.00, "currency": "EUR" }, "payable": "prepaid" }
  ],
  "cancellation_policy": [
    { "from_days_before": 2, "charge_pct": 0 },
    { "from_days_before": 0, "charge_pct": 100 }
  ]
}
```

**Hold a car.** Send `lead_guest` (the booker), a `driver` and any prepaid `extras`. The driver is PII and is held server-side only, never returned in full.

```bash
curl -X POST https://contracts.travelify.io/api/v1/bookings/hold \
  -H "Authorization: Bearer sk_test_example" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 9d4f2a71-..." \
  -d '{
    "quote_id": "qte_01J8...",
    "external_ref": "YOUR-REF-20491",
    "lead_guest": { "title": "Mr", "first_name": "James", "last_name": "Park", "email": "j.park@example.com", "phone": "+447700900456" },
    "driver": { "title": "Mr", "first_name": "James", "last_name": "Park", "date_of_birth": "1991-04-22", "licence_number": "PARK9914223JX", "licence_country": "GB" },
    "extras": [ { "extra_id": "x_child", "quantity": 1 } ],
    "additional_drivers": [ { "first_name": "Anna", "last_name": "Park" } ],
    "notes": "Flight TP1234, landing 09:20"
  }'
```

```json
{
  "id": "bkg_01J9...",
  "reference": "TGC-2M8XQ4",
  "status": "held",
  "expires_at": "2026-06-06T14:50:00Z",
  "hold_window_seconds": 900,
  "product": { "id": "7c2a9f10-...", "type": "car_hire", "name": "Algarve Auto, Faro Airport" },
  "vehicle": { "id": "v_91a2...", "category": "Compact", "example_model": "Volkswagen Polo or similar" },
  "pickup_at": "2026-07-10T10:00:00Z",
  "dropoff_at": "2026-07-17T10:00:00Z",
  "rental_days": 7,
  "pickup_station": { "name": "Faro Airport", "collection_type": "shuttle", "airport_code": "FAO" },
  "fuel_policy": "full_full",
  "mileage_policy": "unlimited",
  "excess": { "amount": 1200.00, "currency": "EUR" },
  "extras": [ { "id": "x_child", "type": "child_seat", "name": "Child seat", "price": { "amount": 35.00, "currency": "EUR" }, "payable": "prepaid" } ],
  "lead_guest": { "name": "James Park", "email": "j.park@example.com" },
  "price": { "amount": 322.00, "currency": "EUR", "per": "rental" },
  "security_deposit": { "amount": 1200.00, "currency": "EUR", "refundable": true, "method": "card_pre_authorisation" },
  "external_ref": "YOUR-REF-20491"
}
```

If the driver does not meet the age policy you get `422 unprocessable` naming the rule. If the quote has expired you get `409 availability_changed`; start again from quote.

**Confirm a car.** Identical to accommodation. Take payment your side, then commit:

```bash
curl -X POST https://contracts.travelify.io/api/v1/bookings/bkg_01J9.../confirm \
  -H "Authorization: Bearer sk_test_example" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 9d4f2a71-..." \
  -d '{ "payment_reference": "your_pmt_ref_456", "payment_status": "paid", "amount_paid": { "amount": 322.00, "currency": "EUR" } }'
```

The response is the booking with `status: "confirmed"`. At that instant it becomes a read-only booking on the client's calendar. The security deposit stays a pre-authorisation the traveller arranges at the desk; you never charge it through this API.

### Cancelling a car booking

Same call as accommodation. The engine applies the car cancellation policy as at today and returns the sell-side charge and refund, alongside the free-text `cancellation_notes`. There is nothing to refund on the security deposit, because it is only ever a pre-authorisation.

---

## Transfers

Transfers move travellers between points (an airport, a city or a resort) by road. A contract carries a set of **points**, the **routes** between them, the **vehicles** offered, and seasonal **rates**. Pricing is per journey: a **private** vehicle is priced per vehicle, a **shared** shuttle per person. A **return** is two legs unless the contract sets a dedicated return rate.

### Transfer concepts

- **Route.** A point-to-point pair (point A to point B), priced in either direction.
- **Point names.** Each point's `name` is what to show travellers: the operator's own display name when one is set (for example `Courmayeur`), otherwise the full geolocated name. The full geolocated name always rides alongside in `full_name`, and route labels are built from the resolved names.
- **Vehicle.** A `private` or `shared` option with a passenger range and a luggage allowance.
- **Direction.** `arrival`, `departure` or `any` — which leg of a trip a rate applies to.
- **Booking cutoff.** A contract may require booking a set number of hours before travel; availability respects it.

### List transfers

```
GET /v1/transfers
```

Supports the full filter grammar — see **Searching the catalogue**. Parameters (all optional): `ids`, `country`, `region`, `airport_code`, `point_type`, `transfer_mode`, `vehicle_class`, `pax`, `meet_and_greet`, `supplier`, `near`, `q`, `sort`, `page`, `page_size`. The same filters work as a JSON body on `POST /v1/transfers/search`. For a point-to-point search, send `origin` and `destination` objects (each with the location fields above) to return only routes that run between the two ends — see **Transfers** under the bulk availability search. Each item carries the supplier, currency, served countries and regions, route and vehicle counts, a sample route and whether meet and greet is included.

### Get a transfer product

```
GET /v1/transfers/{transfer_id}
```

Full content: points, routes, vehicles, extras and policies. Use a route's `id` to price it.

Not every vehicle serves every route — the link is the loaded rates. Each route therefore carries `vehicle_ids`: the vehicles with at least one rate on that route, so you can build a route-to-vehicle picker without pricing each route first. A vehicle listed for a route can still drop out of a specific search (seasonal rates, direction-specific rates, passenger limits or a full day), so treat `vehicle_ids` as "can ever serve this route" and the availability call as the truth for a given journey.

### Price a transfer

Return priced vehicle options for a journey. Honours stop-sales, the sell window, contract validity and the booking cutoff.

```
POST /v1/transfers/{transfer_id}/availability
```

Body:

```json
{
  "route_id": "7b3f...",
  "date": "2026-07-10",
  "pax": 3,
  "direction": "arrival",
  "is_return": true,
  "return_date": "2026-07-17"
}
```

`route_id` is optional (omit it to price every route); `direction` defaults to `any` and `is_return` to `false`. Each option carries a `rate_reference`, the priced `route` and `vehicle`, the `pricing_basis` (`per_vehicle` or `per_person`), `legs` (1 or 2) and the total `price`. An empty `options` array means nothing is bookable for that journey (sold out, outside the sell window or validity, or inside the booking cutoff).

**Which way round.** A route is stored one way but runs both, so give `origin` and `destination` and the reply describes the journey the way you asked for it: `route.from` and `route.to` are the two ends in travel order and `direction` resolves to `arrival` (airport to resort) or `departure` (resort to airport). Each option also carries `journey`, the trip leg by leg, so a return reads as the outbound leg and then the way back, always starting from the origin you searched. `point_a`, `point_b` and `label` are oriented to that journey too: `point_a` is the pick-up on leg 1 and `point_b` the drop-off, so the airport code and the coordinates sit on the right end and the label reads the same way round as everything else. The same stored route comes back either way round depending on the search, and on a return the pair stays on the outbound leg.

**Returns.** Send the return leg's date as `return_date` (it implies `is_return`). Each leg is then priced on its own date and direction: the outbound leg on `date` with the direction you sent, the return leg on `return_date` running the opposite way. Seasons, date surcharges and arrival/departure-specific rates therefore apply per leg, and the option's `breakdown` shows one line per leg. `return_date` must be the same day as or after `date` and inside the contract's validity; both dates are checked against stop-sales and vehicle capacity. If you send `is_return: true` **without** a `return_date`, the return is priced as two legs of the outbound date, which is also how references issued before `return_date` existed keep working.

### Booking a transfer

Transfers book through the same three steps as everything else: `quote` the `rate_reference`, `hold` with a `lead_guest` (no driver is needed), then `confirm` with a payment reference. The booking object carries `route`, `vehicle`, `direction`, `pax`, `legs`, `is_return`, `travel_date` and, when one was priced, `return_date` — the return date rides the `rate_reference`, so quote, hold and the voucher all carry it automatically. The booked `route` is a booking-time snapshot, so its point names are the names as they stood when the booking was made; snapshots from before display names existed carry no `full_name`. Note the difference from a search result: a search reorients the route to the journey you asked for, a **booking does not**. Its `point_a` and `point_b` stay in the order the route is stored, frozen at the moment of booking, and `direction` tells you which way the journey runs (`arrival` is airport to resort, `departure` is resort to airport).

**Supplier journey details.** At `hold`, pass a `transfer_details` object so the supplier's booking letter carries the journey. All fields are optional strings:

- `flight_number` and `flight_arrival_time`: the arriving (outbound) flight. On the letter these are the Outbound rows.
- `return_flight_number` and `flight_departure_time`: the departing (inbound) flight of a return, shown in the letter's Inbound block.
- `child_seats`: child or booster seat needs in your own words (for example `"1 child seat, 1 booster"`), shown as Child seat requirements.
- `extra_luggage`: bags beyond the vehicle's standard allowance (for example `"2 ski bags"`), shown as Additional luggage.
- `accommodation_name`: the property, shown in the route line and as the Accommodation row.
- `agent_email`: the account manager handling this booking. They are **copied on the supplier letter** and set as its reply-to, so they hold a copy of what went out and the supplier's reply comes back to them rather than a shared inbox. Must be a valid email address (a malformed one rejects the hold); when omitted the reply-to falls back to the account-wide address set on the Brand page and nobody extra is copied.

Include the lead traveller's mobile in `lead_guest.phone` — it is passed to the supplier as the contact on the day. The supplier letter also asks the supplier to advise the pick-up time. Send the named passenger list in the top-level `guests` array (`{ "type": "adult" | "child" | "infant", "first_name", "last_name", "age?" }`) and the letter shows the full party with the adult/child/infant split.

`transfer_details` also takes a `questions` array of free-form `{ "name": …, "answer": … }` pairs, for anything you collect at booking that does not have a dedicated field (for example a gate code). Both fields are required on each entry and must be non-empty; up to 50 entries, `name` up to 200 characters and `answer` up to 1000. They are stored against the booking in the order you send them (no reordering, no dedup) and shown to the contract owner in the contracting engine. Questions whose names clearly mean one of the dedicated fields above (for example `BoosterSeats`, `ExtraBags`, `DepartureFlightNumber`, `DepartureTime`) are additionally placed on the letter where that field would go, though the dedicated fields are preferred and always win. Whatever you supply in `transfer_details` is echoed back on the booking object under the same `transfer_details` key.

```json
{ "quote_id": "qt_...", "lead_guest": { ... }, "guests": [ { "type": "adult", "first_name": "Sam", "last_name": "Lead" }, { "type": "child", "first_name": "Ava", "last_name": "Lead", "age": 6 } ], "transfer_details": { "flight_number": "BA346", "flight_arrival_time": "14:25", "return_flight_number": "BA347", "flight_departure_time": "17:05", "child_seats": "1 booster", "extra_luggage": "2 ski bags", "accommodation_name": "Hotel Pavillon, Courmayeur", "agent_email": "sarah@agency.example" } }
```

**Extras.** Add optional prepaid extras (a child seat, a meet and greet upgrade) at `hold`, using the `id` from the product's `extras` list:

```json
{ "quote_id": "qt_...", "lead_guest": { ... }, "extras": [ { "extra_id": "…", "quantity": 2 } ] }
```

Each extra is priced against the contract by its `charge_basis` (`per_journey` and `per_unit_per_journey` multiply by the booked legs, so a return charges both ways; `per_person` by passengers and legs; `per_unit` by quantity alone) and lands in the hold response: the booking's `extras` list the priced lines, the `breakdown` gains one line each and `price_total` includes them. A `mandatory` extra is already inside every quoted price (the breakdown shows it as "Compulsory extras") and is rejected if sent, as is a pay-local extra, which is settled at the destination.

**Supplier notification.** When the client has loaded a supplier email against the transfer contract, confirming the booking also emails the supplier a confirmation in the client's own brand, with the client copied and any booked extras listed. This is automatic and asynchronous; it never affects your confirm response.

## Flights

Flights are seats you have contracted directly - a block of seats or a negotiated fare - not a live GDS feed. A deal carries the **airports** it touches, the **aircraft** (each with a seat map), the **routes** you sell (an origin to a destination, **direct** or with **one connection**), the **flights** on each route (on a weekly pattern or one-off dates), optional **seasons**, and **fares** priced per cabin and passenger type. Pricing is per passenger: adults, children and infants each have their own fare, and taxes and the carrier surcharge are added on top. Capacity comes from the seat map, so travellers can pick a seat.

### Flight concepts

- **Route.** An origin to a destination. Direct is one flight; a connection is two, in order, with a minimum connection time at the stop.
- **Cabin.** `Y` Economy, `W` Premium Economy, `J` Business, `F` First. A route sells the cabins it has fares for.
- **Passenger types.** `adult`, `child`, `infant`. Infants do not take a seat.
- **Seat map.** The sellable seats in an aircraft are the contracted block; each seat has a type (window, middle or aisle), attributes (extra legroom, exit row) and an optional paid-seat fee.
- **Ticketing cutoff.** A deal may require ticketing a set number of hours before departure; availability respects it.

### List flight deals

```
GET /v1/flights
```

Parameters (all optional): `ids`, `airline`, `origin`, `dest`, `country`, `city`, `supplier`, `q`, `sort`, `page`, `page_size`. `origin` and `dest` are airport IATA codes. The same filters work as a JSON body on `POST /v1/flights/search`. Each item carries the airline, currency, served countries, the routes (origin and destination) and the route count.

### Get a flight deal

```
GET /v1/flights/{flight_id}
```

Full content: airports, routes (each with its flights and their schedules), aircraft (with seat-map summaries), the cabins each route sells, extras and policies.

### Price a flight

Return priced journeys for a route and date, one-way or return, for a passenger mix. Honours the sell window, contract validity and the ticketing cutoff.

```
POST /v1/flights/{flight_id}/availability
```

Body:

```json
{
  "origin": "LHR",
  "dest": "JFK",
  "date": "2026-07-10",
  "return_date": "2026-07-17",
  "adults": 2,
  "children": 1,
  "cabin": "Y"
}
```

`return_date` and `cabin` are optional (omit `cabin` to price every cabin). Each option carries a `rate_reference`, the `outbound` (and `return`) direction with its flown `segments` and a per-passenger fare breakdown, `seats_available`, and the total `price`. An empty `options` array means nothing is bookable for that route and date.

To price across **many deals** for one route in a single call, use `POST /v1/flights/availability/search` with `origin`, `dest`, `date` and at least one adult. Each result is a one-call entry: a lead (cheapest) option with a `rate_reference`, `options` (every bookable cabin and routing priced, each with its own `rate_reference`) and `product` (the full detail `GET /v1/flights/{flight_id}` would return), so you can render and book from the search response alone.

### Choosing seats

```
POST /v1/flights/{flight_id}/seatmap
```

Send the option's `rate_reference` (it pins the route, cabin and dates), or `route_id` + `date`. You get one seat grid per flown segment; each seat shows its type, attributes, any fee and whether it is `available` (false when blocked or already taken). Pass the chosen seats to `hold`.

A paid seat may also carry a `price_label` with the operator's own name for that charge (for example `Extra legroom`). It is null for unnamed or free seats. Show it next to the fee in your seat picker so travellers see why the seat costs more. The fee is charged per person for each flown segment the seat is picked on.

### Booking a flight

Flights book through the same three steps as everything else:

1. **Quote** the option's `rate_reference`, with the passenger mix:

```json
{ "rate_reference": "flt_...", "passengers": { "adults": 2, "children": 1, "infants": 0 } }
```

2. **Hold** with a `lead_guest`, and optionally the chosen `seats`, the `passengers` details and prepaid `extras`:

```json
{
  "quote_id": "qt_...",
  "lead_guest": { "first_name": "Jo", "last_name": "Bloggs", "email": "jo@example.com" },
  "seats": [ { "leg_id": "...", "seat": "12A", "passenger_ref": 0 } ]
}
```

Seats are validated against the live seat map and any paid-seat fee is added; seat selection is optional.

3. **Confirm** with a payment reference.

The booking object carries `trip_type`, `cabin`, `outbound_date`, `return_date`, passenger counts, the `itinerary` snapshot and the chosen `seats`. Passenger names and documents are stored but never returned. The voucher renders a flight layout in your brand.

## Tours

Tours are multi-day and single-day trips you have contracted directly: a guided itinerary that runs on **set departure dates**, priced **per person** by **room basis**. A tour carries its length (days and nights), where it starts and ends, the room bases it sells (twin or double share, single), the **traveller types** (adult, child), optional **seasons**, a per-person **price grid**, a day-by-day **itinerary**, what is included, deposit and cancellation terms, and a list of **departures**. Each departure has a date, a return date, a status, a capacity and the spaces left. Tours are a separate product family from attractions: they are not returned by `/v1/tickets`, and they book against a specific departure.

### Tour concepts

- **Departure.** One dated run of the tour. It is the unit of availability and inventory: it has a `capacity`, a minimum number to run, the spaces left, and a `status`.
- **Departure status.** `available` and `guaranteed` are bookable; `on_request`, `closed` and `cancelled` are not bookable through the API.
- **Room basis.** How the per-person price is set: twin or double share, or single (the single price is the full solo price, not a supplement).
- **Traveller types.** `adult`, and optionally `child` with an age band. Each is priced per person.
- **Spaces.** A booking claims `spaces_left = capacity - spaces_booked`. A departure with no capacity set is unlimited. Holds count against capacity and are freed when they expire or are cancelled, so a departure can never oversell.

### List tours

```
GET /v1/tours
```

Parameters (all optional): `ids`, `country`, `region`, `city`, `category`, `supplier`, `near` (`lat,lon,radius_km`), `q`, `sort`, `page`, `page_size`, all multi-value. The same filters work as a JSON body on `POST /v1/tours/search`. Each item carries the tour name, supplier, currency, duration, where it starts and ends, the number of departures, the next bookable departure date, and the lead `price_from` per person. No prices per departure here; use the availability call.

### Get a tour

```
GET /v1/tours/{tour_id}
```

Full content: room bases, traveller types, seasons, the day-by-day `itinerary`, `inclusions` and `exclusions`, `deposit` and `cancellation_policy`, group size and minimum age, and every `departure` with its `departure_date`, `return_date`, `status`, `capacity`, `spaces_left`, `min_to_run`, whether it is `bookable`, and a `price_from`. Only active tours resolve; anything else is a 404.

### Search availability across tours

Find and price bookable departures across **many tours** in one call, rather than listing then pricing each tour separately.

```
POST /v1/tours/availability/search
```

Takes any `POST /v1/tours/search` filter plus an optional departure window and traveller basket. At least one scoping filter is required (`ids`, `near`, `country`, `region`, `city` or `category`). `date_from` defaults to today; omit `date_to` for no upper bound.

```json
{
  "region": "Cappadocia",
  "date_from": "2026-09-01",
  "date_to": "2026-10-31",
  "travellers": [ { "ticket_type_id": "<adult_id>", "qty": 2 } ]
}
```

Each entry is a one-call result for a tour with at least one bookable departure in the window:

- `departures`: up to 10 departures, soonest first, each with the `departure` (dates, status, `spaces_left`, `bookable`), its `room_bases` priced for the basket (each with a `rate_reference` that feeds `quote` directly) and a `price_from`,
- `departure_count`: the total bookable departures in the window, so a capped list is never mistaken for the whole schedule,
- `lead_option`: the cheapest priced room basis across the included departures, with its `rate_reference`, `departure_id`, `departure_date` and `room_basis`,
- `extras` and `product`: the bookable add-ons and the full detail `GET /v1/tours/{tour_id}` would return.

Departures that are not bookable (sold out, within cutoff, on request, departed) or without enough spaces for the basket are excluded. When travellers are given, each room basis also carries a basket `price`. The response echoes `priced_for` and follows the shared bulk-search limits (see **Search priced availability across products** above).

### Price a departure

Return the room bases at the sell price per person for one departure, for a traveller basket. Honours the sell window, the booking cutoff, the departure status and the spaces left.

```
POST /v1/tours/{tour_id}/availability
```

Body (pin the departure by id or date; `travellers` optional, omit to see per-person prices only):

```json
{
  "departure_date": "2026-07-04",
  "travellers": [ { "ticket_type_id": "<adult_id>", "qty": 2 } ]
}
```

The response carries the `departure` (status, capacity, `spaces_left`, `bookable`) and `room_bases`, each with a `rate_reference`, a per-person price for every traveller type, a basket `price` when quantities are given, and the cancellation policy. A `reason` (for example `sold_out`, `within_cutoff`, `on_request`, `departed`, `not_on_sale`) is returned when nothing is bookable.

### Booking a tour

Tours book through the same three steps as everything else. The `rate_reference` pins the tour, the departure and the room basis; the traveller basket is supplied at quote time.

1. **Quote** the option's `rate_reference` with the travellers:

```json
{ "rate_reference": "tour_...", "travellers": [ { "ticket_type_id": "<adult_id>", "qty": 2 } ] }
```

2. **Hold** with a `lead_guest`. The departure's spaces are claimed before the hold is written, so a race for the last places loses cleanly with `availability_changed`:

```json
{
  "quote_id": "qt_...",
  "lead_guest": { "first_name": "Jo", "last_name": "Bloggs", "email": "jo@example.com" }
}
```

3. **Confirm** with a payment reference.

The booking object carries the `room_basis`, the `departure_date` and `return_date`, and the `travellers`. Cancelling (or letting a hold expire) returns the spaces to the departure. The voucher renders in your brand.

## Coaches

Coaches are scheduled coach and minibus services you have contracted directly: a route with ordered **pick-up points** all heading to **one destination**, sold **per seat** on **dated departures**. A route carries the operator, the vehicle, the destination, the pick-up points (each with a board time), the **passenger types** (adult, child), optional **seasons**, a per-seat **fare grid** (pick-up x passenger x season) and a list of **departures**, each with a date, a status, a capacity and the seats left. The return run (terminal back out) is a separate route. Coaches are a separate product family: they are not returned by `/v1/tickets`, and they book against a specific departure and pick-up point.

### Coach concepts

- **Direction.** Each route is `outbound` (the towns to one terminal) or `return` (the terminal out to the towns). The product carries `direction`, the `terminal`, and the `start_point`/`destination` with the arrow already resolved. Either way the pick-up points are the priced stops.
- **Departure.** One dated run of the route. It is the unit of availability and inventory: it has a `capacity` (seats), a minimum to run, the seats left, and a `status`.
- **Departure status.** `available` and `guaranteed` are bookable; `on_request`, `closed` and `cancelled` are not bookable through the API.
- **Pick-up point.** Where a passenger boards, with a board time. The fare is the per-seat price from that pick-up to the destination.
- **Seats.** A booking claims `seats_left = capacity - booked`. Holds count against capacity and are freed when they expire or are cancelled, so a departure can never oversell.

### List coaches

```
GET /v1/coaches
```

Parameters (all optional): `ids`, `country`, `region`, `city`, `operator` (or `supplier`), `near` (`lat,lon,radius_km`), `q`, `sort`, `page`, `page_size`. The same filters work as a JSON body on `POST /v1/coaches/search`. Each item carries the route name, operator, vehicle, the start and destination, the number of departures, the next bookable departure date and the lead `price_from` per seat.

**One call with `include`.** By default each item is a light summary. Add `include` to embed the full route detail on every result, so you do not have to list and then call `GET /v1/coaches/{coach_id}` for each route. Use `include=all`, or pick what you need from `pick_up_points`, `passenger_types`, `seasons` and `departures` (comma-separated); `departures` carry their `sailings`, and `sailings` and `pickups` are accepted as aliases. Only the current page is expanded, so page through a large catalogue. Example: `GET /v1/coaches?include=all` returns each route with its pick-up points, passenger types, seasons and every dated departure (each with its sailings) in the one response.

Each item also carries both endpoint coordinates, resolved by direction: `origin_location` and `destination_location`. A coach route has one hub (the terminal, also given as `location`) and a spread of pick-up towns. The terminal is a single coordinate; the towns end is represented by its first located town in the timetable. On an outbound route everyone heads **to** the terminal, so `destination_location` is the terminal (e.g. the airport) and `origin_location` is the towns; on a return it flips. Use `destination_location` to place the drop-off. Every town's own coordinates are on `pick_up_points` in the full route (below), so use those to offer the individual boarding points.

### Get a coach route

```
GET /v1/coaches/{coach_id}
```

Full content: pick-up points (with board times and `latitude`/`longitude`), passenger types, seasons, and every departure with its `departure_date`, `status`, `capacity`, `seats_left`, `min_to_run`, whether it is `bookable`, and a `price_from`. The route carries `location` (the terminal's coordinates) plus `origin_location` and `destination_location`, both resolved by direction: the terminal is one end (the destination outbound, the origin on a return) and the pick-up towns are the other, represented by the first located town. Every town has its own `latitude`/`longitude` on `pick_up_points`. Only active routes resolve; anything else is a 404. Coordinates are sourced in the app with "Locate stops".

**Sailing-fed coaches (for example cruise-port transfers).** When a coach feeds a scheduled service, each departure also carries a `sailings` array naming the sailing(s) it connects to: `name` (the sailing or cruise name), `ship` (the vessel) and `reference` (the operator's voyage code), any of which may be absent. A single run can list more than one sailing when two services leave on the same day. This lets you build the search box in the natural order for this kind of product: **sailing first** (group the departures by `sailings[].ship` and `name`), then **the date(s)** for the chosen sailing, then **the pick-up point**. A plain point-to-point coach simply omits `sailings`, so the same code handles both. Booking is unchanged: the chosen departure and pick-up resolve to one `rate_reference`.

### Search availability across routes

Find bookable departures across **all** routes in one call, rather than looking up each route id separately.

```
POST /v1/coaches/availability
```

Body (a `date` is required; give a `pick_up` and/or `destination` geo radius):

```json
{
  "date": "2026-07-04",
  "pick_up":     { "lat": 51.48, "lon": -3.18, "radius_km": 30 },
  "destination": { "lat": 50.90, "lon": -1.40, "radius_km": 15 },
  "passengers":  { "adults": 2, "children": 1 }
}
```

Returns a page of matching routes, each with the bookable pick-up points within the pick-up radius, priced for the party, the seats left, and a `rate_reference` per pick-up. Each entry also carries `product`, the full detail `GET /v1/coaches/{coach_id}` would return, so one search call is enough to render and book a route. The match is direction-aware: `pick_up` matches where the passenger boards and `destination` where they alight (these are the towns for an outbound route and the terminal for a return, and vice versa). Routes with no service on the date, or without enough seats for the party, are excluded.

### Price a departure

Return the pick-up points at the sell price per seat for one departure, for a passenger basket. Honours the sell window, the booking cutoff, the departure status and the seats left.

```
POST /v1/coaches/{coach_id}/availability
```

Body (pin the departure by id or date; `passengers` optional):

```json
{
  "departure_date": "2026-07-04",
  "passengers": [ { "passenger_type_id": "<adult_id>", "qty": 2 } ]
}
```

The response carries the `departure` (status, capacity, `seats_left`, `bookable`) and `pick_up_points`, each with a `rate_reference`, a per-seat price for every passenger type, and a basket `price` when quantities are given. A `reason` (for example `sold_out`, `within_cutoff`, `departed`) is returned when nothing is bookable.

### Booking a coach

Coaches book through the same three steps as everything else. The `rate_reference` pins the route, the departure and the pick-up point; the passenger basket is supplied at quote time.

1. **Quote** the option's `rate_reference` with the passengers:

```json
{ "rate_reference": "coach_...", "passengers": [ { "passenger_type_id": "<adult_id>", "qty": 2 } ] }
```

2. **Hold** with a `lead_guest`. The departure's seats are claimed before the hold is written, so a race for the last seats loses cleanly with `availability_changed`.

3. **Confirm** with a payment reference.

The booking object carries the `pick_up`, the `destination`, the `departure_date` and the `passengers`. Cancelling (or letting a hold expire) returns the seats to the departure.

### Round-trip coaches (optional)

Some coach routes run out and back (for example a cruise-port transfer: home to the port on sailing day, port to home on the day the ship returns). You can book both legs as one booking by adding a `return_date` when you quote. It is entirely optional. Send no `return_date` and you get the one-way booking exactly as above.

```json
{ "rate_reference": "coach_...", "passengers": [ { "passenger_type_id": "<adult_id>", "qty": 2 } ], "return_date": "2027-01-20" }
```

What happens:

- The paired inbound coach (the same pick-up town, on `return_date`) is priced too, and the quote `price` becomes the sum of both one-way fares. There is no return discount. The `breakdown` marks the outbound lines `(outbound)` and adds a `Return coach (date)` line, and the quote carries `is_return: true` and `return_date`.
- **Hold** claims the seats on **both** departures together. If either leg is sold out the hold is rejected with `availability_changed` and neither leg is left half-booked.
- The booking object carries `is_return: true`, the `return_date` and a `return_leg` (its date, the pick-up town and the inbound route). The pick-up is the same town both ways, and there are no separate return times. Cancelling (or letting the hold expire) returns the seats to **both** departures.

The full developer walkthrough, quote to confirm, is two sections below. The route must offer a return (its outbound and inbound routes are linked in the engine). Check `round_trip` on the route before offering one: it is `true` when a pair exists, and `return_route_id` names the partner. If it does not, the quote replies `unprocessable`. The two routes carry their own passenger types; the basket is matched across the pair by passenger type **name**, so a return route needs an "Adult" if the outbound sold an "Adult". On a cruise feeder, do not use `return_date` at all: a date cannot tell two cruises on the same coach apart. Use `sailing_id` instead, described in the next section.

### Cruise feeders: ask for the cruise, not the date

Skip this unless your coaches feed a scheduled service such as a cruise. If they
do, **use this instead of a date and instead of `return_date`.** It is the
difference between offering the right coach home and offering somebody else's.

A cruise-feeder departure names the sailing(s) it serves. One coach can serve
more than one cruise, and those cruises can come home on different days. A live
example: one run to Southampton on 2027-05-27 carries passengers for *Celtic
Dreams*, home on 2027-06-08, and for *The Crowning Jewels*, home on 2027-06-22.
A date cannot tell those two apart, and a date search across all routes answers
with every cruise line running that day, in both directions.

So a cruise, not a coach date, is the thing to ask for. Same endpoint you are
already calling, one extra field.

**Every sailing carries a `sailing_id`.** It is already on every departure you
get back from `GET /v1/coaches`, `GET /v1/coaches/{id}` and every availability
response:

```json
"sailings": [
  { "sailing_id": "sail_...", "name": "Celtic Dreams", "ship": "Marina", "return_date": "2027-06-08" },
  { "sailing_id": "sail_...", "name": "The Crowning Jewels", "ship": "Marina", "return_date": "2027-06-22" }
]
```

**Send it to the availability search you already use.** No date needed: the
sailing carries its own.

```
POST /v1/coaches/availability
```

```json
{
  "sailing_id": "sail_...",
  "passengers": { "adults": 2, "children": 1 },
  "pick_up": { "lat": 51.48, "lon": -3.18, "radius_km": 30 }
}
```

You get back **exactly two results** in the same shape as always: the outbound
coach for that sailing and the inbound coach for that same sailing. No other
cruises, no other dates, no other routes. Each result carries a `leg`, and each
is priced on its own with its own `price_from` and its own `rate_reference` per
pick-up, so you can show the two prices separately.

```json
{
  "data": [
    { "leg": "outbound", "id": "...", "name": "Oceania cruise coach to Southampton (2027)",
      "direction": "outbound", "departure": { "departure_date": "2027-05-27", "seats_left": 43 },
      "price_from": { "amount": 39, "currency": "GBP" }, "pick_up_points": [ /* rate_reference on each */ ] },
    { "leg": "inbound", "id": "...", "name": "Oceania cruise coach from Southampton (2027)",
      "direction": "return", "departure": { "departure_date": "2027-06-22", "seats_left": 48 },
      "price_from": { "amount": 39, "currency": "GBP" }, "pick_up_points": [ /* rate_reference on each */ ] }
  ],
  "sailing": {
    "sailing_id": "sail_...", "name": "The Crowning Jewels", "ship": "Marina",
    "outbound_date": "2027-05-27", "return_date": "2027-06-22"
  },
  "pagination": { "page": 1, "page_size": 2, "total": 2 }
}
```

`pick_up` is the passenger's own town. It is applied to the boarding side of the
outbound leg and to the drop-off side of the inbound leg, because it is the same
town at both ends of the trip.

When there is no coach home you get one result and an `inbound_unavailable`
saying why: `no_return_date`, `no_return_route`, or
`no_return_departure_on_<date>`. Nothing is guessed. Likewise, a leg that cannot
be sold comes back with a `reason` (`sold_out`, `within_cutoff`, `departed` and
so on) rather than disappearing, because you asked for that cruise by name.

**Book it.** Quote the outbound `rate_reference` and pass the same `sailing_id`:

```json
{
  "rate_reference": "coach_...",
  "passengers": [ { "passenger_type_id": "<adult_id>", "qty": 2 } ],
  "sailing_id": "sail_..."
}
```

The inbound leg is taken from the sailing itself, so the right coach home is
booked every time. Everything else behaves exactly as the round trip above: one
booking, seats claimed on both departures together, both released on cancel.

`sailing_id` takes precedence over `return_date`. It must belong to the departure
the `rate_reference` already pins, otherwise the quote is refused rather than
booking a cruise the customer did not choose. If the operator later re-dates the
cruise, an id minted before the change stops resolving rather than quietly
pointing at a different sailing. Read the departures again and use the new id.

**Narrowing a date search instead.** If you do want to search by date, add
`sailing` to keep other cruises out. It matches the sailing name, the ship or the
voyage reference.

```json
{ "date": "2027-05-27", "sailing": "Marina", "passengers": { "adults": 2 } }
```

### Return journeys end to end: quote, hold, confirm

A return coach journey is **one booking with two legs**. It is not two bookings.
The seats are claimed on both departures together, both are released together,
and there is one reference, one voucher and one cancellation policy.

This section is the whole flow, in the order you code it.

#### The shape of it

| | |
|---|---|
| Bookings created | **1** |
| Departures the seats come off | **2** (outbound and inbound) |
| `rate_reference` you send | **1** (the outbound pick-up) |
| Quote calls | **1** |
| Hold calls | **1** |
| Confirm calls | **1** |
| Price | the two one-way fares added together. There is no return discount |
| Cancellation policy | the **outbound** route's ladder governs the whole booking |

#### Step 0 — get the outbound `rate_reference`

Price the journey first. On a cruise feeder, send the `sailing_id`:

```
POST /v1/coaches/availability
{
  "sailing_id": "sail_...",
  "passengers": { "adults": 2, "children": 1 }
}
```

You get two results, `leg: "outbound"` and `leg: "inbound"`. **Take the
`rate_reference` from the pick-up point you want on the OUTBOUND leg.** You do
not send the inbound `rate_reference` anywhere: the inbound leg is added by
naming the sailing, not by quoting it separately.

On a non-cruise route, search by date as usual and take the outbound
`rate_reference` the same way.

#### Step 1 — quote

```
POST /v1/bookings/quote
Authorization: Bearer sk_live_...
```

```json
{
  "rate_reference": "coach_...",
  "passengers": [
    { "passenger_type_id": "<adult_id>", "qty": 2 },
    { "passenger_type_id": "<child_id>", "qty": 1 }
  ],
  "sailing_id": "sail_..."
}
```

| Field | Required | Notes |
|---|---|---|
| `rate_reference` | yes | The **outbound** pick-up point |
| `passengers` | yes | The outbound route's `passenger_type_id`s. Aliases `travellers` and `tickets` are accepted |
| `sailing_id` | for a cruise | Adds the inbound leg, taken from the sailing's own return date |
| `return_date` | otherwise | Adds the inbound leg by date. **Ignored when `sailing_id` is sent.** Do not use it on a cruise feeder: it cannot tell two cruises on one coach apart |

Send neither `sailing_id` nor `return_date` and you get a one-way quote. That is
the only difference between a single and a return at this stage.

Response:

```json
{
  "quote_id": "qt_...",
  "expires_at": "2027-01-05T10:32:00Z",
  "product": { "id": "...", "type": "coach", "name": "Oceania cruise coach to Southampton (2027)" },
  "pick_up": { "id": "...", "name": "Cardiff", "board_time": "06:30" },
  "destination": "Southampton",
  "departure_date": "2027-05-27",
  "is_return": true,
  "return_date": "2027-06-22",
  "passengers": [
    { "passenger_type_id": "...", "name": "Adult", "quantity": 2,
      "price_per_seat": { "amount": 39, "currency": "GBP" },
      "price": { "amount": 78, "currency": "GBP" } }
  ],
  "price": { "amount": 195, "currency": "GBP", "per": "seat" },
  "breakdown": [
    { "label": "Adult x2 (outbound)", "amount": 78 },
    { "label": "Child x1 (outbound)", "amount": 25 },
    { "label": "Return coach (2027-06-22)", "amount": 92 }
  ],
  "cancellation_policy": [ { "from_days_before": 30, "charge_pct": 25 } ]
}
```

**What to code against:**

- `price.amount` is the **combined** total for both legs. Charge this.
- `is_return` and `return_date` tell you a return was priced. If you sent a
  `sailing_id` or a `return_date` and `is_return` comes back `false`, treat it as
  a bug and stop, do not take money for a single as if it were a return.
- The outbound passenger lines are in `passengers` and are marked `(outbound)` in
  `breakdown`. The inbound leg is the single `Return coach (date)` line.
- **Do not parse `breakdown` labels to get the two leg prices.** If you want to
  show them separately, take them from the availability call in step 0, where
  each leg carries its own `price_from` and its own priced passenger lines.
- **The quote holds the price for 30 minutes** (`expires_at`). It reserves no
  seats. Somebody else can still take the last seat while your customer is
  typing, which is what step 2 is for.
- `quote_id` is opaque. Store it whole and send it back unchanged.

Errors worth handling here:

| Code | Meaning |
|---|---|
| `422 unprocessable` "This coach route does not offer a return." | The route has no linked return partner |
| `422 unprocessable` "No return coach runs on `<date>`." | You sent a `return_date` with no service |
| `422 unprocessable` "That sailing is not on this coach departure." | The `sailing_id` and the `rate_reference` are for different departures |
| `422 unprocessable` "The return coach has no \"Child\" fare." | The two routes' passenger types do not match by name |
| `409 availability_changed` | The departure closed, sold out or passed its cut-off between search and quote |

#### Step 2 — hold

```
POST /v1/bookings/hold
Authorization: Bearer sk_live_...
Idempotency-Key: <your own uuid>
```

```json
{
  "quote_id": "qt_...",
  "lead_guest": { "first_name": "Jane", "last_name": "Doe", "email": "jane@example.com" },
  "external_ref": "your-order-1234",
  "notes": "Wheelchair at Cardiff"
}
```

| Field | Required | Notes |
|---|---|---|
| `quote_id` | yes | From step 1 |
| `lead_guest` | yes | `first_name`, `last_name` and `email` are all required |
| `external_ref` | recommended | Your own reference. Enforced unique, so a retry returns the booking you already made |
| `Idempotency-Key` header | recommended | Used as the `external_ref` when you send no explicit one. Either is enough |
| `seats` | optional | **Outbound leg only**, see below |
| `notes` | optional | Free text, shown to staff |

**What happens on the server, in this order.** Code defensively against each one:

1. The quote is decoded and checked for expiry.
2. **Both legs are re-priced from live data.** If either total has moved by a
   penny you get `409 availability_changed` and must re-quote. The quoted price
   is never simply trusted.
3. **The outbound seats are claimed atomically** in the database.
4. **The inbound seats are claimed atomically.** If the inbound is sold out, the
   outbound claim is **released** and the whole hold is refused. You are never
   left holding half a round trip.
5. The booking row is written. If that write fails, both claims are released.

Response is the booking object, `status: "held"`, with:

```json
{
  "id": "...",
  "reference": "TGC-A1B2C3D4",
  "status": "held",
  "expires_at": "2027-01-05T10:32:00Z",
  "hold_window_seconds": 1800,
  "is_return": true,
  "return_date": "2027-06-22",
  "return_leg": {
    "departure_date": "2027-06-22",
    "pick_up": { "id": "...", "name": "Cardiff" },
    "route": { "id": "...", "name": "Oceania cruise coach from Southampton (2027)" }
  },
  "price": { "amount": 195, "currency": "GBP" }
}
```

**The hold lasts 30 minutes** (`hold_window_seconds`). If you do not confirm in
that window it expires on its own and both legs' seats go back on sale. There is
nothing to clean up.

Errors specific to the return leg:

| Code | Message | What to do |
|---|---|---|
| `409` | "The return coach is sold out. Please choose another." | Nothing is held. Re-search |
| `409` | "The return coach is no longer bookable." | It closed or passed its cut-off. Re-search |
| `409` | "The price has changed since you were quoted." | Re-quote and show the new price |

#### Step 3 — confirm

```
POST /v1/bookings/{booking_id}/confirm
Authorization: Bearer sk_live_...
Idempotency-Key: <your own uuid>
```

```json
{
  "payment_reference": "pi_3Abc123",
  "payment_status": "paid",
  "amount_paid": 195
}
```

| Field | Required | Notes |
|---|---|---|
| `payment_reference` | yes | Your own payment id. We never touch money, this is for reconciliation |
| `payment_status` | optional | `paid` or `deposit_paid`. Anything else is refused |
| `amount_paid` | optional | Recorded against the booking |

Take the payment **before** you confirm. The confirm is what commits both legs.

- Confirming an **already confirmed** booking returns it unchanged, so a retry is
  safe.
- Confirming an **expired** hold returns `409 availability_changed` and the seats
  are released on the spot. Re-quote.
- Confirming a **cancelled** booking returns `409`.

On success you get the booking with `status: "confirmed"`. Both legs are now
sold. If the operator has seat-fill alerts, they fire for the outbound and the
inbound departure independently.

#### Cancelling

```
POST /v1/bookings/{booking_id}/cancel
```

One call cancels the whole booking. **Both** departures get their seats back, and
any held seats are released. The charge comes from the cancellation ladder
snapshotted onto the booking when it was made, measured against the **outbound**
departure date, so editing the contract later cannot change what a customer was
already sold. The response carries `cancellation_pct` and `cancellation_charge`.
Cancelling twice is harmless.

#### Seat selection on a return

If the route has a seat plan you can pass `seats` on the hold:

```json
{ "quote_id": "qt_...", "lead_guest": { ... }, "seats": ["1A", "1B", "1C"] }
```

Three rules:

1. **Exactly one seat per passenger**, or the hold is refused. Three passengers
   means three seats.
2. **The seats apply to the outbound leg only.** The inbound leg is sold by seat
   count, even when the return route has its own seat plan. There is no way to
   pick named seats on the coach home today.
3. Fetch the plan with `POST /v1/coaches/{coach_id}/seatmap` for the **outbound**
   route and departure. Each seat carries its surcharge and an `available` flag.
   Any surcharge is added to the booking total on top of the fares.

Leave `seats` out entirely and the booking is sold by seat count on both legs,
which is what most cruise feeders do.

#### Things that catch people out

- **The two routes are separate contracts with their own passenger type ids.**
  You only ever send the outbound's ids. The engine re-keys the basket onto the
  inbound route by passenger type **name**, so a route selling "Adult" needs an
  "Adult" on its partner. A missing one is named in the error.
- **The pick-up town is the same both ways.** The inbound leg is matched on the
  town name, so a customer boarding at Cardiff is dropped at Cardiff. There is no
  way to go out from one town and come back to another.
- **There is no return discount.** The total is the two one-way fares added up.
- **One cancellation policy.** The outbound route's ladder governs both legs.
- **`return_date` still works** for non-cruise routes and is unchanged. On a
  cruise feeder use `sailing_id`: a date names a coach, and one coach can feed two
  cruises that come home on different days.
- **Poll, do not wait.** There are no webhooks. If you need to know a hold
  expired, read `GET /v1/bookings/{id}`.

### Picking coach seats (optional)

If the route has a seat plan, travellers can pick specific seats and pay by seat type (Standard, First, extra legroom and so on). Fetch the plan for a departure:

```
POST /v1/coaches/{coach_id}/seatmap
```

Body: `{ "departure_id": "..." }` or `{ "departure_date": "2026-07-04" }`. The response carries `has_seat_map`, the `seat_types` and every `seat`.

Each seat type is a named zone with the shape of its block: `rows`, `columns` (the seat letters across a row), `aisles_after` (the columns to draw an aisle after, as letters) and `seat_count`, which is what you need to lay the plan out. **The surcharge is on the seat, not the zone**: read `fee` on each `seat`, since seats inside one zone can be priced apart. Every seat also carries its position and an `available` flag (false when the seat is blocked or already taken). `has_seat_map` is `false` when the route sells by seat count only, in which case no seat is picked.

To book seats, add a `seats` array to the **hold** body, one entry per chosen seat:

```json
{ "quote_id": "...", "lead_guest": { "first_name": "Jo", "last_name": "Bloggs", "email": "jo@example.com" }, "seats": [ { "seat": "12A" }, { "seat": "12B" } ] }
```

Send **exactly one seat per passenger** or none at all: a partial selection is refused with `unprocessable`, so the seats claimed can never diverge from the capacity claimed. Seats sent for a route with no seat plan (`has_seat_map: false`) are ignored rather than refused.

Each seat's surcharge is added to the booking total, the seats are claimed atomically (a race for the same seat loses cleanly with `availability_changed`), and they appear on the voucher and in the booking's `seats`. Cancelling frees them.

## Trains

Trains are scheduled train services you have contracted directly. They follow the same model as coaches: a service with ordered **boarding stations** all heading to **one destination**, sold **per seat** on **dated departures**. A service carries the operator, the class, the destination, the stations (each with a time), the **passenger types** (adult, child), optional **seasons**, a per-seat **fare grid** (station x passenger x season) and a list of **departures**, each with a date, a status, a capacity and the seats left. The return run is a separate service. Trains are a separate product family: they are not returned by `/v1/tickets`, and they book against a specific departure and boarding station.

### Already integrated coaches? Trains are the same API, renamed

If you have integrated coaches, trains are quick. **Booking is identical:** the same `/v1/bookings/quote`, `hold`, `confirm`, `cancel` and `voucher` endpoints serve every product. Pass a train `rate_reference` (it starts with `train_` instead of `coach_`) into the same `quote` call and carry on; the booking comes back with `type: "train"`.

**Search and availability** are the same shape at `/v1/trains` instead of `/v1/coaches`, with a few fields renamed to suit rail. To port coach code: change the path to `/trains` and apply these renames.

| Coaches | Trains |
|---|---|
| `GET/POST /v1/coaches`, `/coaches/{coach_id}`, `/coaches/{id}/availability`, `/coaches/availability`, `/coaches/{id}/seatmap` | `/v1/trains`, `/trains/{train_id}`, `/trains/{id}/availability`, `/trains/availability`, `/trains/{id}/seatmap` |
| `coach_id` (path + body) | `train_id` |
| `pick_up_points` (array on availability) | `stations` |
| `pick_up` (the priced point, and on the booking) | `station` (on the booking: `boarding_station`) |
| `vehicle` (summary/detail) | `service_class` |
| bulk filter `pick_up` (lat/lon/radius) | `boarding` |
| `rate_reference` prefix `coach_` | `train_` |

Everything else is identical **except two things**: there is no `include` parameter on `GET /v1/trains` (it is a coach feature, and a train request carrying it is ignored), and round trips are coaches only (a `return_date` on a train quote is ignored and you get a one-way). Otherwise the parameters, pagination,

### Train concepts

- **Direction.** Each service is `outbound` (the stations to one destination) or `return` (the destination out to the stations). The product carries `direction`, the `terminal` (destination), and the `start_point`/`destination` with the arrow already resolved. Either way the stations are the priced stops.
- **Departure.** One dated run of the service. It is the unit of availability and inventory: it has a `capacity` (seats), a minimum to run, the seats left, and a `status`.
- **Departure status.** `available` and `guaranteed` are bookable; `on_request`, `closed` and `cancelled` are not bookable through the API.
- **Boarding station.** Where a passenger boards, with a time. The fare is the per-seat price from that station to the destination.
- **Class.** Travel class (Standard, First) is carried as `service_class` on the service, and priced through the passenger/fare types (for example "Adult Standard" and "Adult First").
- **Seats.** A booking claims `seats_left = capacity - booked`. Holds count against capacity and are freed when they expire or are cancelled, so a departure can never oversell.

### List trains

```
GET /v1/trains
```

Parameters (all optional): `ids`, `country`, `region`, `city`, `operator` (or `supplier`), `near` (`lat,lon,radius_km`), `q`, `sort`, `page`, `page_size`. The same filters work as a JSON body on `POST /v1/trains/search`. Each item carries the service name, operator, class, the start and destination, the number of departures, the next bookable departure date and the lead `price_from` per seat.

### Get a train service

```
GET /v1/trains/{train_id}
```

Full content: stations (with times and `latitude`/`longitude`), passenger types, seasons, and every departure with its `departure_date`, `status`, `capacity`, `seats_left`, `min_to_run`, whether it is `bookable`, and a `price_from`. The service also carries `location` (the destination's coordinates). Only active services resolve; anything else is a 404.

### Search availability across services

Find bookable departures across **all** services in one call, rather than looking up each service id separately.

```
POST /v1/trains/availability
```

Body (a `date` is required; give a `boarding` and/or `destination` geo radius):

```json
{
  "date": "2026-07-04",
  "boarding":    { "lat": 50.90, "lon": -1.41, "radius_km": 30 },
  "destination": { "lat": 51.50, "lon": -0.11, "radius_km": 15 },
  "passengers":  { "adults": 2, "children": 1 }
}
```

Returns a page of matching services, each with the bookable stations within the boarding radius, priced for the party, the seats left, and a `rate_reference` per station. Each entry also carries `product`, the full detail `GET /v1/trains/{train_id}` would return, so one search call is enough to render and book a service. The match is direction-aware: `boarding` matches where the passenger boards and `destination` where they alight. Services with no service on the date, or without enough seats for the party, are excluded.

### Price a departure

Return the stations at the sell price per seat for one departure, for a passenger basket. Honours the sell window, the booking cutoff, the departure status and the seats left.

```
POST /v1/trains/{train_id}/availability
```

Body (pin the departure by id or date; `passengers` optional):

```json
{
  "departure_date": "2026-07-04",
  "passengers": [ { "passenger_type_id": "<adult_id>", "qty": 2 } ]
}
```

The response carries the `departure` (status, capacity, `seats_left`, `bookable`) and `stations`, each with a `rate_reference`, a per-seat price for every passenger type, and a basket `price` when quantities are given. A `reason` (for example `sold_out`, `within_cutoff`, `departed`) is returned when nothing is bookable.

### Booking a train

Trains book through the same three steps as everything else. The `rate_reference` pins the service, the departure and the boarding station; the passenger basket is supplied at quote time.

1. **Quote** the station's `rate_reference` with the passengers:

```json
{ "rate_reference": "train_...", "passengers": [ { "passenger_type_id": "<adult_id>", "qty": 2 } ] }
```

2. **Hold** with a `lead_guest`. The departure's seats are claimed before the hold is written, so a race for the last seats loses cleanly with `availability_changed`.

3. **Confirm** with a payment reference.

The booking object carries the `boarding_station`, the `destination`, the `departure_date` and the `passengers`. Cancelling (or letting a hold expire) returns the seats to the departure.

### Picking train seats (optional)

Seat selection works exactly as it does for coaches. If the service has a seat plan, fetch it with `POST /v1/trains/{train_id}/seatmap` (body `{ "departure_id": "..." }` or `{ "departure_date": "..." }`) and add a `seats` array to the **hold** body, one entry per chosen seat (for example `{ "seat": "12A" }`). Each seat's surcharge is added to the total, the seats are claimed atomically, and they appear on the voucher and in the booking's `seats`. `has_seat_map` is `false` when the service sells by seat count only.

## The OpenAPI spec

The file `travelgenix-contracting-api.openapi.yaml` is the machine-readable contract for this API and the source of truth; this reference and the spec are kept in step. Three things you can do with it straight away:

- **Import a ready-made request collection.** Open the spec in Postman or Insomnia to get every endpoint, with example bodies, as a collection you can fire immediately.
- **Generate a typed client.** Point an OpenAPI generator at it to produce a client library in your language, so you call the API through native methods rather than hand-building requests.
- **Render interactive docs.** Serve the spec through Redoc or Swagger UI for a browsable, try-it-out version of this reference.

```bash
# Example: generate a TypeScript client
npx @openapitools/openapi-generator-cli generate \
  -i travelgenix-contracting-api.openapi.yaml \
  -g typescript-fetch -o ./contracting-client
```

## Sandbox and test data

Build against the sandbox first. Use a `sk_test_` key; the surface is identical to production, only the data and the key differ, and nothing you do creates a real booking. When you are ready, swap in your `sk_live_` key and the same code runs against real product.

The sandbox is seeded with demo product across every family, so each product path can be run end to end. Representative items:

| What | Type | Currency | Good for |
|---|---|---|---|
| Vale do Sol Beach Hotel, Albufeira (Portugal) | hotel | EUR | room types, boards, child pricing, multi-unit availability |
| Casa del Sol, Algarve | villa | EUR | whole-unit weekly pricing, Saturday changeover, refundable deposit |
| Algarve Auto, Faro Airport (Portugal) | car_hire | EUR | per-day length tiers, fuel and mileage policy, prepaid and pay-local extras, refundable deposit |
| Antalya Bay Resort, Antalya (Turkey) | hotel | EUR | fully refundable and non-refundable rates side by side |
| Antalya airport transfers (Lara, Belek, Side) | transfer | EUR | one-way and return pricing, seasons, private and shared vehicles, extras, capacity caps |
| Cornwall to Heathrow Airport | coach | GBP | reserved seat selection with a seat plan, a tiered cancellation policy, three pick-up points (Truro, Plymouth, Exeter), dated departures |

For accommodation, use a summer week such as `2026-07-10` to `2026-07-17`, which the demo rates cover. For car hire, use pickup and dropoff datetimes in the same window, for example `2026-07-10T10:00:00Z` to `2026-07-17T10:00:00Z`. For the coach, list the route's `departures` first and take one that is still bookable (a run stops selling once it has passed its last boarding point); it carries a seat plan, so it also exercises `POST /v1/coaches/{coach_id}/seatmap` and seat selection at hold, and a four-band cancellation policy that shows on the booking. Payment is always taken your side, so in the sandbox simply pass any string as `payment_reference` at confirm. Detailed test packs with exact ids and expected prices are issued alongside this reference; ask Travelgenix if you do not have them.

## Worked example: search to a confirmed booking

Five calls take you from a destination search to a confirmed accommodation booking. Each step feeds the next; the value you carry forward is shown in bold prose under each call.

**1. Find the property.**

```bash
curl "https://contracts.travelify.io/api/v1/properties?city=Albufeira&property_type=hotel" \
  -H "Authorization: Bearer sk_test_example"
```

Returns a page of properties, each with an `id`. Take the property you want.

**2. Price that property** for the full option list.

```bash
curl -X POST https://contracts.travelify.io/api/v1/properties/d865cac7-.../availability \
  -H "Authorization: Bearer sk_test_example" \
  -H "Content-Type: application/json" \
  -d '{
    "check_in": "2026-07-10", "check_out": "2026-07-17",
    "occupancy": { "adults": 2, "children": [6] }
  }'
```

Each option carries a `rate_reference`. Pick the room and board the traveller chose.

**3. Quote** to lock that price.

```bash
curl -X POST https://contracts.travelify.io/api/v1/bookings/quote \
  -H "Authorization: Bearer sk_test_example" \
  -H "Content-Type: application/json" \
  -d '{ "rate_reference": "rate_ref_01J...redacted" }'
```

Returns a `quote_id` and an `expires_at`. Carry the `quote_id` forward.

**4. Hold** the room with the guests. Send an `Idempotency-Key` and your own `external_ref`.

```bash
curl -X POST https://contracts.travelify.io/api/v1/bookings/hold \
  -H "Authorization: Bearer sk_test_example" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 6a1f9c2e-..." \
  -d '{
    "quote_id": "qte_01J8...",
    "external_ref": "YOUR-REF-10482",
    "lead_guest": { "title": "Ms", "first_name": "Sarah", "last_name": "Hughes", "email": "s.hughes@example.com", "phone": "+447700900123" },
    "guests": [
      { "type": "adult", "first_name": "Sarah", "last_name": "Hughes" },
      { "type": "adult", "first_name": "Tom", "last_name": "Hughes" },
      { "type": "child", "first_name": "Mia", "last_name": "Hughes", "age": 6 }
    ]
  }'
```

Returns a held booking with a `booking_id`, a `reference` and an `expires_at`. Take payment your side now, before the hold expires.

**5. Confirm** once payment is taken.

```bash
curl -X POST https://contracts.travelify.io/api/v1/bookings/bkg_01J8.../confirm \
  -H "Authorization: Bearer sk_test_example" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 6a1f9c2e-..." \
  -d '{ "payment_reference": "your_pmt_ref_123", "payment_status": "paid", "amount_paid": { "amount": 1043.00, "currency": "EUR" } }'
```

Returns the booking with `status: "confirmed"`. At that instant it becomes a read-only reservation on the client's calendar, and because availability had already netted off everything unsellable, it cannot have oversold.

## Worked example: car hire, search to a confirmed booking

The same five-call shape for a car. The only different body is the hold, which carries a driver and prepaid extras.

**1. Search the location.**

```bash
curl -X POST https://contracts.travelify.io/api/v1/cars/search \
  -H "Authorization: Bearer sk_test_example" \
  -H "Content-Type: application/json" \
  -d '{
    "airport_code": "FAO",
    "pickup_at": "2026-07-10T10:00:00Z", "dropoff_at": "2026-07-17T10:00:00Z",
    "driver_age": 35
  }'
```

Returns a page of car products, each with a `car_product.id`, a lead `vehicle` and a `rate_reference`. Take the one you want.

**2. Price that car product** for the full fleet.

```bash
curl -X POST https://contracts.travelify.io/api/v1/cars/7c2a9f10-.../availability \
  -H "Authorization: Bearer sk_test_example" \
  -H "Content-Type: application/json" \
  -d '{ "pickup_at": "2026-07-10T10:00:00Z", "dropoff_at": "2026-07-17T10:00:00Z", "driver_age": 35 }'
```

Each option carries a `rate_reference` and lists the prepaid extras you can add. Pick the vehicle the traveller chose.

**3. Quote** to lock the price.

```bash
curl -X POST https://contracts.travelify.io/api/v1/bookings/quote \
  -H "Authorization: Bearer sk_test_example" \
  -H "Content-Type: application/json" \
  -d '{ "rate_reference": "rate_ref_01J...redacted" }'
```

Returns a `quote_id` and an `expires_at`, plus the vehicle, the rental days and the policies. Carry the `quote_id` forward.

**4. Hold** the car with the driver and any prepaid extras.

```bash
curl -X POST https://contracts.travelify.io/api/v1/bookings/hold \
  -H "Authorization: Bearer sk_test_example" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 9d4f2a71-..." \
  -d '{
    "quote_id": "qte_01J8...",
    "external_ref": "YOUR-REF-20491",
    "lead_guest": { "title": "Mr", "first_name": "James", "last_name": "Park", "email": "j.park@example.com", "phone": "+447700900456" },
    "driver": { "first_name": "James", "last_name": "Park", "date_of_birth": "1991-04-22", "licence_number": "PARK9914223JX", "licence_country": "GB" },
    "extras": [ { "extra_id": "x_child", "quantity": 1 } ]
  }'
```

Returns a held booking with a `booking_id`, a `reference` and an `expires_at`. Take payment your side now.

**5. Confirm** once payment is taken.

```bash
curl -X POST https://contracts.travelify.io/api/v1/bookings/bkg_01J9.../confirm \
  -H "Authorization: Bearer sk_test_example" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 9d4f2a71-..." \
  -d '{ "payment_reference": "your_pmt_ref_456", "payment_status": "paid", "amount_paid": { "amount": 322.00, "currency": "EUR" } }'
```

Returns the booking with `status: "confirmed"`, on the client's calendar as read-only. The security deposit stays a pre-authorisation the traveller settles at the desk.

---

*This reference covers all eight product families: accommodation, car hire, transfers, attractions, flights, tours, coaches and trains. The endpoints are built and live, and the surface is hardened: scoped keys, per-key rate limiting, request validation, idempotent writes and structured request logging. Integrate against the sandbox with a test key, then switch the same calls to a live key.*
