# Book booking Source: https://docs.prexsell.com/api-reference/bookings/book-booking /docs/openapi/openapi.json post /v2/bookings/{id}/book Marks targeted orders as Booked. Authenticated via the api-key header; data scoped to the caller's partner. 409 Conflict when any targeted order is already Booked, Paid, Canceled, or Refund. A Paid order is blocked from a Booked downgrade (getUpdatedBookedOrders would overwrite unconditionally). Single write only — legacy double-write removed (one history row instead of two). Returns the refreshed booking after the write. # Cancel booking Source: https://docs.prexsell.com/api-reference/bookings/cancel-booking /docs/openapi/openapi.json post /v2/bookings/{id}/cancel Cancels targeted orders (status → Canceled; Paid PrexSell orders → Refund + refundAmount). Authenticated via the api-key header; data scoped to the caller's partner. 409 Conflict when any targeted order is already Canceled or Refund. Unpaid/booked orders and external-operator orders cancel at any time with no refund; a paid PrexSell order is refunded per its refund rules. Response carries the just-persisted refundAmount on refunded tickets. Returns the refreshed booking after the write. # Create booking Source: https://docs.prexsell.com/api-reference/bookings/create-booking /docs/openapi/openapi.json post /v2/bookings Creates a booking for one or more passengers. Authenticated via the api-key header; data scoped to the caller's partner. Returns 201 with the booking aggregate. offer token failures: expired → 400 OFFER_EXPIRED; malformed → 400 VALIDATION_ERROR. Undeclared body fields are rejected (strict body — discovery-gated per ADR-0033 §7). # Get booking by ID Source: https://docs.prexsell.com/api-reference/bookings/get-booking-by-id /docs/openapi/openapi.json get /v2/bookings/{id} Returns a single booking by its booking ID. Authenticated via the api-key header; data scoped to the caller's partner. Returns 404 when the booking does not exist or does not belong to the actor's partner (foreign and unknown are indistinguishable — ADR-0033 §1). # Get refund rules for a booking Source: https://docs.prexsell.com/api-reference/bookings/get-refund-rules-for-a-booking /docs/openapi/openapi.json get /v2/bookings/{id}/refund-rules Returns refund rules for each partner-owned order within a booking. Authenticated via the api-key header; data scoped to the caller's partner. Returns 404 when the booking does not exist or does not belong to the actor's partner. Fixes legacy defect 3 (IDOR) and defect 4 (500 on unknown ID). # List bookings Source: https://docs.prexsell.com/api-reference/bookings/list-bookings /docs/openapi/openapi.json get /v2/bookings Returns a paginated list of the caller's bookings, newest first. Authenticated via the api-key header; data scoped to the caller's partner. Optionally filter to a single departure date, matched against the booking's UTC onboarding instant for that calendar day. Omit it to list all your bookings. Only the caller's own tickets are included in each booking. # Pay booking Source: https://docs.prexsell.com/api-reference/bookings/pay-booking /docs/openapi/openapi.json post /v2/bookings/{id}/pay Marks targeted orders as Paid. Authenticated via the api-key header; data scoped to the caller's partner. 409 Conflict when any targeted order is already Paid, Canceled, or Refund. paymentRecipient is always Agent; orders fiscalize only when the partner's website config carries Checkbox credentials (ADR-0069). Returns the refreshed booking after the write. # Get a city by slug Source: https://docs.prexsell.com/api-reference/cities/get-a-city-by-slug /docs/openapi/openapi.json get /v2/cities/{slug} Returns a single city record identified by its unique slug. # List cities Source: https://docs.prexsell.com/api-reference/cities/list-cities /docs/openapi/openapi.json get /v2/cities Returns a paginated list of cities. Optionally filtered by name search string and/or country code. # List stops for a city Source: https://docs.prexsell.com/api-reference/cities/list-stops-for-a-city /docs/openapi/openapi.json get /v2/cities/{slug}/stops Returns a paginated list of bus stops belonging to the specified city. # Get schedule for a city pair on a date Source: https://docs.prexsell.com/api-reference/offers/get-schedule-for-a-city-pair-on-a-date /docs/openapi/openapi.json get /v2/offers/schedule Returns a dated timetable of individual departures for the given city pair. Each row includes departure/arrival datetime (wall-clock, timezone-aware), company, route name, travel time, and price. Sorted by departure time ascending. Paginated with take/skip. # Search bookable offers Source: https://docs.prexsell.com/api-reference/offers/search-bookable-offers /docs/openapi/openapi.json get /v2/offers Returns bookable offers for the given city pair, date, and passenger count. Authenticated via the api-key header; data scoped to the caller's partner. Offer ids are 20-minute signed JWT booking tokens — use expiresAt to detect expiry before attempting to book. # Get a stop by ID Source: https://docs.prexsell.com/api-reference/stops/get-a-stop-by-id /docs/openapi/openapi.json get /v2/stops/{id} Returns a single bus stop record including coordinates and an embedded city summary. # List stops Source: https://docs.prexsell.com/api-reference/stops/list-stops /docs/openapi/openapi.json get /v2/stops Returns a paginated list of bus stops across all cities. Optionally filtered by city ID or country code. # Cancel ticket Source: https://docs.prexsell.com/api-reference/tickets/cancel-ticket /docs/openapi/openapi.json post /v2/tickets/{id}/cancel Cancels a single ticket (order) by its ID, independent of the booking it currently belongs to — the ticket id is stable across a transfer to a new booking, unlike the booking id. (status → Canceled; Paid PrexSell orders → Refund + refundAmount). Authenticated via the api-key header; data scoped to the caller's partner. 409 Conflict when the ticket is already Canceled or Refund. Unpaid/booked orders and external-operator orders cancel at any time with no refund; a paid PrexSell order is refunded per its refund rules. Returns 404 when the ticket does not exist or does not belong to the actor's partner (foreign and unknown are indistinguishable — ADR-0033 §1). Response carries the refreshed ticket, including its current bookingId and any just-persisted refundAmount. # Get ticket by ID Source: https://docs.prexsell.com/api-reference/tickets/get-ticket-by-id /docs/openapi/openapi.json get /v2/tickets/{id} Returns a single ticket (order) by its ID. Authenticated via the api-key header; data scoped to the caller's partner. Returns 404 when the ticket does not exist or does not belong to the actor's partner (foreign and unknown are indistinguishable — ADR-0033 §1). # Getting started with the Distribution API Source: https://docs.prexsell.com/guides/distribution-getting-started 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 ```bash Production theme={null} https://api.prexsell.com ``` ```bash Staging theme={null} https://staging.api.prexsell.com ``` 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: " ``` API keys are scoped to a single environment (staging vs production). Keep them server-side — never ship them in browser or mobile bundles. 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. The `x-api-key` header is the only credential the Distribution API accepts — there is no cookie- or session-based access to these endpoints. ### 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: " ``` ```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: " ``` ```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`. **`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. 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"`. 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: " \ -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: " ``` `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: " ``` Returns `{ "data": { "booking": ... } }` with all your tickets nested under `booking.tickets`. `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. ### 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: " \ -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: " ``` ```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 Authentication, base URLs, envelopes, and status codes for both API surfaces. Already on the legacy endpoints? The migration guide maps every legacy call to its v2 replacement. # Migrating from /rest to /v2 Source: https://docs.prexsell.com/guides/rest-to-v2-migration Step-by-step guide for external partners moving from the legacy /rest endpoints to the v2 Distribution API. The legacy `/rest/offers` and `/rest/orders` endpoints are deprecated and will be removed after a usage-gated window of at least 90 days. This guide covers everything you need to migrate to their v2 replacements. Deprecated endpoints respond with `Deprecation`, `Sunset`, and `Link` headers pointing here. Monitor those headers in your client to track the sunset date. ## Path mapping | Legacy endpoint | v2 endpoint | Notes | | ----------------------------------------------------------- | ----------------------------------- | -------------------------------------- | | `GET /rest/offers` | `GET /v2/offers` | See [Offers](#offers) below | | `POST /rest/orders` | `POST /v2/bookings` | Returns a booking, not a ticket array | | `GET /rest/orders` | `GET /v2/bookings` | Paginates bookings, not bare tickets | | `GET /rest/orders/:id` | `GET /v2/bookings/:id` | `:id` is now a **booking id** | | `GET /rest/orders/:id/refund-rules` | `GET /v2/bookings/:id/refund-rules` | Booking-level ownership check | | `PATCH /rest/orders` (`paymentStatus: Paid`) | `POST /v2/bookings/:id/pay` | Booking-scoped; no `status` field | | `PATCH /rest/orders` (`paymentStatus: Booked`) | `POST /v2/bookings/:id/book` | Booking-scoped | | `PATCH /rest/orders` (`status: Canceled`) | `POST /v2/bookings/:id/cancel` | Booking-scoped | | `PATCH /rest/orders` (`Confirmed`/`NotConfirmed`/`Prepaid`) | **not migrated** | Carrier lifecycle — use the backoffice | ## Authentication The auth mechanism is the same: send your API key in the `x-api-key` header on every request. ```bash theme={null} curl https://api.prexsell.com/v2/bookings \ -H "x-api-key: " ``` One change: a missing or unrecognized key now returns **401** (was 403 with a 401-semantics body in the legacy tree). Update any code that branches on the exact status code. ## Response envelope All v2 responses wrap the resource in a `{ data: {...} }` envelope: ```json theme={null} { "data": { "booking": { ... } } } ``` List endpoints include `total` for the unpaginated count: ```json theme={null} { "data": { "bookings": [...], "total": 42 } } ``` Errors use the standard shape across all v2 endpoints: ```json theme={null} { "errors": [ { "message": "Offer expired", "errorCode": "OFFER_EXPIRED" } ] } ``` ## Status codes | Status | When v2 returns it | | ------ | -------------------------------------------------------------- | | `201` | `POST /v2/bookings` — booking created | | `400` | Validation error (bad date, expired offer token, etc.) | | `401` | Missing or invalid API key | | `404` | Booking not found, or belongs to a different partner | | `409` | Conflict — see [Actions and conflicts](#actions-and-conflicts) | | `500` | Unexpected server error | 404 is returned for both unknown ids and ids that belong to another partner — the API does not reveal whether a foreign resource exists. ## Offers `GET /v2/offers` replaces `GET /rest/offers`. The search parameters are mostly the same with these changes: * `passengersQty` is renamed to `passengers` (integer ≥ 1). * `departureDate` is validated as an ISO 8601 date string; a garbage value returns 400 instead of 500. * The response shape changes from `{ data: { source, results } }` to `{ data: { offers, source } }`. * Each offer now carries an `expiresAt` timestamp (ISO 8601 UTC). This is the exact expiry of the 20-minute booking token — a per-offer value decoded from the token itself. Book before this time; an expired token returns **400 + `OFFER_EXPIRED`**. ## The booking pivot The most significant change: **the addressing unit is now the booking, not the individual ticket (order)**. When you call `POST /v2/bookings`, one booking is created for all the passengers in that single request. Every ticket carries both its own `id` and a `bookingId`. Use the `bookingId` as the handle for reads and actions going forward. ### Booking id stability Booking ids reflect **current state**, not the creation snapshot. If a passenger's identity fields are edited internally (date change, reassignment, route transfer), the affected ticket may be moved to a new booking id — one you have never seen. Consequences: * Always use the `bookingId` field on each returned ticket as the authoritative crosswalk. If a previously valid booking id returns 404, re-resolve via the ticket's current `bookingId`. * `GET /v2/bookings` and `GET /v2/bookings/:id` always reflect the current grouping. * An action's `orderIds` subset (see below) may return 404 if an order moved to a different booking since you last fetched it. Re-fetch the booking first. ## Creating a booking ```bash theme={null} POST /v2/bookings Content-Type: application/json x-api-key: { "offerId": "", "currency": "UAH", "passengers": [ { "firstName": "Anna", "lastName": "Kovalenko", "phones": [{ "phone": "+380991234567" }], "email": "anna@example.com" } ] } ``` Changes from `POST /rest/orders`: * Returns `201` with `{ data: { booking } }` — a booking object containing all tickets — instead of `200` with a bare ticket array. * The body is **strictly validated** — unknown fields are rejected with 400. Remove any undeclared legacy fields (`price`, `seats`, `paymentRecipient`, `invoice`, `sessionId`, `octobusRaceId`, `blaBlaCarRaceId`) from your request body. * `offerId` is now a top-level body field (not per-passenger). All passengers in a booking share the same offer token. v2 no longer accepts a `source` field — every v2 booking is created against the single top-level PrexSell `offerId`; external-source routing is not available on v2. * The booking response includes `paymentRequiredBy` — see [Time-limited fields](#time-limited-fields). ## Booking status The booking's `status` field is derived from the statuses of its member tickets: * `Canceled` — all tickets are Refund or Canceled. * `Upcoming` / `Boarding` / `InTransit` / `Completed` — derived from `departureDate` and 8h/24h windows when any ticket is still active. `Booking.departureDate` is the **UTC onboarding instant** for the trip (timezone-aware, sourced from ADR-0008). It is not the same value as the per-ticket local `departureDate`. Use the booking-level field for booking-aggregate logic; use the ticket-level field when you need the local departure time. ## Reading bookings `GET /v2/bookings` paginates bookings, newest first (default `take: 50`, maximum `take: 50`). The optional `departureDate` query param (ISO 8601 date, `YYYY-MM-DD`) filters to bookings whose **UTC onboarding instant** (`booking.departureDate`) falls on that calendar day; omit it to list all your bookings. The legacy `createdAt` filter is **not** carried over. Each booking carries only your own tickets. `total` counts bookings, not tickets. `GET /v2/bookings/:id` returns a single booking by its id with all partner-owned tickets nested under `booking.tickets`. ## Refund rules `GET /v2/bookings/:id/refund-rules` performs a booking-level ownership check first (404 if not yours), then returns per-order refund rules: ```json theme={null} { "data": { "refundRules": [ { "orderId": "", "rules": [{ "id": "...", "hours": 24, "amount": 50 }] } ] } } ``` ## Actions and conflicts The three PATCH branches are replaced by per-action POST endpoints that are scoped to a booking id: * `POST /v2/bookings/:id/pay` * `POST /v2/bookings/:id/book` * `POST /v2/bookings/:id/cancel` All three accept an optional `orderIds` array to target a subset of the booking's tickets. When omitted, the action applies to **all current members** of the booking. If you supply `orderIds`, every id must belong to the booking — unknown or foreign ids return 404. Cross-booking batches (a single legacy `PATCH /rest/orders` call with ids from multiple creation pools) must become N separate booking-scoped calls, one per booking id. Each action returns **409** with a list of offending ids when the targeted tickets are in an incompatible state: | Endpoint | 409 conditions | | ------------------------------ | ------------------------------------------------------------------------ | | `POST /v2/bookings/:id/pay` | Any targeted ticket is already `Paid`, `Canceled`, or `Refund` | | `POST /v2/bookings/:id/book` | Any targeted ticket is already `Booked`, `Paid`, `Canceled`, or `Refund` | | `POST /v2/bookings/:id/cancel` | Any targeted ticket is already `Canceled` or `Refund` | The conflict check is best-effort (not transactional). Under concurrent duplicate requests, both may pass the check and execute. Use the subset-retry path (supply explicit `orderIds` for the non-conflicting tickets) to recover from a partial 409. ### Partial failure semantics Pay and book actions process tickets concurrently. If one ticket fails mid-batch, already-processed siblings remain committed and their side effects (provider confirmations, emails, Viber notifications) have fired. The error response reflects the failure; successfully processed tickets are not rolled back. On partial failure, retry with the explicit `orderIds` subset that still needs processing. Do not retry the full set — the already-`Paid` or already-`Booked` tickets will 409. ### Book action: one history row The legacy `PATCH /rest/orders` Booked branch wrote two history rows per ticket. `POST /v2/bookings/:id/book` writes one. If your tooling parses order history counts, update it accordingly. ## Time-limited fields ### `expiresAt` on offers Each offer carries an `expiresAt` ISO 8601 timestamp. Attempting to book an expired token returns: ```json theme={null} { "errors": [{ "message": "Offer expired", "errorCode": "OFFER_EXPIRED" }] } ``` A malformed (non-JWT) token returns 400 with `errorCode: "VALIDATION_ERROR"`. ### `paymentRequiredBy` on bookings A booking with unpaid tickets includes `paymentRequiredBy` — the deadline to pay derived from the earliest unpaid ticket's creation time plus the 20-minute payment window. After this deadline, unpaid tickets flip to `Uncompleted`. Paying after the flip still revives the tickets; `paymentRequiredBy` is informational, not a hard cutoff. Once all tickets are paid, booked, canceled, or uncompleted, `paymentRequiredBy` is `null`. ### `refundAmount` on canceled tickets `POST /v2/bookings/:id/cancel` returns the refreshed booking. Each canceled ticket carries its `refundAmount` directly in the response — you do not need to re-query. Cancellation is always allowed — there is no timing-based block. 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. Unpaid or merely booked tickets, and paid tickets from external operators, cancel at any time (they become `Canceled`, with no refund). ## Response shape changes The following fields changed between `/rest` and `/v2`: * **Offer response:** `{ data: { source, results } }` → `{ data: { offers, source } }`. The array key is `offers`, not `results`. * **Create response:** bare ticket array → `{ data: { booking } }` with tickets nested under `booking.tickets`. * **Slice-level `seat`:** now declared and returned in each slice. The legacy phantom top-level `seat` field (declared but never sent) is removed. * **Prices:** `amount` is always a number (explicit `Decimal → number` conversion). The legacy behavior was implicit; no value change expected. The following fields from the offer or ticket response may be removed during your partner cutover. Confirm with PREXSELL before migration: * `company.ceo` * `company.socials` * `company.partnerCompany` ## Deprecation timeline The `/rest/offers` and `/rest/orders` endpoints serve `Deprecation`, `Sunset`, and `Link` headers from the moment v2 goes live. The sunset window is **at least 90 days AND** requires per-partner Moesif traffic to reach zero — it is usage-gated, not calendar-gated. Once the window closes, both endpoints return **410 Gone** with a pointer to this guide. Plan your migration accordingly and coordinate with PREXSELL support. # Introduction Source: https://docs.prexsell.com/introduction REST API for the PREXSELL bus booking platform. The PREXSELL REST API exposes a **Distribution API** for agents and PREXSELL's own client applications. ## Client types For agents (third-party resellers), the PREXSELL website, and the PREXSELL app. Authenticated with a long-lived **API key** sent in the `x-api-key` header. Use this for booking flows, catalog reads, and any integration that runs outside the PREXSELL backoffice. Distribution catalog endpoints (cities, stops) are read-only reference data. ## Base URL ```bash Production theme={null} https://api.prexsell.com ``` ```bash Staging theme={null} https://staging.api.prexsell.com ``` All endpoints in this reference are versioned under `/v2`. ## Authentication ### Distribution API — API key Distribution endpoints are authenticated with an API key passed in the `x-api-key` header: ```bash theme={null} curl https://api.prexsell.com/v2/cities \ -H "x-api-key: " ``` API keys are issued from the PREXSELL backoffice. Register as a PREXSELL partner at backoffice.prexsell.com (or staging.backoffice.prexsell.com for staging) to obtain one, and contact support if you need help. Keys are scoped to a single environment (staging vs production), so register in the environment you'll integrate against. Treat them as secrets — keep them on the server side, never ship them in browser or mobile bundles. API-key enforcement is being rolled out across the v2 Distribution surface. Read-only catalog endpoints (cities, stops) are currently open while partner provisioning is finalized; pass the `x-api-key` header anyway so your client keeps working once enforcement turns on. ## Quick start ```bash theme={null} # List cities — Distribution endpoint, API-key authenticated curl https://staging.api.prexsell.com/v2/cities \ -H "x-api-key: " ``` ## Response envelope Successful responses are wrapped in a `data` object: ```json theme={null} { "data": { "...": "..." } } ``` List endpoints additionally include a `total` counter for the unpaginated result set and accept `take` (max 100, default 50) and `skip` (default 0) query parameters. ## Errors Errors are returned with an appropriate HTTP status and an `errors` array describing what went wrong: ```json theme={null} { "errors": [ { "code": "UNAUTHORIZED", "message": "Missing or invalid credentials." } ] } ``` Common status codes: | Status | Meaning | | ------ | ---------------------------------------------------- | | `200` | Success. | | `201` | Resource created. | | `204` | Success, no response body. | | `400` | Validation error — check the request shape. | | `401` | Missing or invalid API key / access token. | | `403` | Authenticated, but not allowed to do this. | | `404` | Resource not found. | | `409` | Conflict — the resource is in an incompatible state. | | `429` | Rate limit exceeded. | | `500` | Unexpected server error. | ## Next steps Walk through a full Distribution integration end to end — search offers, create a booking, pay, and cancel.