# Flights: end-to-end test pack

**Travelgenix Contracting API.** Real ids on a real loaded contract, and the
exact calls to run a full search, quote, hold, confirm and cancel.

| | |
|---|---|
| Base URL | `https://contracts.travelify.io/api/v1` |
| Auth | `Authorization: Bearer <key>` on every call |
| Account | **Demo Account** |
| Spec | 1.9.0 |

Everything below was checked against the live database and priced through the
real engine before this was written. The figures are what you should get.

---

## Read this first: use a live key

**A `sk_test_` key is read-only.** It will search and price, then return
`403 forbidden` at `POST /bookings/hold`. There is no separate sandbox: a write
would consume real inventory, so test keys are blocked from writing rather than
pretending.

For a full end-to-end run, use the **Demo Account live key** (`sk_live_…`). The
Demo Account exists for this, so a booking there costs nobody anything. **Cancel
what you create** when you are done, with `POST /bookings/{id}/cancel`.

Do not run the write half of this against a client account.

---

## The contract

**Coral Wings (CW)**, supplier `Leisure Air Group`, GBP.

| | |
|---|---|
| `flight_id` (contract) | `f2000000-0000-4000-8000-000000000001` |
| Travel validity | **2026-01-01 to 2026-12-31** |
| Sell window | none, always on sale |
| Ticketing cutoff | 0 hours |
| Aircraft | Boeing 737-800, `G-CWAA`, **180 seats** (rows 1 to 30, columns A to F) |
| Seat holds right now | **0**, so every date is wide open |

### Routes

| Route id | Pair | Flight | Departs | Arrives | Runs |
|---|---|---|---|---|---|
| `f2000000-0000-4000-8000-0000000000d1` | **LGW → PMI** | CW201 | 07:00 | 10:30 | **every day** |
| `f2000000-0000-4000-8000-0000000000d2` | **PMI → LGW** | CW202 | 11:15 | 12:45 | **every day** |
| `f2000000-0000-4000-8000-0000000000d3` | LGW → FAO | CW301 | 14:00 | 16:50 | Mon, Thu, Sat |
| `f2000000-0000-4000-8000-0000000000d4` | FAO → LGW | CW302 | 17:40 | 18:30 | Mon, Thu, Sat |

**Leg ids**, which you need to pick seats (see step 4):

| Leg id | Flight | Pair |
|---|---|---|
| `6e731620-b31a-4757-a865-21a854d3b413` | CW201 | LGW → PMI |
| `d0ccebb7-9293-4603-8bb2-d936c8bd0e17` | CW202 | PMI → LGW |

You do not have to hard-code these. They come back on every segment of a
`seatmap` response, which is where you would read them in a real integration.

**Use LGW to PMI for your happy path.** It flies every day, so any date inside
the validity window works and you will never chase a phantom failure caused by
picking a day the aircraft does not fly.

### Fares, LGW ↔ PMI

Net rates with a 15% markup, except the infant which is gross.

| Passenger | Base | Tax | Surcharge | **Sell, per leg** |
|---|---|---|---|---|
| Adult | 80 | 25 | 10 | **127.00** |
| Child (2 to 11) | 60 | 25 | 10 | **104.00** |
| Infant (under 2) | 20 | 8 | 0 | **28.00** |

### Extras

| Extra id | Name | Basis | Price |
|---|---|---|---|
| `81b4ca6f-e337-4373-8477-e26e1393c1c2` | Checked bag (23kg) | per passenger | 30.00, max 3 |
| `1859eb52-94ec-437e-a1c9-07cc1179b511` | Extra legroom seat | per segment | 18.00 |

Paid seats are on the seat map itself: rows 1, 14 and 15 are **18.00**, rows 2
and 3 are **12.00**, every other seat is free.

---

## The happy path

Party of four: **2 adults, 1 child, 1 infant**, LGW to PMI on **12 November
2026**, home **19 November 2026**.

### Step 1 — find it

```http
POST /v1/flights/availability/search
Authorization: Bearer <key>
Content-Type: application/json
```

```json
{
  "origin": "LGW",
  "dest": "PMI",
  "date": "2026-11-12",
  "return_date": "2026-11-19",
  "adults": 2,
  "children": 1,
  "infants": 1
}
```

`origin`, `dest`, `date` and at least one adult are required.

**City codes work here too.** `LON` searches every London airport, so
`"origin": "LON"` finds this Gatwick flight without you knowing which airport it
uses. Results always name the real airport (`LGW`), never the city code. Send
`"airport_only": true` to take a code literally.

**Expect one result, one option.** The shape, trimmed:

