Skip to main content

Bookings

The Bookings API gives one view of a customer's bookings across bus, ferry, train and flight. It powers activity screens and support search without the caller having to query the bus, train and flight endpoints separately and stitch the results together.

Overview

The Bookings API currently provides:

  • An activity feed of the caller's bookings across all modes, newest first, with cursor pagination
  • A lookup that finds bookings by payment reference, ticket ID, any known reference, passenger name or passenger phone number
  • One BookingSummary shape for every mode, with a small mode-specific block (bus, train or flight) and enough references to deep-link into the mode-specific ticket endpoints
  • Booking-level status derived from every ticket row of the booking (PENDING_PAYMENT, PROCESSING, CONFIRMED, PARTIALLY_CANCELLED, CANCELLED)

Both endpoints are read-only and never return passenger contact details, identity numbers, payment information or provider payloads.

Authentication

Both endpoints are protected Safiri Booking API endpoints and require JWT authentication. Obtain a JWT token by logging in through the /api/logon endpoint.

note

For detailed information on authenticating and obtaining a token, see the Authentication guide.

Access is decided by the JWT on every request, including every page of the feed:

  • Non-staff callers (permission level below ADMIN) see only bookings bought with their own account. The buyer is the account that paid, so a passenger on a booking made by an agent, or by another customer, will not find that booking through either endpoint. Not-found and not-owned are indistinguishable: both return an empty list.
  • Staff callers (ADMIN, OWNER, SUDO) see every booking and may narrow either endpoint to one buyer with ownerUuid. Staff responses also carry the buyer block.
  • A request that sends x-authentication-context: web-app (case-insensitive, _ and - interchangeable) is never privileged, whatever the token's permission level. The website shares one token between browsers, so it is always treated as a non-staff caller.

Headers

Required:

  • Authorization: Bearer <JWT_TOKEN> - Your JWT authentication token

Optional:

  • x-authentication-context: web-app - Declares the website channel; forces non-staff scoping

Every response is sent with Cache-Control: private, no-store.

Endpoints

Activity Feed

Returns the caller's bookings across every selected mode, newest booking first, one page at a time.

Endpoint: GET /api/bookings/activity

Example Request:

GET /api/bookings/activity?limit=20

Query Parameters

ParameterTypeRequiredDescription
limitstringNoPage size. Whole digits only; the value is clamped to 1..100 (so 0 becomes 1 and 1000 becomes 100). Defaults to 50. Anything else returns 400 BOOKINGS_LIMIT_INVALID.
cursorstringNoOpaque cursor copied from the previous page's nextCursor. Omit it (or send an empty string) for the first page. A cursor that cannot be decoded or validated returns 400 BOOKINGS_CURSOR_INVALID.
kindsstringNoComma-separated subset of BUS, FERRY, TRAIN, FLIGHT. Tokens are trimmed, upper-cased and de-duplicated; the response echoes them in canonical order. Defaults to all four. An unknown token or an empty list returns 400 BOOKINGS_KINDS_INVALID.
ownerUuidstringNoBuyer UUID. Staff use it to narrow the feed to one buyer's bookings. Non-staff callers may send their own UUID (a no-op); any other UUID returns 403 FORBIDDEN_ACCESS_PRIVILEGE. A value that is not shaped like a UUID returns 400 BOOKINGS_OWNER_INVALID for every caller.

Pagination

  • The cursor is opaque. Store and return it verbatim; do not build or edit one.
  • Keep requesting pages with the returned nextCursor until nextCursor is null. There is no total count.
  • A page may contain fewer than limit items even when more pages exist. Each page reads a bounded window of ticket rows per collection (four rows per requested item, at most 400), and bookings with many passengers or legs occupy several rows, so short pages are normal. Never treat a short page as the last page.
  • kinds may change between pages. The cursor only records where the caller is in time, not which modes were requested.
  • ownerUuid is not stored in the cursor and must be sent on every page. Access scope is re-derived from the JWT on every page, so a cursor copied from another user can only ever narrow, never widen, what is returned.
  • Items are ordered by bookedAt descending, then by kind in the order BUS, FERRY, TRAIN, FLIGHT, then by bookingKey ascending. Every booking is returned exactly once across the pages of one walk, including bookings that share a timestamp across the page boundary.
  • A cursor is rejected when it is longer than 4096 characters, is not base64url, does not decode to the expected version, or carries a timestamp outside the range 1970 to 2100.

Success Response

