# Cruise coach transfers: developer handover

**Travelgenix Contracting API.** Everything you need to build a cruise coach
search box and take a booking through it.

| | |
|---|---|
| Base URL | `https://contracts.travelify.io/api/v1` |
| Auth | `Authorization: Bearer <your key>` on every call |
| Spec version | **1.9.0** |
| OpenAPI | `https://contracts.travelify.io/contracting-api/travelgenix-contracting-api.openapi.yaml` |
| Full guide | `https://contracts.travelify.io/contracting-api/travelgenix-contracting-api.md` |
| This document | `https://contracts.travelify.io/contracting-api/cruise-coach-handover.md` |

The published guide is the source of truth. This document is the cruise-specific
subset, in the order you would code it.

---

## 1. What changed, and when

If you have already built against this, read this section first. Everything is
additive inside `/v1` except the one tightening called out below.

**1.8.4 (16 September 2026)**

- Every sailing now carries **`line`**, the cruise line, beside `ship`.
- `POST /coaches/availability` accepts **`line`** as a filter.
- The loose `sailing` filter now searches the line as well.
- **No `sailing_id` changed.** See rule 4.

**1.8.3 (15 September 2026)**

- `POST /coaches/availability` accepts **`ship`**, and with no date it lists that
  ship's sailings instead of searching one day.
- **Breaking-ish:** `ship` used to be a silent alias for `sailing`, a loose
  substring across the cruise name, ship and reference. It now matches the
  **ship field only**, exactly. If you were relying on the old loose behaviour,
  send `sailing` instead.

**1.8.2 and earlier** introduced `sailing_id` and the two-leg response. Unchanged.

---

## 2. The model

A cruise passenger buys a coach to the port and a coach home. In our data that is
**two separate coach routes**, an outbound and its paired return, and **two dated
departures**, one on each.

What links them is a **sailing**: a cruise named on the outbound departure. A
sailing carries five things.

| Field | Example | Notes |
|---|---|---|
| `line` | `Oceania Cruises` | The cruise line |
| `ship` | `Insignia` | |
| `name` | `Bountiful British Isles` | The cruise's own marketing name |
| `reference` | `IN2705` | The line's voyage reference, when the operator has loaded one |
| `return_date` | `2027-05-27` | The day the coach home runs. **Outbound side only** |

Each sailing also carries a **`sailing_id`**, an opaque string starting `sail_`.
That is the handle for everything downstream.

**Lead your UI with `line` and `ship`.** That is what a passenger recognises. Most
do not know the cruise's marketing name at all. `name` is still in every response
because it is the only thing that separates two cruises on the same ship on the
same day, but it is a disambiguator, not a label.

> `operator` on a route object is the **coach company**, not the cruise line. On
> the live data every route's `operator` reads `Port Cruise Transfers`. Never
> show it as the line. Use `line` off the sailing.

---

## 3. Build the search box

Three dropdowns, all from the one availability endpoint plus one catalogue call.
You can skip any middle step: both filters are optional and they combine.

### Step 1 — the cruise lines

```http
GET /v1/coaches?include=departures
Authorization: Bearer <key>
```

Flatten `departures[].sailings[]` across every route whose `direction` is
`"outbound"`, then take the distinct `line` values.

```json
{
  "data": [
    {
      "id": "…",
      "name": "Oceania cruise coach to Southampton (2027)",
      "direction": "outbound",
      "operator": "Port Cruise Transfers",
      "round_trip": true,
      "return_route_id": "…",
      "departures": [
        {
          "id": "b6894f6a-…",
          "departure_date": "2027-05-16",
          "status": "available",
          "bookable": true,
          "capacity": 53,
          "seats_left": 51,
          "price_from": { "amount": 45, "currency": "GBP" },
          "sailings": [
            {
              "sailing_id": "sail_eyJ2IjoxLCJrIjoic2FpbCJ9…",
              "line": "Oceania Cruises",
              "ship": "Insignia",
              "name": "Bountiful British Isles",
              "reference": "IN2705",
              "return_date": "2027-05-27"
            }
          ]
        }
      ]
    }
  ],
  "pagination": { "page": 1, "page_size": 20, "total": 10 }
}
```