```json
{
  "rate_reference": "flt_eyJ2IjoxLCJrIjoiZmxpZ2h0…",
  "trip_type": "return",
  "cabin": "Y",
  "cabin_name": "Economy",
  "outbound": {
    "route_id": "f2000000-0000-4000-8000-0000000000d1",
    "trip_kind": "direct",
    "stops": 0,
    "depart": { "date": "2026-11-12", "time": "07:00", "airport": { "iata": "LGW", "name": "London Gatwick" } },
    "arrive": { "date": "2026-11-12", "time": "10:30", "airport": { "iata": "PMI", "name": "Palma de Mallorca" } },
    "segments": [ { "flight_number": "CW201", "depart": { "at": "2026-11-12T07:00:00.000Z" }, "duration_min": 150 } ],
    "fares": [
      { "passenger_type": "adult",  "count": 2, "price_per_passenger": { "amount": 127 }, "price": { "amount": 254 } },
      { "passenger_type": "child",  "count": 1, "price_per_passenger": { "amount": 104 }, "price": { "amount": 104 } },
      { "passenger_type": "infant", "count": 1, "price_per_passenger": { "amount": 28 },  "price": { "amount": 28 } }
    ]
  },
  "return": { "route_id": "f2000000-0000-4000-8000-0000000000d2", "depart": { "date": "2026-11-19", "time": "11:15" } },
  "price": { "amount": 772, "currency": "GBP" },
  "available": true
}
```

**Check you got `772.00`.** That is (127 x 2 + 104 + 28) x 2 legs. If you get a
different number, stop and tell us before going further.

`rate_reference` is opaque. Store it whole and send it back unchanged.

### Step 2 — the seat map, optional

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

```json
{ "rate_reference": "flt_…" }
```

One grid per flown segment. Every seat carries its fee and an `available` flag.
You can also ask by `{ "route_id": "…", "date": "2026-11-12" }`.

### Step 3 — quote

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

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

The passenger counts must match what you searched. At least one adult, or you
get `422`.

The quote holds the price for **30 minutes** and reserves **no** seats. Store
`quote_id` whole.

### Step 4 — hold

```http
POST /v1/bookings/hold
Idempotency-Key: <your own uuid>
```

```json
{
  "quote_id": "qt_…",
  "lead_guest": { "first_name": "Jane", "last_name": "Doe", "email": "jane@example.com" },
  "guests": [
    { "type": "adult", "first_name": "Jane", "last_name": "Doe", "date_of_birth": "1985-04-12", "passport_number": "123456789", "passport_issuing_country": "GBR", "passport_expiry": "2031-04-12" },
    { "type": "adult", "first_name": "John", "last_name": "Doe", "date_of_birth": "1983-09-30", "passport_number": "234567891", "passport_issuing_country": "GBR", "passport_expiry": "2030-09-30" },
    { "type": "child", "first_name": "Amy", "last_name": "Doe", "age": 7, "date_of_birth": "2019-06-14", "passport_number": "345678912", "passport_issuing_country": "GBR", "passport_expiry": "2029-06-14" },
    { "type": "infant", "first_name": "Ben", "last_name": "Doe", "age": 1, "date_of_birth": "2025-08-02", "passport_number": "456789123", "passport_issuing_country": "GBR", "passport_expiry": "2030-08-02" }
  ],
  "external_ref": "flight-test-001",
  "seats": [
    { "leg_id": "6e731620-b31a-4757-a865-21a854d3b413", "seat": "12A" },
    { "leg_id": "6e731620-b31a-4757-a865-21a854d3b413", "seat": "12B" },
    { "leg_id": "6e731620-b31a-4757-a865-21a854d3b413", "seat": "12C" },
    { "leg_id": "d0ccebb7-9293-4603-8bb2-d936c8bd0e17", "seat": "12A" },
    { "leg_id": "d0ccebb7-9293-4603-8bb2-d936c8bd0e17", "seat": "12B" },
    { "leg_id": "d0ccebb7-9293-4603-8bb2-d936c8bd0e17", "seat": "12C" }
  ],
  "extras": [ { "extra_id": "81b4ca6f-e337-4373-8477-e26e1393c1c2", "quantity": 1 } ]
}
```

Things to know here, because flights differ from the other products:

- **A flight seat is an object, not a string.** It is
  `{ "leg_id": "…", "seat": "12A" }`, and you name a seat **per leg**. This is
  different from coaches, where `seats` is a plain array of codes. `passenger_ref`
  is accepted as an optional third field if you want to tie a seat to a name.
- **Seats are what hold the aircraft.** Every adult and child gets one. Send
  `seats` to choose, or leave it out entirely and they are assigned for you.
  **Infants do not get a seat**, they sit on a lap, so a party of 2 adults + 1
  child + 1 infant takes **three** seats on each leg, six in total on a return.
- **Rows 1, 2, 3, 14 and 15 cost extra.** Pick row 12 for a free hold, or 1A to
  check the fee lands on the total and appears as its own `breakdown` line.
