Skip to main content

Express bus

Intercity coach travel, merged across operators. Unlike the other verticals this one has an optional middle step: a live seat map, for a seat picker.

1 · Find the cities

GET /api/v1/express-bus/cities?q=Penang
GET /api/v1/express-bus/cities?q=Penang&from=101

No API key required. Pass from to restrict results to destinations actually served from that origin — worth doing, since it stops customers picking a pair with no coverage.

[{ "id": 118, "name": "Georgetown", "state": "Penang" }]
POST /api/v1/express-bus/search
{
"originCityId": 101,
"destCityId": 118,
"departDate": "2026-09-14",
"adultQty": 2,
"childQty": 0
}

Each trip carries a quoteId, operator display fields, and a sellAmount locked to the pax mix you searched with. Quotes live 10 minutes.

{
"trips": [{
"quoteId": 90231,
"offeringRef": "bus|55120",
"operatorName": "Super Nice Express",
"coachType": "Executive 27",
"departureAt": "2026-09-14T09:30:00Z",
"pickup": "TBS Kuala Lumpur",
"dropoff": "Sungai Nibong, Penang",
"seatsAvailable": 14,
"sellAmount": 77.00,
"currency": "MYR",
"allowRefund": true,
"allowReschedule": true,
"expiresAt": "2026-09-07T12:10:00Z"
}]
}

3 · Draw the seat map — optional

GET /api/v1/express-bus/quotes/90231/seats
Fetch this per chosen trip, not per search result

It is one live supplier call. Requesting it across a whole result set costs a call per trip for layouts nobody looks at.

{
"selectionAvailable": true,
"seatsAvailable": 14,
"totalDeck": 1,
"seats": [
{ "deck": "1", "row": 3, "column": 1, "label": "3A", "available": false, "price": 77.00, "currency": "MYR" },
{ "deck": "1", "row": 3, "column": 2, "label": null, "available": false, "price": null, "currency": null },
{ "deck": "1", "row": 3, "column": 3, "label": "3B", "available": true, "price": 77.00, "currency": "MYR" }
]
}

Reading a cell:

CellDraw as
label is nullAn aisle or gap — empty space
label set, available: trueFree; selectable
label set, available: falseSold; occupied

The grid comes back whole, gaps included, so you can lay the coach out — dropping the aisles would shift every seat after them.

When selectionAvailable is false, the coach does not support choosing. Do not draw a picker; omit seats when booking and the operator assigns them.

price on a cell is the operator's published fare and is informational only. Your customer pays the quote's sellAmount, fixed at search time.

4 · Book

POST /api/v1/bookings
{
"idempotencyKey": "5c9b1d77-2a10-4f8e-8a41-0d7c3e2b9f10",
"items": [{
"quoteId": 90231,
"details": {
"contactName": "Jane Tan",
"contactPhone": "+60123456789",
"contactEmail": "jane@example.com",
"adultQty": 2,
"childQty": 0,
"seats": ["3B", "3C"]
}
}]
}

The details bag

FieldRequiredNotes
contactNameyesNon-blank
contactPhoneyesNon-blank
contactEmailyesNon-blank
adultQtyyesMust equal the pax you quoted
childQtyyesMust equal the pax you quoted
seatsnoWhat the traveller picked. Omit to let the operator assign

Pax must match the quote. Booking 3 adults against a quote priced for 2 fails with Booked pax 3A+0C does not match the quoted pax 2A+0C — please re-quote. The price is only valid for the mix it was computed for; re-quote rather than editing the request.

Seats accept either the array form ["3B","3C"] or the operator's comma form "3B,3C". Blanks are dropped, so a trailing comma cannot quietly become a passenger without a seat. The count must equal adultQty + childQty, or you get a 400.

A seat sold between drawing the map and booking comes back as a declined reserve carrying the operator's own message. Re-draw the map and let the customer pick again.

5 · Deliver the boarding code

"detail": {
"type": "BOT_TICKET",
"boardingCode": "BC7741",
"ticketNo": "TN-55120",
"bookingPdf": "https://.../booking/55120.pdf",
"seat": "3B,3C",
"allowRefund": true,
"allowReschedule": true,
"policies": []
}

boardingCode is what the passenger shows when boarding, and is mirrored as redemptionRef.