`include=departures` is what embeds the dates and their sailings. Only the
current page is expanded, so page through if the catalogue grows.

### Step 2 — that line's ships

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

```json
{ "line": "MSC Cruises" }
```

Take the distinct `ship` values off the rows that come back. No date is needed
and none should be sent.

### Step 3 — that ship's sailing dates

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

```json
{
  "line": "Royal Caribbean International",
  "ship": "Freedom of the Seas",
  "passengers": { "adults": 2 }
}
```

One row per outbound coach date, in date order:

```json
{
  "data": [
    {
      "sailing_id": "sail_eyJ2IjoxLCJrIjoic2FpbCJ9…",
      "line": "Oceania Cruises",
      "ship": "Marina",
      "name": "Celtic Dreams",
      "reference": "OC270527",
      "departure_date": "2027-05-27",
      "return_date": "2027-06-08",
      "departure_id": "…",
      "status": "available",
      "seats_left": 43,
      "bookable": true,
      "unavailable_reason": null,
      "price_from": { "amount": 39, "currency": "GBP" },
      "route": {
        "id": "…",
        "name": "Oceania cruise coach to Southampton (2027)",
        "direction": "outbound",
        "operator": "Port Cruise Transfers",
        "terminal": "Southampton",
        "round_trip": true,
        "return_route_id": "…"
      }
    }
  ],
  "line": "Oceania Cruises",
  "ship": "Marina",
  "direction": "outbound",
  "pagination": { "page": 1, "page_size": 20, "total": 4 }
}
```

**Label each option with the return date**, for example
`27 May 2027, back 8 Jun`. Rule 1 below explains why this is not optional.

`passengers` is optional here. Send it and `bookable` accounts for whether there
are enough seats left for that party.

`pick_up` and `destination` work on this call too, so "this ship, boarding near
me" is a supported search:

```json
{
  "ship": "Freedom of the Seas",
  "pick_up": { "lat": 51.48, "lon": -3.18, "radius_km": 30 }
}
```

---

## 4. Price the chosen cruise, both legs

Send the `sailing_id` back to the same endpoint, with no date.

