curl examples, then covers the behaviors worth engineering for before you go to production.
Before you start
Base URL
/v2. The examples below use the production host — swap in staging while you integrate.
Authentication
Every authenticated request sends your API key in thex-api-key header:
To obtain a key, register as a PREXSELL partner in the backoffice —
backoffice.prexsell.com for production or
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 atotalcount 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 nomessagefield. See the 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 useYYYY-MM-DD.
Step 1 — Find cities
Offer search addresses cities by theirslug (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). |
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). |
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.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. |
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. |
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:
- The body is strictly validated. Unknown fields are rejected with 400 — do not send anything not listed above.
- 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.idand each ticket’sid+bookingId— you will need all three later. - Pay within the window.
paymentRequiredBygives you roughly 20 minutes (from the earliest unpaid ticket’s creation) to pay before unpaid tickets flip toUncompleted. 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). |
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
{ "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 becomeCanceled; paid PrexSell tickets instead becomeRefundand carry arefundAmount. Paid tickets from external operators are canceled, not refunded.
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 indetails:
| Action | 409 when a targeted ticket is already… |
|---|---|
pay | Paid, Canceled, or Refund |
book | Booked, Paid, Canceled, or Refund |
cancel | Canceled or Refund |
orderIds subset containing only the unpaid ids.
Canceling and refunds
Before canceling, callGET /v2/bookings/{id}/refund-rules to check what the passenger gets back:
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 becomeRefundand carry arefundAmount. 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 currentbookingId, 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. |
{ "errors": [...] } envelope, but the item shape depends on the failure’s origin. Business errors carry a message (plus optional errorCode and details):
departureDate, take above the cap, a bad body field) — instead carry the raw validation constraint parameters, with no message field:
errors[0].message unconditionally — treat it as optional and fall back to the HTTP status.
Next steps
Introduction
Authentication, base URLs, envelopes, and status codes for both API
surfaces.
Migrating from /rest
Already on the legacy endpoints? The migration guide maps every legacy call
to its v2 replacement.
