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
BookingSummaryshape for every mode, with a small mode-specific block (bus,trainorflight) 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.
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 withownerUuid. Staff responses also carry thebuyerblock. - 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
| Parameter | Type | Required | Description |
|---|---|---|---|
limit | string | No | Page 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. |
cursor | string | No | Opaque 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. |
kinds | string | No | Comma-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. |
ownerUuid | string | No | Buyer 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
nextCursoruntilnextCursorisnull. There is no total count. - A page may contain fewer than
limititems 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. kindsmay change between pages. The cursor only records where the caller is in time, not which modes were requested.ownerUuidis 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
bookedAtdescending, then bykindin the orderBUS,FERRY,TRAIN,FLIGHT, then bybookingKeyascending. 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
| Field | Type | Description |
|---|---|---|
items | array | Booking Summary Objects, newest first. May be empty on the last page. |
nextCursor | string or null | Cursor for the next page, or null when there are no more bookings. |
metadata.limit | number | The page size actually applied after clamping. |
metadata.kinds | array | The 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.
| Parameter | Type | Required | Description |
|---|---|---|---|
paymentReference | string | Selector | Safiri payment reference of the booking (912… bus and ferry, SGR… train, FLT… flight). |
ticketId | string | Selector | Ticket number as printed on the ticket: bus or ferry ticketId, train TRC ticket number, flight ticket ID or IATA e-ticket number. |
reference | string | Selector | Catch-all. Matches everything paymentReference and ticketId match, plus the train PNR, the flight booking reference (PNR) and airline record locators. |
name | string | Selector | Passenger name, case-insensitive substring match. |
phoneNumber | string | Selector | Passenger phone number in any common shape (see Phone numbers). |
countryCode | string | No | Calling code digits (for example 255) used to interpret a phoneNumber that has no + prefix. Ignored for other selectors. |
from | string | No | Lower bound on bookedAt, YYYY-MM-DD, inclusive from 00:00 UTC of that day. |
to | string | No | Upper 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). |
kinds | string | No | As in the Activity Feed. Only the selected modes are searched. |
ownerUuid | string | No | As 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.
| Selector | Bus / Ferry fields matched | Train fields matched | Flight fields matched |
|---|---|---|---|
paymentReference | paymentReference (value, and 912 + value) | paymentReference | paymentReference |
ticketId | ticketId (value); paymentReference (912 + value) | ticketNumber | ticketId, ticketNumber |
reference | paymentReference (value, and 912 + value), ticketId | paymentReference, pnr, ticketNumber | paymentReference, ticketId, bookingReference, recordLocators.controlNumber, ticketNumber |
name | passengerDetails.fullName | passenger.passengerName | passengerDetails.fullName, passengerDetails.firstName, passengerDetails.lastName |
phoneNumber | passengerDetails.phoneNumber + passengerDetails.countryCode | passenger.phone | passengerDetails.phoneNumber + passengerDetails.countryCode |
Examples per mode:
| Mode | paymentReference | ticketId | reference also finds |
|---|---|---|---|
| Bus / Ferry | 9122601030805123 | KLM-2601-03118 | either of the two |
| Train | SGR1234567890123456 | TRC0001 (TRC ticket number) | ABC123 (PNR) |
| Flight | FLT1234567890123456 | 0021234567890 (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:
- A value that starts with
+(for example+255714503163) is parsed exactly as given and no other shape is tried. - Otherwise, when
countryCodeis supplied: the digits as an international number if they already start with that code (255714503163withcountryCode=255), then+<countryCode>followed by the national digits with any leading0removed (0714503163or714503163withcountryCode=255). - 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 search
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
fromandtoare optional for every selector and every caller. Each must be a real calendar date inYYYY-MM-DD;fromlater thantoreturns400 BOOKINGS_WINDOW_INVALID. When both bounds are given the span may be at most 92 days. A window with onlyfromis open-ended and is not span-checked, except for staffnamesearches, where the open end is now and the 92-day cap applies.- Staff
namesearches always run inside a window. When a staff caller searches bynamewithoutfrom, the lower bound defaults to 30 days beforeto(or before now), the upper bound defaults to now whentois 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(nullwhen none), withtoexpressed as the exclusive upper bound.
Result rules
- At most 25 bookings are returned, newest first (same ordering as the feed).
metadata.truncatedistruewhen 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, orownerUuid) when it is set. - Each item carries
matchedOn, the field family that produced the hit:PAYMENT_REFERENCE,TICKET_NUMBER,PNR,PHONEorNAME. AticketIdsearch that finds a bus booking through its912-prefixed payment reference still reportsTICKET_NUMBER, because that value is the ticket number; onlypaymentReferenceandreferencesearches reportPAYMENT_REFERENCE. - Each item carries
owned:truewhen the booking's buyer is the calling account. It is alwaystruefor 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
200withitems: []. The endpoint never returns404. - 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
| Field | Type | Description |
|---|---|---|
items | array | Booking Summary Objects extended with matchedOn and owned. At most 25, newest first. |
items[].matchedOn | string | PAYMENT_REFERENCE, TICKET_NUMBER, PNR, PHONE or NAME. |
items[].owned | boolean | Whether the calling account is the buyer of the booking. |
metadata.selector | string | The selector used: paymentReference, ticketId, reference, name or phoneNumber. |
metadata.truncated | boolean | true when the result was cut at 25 bookings or a mode hit its 200-row scan limit. |
metadata.kinds | array | The kinds actually searched, in canonical order. |
metadata.window | object 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).
| Field | Type | Description |
|---|---|---|
id | string | kind + : + bookingKey. Stable across requests and safe to use as a list key. |
kind | string | BUS, FERRY, TRAIN or FLIGHT. |
bookingKey | string | Stable per-booking key (see Booking keys). |
status | string | Booking-level status (see Status). |
statusDetail | string or null | The mode-specific status that decided status. |
bookedAt | string | ISO 8601 timestamp of the booking: the earliest ticket row of the booking. This is the feed's sort key. |
reference.paymentReference | string or null | Safiri payment reference. |
reference.ticketId | string or null | Bus/ferry ticket ID or flight ticket ID; always null for train. |
reference.pnr | string or null | Train PNR or flight booking reference; always null for bus/ferry. |
reference.reservationId | string or null | Flight reservation ID; null for other modes. |
reference.ticketNumbers | array | Train TRC ticket numbers or flight e-ticket numbers, distinct, at most 10; empty for bus/ferry. |
operator.uuid | string or null | Transit agency UUID; null for flights. |
operator.tradingName | string or null | Operator name. For flights: the single airline of the itinerary, otherwise the validating carrier code. |
journeyType | string | ONE_WAY or RETURN. Bus and ferry are always ONE_WAY. |
journey | object or null | The outbound leg without direction: from, to, departureDate, departureTime, arrivalDate, arrivalTime. |
legs | array | One entry per direction, OUTBOUND first, each with direction plus the journey fields. Bus and ferry always have one leg. |
passengers.count | number | Passengers on the booking (see the per-mode table). |
passengers.cancelledCount | number | Passengers whose ticket is cancelled. |
passengers.names | array | Distinct passenger names, at most 5. |
passengers.seats | array | Distinct seat labels, at most 10. Always empty for flights. |
price.amount | string or null | Booking total as a decimal string with two decimals (for example "98000.00"). null when any amount is not numeric or currencies are mixed. |
price.currency | string or null | ISO currency code, or null when currencies are mixed. |
bookingUserType | string or null | Who made the booking, as stored by the mode (for example ONLINE, AGENT). |
bookingChannel | string or null | Channel the booking was made through, as stored by the mode. |
buyer | object | Staff responses only. { "uuid", "fullName" } of the account that bought the booking. |
bus | object | Present for BUS and FERRY (see below). |
train | object | Present for TRAIN (see below). |
flight | object | Present 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:
| Mode | Rule | statusDetail |
|---|---|---|
| Bus / Ferry | every row cancelled → CANCELLED; some rows cancelled → PARTIALLY_CANCELLED; any non-cancelled row unpaid → PENDING_PAYMENT; otherwise CONFIRMED | CANCELLED, PARTIALLY_CANCELLED, UNPAID, PAID |
| Train | every row CANCELLED → CANCELLED; 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 |
| Flight | each row's state is its issuanceStatus, falling back to CANCELLED, ISSUED or CREATED from the cancelled and paid flags. Every row CANCELLED or CANCELLING → CANCELLED; some → PARTIALLY_CANCELLED; every row ISSUED → CONFIRMED; any unpaid row in CREATED, VALIDATION_FAILED, SELLING, PNR_CREATED, PRICE_CHANGED, REPRICE_UNAVAILABLE or TST_CREATED → PENDING_PAYMENT; otherwise PROCESSING (for example ISSUING, PARTIALLY_ISSUED, REFUND_PENDING, REFUND_REQUIRED, RECONCILIATION_REQUIRED) | The deciding flight reservation status, or PARTIALLY_CANCELLED |
status | Meaning |
|---|---|
PENDING_PAYMENT | The booking exists but payment has not completed. |
PROCESSING | Paid, but ticket issuance or a refund is still in progress (flights only). |
CONFIRMED | Every ticket is paid and issued. |
PARTIALLY_CANCELLED | Some, but not all, tickets of the booking are cancelled. |
CANCELLED | Every ticket of the booking is cancelled. |
Per-mode blocks
bus (also used for FERRY)
| Field | Type | Description |
|---|---|---|
ticketId | string or null | The bus or ferry ticket ID (same value as reference.ticketId). |
ferryOption | string or null | The ferry option stored on the ticket (PASSENGER, STUDENT, CAR, MOTORCYCLE, ...) when the operator is a marine operator; otherwise null. |
train
| Field | Type | Description |
|---|---|---|
pnr | string or null | TRC PNR (same value as reference.pnr). |
trainName | string or null | Train name. |
trainNumber | string or null | Train number. |
coach | string or null | Railway car name of the first ticket. |
providerMode | string | LIVE for real TRC bookings, FAKE for simulated ones. Defaults to LIVE. |
flight
| Field | Type | Description |
|---|---|---|
reservationId | string or null | Safiri flight reservation ID (same value as reference.reservationId). |
bookingReference | string or null | Airline booking reference / PNR (same value as reference.pnr). |
airlineCodes | array | Distinct marketing carrier codes across all segments. |
flightNumbers | array | Distinct flight numbers across all segments. |
validatingCarrier | string or null | Validating carrier code. |
issuanceStatus | string or null | The reservation's issuance status as stored on the ticket rows. |
How each mode is summarised
| Field | Bus / Ferry | Train | Flight |
|---|---|---|---|
kind | FERRY when the operator is a marine operator, else BUS | TRAIN | FLIGHT |
bookedAt | dateBought (all rows of a booking share it) | earliest createdAt across rows | earliest dateBought across rows |
legs | one OUTBOUND leg | one leg per journey direction (OUTBOUND, RETURN) | one leg per itinerary, in itinerary order |
passengers.count | number of ticket rows | number of outbound tickets (every row when the booking has no journey marker) | distinct passengers |
passengers.names | distinct names from a sample of at most 12 rows | distinct passenger names of the outbound tickets | distinct passenger names |
passengers.seats | distinct seat labels from the same sample | "<car> <seat>" of the outbound tickets | always empty |
price | sum of every row's price in priceCurrency | sum 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 |
operator | transit agency | transit 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.
| Mode | Key | Fallbacks, in order |
|---|---|---|
| Bus / Ferry | B:p:<paymentReference> | B:i:<ticket ObjectId> |
| Train | T:p:<paymentReference> | T:n:<pnr>, T:g:<passengerGroupId>, T:i:<ticket ObjectId> |
| Flight | F:r:<reservationId> | F:p:<paymentReference>, F:t:<ticketId>, F:i:<ticket ObjectId> |
Deep links
Use the references on the summary to open the full ticket documents through the mode-specific endpoints:
kind | Endpoint |
|---|---|
BUS, FERRY | GET /api/ticket?paymentReference=<reference.paymentReference> (see Bus Booking) |
TRAIN | GET /api/trip/train/tickets?paymentReference=<reference.paymentReference> (see Train Booking) |
FLIGHT | GET /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
boughtByname 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 byjourney.departureDateif 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.countandpassengers.cancelledCountare exact for any booking size, butnamesandseatsare 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_PAYMENTuntil 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
ticketindex dependency. The bus and ferry side of both endpoints relies on indexes that exist on the productionticketcollection but are not declared on theTicketentity:{ "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 withdb.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@Indexwith 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_createdAtonTrainTicket(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_TIMEOUTrather than a500, 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
bookedAtmillisecond 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
namewithownerUuidorphoneNumber, or add an index ondateBought+passengerDetails.fullNameas 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
}
| HTTP | errorCode | When |
|---|---|---|
| 400 | BOOKINGS_LIMIT_INVALID | limit is not a string of digits. |
| 400 | BOOKINGS_CURSOR_INVALID | cursor cannot be decoded or validated (not base64url, too long, wrong version, timestamp out of range, malformed tie list). |
| 400 | BOOKINGS_KINDS_INVALID | kinds contains an unknown token or no tokens at all. |
| 400 | BOOKINGS_OWNER_INVALID | ownerUuid is not shaped like a UUID. |
| 400 | BOOKINGS_SELECTOR_INVALID | Lookup received no selector, or more than one. |
| 400 | BOOKINGS_REFERENCE_INVALID | paymentReference, ticketId or reference is not 4 to 40 letters, digits or hyphens. |
| 400 | BOOKINGS_NAME_INVALID | name is not 2 to 60 letters, digits, spaces, apostrophes, dots or hyphens. |
| 400 | BOOKINGS_PHONE_INVALID | phoneNumber has the wrong shape or does not parse as a valid number with any of the tried calling codes. |
| 400 | BOOKINGS_WINDOW_INVALID | from 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. |
| 400 | BOOKINGS_LOOKUP_TIMEOUT | A database query exceeded its time budget. errorMessage is Narrow the search window. |
| 401 | PROTECTED_RESOURCE | No authenticated user on the request. |
| 403 | FORBIDDEN_ACCESS_PRIVILEGE | A non-staff caller sent an ownerUuid other than their own. |
| 500 | INTERNAL_SERVER_ERROR | Any other failure. The message is fixed (Booking activity request failed or Booking lookup request failed); database details are logged, never returned. |