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

# Getting started with the Distribution API

> First integration walkthrough for partners: find cities, search offers, create a booking, and manage it through pay, book, and cancel.

The Distribution API is the partner-facing surface of the PREXSELL platform. With a single API key you can search bookable bus offers for a city pair, create bookings for one or more passengers, and drive each booking through its lifecycle — pay, book, cancel — over plain REST.

This guide walks the happy path end to end with runnable `curl` examples, then covers the behaviors worth engineering for before you go to production.

## Before you start

### Base URL

<CodeGroup>
  ```bash Production theme={null}
  https://api.prexsell.com
  ```

  ```bash Staging theme={null}
  https://staging.api.prexsell.com
  ```
</CodeGroup>

All endpoints are versioned under `/v2`. The examples below use the production host — swap in staging while you integrate.

### Authentication

Every authenticated request sends your API key in the `x-api-key` header:

```bash theme={null}
curl https://api.prexsell.com/v2/offers \
  -H "x-api-key: <your-api-key>"
```

API keys are scoped to a single environment (staging vs production). Keep them server-side — never ship them in browser or mobile bundles.

<Info>
  To obtain a key, register as a PREXSELL partner in the backoffice —
  [backoffice.prexsell.com](https://backoffice.prexsell.com) for production or
  [staging.backoffice.prexsell.com](https://staging.backoffice.prexsell.com) for
  staging. Register in the environment you intend to integrate against — keys do
  not cross environments. For help, contact support.
</Info>

<Note>
  The `x-api-key` header is the only credential the Distribution API accepts —
  there is no cookie- or session-based access to these endpoints.
</Note>

### Conventions

* **Success envelope** — every successful response wraps the resource in `{ "data": ... }`. List responses also carry a `total` count of the unpaginated result set.
* **Error envelope** — failures return `{ "errors": [...] }` with an appropriate HTTP status. Business errors carry `{ "message", "errorCode?", "details?" }` items; request-validation 400s carry raw constraint objects with **no `message` field**. See the [error reference](#error-reference) at the end.
* **Money** — all monetary fields (`amount`, `refundAmount`, `prepaid`, …) are plain JSON numbers.
* **Dates** — date-times are ISO 8601 strings (e.g. `2026-06-15T08:00:00.000Z`); date-only inputs use `YYYY-MM-DD`.

## Step 1 — Find cities

Offer search addresses cities by their `slug` (or `id`). Use `GET /v2/cities` to resolve the pair you need. It is currently a public read, but API-key enforcement is being rolled out across the Distribution surface — send the `x-api-key` header anyway so your client keeps working once enforcement turns on. It supports a name search, a country filter, and offset pagination:

| Query param | Required | Description                                                  |
| ----------- | -------- | ------------------------------------------------------------ |
| `search`    | no       | Filter cities by name or translation (minimum 2 characters). |
| `country`   | no       | ISO 3166-1 alpha-2 country code (e.g. `UA`).                 |
| `take`      | no       | Maximum number of items to return (1–100, default 50).       |
| `skip`      | no       | Number of items to skip for offset pagination (default 0).   |

```bash theme={null}
curl "https://api.prexsell.com/v2/cities?search=Київ&country=UA" \
  -H "x-api-key: <your-api-key>"
```

```json theme={null}
{
  "data": {
    "cities": [
      {
        "id": "cld1a2b3c4d5e6f7g8h9i0j1",
        "name": "Київ",
        "slug": "kyiv",
        "country": "UA",
        "timeZone": "Europe/Kyiv"
      }
    ],
    "total": 1
  }
}
```

Each city gives you the `slug`/`id` pair you need for search. Store the `slug` — it is the preferred addressing form. Single-city lookups (`GET /v2/cities/{slug}`) and a city's stops (`GET /v2/cities/{slug}/stops`) are also available.

## Step 2 — Search offers

`GET /v2/offers` is the authenticated bookable search. Address the city pair by **slugs** (preferred) or by **ids** — either pair works, but the pair must be complete:

| Query param                             | Required | Description                                                                 |
| --------------------------------------- | -------- | --------------------------------------------------------------------------- |
| `departureCitySlug` + `arrivalCitySlug` | one pair | URL-friendly slugs of the departure and arrival cities.                     |
| `departureCityId` + `arrivalCityId`     | one pair | Database ids of the departure and arrival cities (kept for compatibility).  |
| `departureDate`                         | yes      | Departure date in ISO 8601 format (`YYYY-MM-DD`). Garbage values yield 400. |
| `passengers`                            | yes      | Number of passengers. Minimum 1.                                            |
| `take`                                  | no       | Page size (1–100, default 50).                                              |
| `skip`                                  | no       | Number of results to skip (default 0).                                      |

```bash theme={null}
curl "https://api.prexsell.com/v2/offers?departureCitySlug=kyiv&arrivalCitySlug=warsaw&departureDate=2026-06-15&passengers=2" \
  -H "x-api-key: <your-api-key>"
```

```json theme={null}
{
  "data": {
    "offers": [
      {
        "id": "eyJhbGciOiJIUzI1NiJ9...",
        "source": "PrexSell",
        "seatsLeft": 12,
        "arrivalDate": "2026-06-15T08:00:00.000Z",
        "expiresAt": "2026-06-14T00:20:00.000Z",
        "prices": [{ "id": "price-1", "amount": 1450, "currency": "UAH" }],
        "slices": [
          {
            "id": "seg-123",
            "duration": 480,
            "departureDate": "2026-06-15T00:00:00.000Z",
            "arrivalDate": "2026-06-15T08:00:00.000Z",
            "bus": {
              "id": "bus-1",
              "seatsQty": 50,
              "registrationNumber": "AA1234BB",
              "brand": "Mercedes-Benz",
              "model": "Tourismo",
              "images": [
                {
                  "id": "img-1",
                  "name": "Bus exterior",
                  "url": "https://example.com/bus.jpg"
                }
              ]
            },
            "carriage": {
              "id": "carriage-1",
              "name": "Автопарк №1",
              "phones": ["+380501234567"]
            },
            "route": {
              "id": "route-1",
              "name": "Київ — Варшава",
              "slug": "kyiv-warsaw",
              "description": "Щоденний нічний рейс",
              "company": {
                "id": "company-1",
                "name": "PrexSell Lines",
                "slug": "prexsell-lines",
                "email": "ops@prexsell-lines.com",
                "website": "https://prexsell-lines.com",
                "rules": "Пасажир має прибути за 20 хвилин до відправлення.",
                "logoUrl": "https://example.com/logo.png",
                "ceo": "Олена Коваль",
                "socials": ["https://facebook.com/prexsell.lines"],
                "partnerCompany": null
              },
              "permissions": [
                {
                  "id": "perm-1",
                  "role": "Agent",
                  "accesses": ["book", "cancel"],
                  "type": "Partner"
                }
              ],
              "services": [
                { "id": "svc-1", "name": "Wi-Fi" },
                { "id": "svc-2", "name": "Кондиціонер" }
              ]
            },
            "stops": [
              {
                "id": "stop-1",
                "cityStopId": "cs-1",
                "departureTime": "06:30",
                "arrivalTime": null,
                "platform": "3",
                "place": "Центральний автовокзал",
                "latitude": 50.4501,
                "longitude": 30.5234,
                "city": {
                  "id": "city-kyiv",
                  "name": "Київ",
                  "slug": "kyiv",
                  "country": "UA",
                  "timeZone": "Europe/Kyiv"
                }
              },
              {
                "id": "stop-2",
                "cityStopId": "cs-2",
                "departureTime": null,
                "arrivalTime": "08:00",
                "platform": "5",
                "place": "Dworzec Zachodni",
                "latitude": 52.2297,
                "longitude": 21.0122,
                "city": {
                  "id": "city-warsaw",
                  "name": "Варшава",
                  "slug": "warsaw",
                  "country": "PL",
                  "timeZone": "Europe/Warsaw"
                }
              }
            ]
          }
        ],
        "refundRules": [
          { "id": "rule-1", "hours": 24, "amount": 800 },
          { "id": "rule-2", "hours": 6, "amount": 400 }
        ],
        "discounts": [
          {
            "id": "disc-1",
            "name": "Студентська знижка",
            "description": "Знижка 10% для студентів з дійсним квитком",
            "rule": "Percent",
            "startDate": "2026-06-01T00:00:00.000Z",
            "endDate": "2026-12-31T23:59:59.000Z",
            "amount": 100
          }
        ]
      }
    ],
    "source": "PrexSell"
  }
}
```

Each offer is fully expanded above. `slices` are the journey legs — each leg carries its `route` (with the operating `company`, partner-visible `permissions`, and onboard `services`), the `bus` (with `images`), the `carriage` contact details, and the ordered `stops` (each resolving to a `city` with its timezone). A direct trip has one slice; a journey with transfers has one slice per leg. `refundRules` are the per-tier cancellation refunds (how much is returned at each `hours`-before-departure threshold), and `discounts` are the fare reductions you can apply per passenger at booking time via `discountId`.

<Note>
  **`slices.length` tells you whether the offer has a transfer.** A single slice
  is a direct trip; **more than one slice means the offer includes at least one
  transfer**, with one slice per leg in travel order (the passenger changes
  vehicles between legs). Use `slices.length > 1` to detect and display
  transfers.
</Note>

<Warning>
  Each `offer.id` is a **signed booking token valid for \~20 minutes** — not a
  stable identifier. The per-offer `expiresAt` field tells you exactly when it
  dies (it is `null` in the rare case the expiry cannot be decoded — treat that
  as "re-search before booking"). Book before it expires, or re-run the search
  for fresh tokens. Booking an expired token returns **400** with `errorCode:
      "OFFER_EXPIRED"`.
</Warning>

Offers are **partner-scoped**: companies that have rejected a partnership with you are excluded from your results, so two partners can see different result sets for the same query.

For a dated timetable of departures without creating a booking, use the public, no-auth companion endpoint `GET /v2/offers/schedule`.

## Step 3 — Create a booking

`POST /v2/bookings` creates one booking for all passengers in the request — **one passenger record per ticket**. The body:

| Field        | Required | Description                                                                                       |
| ------------ | -------- | ------------------------------------------------------------------------------------------------- |
| `offerId`    | yes      | The 20-minute offer token from `GET /v2/offers`. Applies to all passengers in this booking.       |
| `currency`   | yes      | Ticket currency: `CZK`, `UAH`, `EUR`, `USD`, or `PLN`. Applies to all passengers in this booking. |
| `note`       | no       | Optional booking note.                                                                            |
| `passengers` | yes      | One record per passenger. Minimum 1.                                                              |

Each `passengers[]` record:

| Field        | Required | Description                                                                                          |
| ------------ | -------- | ---------------------------------------------------------------------------------------------------- |
| `firstName`  | yes      | First name.                                                                                          |
| `lastName`   | yes      | Last name.                                                                                           |
| `phones`     | yes      | At least one phone: `{ "phone": "+380501234567", "messengers": ["Viber"] }` (`messengers` optional). |
| `email`      | no       | Passenger email.                                                                                     |
| `discountId` | no       | Discount identifier to apply.                                                                        |
| `prepaid`    | no       | Prepaid amount.                                                                                      |

```bash theme={null}
curl -X POST https://api.prexsell.com/v2/bookings \
  -H "x-api-key: <your-api-key>" \
  -H "Content-Type: application/json" \
  -d '{
    "offerId": "eyJhbGciOiJIUzI1NiJ9...",
    "currency": "UAH",
    "passengers": [
      {
        "firstName": "Іван",
        "lastName": "Петренко",
        "email": "ivan@example.com",
        "phones": [{ "phone": "+380501234567", "messengers": ["Viber"] }],
        "discountId": "disc-1"
      }
    ]
  }'
```

A successful create returns **201** with the booking aggregate:

```json theme={null}
{
  "data": {
    "booking": {
      "id": "bkg_1",
      "createdAt": "2026-06-14T00:05:00.000Z",
      "updatedAt": "2026-06-14T00:05:00.000Z",
      "departureDate": "2026-06-15T03:30:00.000Z",
      "status": "Upcoming",
      "bookingRequiredBy": "2026-06-14T00:25:00.000Z",
      "paymentRequiredBy": "2026-06-14T00:25:00.000Z",
      "tickets": [
        {
          "id": "ord_abc",
          "bookingId": "bkg_1",
          "note": null,
          "status": "Processing",
          "paymentStatus": "Unpaid",
          "departureDate": "2026-06-15T00:00:00.000Z",
          "source": "PrexSell",
          "refundAmount": null,
          "passenger": {
            "id": "pass_1",
            "firstName": "Іван",
            "lastName": "Петренко",
            "email": "ivan@example.com",
            "phones": [
              {
                "id": "ph_1",
                "phone": "+380501234567",
                "messengers": ["Viber"]
              }
            ]
          },
          "price": { "id": "price-1", "amount": 1450, "currency": "UAH" },
          "discount": {
            "id": "disc-1",
            "name": "Студентська знижка",
            "rule": "Percent",
            "amount": 100,
            "startDate": "2026-06-01T00:00:00.000Z",
            "endDate": "2026-12-31T23:59:59.000Z"
          },
          "slices": [
            {
              "id": "sl_abc",
              "duration": 480,
              "departureDate": "2026-06-15T00:00:00.000Z",
              "arrivalDate": "2026-06-15T08:00:00.000Z",
              "seat": { "id": "seat_1", "label": "12A" },
              "bus": {
                "id": "bus-1",
                "seatsQty": 50,
                "registrationNumber": "AA1234BB",
                "brand": "Mercedes-Benz",
                "model": "Tourismo",
                "images": [
                  {
                    "id": "img-1",
                    "url": "https://example.com/bus.jpg",
                    "name": "Bus exterior"
                  }
                ]
              },
              "carriage": {
                "id": "carriage-1",
                "name": "Автопарк №1",
                "phones": ["+380501234567"]
              },
              "route": {
                "id": "route-1",
                "name": "Київ — Варшава",
                "slug": "kyiv-warsaw",
                "description": "Щоденний нічний рейс",
                "company": {
                  "id": "company-1",
                  "name": "PrexSell Lines",
                  "slug": "prexsell-lines",
                  "ceo": "Олена Коваль",
                  "partnerCompany": null,
                  "logoUrl": "https://example.com/logo.png",
                  "email": "ops@prexsell-lines.com",
                  "website": "https://prexsell-lines.com",
                  "rules": "Пасажир має прибути за 20 хвилин до відправлення.",
                  "socials": ["https://facebook.com/prexsell.lines"]
                },
                "permissions": [
                  {
                    "id": "perm-1",
                    "role": "Agent",
                    "accesses": ["book", "cancel"],
                    "type": "Partner"
                  }
                ],
                "services": [
                  { "id": "svc-1", "name": "Wi-Fi" },
                  { "id": "svc-2", "name": "Кондиціонер" }
                ]
              },
              "stops": [
                {
                  "id": "stop-1",
                  "departureTime": "06:30",
                  "arrivalTime": null,
                  "platform": "3",
                  "arrivalDate": null,
                  "place": "Центральний автовокзал",
                  "latitude": 50.4501,
                  "longitude": 30.5234,
                  "city": {
                    "id": "city-kyiv",
                    "name": "Київ",
                    "slug": "kyiv",
                    "country": "UA",
                    "timeZone": "Europe/Kyiv"
                  }
                },
                {
                  "id": "stop-2",
                  "departureTime": null,
                  "arrivalTime": "08:00",
                  "platform": "5",
                  "arrivalDate": "2026-06-15T08:00:00.000Z",
                  "place": "Dworzec Zachodni",
                  "latitude": 52.2297,
                  "longitude": 21.0122,
                  "city": {
                    "id": "city-warsaw",
                    "name": "Варшава",
                    "slug": "warsaw",
                    "country": "PL",
                    "timeZone": "Europe/Warsaw"
                  }
                }
              ]
            }
          ]
        }
      ]
    }
  }
}
```

`tickets` holds **one ticket per passenger** — the example above creates a single ticket. Each ticket carries its own `id`, `bookingId`, `status`, `paymentStatus`, `passenger` (with `phones`), `price`, the applied `discount` (`null` when none was requested), `refundAmount` (`null` until a cancellation refund is computed), and the full `slices` array — the same leg structure returned by offer search, now also carrying the assigned `seat`. Note the two `departureDate` fields: the booking-level one (`2026-06-15T03:30:00.000Z`) is the UTC onboarding instant, while the ticket- and slice-level ones (`2026-06-15T00:00:00.000Z`) are the local travel date — see the **Two `departureDate` fields** note in Step 4 below.

Three things to get right from day one:

1. **The body is strictly validated.** Unknown fields are rejected with 400 — do not send anything not listed above.
2. **The response is a booking, not a bare ticket list.** The booking is the aggregate that groups every ticket created by this call. Store `booking.id` **and** each ticket's `id` + `bookingId` — you will need all three later.
3. **Pay within the window.** `paymentRequiredBy` gives you roughly 20 minutes (from the earliest unpaid ticket's creation) to pay before unpaid tickets flip to `Uncompleted`. A late payment still revives them — but treat that as a safety net, not a feature you rely on.

## Step 4 — Read bookings

### List bookings

`GET /v2/bookings` paginates your bookings, newest first. All query params are optional — omit `departureDate` to list everything:

| Query param     | Required | Description                                                            |
| --------------- | -------- | ---------------------------------------------------------------------- |
| `departureDate` | no       | Filter to bookings departing on this calendar day, UTC (`YYYY-MM-DD`). |
| `take`          | no       | Number of bookings to return (1–50, default 50).                       |
| `skip`          | no       | Number of bookings to skip (default 0).                                |

```bash theme={null}
curl "https://api.prexsell.com/v2/bookings?departureDate=2026-06-15" \
  -H "x-api-key: <your-api-key>"
```

`total` counts **bookings**, not tickets. `departureDate` is matched against the booking's **UTC onboarding instant** (`booking.departureDate`, see below) for the given calendar day — not the per-ticket local travel date. Each booking carries only your own tickets.

### Get one booking

```bash theme={null}
curl https://api.prexsell.com/v2/bookings/bkg_1 \
  -H "x-api-key: <your-api-key>"
```

Returns `{ "data": { "booking": ... } }` with all your tickets nested under `booking.tickets`.

<Note>
  `GET /v2/bookings/{id}` returns **404** both for ids that do not exist and for
  ids that belong to another partner. You cannot distinguish the two cases —
  this is deliberate, so the API never reveals whether a foreign resource
  exists.
</Note>

### Booking status

`booking.status` is derived from the member tickets and the departure time:

| Status      | Meaning                                                       |
| ----------- | ------------------------------------------------------------- |
| `Canceled`  | All tickets are `Canceled` or `Refund`.                       |
| `Upcoming`  | More than 8 hours before departure.                           |
| `Boarding`  | Within 8 hours before departure, up to the departure instant. |
| `InTransit` | After departure, within 24 hours.                             |
| `Completed` | More than 24 hours after departure.                           |

`Canceled` takes precedence over the time windows — a fully canceled booking reports `Canceled` even after departure. Two edge cases also report `Upcoming` regardless of time: a booking with no departure date (legacy data), and a booking in which none of your tickets remain.

### Two `departureDate` fields — don't compare them

`booking.departureDate` is the **timezone-aware UTC onboarding instant** of the trip. Each ticket's own `departureDate` is the **local travel date**. They are different representations of different things — never compare one to the other. Use the booking-level field for aggregate logic (sorting, status windows) and the ticket-level field when you need the local departure day.

## Step 5 — Pay, book, or cancel

Lifecycle actions are booking-scoped POSTs:

* `POST /v2/bookings/{id}/pay` — mark tickets as paid.
* `POST /v2/bookings/{id}/book` — mark tickets as booked.
* `POST /v2/bookings/{id}/cancel` — cancel tickets. Targeted tickets become `Canceled`; **paid PrexSell tickets** instead become `Refund` and carry a `refundAmount`. Paid tickets from external operators are canceled, not refunded.

By default an action applies to **all current tickets** in the booking. To target a subset — for example, canceling one passenger out of three — pass an optional body:

```bash theme={null}
curl -X POST https://api.prexsell.com/v2/bookings/bkg_1/cancel \
  -H "x-api-key: <your-api-key>" \
  -H "Content-Type: application/json" \
  -d '{ "orderIds": ["ord_abc"] }'
```

Every id in `orderIds` must belong to the booking — any unknown or foreign id returns **404**. Each action returns **200** with the refreshed booking, so you always see the post-write state.

### Conflicts (409)

When any targeted ticket is already in an incompatible state, the action returns **409** and lists the offending ticket ids in `details`:

| Action   | 409 when a targeted ticket is already…    |
| -------- | ----------------------------------------- |
| `pay`    | `Paid`, `Canceled`, or `Refund`           |
| `book`   | `Booked`, `Paid`, `Canceled`, or `Refund` |
| `cancel` | `Canceled` or `Refund`                    |

```json theme={null}
{
  "errors": [
    {
      "message": "Orders already paid",
      "errorCode": "CONFLICT",
      "details": { "ordersIds": ["ord_1"] }
    }
  ]
}
```

If a pay fails partway (some tickets paid, the rest not), do **not** retry the full set — the already-paid tickets will 409. Retry with an explicit `orderIds` subset containing only the unpaid ids.

### Canceling and refunds

Before canceling, call `GET /v2/bookings/{id}/refund-rules` to check what the passenger gets back:

```bash theme={null}
curl https://api.prexsell.com/v2/bookings/bkg_1/refund-rules \
  -H "x-api-key: <your-api-key>"
```

```json theme={null}
{
  "data": {
    "refundRules": [
      {
        "orderId": "ord_abc",
        "rules": [
          { "id": "rule_1", "hours": 24, "amount": 800 },
          { "id": "rule_2", "hours": 6, "amount": 400 }
        ]
      },
      {
        "orderId": "ord_def",
        "rules": [{ "id": "rule_3", "hours": 24, "amount": 800 }]
      }
    ]
  }
}
```

Rules are per ticket, keyed by `orderId` (the ticket id) — one entry per ticket in the booking. Within each ticket, every rule says how many `hours` before departure it activates and what `amount` is refunded; tiers are ordered by `hours`, so the example above refunds 800 up to 24h before departure and 400 from then until 6h before.

Cancel-specific behaviors to know:

* **Cancellation is always allowed** — there is no timing-based block. Targeted tickets become `Canceled`; **paid PrexSell tickets** become `Refund` and carry a `refundAmount`. Unpaid or merely booked tickets, and paid tickets from external operators, cancel with no refund.
* **The refund amount depends on when you cancel.** A **paid PrexSell ticket canceled within 20 minutes of its creation is refunded in full**, regardless of the carrier's refund rules. After that grace window, the refund follows the refund-rule tiers from `GET /v2/bookings/{id}/refund-rules` (see above).
* The cancel response is the refreshed booking, and each refunded ticket carries its final `refundAmount` — no need to re-query.

## Nuances worth engineering for

These behaviors are easy to miss in a first integration and expensive to discover in production.

**Booking ids are current-state, not creation snapshots.** A booking reflects the tickets that belong to it *now*. Internal operational edits (a date change, a reassignment, a route transfer) can move a ticket to a **new booking id** — one you have never seen. Every ticket always carries its current `bookingId`, so on an unexpected 404 against a stored booking id, re-resolve via the ticket's `bookingId` from any read. Treat ticket ids as the stable handle and booking ids as a re-derivable grouping.

**Side-effect delivery is at-most-once under partial failure.** Pay and book trigger downstream side effects (provider confirmations, emails, messenger notifications). If a batch fails partway, side effects for the already-processed tickets have fired and are not retried — if confirmation messages did not arrive after a partial failure, contact support to re-trigger delivery.

**Notifications fan out to the whole creation group.** Messenger notifications on pay and book go to every passenger from the original creation request, not just the tickets you targeted with `orderIds`.

**Idempotency keys are not yet supported.** There is no `Idempotency-Key` header on `POST /v2/bookings` or the action endpoints; the 409 conflict check is best-effort, not transactional. Avoid double-submitting pay — debounce on your side and never fire the same action concurrently for the same booking.

## Error reference

| Status | When you'll see it                                                                                                           |
| ------ | ---------------------------------------------------------------------------------------------------------------------------- |
| `400`  | Request validation failed (bad date, unknown body field, missing required field); `OFFER_EXPIRED` on an expired offer token. |
| `401`  | Missing or invalid API key.                                                                                                  |
| `404`  | Booking (or an `orderIds` member) unknown **or** belonging to another partner — indistinguishable by design.                 |
| `409`  | A targeted ticket is in an incompatible state; the offending ids are listed in `details`.                                    |

All error bodies share the same outer `{ "errors": [...] }` envelope, but the **item shape depends on the failure's origin**. Business errors carry a `message` (plus optional `errorCode` and `details`):

```json theme={null}
{
  "errors": [
    {
      "message": "Offer expired",
      "errorCode": "OFFER_EXPIRED"
    }
  ]
}
```

Request-validation 400s — the failures you will hit first while integrating (a malformed `departureDate`, `take` above the cap, a bad body field) — instead carry the raw validation constraint parameters, with **no `message` field**:

```json theme={null}
{
  "errors": [{ "maximum": 100, "inclusive": true, "type": "number" }]
}
```

Do not parse `errors[0].message` unconditionally — treat it as optional and fall back to the HTTP status.

## Next steps

<CardGroup cols={2}>
  <Card title="Introduction" icon="book" href="/introduction">
    Authentication, base URLs, envelopes, and status codes for both API
    surfaces.
  </Card>

  <Card title="Migrating from /rest" icon="route" href="/guides/rest-to-v2-migration">
    Already on the legacy endpoints? The migration guide maps every legacy call
    to its v2 replacement.
  </Card>
</CardGroup>