The example below is a non-staff response, so no item carries a buyer block.

{
"items": [
{
"id": "FLIGHT:F:r:4f5c3d2e-9a1b-4c8d-8e7f-0a1b2c3d4e5f",
"kind": "FLIGHT",
"bookingKey": "F:r:4f5c3d2e-9a1b-4c8d-8e7f-0a1b2c3d4e5f",
"status": "CONFIRMED",
"statusDetail": "ISSUED",
"bookedAt": "2026-01-06T09:15:00.000Z",
"reference": {
"paymentReference": "FLT1234567890123456",
"ticketId": "6be55963-070f-4389-bc83-2a9300dbf9f4",
"pnr": "X7K9LM",
"reservationId": "4f5c3d2e-9a1b-4c8d-8e7f-0a1b2c3d4e5f",
"ticketNumbers": ["0021234567890", "0021234567891"]
},
"operator": { "uuid": null, "tradingName": "Precision Air" },
"journeyType": "RETURN",
"journey": {
"from": "DAR",
"to": "JRO",
"departureDate": "2026-01-20",
"departureTime": "06:30",
"arrivalDate": "2026-01-20",
"arrivalTime": "07:45"
},
"legs": [
{
"direction": "OUTBOUND",
"from": "DAR",
"to": "JRO",
"departureDate": "2026-01-20",
"departureTime": "06:30",
"arrivalDate": "2026-01-20",
"arrivalTime": "07:45"
},
{
"direction": "RETURN",
"from": "JRO",
"to": "DAR",
"departureDate": "2026-01-24",
"departureTime": "18:10",
"arrivalDate": "2026-01-24",
"arrivalTime": "19:25"
}
],
"passengers": {
"count": 2,
"cancelledCount": 0,
"names": ["Asha Juma", "Juma Ali"],
"seats": []
},
"price": { "amount": "640000.00", "currency": "TZS" },
"bookingUserType": "ONLINE",
"bookingChannel": "MOBILE_APP",
"flight": {
"reservationId": "4f5c3d2e-9a1b-4c8d-8e7f-0a1b2c3d4e5f",
"bookingReference": "X7K9LM",
"airlineCodes": ["PW"],
"flightNumbers": ["PW411", "PW418"],
"validatingCarrier": "PW",
"issuanceStatus": "ISSUED"
}
},
{
"id": "TRAIN:T:p:SGR1234567890123456",
"kind": "TRAIN",
"bookingKey": "T:p:SGR1234567890123456",
"status": "CONFIRMED",
"statusDetail": "PAID",
"bookedAt": "2026-01-05T10:00:00.000Z",
"reference": {
"paymentReference": "SGR1234567890123456",
"ticketId": null,
"pnr": "ABC123",
"reservationId": null,
"ticketNumbers": ["TRC0001", "TRC0002"]
},
"operator": {
"uuid": "9f1c2b3a-4d5e-4f60-8a71-b2c3d4e5f607",
"tradingName": "TRC"
},
"journeyType": "ONE_WAY",
"journey": {
"from": "Dar es Salaam",
"to": "Dodoma",
"departureDate": "2026-01-10",
"departureTime": "07:00",
"arrivalDate": "2026-01-10",
"arrivalTime": "11:00"
},
"legs": [
{
"direction": "OUTBOUND",
"from": "Dar es Salaam",
"to": "Dodoma",
"departureDate": "2026-01-10",
"departureTime": "07:00",
"arrivalDate": "2026-01-10",
"arrivalTime": "11:00"
}
],
"passengers": {
"count": 2,
"cancelledCount": 0,
"names": ["Asha Juma", "Juma Ali"],
"seats": ["A 12", "A 13"]
},
"price": { "amount": "98000.00", "currency": "TZS" },
"bookingUserType": "ONLINE",
"bookingChannel": "MOBILE_APP",
"train": {
"pnr": "ABC123",
"trainName": "SGR Express",
"trainNumber": "T101",
"coach": "A",
"providerMode": "LIVE"
}
},
{
"id": "FERRY:B:p:9122601041430987",
"kind": "FERRY",
"bookingKey": "B:p:9122601041430987",
"status": "PENDING_PAYMENT",
"statusDetail": "UNPAID",
"bookedAt": "2026-01-04T14:30:00.000Z",
"reference": {
"paymentReference": "9122601041430987",
"ticketId": "AZM-2601-04431",
"pnr": null,
"reservationId": null,
"ticketNumbers": []
},
"operator": {
"uuid": "3c4d5e6f-7a8b-4c9d-8e0f-1a2b3c4d5e6f",
"tradingName": "Azam Marine"
},
"journeyType": "ONE_WAY",
"journey": {
"from": "Dar es Salaam",
"to": "Zanzibar",
"departureDate": "2026-01-08",
"departureTime": "09:30",
"arrivalDate": "2026-01-08",
"arrivalTime": "11:30"
},
"legs": [
{
"direction": "OUTBOUND",
"from": "Dar es Salaam",
"to": "Zanzibar",
"departureDate": "2026-01-08",
"departureTime": "09:30",
"arrivalDate": "2026-01-08",
"arrivalTime": "11:30"
}
],
"passengers": {
"count": 1,
"cancelledCount": 0,
"names": ["Asha Juma"],
"seats": ["A12"]
},
"price": { "amount": "35000.00", "currency": "TZS" },
"bookingUserType": "ONLINE",
"bookingChannel": "MOBILE_APP",
"bus": { "ticketId": "AZM-2601-04431", "ferryOption": "PASSENGER" }
},
{
"id": "BUS:B:p:9122601030805123",
"kind": "BUS",
"bookingKey": "B:p:9122601030805123",
"status": "PARTIALLY_CANCELLED",
"statusDetail": "PARTIALLY_CANCELLED",
"bookedAt": "2026-01-03T08:05:00.000Z",
"reference": {
"paymentReference": "9122601030805123",
"ticketId": "KLM-2601-03118",
"pnr": null,
"reservationId": null,
"ticketNumbers": []
},
"operator": {
"uuid": "7a8b9c0d-1e2f-4a3b-9c4d-5e6f7a8b9c0d",
"tradingName": "Kilimanjaro Express"
},
"journeyType": "ONE_WAY",
"journey": {
"from": "Dar es Salaam",
"to": "Arusha",
"departureDate": "2026-01-07",
"departureTime": "06:00",
"arrivalDate": "2026-01-07",
"arrivalTime": "16:30"
},
"legs": [
{
"direction": "OUTBOUND",
"from": "Dar es Salaam",
"to": "Arusha",
"departureDate": "2026-01-07",
"departureTime": "06:00",
"arrivalDate": "2026-01-07",
"arrivalTime": "16:30"
}
],
"passengers": {
"count": 3,
"cancelledCount": 1,
"names": ["Asha Juma", "Juma Ali", "Neema Said"],
"seats": ["12", "13", "14"]
},
"price": { "amount": "135000.00", "currency": "TZS" },
"bookingUserType": "ONLINE",
"bookingChannel": "MOBILE_APP",
"bus": { "ticketId": "KLM-2601-03118", "ferryOption": null }
}
],
"nextCursor": "eyJ2IjoxLCJ0IjoxNzY3NDI3NTAwMDAwLCJ4IjpbIkI6cDo5MTIyNjAxMDMwODA1MTIzIl19",
"metadata": { "limit": 20, "kinds": ["BUS", "FERRY", "TRAIN", "FLIGHT"] }
}