```http
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 **exactly two results**: that sailing's outbound coach and its inbound
coach. No other cruises, no other dates, no other routes. Each carries a `leg`
and each is priced on its own, so you can show two prices.

```json
{
  "data": [
    { "leg": "outbound", "name": "Oceania cruise coach to Southampton (2027)",
      "departure": { "departure_date": "2027-05-27", "seats_left": 43 },
      "price_from": { "amount": 39, "currency": "GBP" },
      "pick_up_points": [ { "pick_up": { "name": "Cardiff" }, "rate_reference": "coach_…" } ] },
    { "leg": "inbound", "name": "Oceania cruise coach from Southampton (2027)",
      "departure": { "departure_date": "2027-06-22", "seats_left": 48 },
      "price_from": { "amount": 39, "currency": "GBP" },
      "pick_up_points": [ { "pick_up": { "name": "Cardiff" }, "rate_reference": "coach_…" } ] }
  ],
  "sailing": {
    "sailing_id": "sail_…",
    "line": "Oceania Cruises",
    "name": "The Crowning Jewels",
    "ship": "Marina",
    "reference": "OC270527",
    "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 the drop-off side of the inbound leg, because it is the same
town at both ends.

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. A leg that exists but cannot
be sold comes back with a `reason` rather than disappearing, because you asked
for that cruise by name. The full set is in the next section.

**Take the `rate_reference` from the pick-up point you want on the OUTBOUND leg.**
You never send the inbound `rate_reference` anywhere.

---

## 5. Quote, hold, confirm

One booking, two legs. Full detail with every field and error is in the
"Return journeys end to end" section of the published guide. The short version:

```http
POST /v1/bookings/quote
```
```json
{
  "rate_reference": "coach_…",
  "passengers": [ { "passenger_type_id": "<adult_id>", "qty": 2 } ],
  "sailing_id": "sail_…"
}
```

The `sailing_id` is what adds the inbound leg. **Do not send `return_date` on a
cruise feeder**: a date names a coach, not a cruise, and it cannot tell two
cruises on one coach apart. `return_date` is still correct for ordinary
point-to-point return coaches.

Check `is_return` comes back `true`. `price.amount` is the combined total for
both legs. The quote holds the price for **30 minutes** and reserves no seats.

```http
POST /v1/bookings/hold
Idempotency-Key: <your uuid>
```
```json
{
  "quote_id": "qt_…",
  "lead_guest": { "first_name": "Jane", "last_name": "Doe", "email": "jane@example.com" },
  "guests": [ { "first_name": "Jane", "last_name": "Doe" }, { "first_name": "Sam", "last_name": "Doe" } ],
  "external_ref": "your-order-1234"
}
```

Both legs are re-priced from live data and both seat claims are atomic. If the
inbound is sold out the outbound claim is released and the whole hold is refused,
so you are never left holding half a round trip. The hold lasts **30 minutes**
and expires on its own with nothing to clean up.

Send `guests` with the whole party, lead passenger included. They appear on the
booking, the manifest and the voucher. Send only a `lead_guest` and only one name
is carried.

```http
POST /v1/bookings/{booking_id}/confirm
Idempotency-Key: <your uuid>
```
```json
{ "payment_reference": "pi_3Abc123", "payment_status": "paid", "amount_paid": 195 }
```

Take the payment before you confirm. Confirming twice is safe. Confirming an
expired hold returns `409` and releases the seats.

---

## 6. Rules that will bite you

**1. Line plus ship plus date is NOT unique.** One ship can run two cruises fed
by the same coach that come home on different days. Live today:

| Ship | Coach date | Cruises |
|---|---|---|
| Marina | 27 May 2027 | *Celtic Dreams* (home 8 Jun) and *The Crowning Jewels* (home 22 Jun) |
| Meraviglia | 6 Nov 2027 | *MSC Grand Voyages* home 19 Nov, and again home 20 Nov |

So never rebuild a selection from line, ship and date. Carry the `sailing_id`
through your whole flow. Show the return date on the option so a human can tell
the two apart, and fall back to `name` if you need a third discriminator.

**2. `sailing_id` is the identity. Treat it as opaque.** Store it whole, send it
back unchanged, do not parse it, do not construct one. It is evergreen, has no
expiry, and is re-resolved against your own account every time it is used.

**3. A `sailing_id` stops resolving if the operator edits that cruise.** It pins
the cruise name, ship, voyage reference and return date. If any of those change
you get `404 not_found` with *"This sailing is no longer on that coach. Search
the sailings again."* That is deliberate: the alternative is quietly selling a
different cruise. Handle it by re-running the search rather than failing hard.
Bookings already taken are unaffected, they hold departure ids rather than
sailing ids.

**4. `line` is outside the `sailing_id`.** A route already pins one cruise line,
so two cruises never differ by it alone. That means an operator naming or
correcting the line on an existing sailing does **not** invalidate ids you
already hold. Ids minted before 1.8.4 still work.

**5. Grey out, do not drop.** A closed, cancelled, past or sold-out date comes
back with `"bookable": false` and an `unavailable_reason`. It is returned on
purpose. A customer who can see that sailing on the cruise line's own website
must not find it simply missing from yours.

**6. `line` and `ship` are exact matches.** Case and surrounding whitespace are
ignored, but they are never substrings. `"Marin"` does not reach `"Marina"`, and
`"Oceania"` does not reach `"Oceania Cruises"`. Feed them from values you got
from us. For a free-text box use `sailing`, which matches loosely across the
name, line, ship and reference. Combine them and one sailing has to satisfy them
all.

**7. Outbound only in the sailing list.** A line or ship search returns outbound
runs, marked `"direction": "outbound"` on the response. The coach home arrives
with the `sailing_id`, so listing return runs too would offer every cruise twice
with no way to tell the entries apart.

**8. `line` can be null.** Nothing forces an operator to fill it in. Every live
sailing has one today, but code for null rather than assuming.

**9. No webhooks.** If you need to know a hold expired, poll
`GET /v1/bookings/{id}`.

---

## 7. Filter reference

`POST /v1/coaches/availability` request:

| Field | Type | Notes |
|---|---|---|
| `date` | date | Required **unless** `sailing_id`, `line` or `ship` is sent |
| `sailing_id` | string | One cruise, both legs. Overrides every filter below |
| `line` | string | Exact on the line. No date lists that line's sailings, with a date narrows it. Alias `cruise_line` |
| `ship` | string | Exact on the ship. Same two behaviours |
| `sailing` | string | Loose contains across name, line, ship and reference |
| `pick_up` | object | `{ lat, lon, radius_km }`, where the passenger boards |
| `destination` | object | `{ lat, lon, radius_km }`, where they alight |
| `passengers` | object | `{ adults, children, infants }` |
| `page`, `page_size` | integer | `page_size` max 100, default 20 |

Sailing option response, per row:

| Field | Type | Notes |
|---|---|---|
| `sailing_id` | string | Opaque. This is the thing you carry |
| `line` | string, nullable | Cruise line |
| `ship` | string, nullable | |
| `name` | string | Cruise's own name. Disambiguator, not a label |
| `reference` | string, nullable | The line's voyage reference |
| `departure_date` | date | The outbound coach date, which is the sailing date |
| `return_date` | date, nullable | The day the coach home runs |
| `departure_id` | uuid | |
| `status` | string | `available`, `cancelled` and so on |
| `seats_left` | integer, nullable | Null means no seat limit |
| `bookable` | boolean | False for closed, cancelled, past, sold out, or short of seats for the party sent |
| `unavailable_reason` | string, nullable | Why `bookable` is false. See below |
| `price_from` | money, nullable | Lead one-way fare on the outbound coach |
| `route` | object | `id`, `name`, `direction`, `operator`, `terminal`, `round_trip`, `return_route_id` |

The complete set of values for `unavailable_reason`, and for `reason` on a leg of
a `sailing_id` search:

| Value | Means |
|---|---|
| `insufficient_seats` | Fewer seats left than the party you sent |
| `sold_out` | No seats left at all |
| `within_cutoff` | Past the route's booking cut-off before boarding |
| `departed` | The date has passed, or the last pick-up has been passed today |
| `on_stop_sale` | The date is inside a closure the operator has set, or the departure is closed |
| `cancelled` | The departure is cancelled |
| `not_on_sale` | Today is outside the route's selling window |
| `on_request` | The departure is neither open nor closed, it needs asking about |

Treat anything you do not recognise as simply not bookable. New values can be
added inside `/v1`.

---

## 8. Errors

| Code | Meaning | Do |
|---|---|---|
| `400 invalid_request` | No `date`, `sailing_id`, `line` or `ship` sent | Fix the request |
| `404 not_found` | "This sailing is no longer on that coach." | Re-run the search, rule 3 |
| `422 unprocessable` | "That sailing is not on this coach departure." | The `sailing_id` and `rate_reference` are from different departures |
| `422 unprocessable` | "This coach route does not offer a return." | The route has no paired return |
| `422 unprocessable` | "The return coach has no Child fare." | The return route has no fare for a child: no Child fare, and its only fare is age-limited. A single fare with no age limit carries children, so you will not see this on such a route |
| `409 availability_changed` | Sold out, closed, past cut-off, or the price moved | Re-quote and show the new price |
| `401` / `403` | Key problem | Check the `Authorization` header |

---

## 9. Build checklist

- [ ] Dropdowns read `line` and `ship`, not `name`
- [ ] `operator` is never shown as the cruise line
- [ ] Every option label carries the return date
- [ ] `sailing_id` is carried from search through quote to hold, untouched
- [ ] `"bookable": false` rows render greyed, not filtered out
- [ ] `404 "no longer on that coach"` re-runs the search instead of erroring
- [ ] `null` handled on `line`, `ship`, `reference`, `return_date`, `seats_left`, `price_from`
- [ ] `return_date` is **not** sent on the quote for a cruise, only `sailing_id`
- [ ] `is_return` checked on the quote before taking money
- [ ] `guests` sent on the hold with the whole party
- [ ] `Idempotency-Key` or `external_ref` on hold and confirm
- [ ] Payment taken before confirm
- [ ] Pagination followed on `GET /coaches`
