> ## Documentation Index
> Fetch the complete documentation index at: https://docs.dev.gojinko.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Testing in sandbox

> Use Jinko Test Air, a fake airline in sandbox, to test every step of a flight integration and to trigger failures on purpose.

Sandbox runs against the test systems of real airlines and booking providers. Those systems are often slow, change without notice, and fail for reasons unrelated to your code. A failure path is hard to reach on purpose: you cannot ask a real airline to sell out a seat after payment.

**Jinko Test Air** is a fake airline that exists only in sandbox. It always behaves the same way, and each flight number triggers one documented behaviour, so you can test the normal flow and every failure path on demand. It appears only in live flight search (`flight_search` in search mode) on sandbox. It never appears in production, and never in cached search: flight calendar, destination discovery and price monitoring do not return it.

<Note>
  Use the sandbox URLs and a sandbox key, as described in [Sandbox keys](/authentication/api-keys#sandbox-keys): `https://api.sandbox.gojinko.com` for REST, the SDK and the CLI (`--env sandbox`), and `https://mcp.builders.sandbox.gojinko.com/mcp` for the Builder MCP. To pay without a browser, follow the [sandbox walkthrough](/guides/sandbox-walkthrough).
</Note>

## Find the test airline

| Item | Value |
| - | - |
| Airline code | `ZZ` (an IATA placeholder code, never assigned to a real carrier) |
| Airline name | Jinko Test Air |
| Dates | Any departure date from tomorrow (UTC) onwards. The schedule is the same every day. |
| Trip types | One-way and round trip. Multi-city and open-jaw searches return no Jinko Test Air flights. |
| Stops | Every flight is non-stop. |
| Passengers | Adults, children and lap infants. |

Jinko Test Air flies both directions of seven city pairs, 14 routes in total. The city codes `NYC`, `LON` and `PAR` resolve to JFK, LHR and CDG.

| Pair | First direction | Block time | Reverse direction | Block time |
| - | - | - | - | - |
| JFK–LHR | JFK → LHR | 7 h 10 | LHR → JFK | 7 h 55 |
| CDG–JFK | CDG → JFK | 8 h 15 | JFK → CDG | 7 h 25 |
| LHR–CDG | LHR → CDG | 1 h 20 | CDG → LHR | 1 h 25 |
| LAX–SFO | LAX → SFO | 1 h 35 | SFO → LAX | 1 h 30 |
| SIN–BKK | SIN → BKK | 2 h 20 | BKK → SIN | 2 h 15 |
| DXB–LHR | DXB → LHR | 7 h 50 | LHR → DXB | 7 h 10 |
| SIN–LHR | SIN → LHR | 13 h 50 | LHR → SIN | 13 h 30 |

Each search on one of these routes returns 10 flights × 4 brands = 40 fares from Jinko Test Air, one-way or round trip. A round trip always pairs an outbound and a return flight of the same scenario (for example ZZ201 out, ZZ202 back; ZZ1001 out, ZZ1002 back).

The 40 fares arrive as 10 offers, one per flight, each with 4 fares.

On a Jinko Test Air segment, `airline` and `operating_carrier` are `ZZ` and `flight_number` is the numeric part (`101` for ZZ101). To keep only the test airline in the results, send `include_carriers: ["ZZ"]` and check that `include_carriers_widened` is not `true`: when no Jinko Test Air itinerary matches the route, date and other filters you sent (a route it does not fly, today's date, or a time window, cabin, stop or price filter that excludes all ten flights), the search widens to other carriers and sets `include_carriers_widened: true`.

```bash theme={null}
curl -sS -X POST "https://api.sandbox.gojinko.com/v1/flight_search" \
  -H "X-API-Key: $JINKO_SANDBOX_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "origin": "JFK",
    "destination": "LHR",
    "departure_date": "'"$DEPARTURE_DATE"'",
    "trip_type": "oneway",
    "include_carriers": ["ZZ"],
    "adults": 1,
    "currency": "USD"
  }'
```

## Flights and scenarios

The flight number is `ZZ`, then the scenario, then one digit for the city pair, then one digit for the direction. Read it from the right:

| Part | Meaning |
| - | - |
| Last digit | Direction: `1` for the first direction, `2` for the reverse |
| Second-to-last digit | City pair, 0 to 6 |
| Everything before those two digits | Scenario, 1 to 10 |

Scenarios 1 to 9 give three-digit flight numbers (ZZ101 to ZZ962) and scenario 10 gives four-digit ones (ZZ1001 to ZZ1062). Any other number, for example ZZ1101, is not a Jinko Test Air flight.

| Pair digit | Pair | Direction digit 1 | Direction digit 2 |
| - | - | - | - |
| 0 | JFK–LHR | JFK → LHR | LHR → JFK |
| 1 | CDG–JFK | CDG → JFK | JFK → CDG |
| 2 | LHR–CDG | LHR → CDG | CDG → LHR |
| 3 | LAX–SFO | LAX → SFO | SFO → LAX |
| 4 | SIN–BKK | SIN → BKK | BKK → SIN |
| 5 | DXB–LHR | DXB → LHR | LHR → DXB |
| 6 | SIN–LHR | SIN → LHR | LHR → SIN |

So ZZ101 is JFK → LHR in the normal scenario, ZZ102 is LHR → JFK, ZZ111 is CDG → JFK, ZZ362 is LHR → SIN in the sold-out scenario, and ZZ1001 is JFK → LHR in scenario 10.

| Flight | Departure (local time at origin) | Scenario |
| - | - | - |
| ZZ1xx | 06:00 | Normal |
| ZZ2xx | 08:00 | Price changes at checkout |
| ZZ3xx | 10:00 | Sold out after payment |
| ZZ4xx | 12:00 | Ticketing fails after payment |
| ZZ5xx | 14:00 | Ticketing delayed |
| ZZ6xx | 16:00 | Refund needs manual handling |
| ZZ7xx | 18:00 | Exchange not permitted |
| ZZ8xx | 20:00 | Offer no longer available at checkout |
| ZZ9xx | 22:00 | Booking confirms after about 90 seconds |
| ZZ10xx | 05:00 | Booking refused after about 90 seconds |

Arrival is departure plus block time, in local time at the destination.

### What you observe in each scenario

Steps not listed behave as in the normal scenario.

| Scenario | Search | Checkout | After payment | Refund and exchange |
| - | - | - | - | - |
| Normal (ZZ1xx) | Listed with the prices below | The quoted total equals the search price | `fulfillment.status` reaches `completed`. `bookings[]` carries a 6-character airline reference. Ticket numbers start with `999`. | As described in [Refund, void and exchange](#refund-void-and-exchange) |
| Price change (ZZ2xx) | Listed with the normal prices. A price check (`flight_search` with `offer_token`, or `price_check` on MCP) still returns the normal price. | The quote is 110 % of the search price: every base fare and tax is raised by 10 %, rounded per amount. The item carries `price_changed: true`, `original_price` (the search price), `price` (the new price) and `price_change` (`original`, `current`, `delta`). `total_amount` is the new price. | The booking completes and the customer pays the new price | Both fares in an exchange use the 110 % price |
| Sold out (ZZ3xx) | Listed | Succeeds | The airline refuses the booking. `fulfillment.status` ends `failed` with `failure_reason: "offer_not_available"`, `bookings[]` stays empty, and the payment authorization is released. | Not reachable |
| Ticketing fails (ZZ4xx) | Listed | Succeeds | The airline creates the booking but never issues tickets. `fulfillment.status` ends `failed` with no `failure_reason`, and the payment authorization is released. `bookings[]` keeps the airline's 6-character `pnr` with no ticket. | Not reachable |
| Ticketing delayed (ZZ5xx) | Listed | Succeeds | Tickets are issued 2 minutes after the booking is created. `fulfillment.status` is neither `completed` nor `failed` for a few minutes, then reaches `completed`. Treat any other value as in progress. | Normal once ticketed |
| Refund manual (ZZ6xx) | Listed | Succeeds | Completes | The refund preview looks normal. After commit, the operation never finishes on its own: the tickets stay `ACTIVE` and no money moves. A void on this flight is handled the same way. |
| Exchange not permitted (ZZ7xx) | Listed | Succeeds | Completes | Refunds and voids work. Exchange shop answers `support_level: "UNSUPPORTED"` with the reason in `warnings`. |
| Offer no longer available (ZZ8xx) | Listed with the normal prices. A price check may still return the fare. | Fails with HTTP 410 and `error.code: "OFFER_EXPIRED"`. `get_trip` then shows `quote.status: "failed"` with `quote.failure_reason: "offer_expired"`. No payable quote exists. | Not reachable: nothing is charged | Not reachable |
| Asynchronous booking that confirms (ZZ9xx) | Listed | Succeeds | For about 90 seconds the airline holds the booking as pending: `fulfillment.status` stays non-terminal, and the booking's `pnr` and confirmation number carry a provisional reference that starts with `zzo_` (44 to 49 characters). The airline then confirms and tickets it: `fulfillment.status` reaches `completed`, and the provisional reference is replaced by the 6-character airline reference. Never treat a `zzo_` value as an airline reference. The fields are `bookings[].pnr` and the confirmation number on `get_booking`. | Normal once ticketed |
| Asynchronous booking that fails (ZZ10xx) | Listed | Succeeds | The booking stays pending for about 90 seconds, then the airline refuses it. `fulfillment.status` ends `failed` with no `failure_reason`, and the payment authorization is released. No airline reference or ticket is ever issued: the provisional `zzo_` reference in the booking's `pnr` and confirmation number stays after the failure. Never treat it as an airline reference. | Not reachable |

## Brands

Every flight is sold in four brands. In search results each offer is one flight, and its `fares[]` are the four brands in this order, cheapest first: ZZ Basic, ZZ Standard, ZZ Flex, ZZ Business. `brand_name` shows the cabin (`Economy` or `Business`), not the brand, so tell the three economy brands apart by position, price, or `is_refundable`, `is_changeable` and `checked_bag_included`:

| Brand | `is_refundable` | `is_changeable` | `checked_bag_included` |
| - | - | - | - |
| ZZ Basic | `false` | `false` | `false` |
| ZZ Standard | `false` | `true` | `true` |
| ZZ Flex | `true` | `true` | `true` |
| ZZ Business | `true` | `true` | `true` |

| Brand | Cabin | Refund | Change | Void | Checked baggage |
| - | - | - | - | - | - |
| ZZ Basic | Economy | Not permitted | Not permitted | No | None |
| ZZ Standard | Economy | Not permitted | Fee 25 USD | Within 24 h of ticketing | 1 × 23 kg |
| ZZ Flex | Economy | Penalty 60 USD | Fee 30 USD | No | 1 × 23 kg |
| ZZ Business | Business | No penalty | No fee | Within 24 h of ticketing | 2 × 32 kg |

Every brand includes one 7 kg carry-on bag. Fees and penalties are defined in USD and charged once per booking, not per passenger.

## Prices

Base fare and tax per adult, one way, in USD:

| Leg | Base | Tax | Leg | Base | Tax |
| - | - | - | - | - | - |
| JFK → LHR | 420 | 180 | LHR → JFK | 430 | 160 |
| CDG → JFK | 410 | 170 | JFK → CDG | 415 | 175 |
| LHR → CDG | 90 | 45 | CDG → LHR | 95 | 42 |
| LAX → SFO | 80 | 25 | SFO → LAX | 82 | 25 |
| SIN → BKK | 70 | 35 | BKK → SIN | 72 | 33 |
| DXB → LHR | 330 | 150 | LHR → DXB | 340 | 140 |
| SIN → LHR | 520 | 210 | LHR → SIN | 530 | 200 |

For each passenger:

* **Base** = sum of the legs' base fares × brand factor × passenger factor.
* **Tax** = sum of the legs' taxes. It is the same for every passenger, infants included.

| Brand | Factor | | Passenger | Factor |
| - | - | - | - | - |
| ZZ Basic | 1.00 | | Adult | 100 % |
| ZZ Standard | 1.45 | | Child | 75 % |
| ZZ Flex | 2.10 | | Lap infant | 10 % |
| ZZ Business | 3.50 | | | |

All Jinko Test Air amounts are defined in USD and rounded to the cent. A search in another `currency` returns amounts that Jinko converts from USD at its current exchange rate, so they change from day to day. Search with `currency: "USD"` when you assert exact amounts.

**Example.** ZZ Standard, JFK → LHR, one adult:

| | USD |
| - | - |
| Base (420 × 1.45) | 609.00 |
| Tax | 180.00 |
| Total | 789.00 |
| ZZ Standard change fee | 25.00 |

A round trip adds the two legs: ZZ Basic JFK → LHR → JFK for one adult is (420 + 430) + (180 + 160) = 1190.00 USD.

The checkout total equals the airline total: ZZ101 ZZ Standard checks out at 789.00 USD.

## Extra bags, seats and meals

Jinko Test Air sells extra bags, seats and meals on every flight and brand. After the trip is quoted, list them with `GET /v1/trip/{trip_id}/ancillaries` (or read `items[].available_ancillaries` on the trip). While the trip is still being priced that call answers HTTP 202 with `status: "pricing"`; poll the same URL after `Retry-After` until it answers 200. Select with `POST /v1/select_ancillaries`. Each call replaces the item's whole selection, so send every bag, seat and meal you want in the same call; a second call with only a seat drops the bag. A bag or meal selection is `{offer_id, pax_ref_id, quantity: 1}`, with `pax_ref_id` the traveler's `pax_<N>`. A seat selection is one per traveler per flight and also needs `seat_number`, picked from the offer's `seat_map.seats` with `available: true`, and `segment_ref_ids` with the offer's one segment, for example `{offer_id: "seat-seg-1", pax_ref_id: "pax_1", segment_ref_ids: ["seg-1"], seat_number: "14F", quantity: 1}`. The item's `total_with_ancillaries` is the fare plus the selections, and that is the amount charged at checkout.

| What | Offer | Price (USD) | Sold |
| - | - | - | - |
| Extra bag 23 kg | `bag-x1-out` (outbound), `bag-x1-ret` (return) | 40.00 | per traveler, per journey |
| Second extra bag 23 kg | `bag-x2-out`, `bag-x2-ret` | 60.00 | per traveler, per journey |
| Oversize bag 32 kg | `bag-ov-out`, `bag-ov-ret` | 80.00 | per traveler, per journey; **always fails after ticketing** |
| Seat | `seat-seg-1`, `seat-seg-2` (one seat map per flight) | economy row 12: 35.00; rows 10 to 19: 25.00 window or aisle, 15.00 middle; rows 20 to 30: free; business: free | per traveler, per flight; not for lap infants; **row 13 always fails after ticketing** |
| Vegetarian meal | `meal-vg-seg-1`, `meal-vg-seg-2` | 15.00 | per traveler, per flight |
| Kosher meal | `meal-ks-seg-1`, `meal-ks-seg-2` | 15.00 | per traveler, per flight |
| Child meal | `meal-ch-seg-1`, `meal-ch-seg-2` | 10.00 | per traveler, per flight; children only |

Each bag and meal can be selected once per traveler. Labels name the journey ("Extra bag 23 kg (outbound)") so the two bags of a round trip are told apart. Seat maps list only the seats still available, free and paid: some seats are taken, always the same ones for a given flight and date, and taken seats are left out of the map.

After ticketing, each paid extra is issued on its own document, an EMD whose number starts with `999`, separate from the ticket. What happens next:

* **A selection the airline cannot honour never stops the booking.** Examples: a second unit of a bag, a child meal for an adult, a seat for a lap infant, or a seat another traveler already holds. The flight is booked and ticketed; that extra alone is marked failed with the airline's reason.
* **Row 13 seat:** the ticket is issued, the seat fails with "airline refused the seat", and Jinko refunds the seat's price.
* **Oversize bag:** the ticket is issued and the bag fails with "airline could not add the oversize bag".&#x20;
* **Void of the booking (ZZ Standard or ZZ Business within 24 hours):** the extras' EMDs are voided with the tickets and the whole charge comes back, extras included. On the refund preview every EMD document carries `recoverable: true` and the quote carries `ancillary_recoverable: true`; after commit each EMD reads `VOIDED`. This is the agency void of a whole order: a void that does not cover every ticket and EMD of the booking is not offered.
* **Refund of the booking (ZZ Flex, or ZZ Business after 24 hours):** only the flight is refunded. The preview marks each EMD `recoverable: false` and the quote `ancillary_recoverable: false`; the EMDs stay `ACTIVE`, and the refund excludes what was paid for them.
* **Exchange:** the extras move to the new booking with the same EMD numbers. A seat moves only if the same seat is free on the new flight; otherwise it is marked failed. An exchange from an economy brand to ZZ Business is one example.

Bags and seats are selectable in sandbox now, on the REST API and the Builder MCP. Meals are not listed by `GET /v1/trip/{trip_id}/ancillaries` yet, so you cannot discover them; they are being added.

## Refund, void and exchange

Start every servicing flow with `get_booking`, then follow the [refund flow](/tools/flight-refund) or the [exchange flow](/tools/flight-exchange). Tickets are issued right after payment, so a new booking is inside its 24-hour void window.

### Refund and void

| Brand | Within 24 h of ticketing | After 24 h |
| - | - | - |
| ZZ Basic | Neither refundable nor voidable | Same |
| ZZ Standard | Voidable: the whole charge is returned, extras included | Neither refundable nor voidable |
| ZZ Flex | Refundable, penalty 60 USD | Same |
| ZZ Business | Voidable: the whole charge is returned, extras included | Refundable, no penalty |

What you see on the refund calls:

| Case | `flight_refund_preview` | `flight_refund_status` after commit |
| - | - | - |
| Void | `operation_kind: "void"`, `support_level: "AUTO_VOID"`, `refund` = the whole charge, extras included, `penalty` zero | `state: "succeeded"`, every ticket and EMD `VOIDED`, `money.vehicle_state` `paid` or `payout_settled` |
| Refund | `operation_kind: "cancel"`, `support_level: "AUTO"`, `refund` = the fare paid less the penalty | `state: "succeeded"`, every ticket `REFUNDED`, `money.vehicle_state` `paid` or `payout_settled` |
| Neither | Refused with HTTP 409, `code: "not_cancellable"` | Not reachable |
| Already refunded or voided | Refused with HTTP 409, `code: "not_cancellable"` | Not reachable |

In the preview, `refund` and `penalty` are objects: read the exact figure from `refund.amount_money.value` and `penalty.amount_money.value`, integers in minor units (1002.00 USD is `value: 100200` with `decimal_places: 2`).

A void retires the extras' EMDs with the tickets, so it returns everything charged. A refund covers the flight fare only: paid bags, seats and meals keep their EMDs and their price is not returned (see "Extra bags, seats and meals" above), so on a refunded booking with extras the refund is smaller than the amount charged.

You cannot ask for a void or a refund: the platform picks the one the table gives. ZZ Business inside 24 hours is voided, not refunded.

### Exchange

Exchange shop offers the booked flight number on the new date(s), once per brand. It leaves out the booked brand on the booked dates, because that would change nothing. ZZ Basic and ZZ7xx bookings get `support_level: "UNSUPPORTED"` with the reason in `warnings`.

`get_booking` reports the same before you shop: on a ZZ7xx booking `can_exchange` is `false` and `exchange_support_level` is `"UNSUPPORTED"`; on an exchangeable brand they are `true` and `"AUTO"`.

Exchange price returns `payment_outcome` and the amounts. Both fares are the booked prices of all passengers:

```
difference = new fare − old fare + change fee
```

The change fee is the booked brand's, and it is waived when you keep the same brand. Prices do not depend on the date.

| Difference | Outcome | Amount field |
| - | - | - |
| Greater than zero | `ADD_COLLECT`: the customer pays more | `total_due` = difference |
| Less than zero | `REFUND`: the customer gets money back | `total_refund` = − difference |
| Zero | `EVEN`: nothing moves | none |

All three outcomes can be priced on sandbox. Committing an `EVEN` exchange is tested end to end; committing an `ADD_COLLECT` or `REFUND` exchange is not covered by this guide yet.

One shop on a new date therefore reaches every outcome. Moving to another date in the same brand is always even. From ZZ Standard or ZZ Flex, a dearer brand means paying more and a cheaper brand means a refund. From ZZ Business, every other brand means a refund.

Examples, JFK → LHR, one adult, USD. The amounts below are shown in dollars; on the wire `total_due` and `total_refund` are money objects whose `value` is in minor units (298.00 is `value: 29800`).

| From | To | Outcome |
| - | - | - |
| ZZ Standard (789.00) | ZZ Flex (1062.00) | `total_due` 298.00 (273.00 + 25.00 fee) |
| ZZ Standard (789.00) | ZZ Basic (600.00) | `total_refund` 164.00 (189.00 − 25.00 fee) |
| ZZ Flex (1062.00) | ZZ Standard (789.00) | `total_refund` 243.00 (273.00 − 30.00 fee) |
| ZZ Standard | ZZ Standard, new date | Even |

After commit, the exchange is confirmed at once. `new_ticket_numbers` lists new tickets, and `airline_locator` keeps the same airline reference. Keep using the same `booking_ref` and `item_id`. A booking that is not ticketed, already refunded, has a refund in progress or was already exchanged cannot be exchanged.

On a ZZ Basic or ZZ7xx booking, exchange shop returns no offers, so there is nothing to price or commit.

### ZZ6xx and ZZ7xx

* **ZZ6xx, refund needs manual handling.** The preview is the brand's normal answer, so you can commit as usual. The airline accepts the request but a person has to settle it: `flight_refund_status` stays non-terminal, every ticket stays `ACTIVE`, and no money moves. The test airline never settles it. Use it to test that your integration keeps polling and does not tell the customer the refund failed or completed. On sandbox the operation reads `state: "in_progress"` for about 20 minutes after commit, then `state: "attention_required"`, waiting for a Jinko operator.
* **ZZ7xx, exchange not permitted.** Every brand, ZZ Business included, refuses exchanges: exchange shop answers `support_level: "UNSUPPORTED"` with the reason in `warnings`. Refunds and voids follow the brand table.

## Recipes

Every recipe searches JFK → LHR for one adult in USD, with `include_carriers: ["ZZ"]`, on a date from tomorrow. "Book" means: add the fare's `trip_item_token` to a trip with travelers and contact, call `checkout`, pay (see the [sandbox walkthrough](/guides/sandbox-walkthrough)), and poll `get_trip`.

Amounts in the recipes are written in dollars for readability. On the wire every amount is a money object: assert the integer in minor units, from the `*_money.value` field where the response has one (for example `total_amount_money.value` 78900 for 789.00 USD), as the [Prices](#prices) and refund sections show.

The asynchronous scenarios (ZZ9xx, ZZ10xx) take about 90 seconds at the airline, plus the interval at which Jinko polls the airline. Allow several minutes before your test gives up.

**Normal: book, then refund with a penalty (ZZ101, ZZ Flex)**

1. Search. Assert the ZZ Flex fare on flight `101` costs 1062.00 USD (882.00 + 180.00).
2. Book. Assert `fulfillment.status` is `completed`.
3. `get_booking`, then `flight_refund_preview`. Assert `operation_kind: "cancel"`, `refund.amount_money.value` 100200 and `penalty.amount_money.value` 6000 (USD, `decimal_places: 2`, so 1002.00 and 60.00).
4. Commit with the preview's `refund` as `acknowledged`, then poll status. Assert `state: "succeeded"` and every ticket `REFUNDED`, and keep polling until `money.vehicle_state` is `paid` or `payout_settled`: the tickets and the money move independently, and only those two states mean the customer has the money. Sandbox answers `payout_settled`.

**Void within 24 hours (ZZ101, ZZ Standard)**

1. Search. Assert the ZZ Standard fare on flight `101` costs 789.00 USD.
2. Book. Assert `fulfillment.status` is `completed`.
3. `get_booking`, then `flight_refund_preview`. Assert `operation_kind: "void"`, `refund.amount_money.value` 78900 (USD, `decimal_places: 2`, so 789.00) and `penalty.amount_money.value` 0.
4. Commit, then poll status. Assert `state: "succeeded"` and every ticket `VOIDED`, and keep polling until `money.vehicle_state` is `paid` or `payout_settled`.

**Price change (ZZ201, ZZ Standard)**

1. Search, then price-check the fare. Assert both return 789.00 USD.
2. Add to a trip and call `checkout`. On the item, assert `price_changed: true`, `original_price_money.value` 78900 and `price_money.value` 86790 (669.90 + 198.00, each 110 % of the search amount). Money fields are objects whose `value` is in minor units (`decimal_places: 2` for USD).
3. Assert `price_change.delta_money.value` is 7890 and the checkout `total_amount_money.value` is 86790.
4. Pay. Assert `fulfillment.status` is `completed`, charged at 867.90.

**Sold out after payment (ZZ301, any brand)**

1. Search, add to a trip, call `checkout`. Assert checkout succeeds.
2. Pay and poll. Assert `fulfillment.status` ends `failed` and the customer is not charged.

**Ticketing fails (ZZ401, any brand)**

1. Search, add to a trip, call `checkout`. Assert checkout succeeds.
2. Pay and poll. Assert `fulfillment.status` ends `failed` and the customer is not charged.

**Ticketing delayed (ZZ501, any brand)**

1. Book, polling every few seconds.
2. Assert `fulfillment.status` stays non-terminal for at least 2 minutes.
3. Assert it then reaches `completed`. Give it up to 5 minutes before failing your test.

**Refund needs manual handling (ZZ601, ZZ Flex)**

1. Book. `get_booking`, then `flight_refund_preview`. Assert `operation_kind: "cancel"`, `refund.amount_money.value` 100200 and `penalty.amount_money.value` 6000 (USD, `decimal_places: 2`, so 1002.00 and 60.00).
2. Commit with the preview's `refund` as `acknowledged`.
3. Poll status. Assert it stays non-terminal and every ticket stays `ACTIVE`.

**Exchange not permitted (ZZ701, ZZ Standard)**

1. Book. `get_booking`, then exchange shop with a new `preferred_departure_date`.
2. Assert `support_level: "UNSUPPORTED"`, a reason in `warnings`, and no offer you can price.
3. `flight_refund_preview` still works: assert `operation_kind: "void"`.

**Offer no longer available (ZZ801, ZZ Standard)**

1. Search. Assert the ZZ Standard fare on flight `801` costs 789.00 USD.
2. Add to a trip and call `checkout`. Assert HTTP 410 with `error.code: "OFFER_EXPIRED"`.
3. `get_trip`. Assert `quote.status: "failed"`, `quote.failure_reason: "offer_expired"`, and no `fulfillment`.

**Asynchronous booking that confirms, then void (ZZ901, ZZ Standard)**

1. Book. For about 90 seconds, assert `fulfillment.status` stays non-terminal and the booking's `pnr` starts with `zzo_` (provisional, not an airline reference).
2. Assert `fulfillment.status` then reaches `completed` and `pnr` is now a 6-character airline reference.
3. `get_booking`, then `flight_refund_preview`. Assert `operation_kind: "void"` and `refund.amount_money.value` 78900 (789.00 USD).
4. Commit, then poll status. Assert `state: "succeeded"` and every ticket `VOIDED`, and keep polling until `money.vehicle_state` is `paid` or `payout_settled`.

**Asynchronous booking that fails (ZZ1001, ZZ Standard)**

1. Search, add to a trip, call `checkout`. Assert checkout succeeds.
2. Pay and poll. Assert `fulfillment.status` stays non-terminal for about 90 seconds.
3. Assert it then ends `failed` with no `failure_reason` and the customer not charged. `pnr` still holds the provisional `zzo_` value: assert it is not used as an airline reference.

**Exchange with a payment outcome (ZZ101, ZZ Standard)**

1. Book. `get_booking`, then exchange shop with a new `preferred_departure_date`.
2. Price the ZZ Flex offer. Assert `total_due` is `{value: 29800, currency: "USD", decimal_places: 2}`, which is 298.00: `value` is in minor units.
3. Price the ZZ Basic offer. Assert `total_refund` is `{value: 16400, currency: "USD", decimal_places: 2}`, which is 164.00. Price the ZZ Standard offer. Assert even.
4. Commit one offer, then poll exchange status. Assert new ticket numbers and the same `airline_locator`.

**Extra bag (ZZ101, ZZ Standard)**

1. Search, add the ZZ Standard fare, add travelers, then list ancillaries, polling while the call answers 202 `pricing`. Assert `bag-x1-out` at 40.00 USD.
2. Select it for `pax_1`. Assert `total_with_ancillaries` is `{amount: 82900, currency: "USD", decimal_places: 2}`, which is 829.00 (789.00 + 40.00): on this response `amount` is the integer in minor units.
3. Book. Assert the charge is 829.00 and the ticket is issued.

**Oversize bag fails (ZZ101, ZZ Standard)**

1. Select `bag-ov-out` (80.00) for `pax_1` and book.
2. Assert the ticket is issued and the bag is marked failed, "airline could not add the oversize bag".

## Limits

* **Extras.** Bags and seats are selectable in sandbox; meals are not listed yet (see [Extra bags, seats and meals](#extra-bags-seats-and-meals)). Included baggage follows the brand.
* **Live search only.** Flight calendar, destination discovery and price monitoring never return Jinko Test Air.
* **No same-day departures.** Jinko Test Air returns nothing for today (UTC) or earlier. With `include_carriers: ["ZZ"]` the search does not fail: it widens to other carriers and sets `include_carriers_widened: true`. Search from tomorrow.
* **90-day memory.** The test airline forgets a booking's tickets, refunds and exchanges 90 days after its last change. After that, refunding or exchanging it is refused.
* **No reset.** Test data is cleared only by that 90-day expiry. Make a new booking for each test run.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.