Response Fields

FieldTypeDescription
itemsarrayBooking Summary Objects, newest first. May be empty on the last page.
nextCursorstring or nullCursor for the next page, or null when there are no more bookings.
metadata.limitnumberThe page size actually applied after clamping.
metadata.kindsarrayThe kinds actually applied, in canonical order (BUS, FERRY, TRAIN, FLIGHT).

Booking Lookup

Finds bookings that match one selector across every selected mode. Use it for support search and for resolving a reference a customer reads out.

Endpoint: GET /api/bookings/lookup

Example Requests:

GET /api/bookings/lookup?paymentReference=SGR1234567890123456
GET /api/bookings/lookup?ticketId=TRC0001&kinds=TRAIN
GET /api/bookings/lookup?reference=X7K9LM
GET /api/bookings/lookup?phoneNumber=0714503163
GET /api/bookings/lookup?phoneNumber=714503163&countryCode=255
GET /api/bookings/lookup?name=Asha%20Juma&from=2026-01-01&to=2026-01-31

Query Parameters

Exactly one selector must be present (non-empty after trimming). Sending none, or more than one, returns 400 BOOKINGS_SELECTOR_INVALID.

ParameterTypeRequiredDescription
paymentReferencestringSelectorSafiri payment reference of the booking (912… bus and ferry, SGR… train, FLT… flight).
ticketIdstringSelectorTicket number as printed on the ticket: bus or ferry ticketId, train TRC ticket number, flight ticket ID or IATA e-ticket number.
referencestringSelectorCatch-all. Matches everything paymentReference and ticketId match, plus the train PNR, the flight booking reference (PNR) and airline record locators.
namestringSelectorPassenger name, case-insensitive substring match.
phoneNumberstringSelectorPassenger phone number in any common shape (see Phone numbers).
countryCodestringNoCalling code digits (for example 255) used to interpret a phoneNumber that has no + prefix. Ignored for other selectors.
fromstringNoLower bound on bookedAt, YYYY-MM-DD, inclusive from 00:00 UTC of that day.
tostringNoUpper bound on bookedAt, YYYY-MM-DD, inclusive of the whole day (the query uses the next day's 00:00 UTC as an exclusive bound).
kindsstringNoAs in the Activity Feed. Only the selected modes are searched.
ownerUuidstringNoAs in the Activity Feed. Staff narrow the search to one buyer; non-staff callers are always limited to their own bookings.

Selectors

Reference-style selectors (paymentReference, ticketId, reference) are trimmed and upper-cased, then must match ^[A-Z0-9-]{4,40}$; otherwise the response is 400 BOOKINGS_REFERENCE_INVALID. Matching is exact on the normalised value; there is no prefix or fuzzy matching. When the value does not already start with the bus payment prefix (912), the prefixed form is also tried against bus and ferry payment references, so a customer who reads out a bare bus number is still found.

SelectorBus / Ferry fields matchedTrain fields matchedFlight fields matched
paymentReferencepaymentReference (value, and 912 + value)paymentReferencepaymentReference
ticketIdticketId (value); paymentReference (912 + value)ticketNumberticketId, ticketNumber
referencepaymentReference (value, and 912 + value), ticketIdpaymentReference, pnr, ticketNumberpaymentReference, ticketId, bookingReference, recordLocators.controlNumber, ticketNumber
namepassengerDetails.fullNamepassenger.passengerNamepassengerDetails.fullName, passengerDetails.firstName, passengerDetails.lastName
phoneNumberpassengerDetails.phoneNumber + passengerDetails.countryCodepassenger.phonepassengerDetails.phoneNumber + passengerDetails.countryCode

Examples per mode:

ModepaymentReferenceticketIdreference also finds
Bus / Ferry9122601030805123KLM-2601-03118either of the two
TrainSGR1234567890123456TRC0001 (TRC ticket number)ABC123 (PNR)
FlightFLT12345678901234560021234567890 (IATA e-ticket number)X7K9LM (booking reference), airline record locators

A hit on any ticket row returns the whole booking that row belongs to (every passenger and leg), subject to the caller's access scope. A non-staff caller who shares a reference with someone else's booking still sees nothing.

Phone numbers

phoneNumber must be 6 to 20 characters made of digits, spaces, +, (, ) and -. It is parsed into a calling code and a national number by trying, in order:

  1. A value that starts with + (for example +255714503163) is parsed exactly as given and no other shape is tried.
  2. Otherwise, when countryCode is supplied: the digits as an international number if they already start with that code (255714503163 with countryCode=255), then +<countryCode> followed by the national digits with any leading 0 removed (0714503163 or 714503163 with countryCode=255).
  3. Otherwise, or when those fail: the same two shapes with Safiri's default calling code 255.

The first shape that parses as a valid number wins; if none does, the response is 400 BOOKINGS_PHONE_INVALID. Each mode is then searched with the phone shapes it stores: bus and ferry with the national number, 0 + national number or code + national number together with the stored country code; train with those plus the + form; flight with the national number or 0 + national number together with the stored calling code, with or without a +.

name is trimmed and internal whitespace is collapsed. It must be 2 to 60 characters made of letters (any script), digits, spaces, apostrophes, dots and hyphens; otherwise the response is 400 BOOKINGS_NAME_INVALID. The search is a case-insensitive substring match over passenger names (regex metacharacters in the value are escaped). It matches passengers, not buyers.

Date window and the staff rule

  • from and to are optional for every selector and every caller. Each must be a real calendar date in YYYY-MM-DD; from later than to returns 400 BOOKINGS_WINDOW_INVALID. When both bounds are given the span may be at most 92 days. A window with only from is open-ended and is not span-checked, except for staff name searches, where the open end is now and the 92-day cap applies.
  • Staff name searches always run inside a window. When a staff caller searches by name without from, the lower bound defaults to 30 days before to (or before now), the upper bound defaults to now when to is absent, and the span is capped at 92 days. This is the only search that cannot use an index, so the window keeps it bounded. Non-staff callers never receive a default window because their searches are always scoped to one buyer.
  • The window actually applied is echoed in metadata.window (null when none), with to expressed as the exclusive upper bound.

Result rules

  • At most 25 bookings are returned, newest first (same ordering as the feed). metadata.truncated is true when more bookings matched than were returned, or when any mode hit its scan limit of 200 ticket rows; narrow the search (a tighter window, kinds, or ownerUuid) when it is set.
  • Each item carries matchedOn, the field family that produced the hit: PAYMENT_REFERENCE, TICKET_NUMBER, PNR, PHONE or NAME. A ticketId search that finds a bus booking through its 912-prefixed payment reference still reports TICKET_NUMBER, because that value is the ticket number; only paymentReference and reference searches report PAYMENT_REFERENCE.
  • Each item carries owned: true when the booking's buyer is the calling account. It is always true for non-staff callers and tells staff whether the customer they are helping bought the booking themselves.
  • A search that finds nothing, or finds only bookings the caller may not see, returns 200 with items: []. The endpoint never returns 404.
  • A search that exceeds its database time budget (5 seconds) returns 400 BOOKINGS_LOOKUP_TIMEOUT; narrow it and retry.

Success Response

The example below is a staff response, so items carry buyer.

{
"items": [
{
"id": "TRAIN:T:p:SGR1234567890123456",
"kind": "TRAIN",
"bookingKey": "T:p:SGR1234567890123456",
"status": "CONFIRMED",
"statusDetail": "PAID",
"bookedAt": "2026-01-05T10:00:00.000Z",
"reference": {
"paymentReference": "SGR1234567890123456",
"ticketId": null,
"pnr": "ABC123",
"reservationId": null,
"ticketNumbers": ["TRC0001", "TRC0002"]
},
"operator": {
"uuid": "9f1c2b3a-4d5e-4f60-8a71-b2c3d4e5f607",
"tradingName": "TRC"
},
"journeyType": "ONE_WAY",
"journey": {
"from": "Dar es Salaam",
"to": "Dodoma",
"departureDate": "2026-01-10",
"departureTime": "07:00",
"arrivalDate": "2026-01-10",
"arrivalTime": "11:00"
},
"legs": [
{
"direction": "OUTBOUND",
"from": "Dar es Salaam",
"to": "Dodoma",
"departureDate": "2026-01-10",
"departureTime": "07:00",
"arrivalDate": "2026-01-10",
"arrivalTime": "11:00"
}
],
"passengers": {
"count": 2,
"cancelledCount": 0,
"names": ["Asha Juma", "Juma Ali"],
"seats": ["A 12", "A 13"]
},
"price": { "amount": "98000.00", "currency": "TZS" },
"bookingUserType": "AGENT",
"bookingChannel": "API",
"buyer": {
"uuid": "5d6e7f80-9a1b-4c2d-8e3f-4a5b6c7d8e9f",
"fullName": "Safari Travel Agency"
},
"train": {
"pnr": "ABC123",
"trainName": "SGR Express",
"trainNumber": "T101",
"coach": "A",
"providerMode": "LIVE"
},
"matchedOn": "TICKET_NUMBER",
"owned": false
}
],
"metadata": {
"selector": "ticketId",
"truncated": false,
"kinds": ["TRAIN"],
"window": null
}
}

Response Fields

FieldTypeDescription
itemsarrayBooking Summary Objects extended with matchedOn and owned. At most 25, newest first.
items[].matchedOnstringPAYMENT_REFERENCE, TICKET_NUMBER, PNR, PHONE or NAME.
items[].ownedbooleanWhether the calling account is the buyer of the booking.
metadata.selectorstringThe selector used: paymentReference, ticketId, reference, name or phoneNumber.
metadata.truncatedbooleantrue when the result was cut at 25 bookings or a mode hit its 200-row scan limit.
metadata.kindsarrayThe kinds actually searched, in canonical order.
metadata.windowobject or null{ "from": ISO string or null, "to": ISO string or null } actually applied to bookedAt; to is exclusive. null when none.

Booking Summary Object

Every item returned by both endpoints has this shape. Exactly one of bus, train or flight is present, chosen by kind (FERRY uses bus). Text fields are trimmed and empty text is returned as null. Dates are YYYY-MM-DD; times are returned as stored by the mode (HH:mm).

FieldTypeDescription
idstringkind + : + bookingKey. Stable across requests and safe to use as a list key.
kindstringBUS, FERRY, TRAIN or FLIGHT.
bookingKeystringStable per-booking key (see Booking keys).
statusstringBooking-level status (see Status).
statusDetailstring or nullThe mode-specific status that decided status.
bookedAtstringISO 8601 timestamp of the booking: the earliest ticket row of the booking. This is the feed's sort key.
reference.paymentReferencestring or nullSafiri payment reference.
reference.ticketIdstring or nullBus/ferry ticket ID or flight ticket ID; always null for train.
reference.pnrstring or nullTrain PNR or flight booking reference; always null for bus/ferry.
reference.reservationIdstring or nullFlight reservation ID; null for other modes.
reference.ticketNumbersarrayTrain TRC ticket numbers or flight e-ticket numbers, distinct, at most 10; empty for bus/ferry.
operator.uuidstring or nullTransit agency UUID; null for flights.
operator.tradingNamestring or nullOperator name. For flights: the single airline of the itinerary, otherwise the validating carrier code.
journeyTypestringONE_WAY or RETURN. Bus and ferry are always ONE_WAY.
journeyobject or nullThe outbound leg without direction: from, to, departureDate, departureTime, arrivalDate, arrivalTime.
legsarrayOne entry per direction, OUTBOUND first, each with direction plus the journey fields. Bus and ferry always have one leg.
passengers.countnumberPassengers on the booking (see the per-mode table).
passengers.cancelledCountnumberPassengers whose ticket is cancelled.
passengers.namesarrayDistinct passenger names, at most 5.
passengers.seatsarrayDistinct seat labels, at most 10. Always empty for flights.
price.amountstring or nullBooking total as a decimal string with two decimals (for example "98000.00"). null when any amount is not numeric or currencies are mixed.
price.currencystring or nullISO currency code, or null when currencies are mixed.
bookingUserTypestring or nullWho made the booking, as stored by the mode (for example ONLINE, AGENT).
bookingChannelstring or nullChannel the booking was made through, as stored by the mode.
buyerobjectStaff responses only. { "uuid", "fullName" } of the account that bought the booking.
busobjectPresent for BUS and FERRY (see below).
trainobjectPresent for TRAIN (see below).
flightobjectPresent for FLIGHT (see below).

Never included: passenger phone numbers, country codes, email addresses, identity or passport numbers, nationality, disability flags, payment information, validation and fiscal signatures, device information, scans, fees, seat-hold keys, provider and workflow payloads, ZRA fields, disbursements and logos. boughtByUser is only exposed as the staff-only buyer block.

Status

status is derived from every ticket row of the booking, first matching rule wins:

ModeRulestatusDetail
Bus / Ferryevery row cancelled → CANCELLED; some rows cancelled → PARTIALLY_CANCELLED; any non-cancelled row unpaid → PENDING_PAYMENT; otherwise CONFIRMEDCANCELLED, PARTIALLY_CANCELLED, UNPAID, PAID
Trainevery row CANCELLEDCANCELLED; some → PARTIALLY_CANCELLED; any row RESERVED (seat held, payment outstanding) → PENDING_PAYMENT; otherwise CONFIRMED. Draft rows (RESERVATION_PENDING) are never listed.CANCELLED, PARTIALLY_CANCELLED, RESERVED, PAID
Flighteach row's state is its issuanceStatus, falling back to CANCELLED, ISSUED or CREATED from the cancelled and paid flags. Every row CANCELLED or CANCELLINGCANCELLED; some → PARTIALLY_CANCELLED; every row ISSUEDCONFIRMED; any unpaid row in CREATED, VALIDATION_FAILED, SELLING, PNR_CREATED, PRICE_CHANGED, REPRICE_UNAVAILABLE or TST_CREATEDPENDING_PAYMENT; otherwise PROCESSING (for example ISSUING, PARTIALLY_ISSUED, REFUND_PENDING, REFUND_REQUIRED, RECONCILIATION_REQUIRED)The deciding flight reservation status, or PARTIALLY_CANCELLED
statusMeaning
PENDING_PAYMENTThe booking exists but payment has not completed.
PROCESSINGPaid, but ticket issuance or a refund is still in progress (flights only).
CONFIRMEDEvery ticket is paid and issued.
PARTIALLY_CANCELLEDSome, but not all, tickets of the booking are cancelled.
CANCELLEDEvery ticket of the booking is cancelled.

Per-mode blocks

bus (also used for FERRY)

FieldTypeDescription
ticketIdstring or nullThe bus or ferry ticket ID (same value as reference.ticketId).
ferryOptionstring or nullThe ferry option stored on the ticket (PASSENGER, STUDENT, CAR, MOTORCYCLE, ...) when the operator is a marine operator; otherwise null.

train

FieldTypeDescription
pnrstring or nullTRC PNR (same value as reference.pnr).
trainNamestring or nullTrain name.
trainNumberstring or nullTrain number.
coachstring or nullRailway car name of the first ticket.
providerModestringLIVE for real TRC bookings, FAKE for simulated ones. Defaults to LIVE.

flight

FieldTypeDescription
reservationIdstring or nullSafiri flight reservation ID (same value as reference.reservationId).
bookingReferencestring or nullAirline booking reference / PNR (same value as reference.pnr).
airlineCodesarrayDistinct marketing carrier codes across all segments.
flightNumbersarrayDistinct flight numbers across all segments.
validatingCarrierstring or nullValidating carrier code.
issuanceStatusstring or nullThe reservation's issuance status as stored on the ticket rows.

How each mode is summarised

FieldBus / FerryTrainFlight
kindFERRY when the operator is a marine operator, else BUSTRAINFLIGHT
bookedAtdateBought (all rows of a booking share it)earliest createdAt across rowsearliest dateBought across rows
legsone OUTBOUND legone leg per journey direction (OUTBOUND, RETURN)one leg per itinerary, in itinerary order
passengers.countnumber of ticket rowsnumber of outbound tickets (every row when the booking has no journey marker)distinct passengers
passengers.namesdistinct names from a sample of at most 12 rowsdistinct passenger names of the outbound ticketsdistinct passenger names
passengers.seatsdistinct seat labels from the same sample"<car> <seat>" of the outbound ticketsalways empty
pricesum of every row's price in priceCurrencysum of price.grandTotal over every row (one row per passenger per leg)sum of price.grandTotal (or price.total) counting each passenger once, because every row carries the full journey fare
operatortransit agencytransit agency (TRC)uuid is null; tradingName is the single airline, else the validating carrier

Booking keys

bookingKey identifies one booking within its collection and is the value the feed cursor records. The middle letter names the field the key was taken from.

ModeKeyFallbacks, in order
Bus / FerryB:p:<paymentReference>B:i:<ticket ObjectId>
TrainT:p:<paymentReference>T:n:<pnr>, T:g:<passengerGroupId>, T:i:<ticket ObjectId>
FlightF:r:<reservationId>F:p:<paymentReference>, F:t:<ticketId>, F:i:<ticket ObjectId>

Use the references on the summary to open the full ticket documents through the mode-specific endpoints:

kindEndpoint
BUS, FERRYGET /api/ticket?paymentReference=<reference.paymentReference> (see Bus Booking)
TRAINGET /api/trip/train/tickets?paymentReference=<reference.paymentReference> (see Train Booking)
FLIGHTGET /api/flight/tickets?paymentReference=<reference.paymentReference> or GET /api/flight/tickets?ticketId=<reference.ticketId> (see Flight Booking)

Limitations

  • Deleted bookings are not listed. A train or flight booking whose ticket rows were removed after cancellation has no rows left to summarise and does not appear in either endpoint, even for staff.
  • Ownership is the buyer. Non-staff callers see bookings bought with their account only. Passengers on agent bookings, and any legacy bus ticket that records only a boughtBy name and no buyer account, are invisible to non-staff callers.
  • Order is booking time, not travel time. The feed is sorted by bookedAt; a ticket for tomorrow bought last month sorts below a ticket bought today for next year. Sort client-side by journey.departureDate if you need an upcoming-trips view.
  • Flight price counts each passenger once. Flight rows carry the whole journey fare per passenger, so a return booking is not double-counted. Train and bus totals are the sum of every ticket row.
  • Bus and ferry names and seats come from a sample. passengers.count and passengers.cancelledCount are exact for any booking size, but names and seats are taken from at most 12 ticket rows (and capped at 5 and 10 respectively).
  • Reference matching is exact and upper-cased. Values are upper-cased before matching, so identifiers stored in lower case cannot be found through the reference selectors; use the mode-specific endpoints for those.
  • Name search matches passengers, not buyers, and is a substring match with no transliteration or fuzzy matching.
  • Train seats still held past their payment window remain PENDING_PAYMENT until the expired reservation is cleaned up.
  • Short feed pages are normal (see Pagination); do not infer the end of the feed from a page size.

Operational notes

  • Production ticket index dependency. The bus and ferry side of both endpoints relies on indexes that exist on the production ticket collection but are not declared on the Ticket entity: { "boughtByUser.uuid": 1, "dateBought": 1 } (non-staff feed and every non-staff lookup), { "dateBought": -1 } (staff feed and staff name search window) and { "passengerDetails.phoneNumber": 1, "passengerDetails.countryCode": 1 } (phone lookup). IDX_ticket_payment_reference ({ "paymentReference": 1 }), which serves reference lookups and booking completion, is declared on the entity. Confirm with db.ticket.getIndexes() before deploying and after any index maintenance. If one is missing the affected query degrades to a bounded but slow collection scan. Do not add an @Index with the same keys under a different name: the entity synchronises indexes at boot and MongoDB rejects same-keys/different-name definitions.
  • Train index. IDX_trainTicket_boughtByUser_uuid_createdAt on TrainTicket (boughtByUser.uuid, createdAt) serves the non-staff train feed and is built automatically at boot.
  • Time budgets. Every database read is an aggregation with a server-side time limit: 8 seconds per query for the feed and 5 seconds for lookup. Exceeding it returns 400 BOOKINGS_LOOKUP_TIMEOUT rather than a 500, and the server logs a warning so a slow or missing index is noticed.
  • Scan bounds. The feed reads at most 4 × limit (max 400) rows per collection per page; lookup reads at most 200 rows per collection; train and flight completion reads at most 2000 rows per collection per request across all candidate bookings, while bus and ferry bookings are grouped server-side with no row cap, so a bulk ferry booking never crosses the wire row by row.
  • Logged warnings. The feed logs a warning (never surfaced to the client) when more than 100 bookings share one bookedAt millisecond at a page boundary, or when more than 2000 rows of one collection share a single millisecond; in both cases the remaining bookings at that instant are skipped and pagination continues.
  • Staff name search is the only unindexed scan (a regex over passenger names within the mandatory date window). If it proves slow in production, require pairing name with ownerUuid or phoneNumber, or add an index on dateBought + passengerDetails.fullName as a background build.
  • Caching. Responses are Cache-Control: private, no-store; never cache them in a shared cache.

Error Responses

When a bookings request fails, the API returns a standard error object:

{
"error": { "name": "bookings" },
"errorCode": "BOOKINGS_SELECTOR_INVALID",
"errorMessage": "Provide exactly one of paymentReference, ticketId, reference, name, phoneNumber",
"status": 400
}
HTTPerrorCodeWhen
400BOOKINGS_LIMIT_INVALIDlimit is not a string of digits.
400BOOKINGS_CURSOR_INVALIDcursor cannot be decoded or validated (not base64url, too long, wrong version, timestamp out of range, malformed tie list).
400BOOKINGS_KINDS_INVALIDkinds contains an unknown token or no tokens at all.
400BOOKINGS_OWNER_INVALIDownerUuid is not shaped like a UUID.
400BOOKINGS_SELECTOR_INVALIDLookup received no selector, or more than one.
400BOOKINGS_REFERENCE_INVALIDpaymentReference, ticketId or reference is not 4 to 40 letters, digits or hyphens.
400BOOKINGS_NAME_INVALIDname is not 2 to 60 letters, digits, spaces, apostrophes, dots or hyphens.
400BOOKINGS_PHONE_INVALIDphoneNumber has the wrong shape or does not parse as a valid number with any of the tried calling codes.
400BOOKINGS_WINDOW_INVALIDfrom or to is not a valid YYYY-MM-DD date, from is after to, or a bounded window (or a staff name search window) spans more than 92 days.
400BOOKINGS_LOOKUP_TIMEOUTA database query exceeded its time budget. errorMessage is Narrow the search window.
401PROTECTED_RESOURCENo authenticated user on the request.
403FORBIDDEN_ACCESS_PRIVILEGEA non-staff caller sent an ownerUuid other than their own.
500INTERNAL_SERVER_ERRORAny other failure. The message is fixed (Booking activity request failed or Booking lookup request failed); database details are logged, never returned.