- **`quantity` on an extra is per unit of its basis, not for the party.** The
  checked bag is `per_passenger`, so `quantity: 1` on this party of four is
  4 x 30 = **120.00**, and `quantity: 2` would be two bags each, 240.00. The
  legroom extra is `per_segment`, so it multiplies by the number of flown
  segments instead.
- `extras` are optional and prepaid only.
- Both legs are re-priced from live data. A penny of movement gives you `409` and
  you re-quote.
- The hold lasts **30 minutes** and expires on its own. Nothing to clean up.

You get the booking back with `status: "held"`, a `reference` like `TGC-…` and a
`breakdown` with a line per passenger type per leg, plus any seat and extra
lines.

### Step 5 — confirm

```http
POST /v1/bookings/{booking_id}/confirm
Idempotency-Key: <your own uuid>
```

```json
{ "payment_reference": "test-payment-001", "payment_status": "paid", "amount_paid": 772 }
```

We never touch money. `payment_reference` is for your own reconciliation.
Confirming twice is safe. Confirming an expired hold returns `409` and releases
the seats.

### Step 6 — cancel, please

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

Puts the seats back. **Do this at the end of every test run** so the demo data
stays clean. Cancelling twice is harmless.

---

## Negative tests worth running

| What to send | What you should get |
|---|---|
| `origin: "LGW", dest: "FAO", date: "2026-11-11"` (a Wednesday) | `200` with an **empty** `data` array. CW301 flies Mon, Thu and Sat only |
| `origin: "LGW", dest: "FAO", date: "2026-11-09"` (a Monday) | `200` with one option, so you can see the difference is the day and not your request |
| `date: "2027-01-05"` | `200`, empty. Outside the contract's travel validity, which ends 2026-12-31 |
| `date` in the past | `200`, empty |
| `origin: "LON"`, `dest: "PMI"` | The same result as `LGW`, because LON covers Gatwick. The result still says `LGW` |
| `origin: "LON"`, `airport_only: true` | `200`, empty. LON is taken literally and no contract has an airport called LON |
| `adults: 0` | `400 invalid_request`, "At least one adult is required." |
| No `origin` or no `dest` | `400 invalid_request` |
| `date: "12/11/2026"` | `400 invalid_request`, "date must be YYYY-MM-DD." |
| A `sk_test_` key at `POST /bookings/hold` | `403 forbidden`. Expected, see the top of this page |
| `seats: ["12A"]` as plain strings | `409`, "Each seat selection needs leg_id and seat." |
| A seat from a leg not in the booking | `409`, "Seat leg … is not part of this booking." |
| The same seat twice, concurrently | One `200`, one `409 availability_changed`. Seats can never be double sold |
| Reuse a `quote_id` after 30 minutes | `409 availability_changed`. Re-quote |

---

## Other flight contracts on Demo Account

Use these if you want a longer route, a connection or a seasonal fare.

| Contract id | Airline | Routes | Notes |
|---|---|---|---|
| `f1000000-0000-4000-8000-000000000001` | Skyline Consolidators | LHR ↔ DXB, DXB → MLE, **LHR → MLE connecting** | Has one season and a **two-segment connecting** route, good for testing a journey with a stop |
| `f3000000-0000-4000-8000-000000000001` | Transatlantic Fares Ltd | LHR ↔ JFK | Long haul, one season, overnight eastbound (AZ101 departs 19:00, arrives 07:00) |

Both are valid 2026-01-01 to 2026-12-31 with no sell window, so they behave the
same way. Watch the days of the week: LHR → DXB runs Sun, Mon, Wed and Fri, and
LHR → MLE runs Mon, Wed and Fri.

---

## Endpoints you have

| Method | Path | Use |
|---|---|---|
| GET | `/v1/flights` | List the deals. Add `include=all` to embed detail |
| GET | `/v1/flights/{flight_id}` | One deal in full |
| POST | `/v1/flights/search` | Catalogue search, unpriced |
| POST | `/v1/flights/availability/search` | **Priced, across deals. Start here** |
| POST | `/v1/flights/{flight_id}/availability` | Priced, one deal |
| POST | `/v1/flights/{flight_id}/seatmap` | The seat grid |
| POST | `/v1/bookings/quote` | Lock the price for 30 minutes |
| POST | `/v1/bookings/hold` | Claim the seats for 30 minutes |
| POST | `/v1/bookings/{id}/confirm` | Commit |
| GET | `/v1/bookings/{id}` | Read it back. **There are no webhooks, so poll** |
| POST | `/v1/bookings/{id}/cancel` | Release it |

Full reference: `https://contracts.travelify.io/contracting-api/travelgenix-contracting-api.md`
