Skip to main content
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

https://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:
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.
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 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 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 paramRequiredDescription
searchnoFilter cities by name or translation (minimum 2 characters).
countrynoISO 3166-1 alpha-2 country code (e.g. UA).
takenoMaximum number of items to return (1–100, default 50).
skipnoNumber of items to skip for offset pagination (default 0).
curl "https://api.prexsell.com/v2/cities?search=Київ&country=UA" \
  -H "x-api-key: <your-api-key>"
{
  "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 paramRequiredDescription
departureCitySlug + arrivalCitySlugone pairURL-friendly slugs of the departure and arrival cities.
departureCityId + arrivalCityIdone pairDatabase ids of the departure and arrival cities (kept for compatibility).
departureDateyesDeparture date in ISO 8601 format (YYYY-MM-DD). Garbage values yield 400.
passengersyesNumber of passengers. Minimum 1.
takenoPage size (1–100, default 50).
skipnoNumber of results to skip (default 0).
curl "https://api.prexsell.com/v2/offers?departureCitySlug=kyiv&arrivalCitySlug=warsaw&departureDate=2026-06-15&passengers=2" \
  -H "x-api-key: <your-api-key>"
{
  "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:
FieldRequiredDescription
offerIdyesThe 20-minute offer token from GET /v2/offers. Applies to all passengers in this booking.
currencyyesTicket currency: CZK, UAH, EUR, USD, or PLN. Applies to all passengers in this booking.
notenoOptional booking note.
passengersyesOne record per passenger. Minimum 1.
Each passengers[] record:
FieldRequiredDescription
firstNameyesFirst name.
lastNameyesLast name.
phonesyesAt least one phone: { "phone": "+380501234567", "messengers": ["Viber"] } (messengers optional).
emailnoPassenger email.
discountIdnoDiscount identifier to apply.
prepaidnoPrepaid amount.
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:
{
  "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 paramRequiredDescription
departureDatenoFilter to bookings departing on this calendar day, UTC (YYYY-MM-DD).
takenoNumber of bookings to return (1–50, default 50).
skipnoNumber of bookings to skip (default 0).
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

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.
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:
StatusMeaning
CanceledAll tickets are Canceled or Refund.
UpcomingMore than 8 hours before departure.
BoardingWithin 8 hours before departure, up to the departure instant.
InTransitAfter departure, within 24 hours.
CompletedMore 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:
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:
Action409 when a targeted ticket is already…
payPaid, Canceled, or Refund
bookBooked, Paid, Canceled, or Refund
cancelCanceled or Refund
{
  "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:
curl https://api.prexsell.com/v2/bookings/bkg_1/refund-rules \
  -H "x-api-key: <your-api-key>"
{
  "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

StatusWhen you’ll see it
400Request validation failed (bad date, unknown body field, missing required field); OFFER_EXPIRED on an expired offer token.
401Missing or invalid API key.
404Booking (or an orderIds member) unknown or belonging to another partner — indistinguishable by design.
409A 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):
{
  "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:
{
  "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

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.