Train Booking Workflow
Use this workflow to discover SGR stations, search train options, retrieve cabin and seat details, hold selected seats, and create an SGR reservation with a 25-minute Safiri payment window.
Step 1: Get Train Stations
Fetch the station list from the train GTFS API.
GET https://train-gtfs-api.safiri.app/api/stops?mini=true
Use the stop_id field from the response as the station identifier in train search requests.
{
"stop_id": "DARESALAAM:SGR:0",
"stop_name": "Dar es Salaam (John Magufuli SGR Station)",
"stop_desc": "Kisutu, Ilala, Dar es Salaam, Tanzania",
"partners": [
{
"provider": "TRC",
"service": "SGR",
"id": 1,
"name": "Dar Es Salaam"
}
]
}
For SGR train search, use stations that include TRC partner metadata with "service": "SGR".
Step 2: Search for Trains
Search with the origin stop ID, destination stop ID, and travel date.
GET /api/trip/train/search?from=DARESALAAM:SGR:0&to=MOROGORO:SGR:0&date=2026-06-10
Query parameters:
| Parameter | Required | Description |
|---|---|---|
from | Yes | Origin station stop_id, for example DARESALAAM:SGR:0. |
to | Yes | Destination station stop_id, for example MOROGORO:SGR:0. |
date | Yes | Travel date in YYYY-MM-DD format. |
lookAheadDays | No | Check later dates after an empty requested-date result. The value must be exactly 1, 2, or 3. |
The response contains a meta object and a data array of train options.
{
"meta": {
"count": 6,
"provider": "TRC_SGR"
},
"data": [
{
"provider": "TRC_SGR",
"trip": {
"id": 98,
"name": "DAR - DOM \\ EXPRESS",
"number": "11902"
},
"departure": {
"date": "2026-06-10",
"time": "06:00:00"
},
"arrival": {
"date": "2026-06-10",
"time": "07:40:00"
},
"price": {
"pricingContext": "ONLINE",
"currency": "TZS",
"total": "22500",
"bookingFeeTotal": "2000",
"grandTotal": "24500",
"fees": [
{ "type": "TRANSPORTER", "amount": "22500", "recipient": "TRC" },
{ "type": "FACILITATION", "amount": "0", "recipient": "SAFIRI_AGENT" },
{ "type": "SAFIRI_COMMISSION", "amount": "2000", "recipient": "SAFIRI" }
],
"otherCurrencies": {
"TZS": {
"amount": 22500,
"formatted": "TZS 22,500.00"
},
"USD": {
"amount": 8.57,
"formatted": "USD 8.57"
}
}
}
}
]
}
Help Customers Find a Nearby Date
Opt in to a nearby-date check by adding lookAheadDays:
GET /api/trip/train/search?from=MOROGORO:SGR:0&to=DODOMA:SGR:0&date=2026-08-25&lookAheadDays=3
If the requested date is empty and a train is found on a later date, data remains empty because it always belongs to the requested date. Use meta.availability.next.date or meta.links.nextAvailable to offer a separate search:
{
"meta": {
"count": 0,
"provider": "TRC_SGR",
"links": {
"self": "https://booking-api.safiri.app/api/trip/train/search?from=MOROGORO%3ASGR%3A0&to=DODOMA%3ASGR%3A0&date=2026-08-25&lookAheadDays=3",
"nextAvailable": "https://booking-api.safiri.app/api/trip/train/search?from=MOROGORO%3ASGR%3A0&to=DODOMA%3ASGR%3A0&date=2026-08-26&lookAheadDays=3"
},
"availability": {
"status": "NO_RESULTS",
"requestedDate": "2026-08-25",
"serviceDate": "2026-08-26",
"serviceTimeZone": "Africa/Dar_es_Salaam",
"lookAhead": {
"requestedDays": 3,
"status": "FOUND",
"firstDate": "2026-08-26",
"confirmedThroughDate": "2026-08-26"
},
"next": {
"date": "2026-08-26"
}
},
"createdAt": "2026-08-25T21:33:59.578Z"
},
"data": []
}
Interpret the two status levels independently:
| Situation | Requested date | Look-ahead | UI behavior |
|---|---|---|---|
| Requested date has trains | RESULTS_FOUND | NOT_NEEDED | Render data normally. |
| A later checked date has trains | NO_RESULTS | FOUND | Explain that the requested date is empty and offer an explicit search action for next.date. |
| All requested candidate dates are empty | NO_RESULTS | NO_RESULTS | Say that no trains were found in the dates checked. Do not imply the route never operates. |
| A candidate check fails, times out, or cannot be validated | NO_RESULTS | INCOMPLETE | Explain that the requested date is empty but nearby-date checking could not be completed. |
When lookAheadDays is omitted, meta.availability is omitted and the existing response shape is unchanged. links.nextAvailable appears only for FOUND. meta.count always describes the requested date's data, never a suggested date.
Use the date-only fields as railway calendar dates. serviceDate is the railway's current date in Africa/Dar_es_Salaam; the first candidate is the later of the next requested calendar date and serviceDate. Returned departure and arrival dates/times are already provider-local and are not UTC values that the client should shift. Avoid new Date("YYYY-MM-DD") for Today/Tomorrow labels; compare and add calendar dates directly, then localize the weekday and month text.
Keep empty-state wording neutral. These statuses do not prove that seats are available, that trains have sold out or departed, or that a route is impossible. A 200 with data: [] is a completed search with no requested-date results; invalid requests and genuine search failures use non-2xx responses.
Pricing and Wallet Charges
For third-party agent integrations, treat the returned price.grandTotal as the amount Safiri will deduct from your Safiri wallet for that train ticket. It is made up of:
| Component | Field | Meaning |
|---|---|---|
| Transporter fee | price.fees[] where type is TRANSPORTER; also price.total | The TRC fare payable to the transporter. |
| Safiri fee | price.fees[] where type is SAFIRI_COMMISSION | Safiri's fee for processing the booking. |
| Wallet debit | price.grandTotal | The total amount Safiri charges your wallet for the ticket. |
You may charge your own customer any additional service, agency, or handling fee above price.grandTotal. That extra customer-facing amount is not sent to Safiri in the reservation request and is not deducted from your Safiri wallet.
Example: if price.total is 22500, SAFIRI_COMMISSION is 2000, and price.grandTotal is 24500, Safiri deducts 24500 from your wallet. If you charge the customer 26000, the extra 1500 is your own markup outside Safiri.
Step 3: Get Train Details
After the customer selects a train option from data, send the full selected item to the details endpoint.
POST /api/trip/train/details
{
"trip": {
"provider": "TRC_SGR",
"trip": {
"id": 98,
"name": "DAR - DOM \\ EXPRESS",
"number": "11902"
},
"route": { "...": "full route object from search" },
"departure": { "...": "full departure object from search" },
"arrival": { "...": "full arrival object from search" },
"price": { "...": "full price object from search" },
"stopTimes": [],
"cars": [],
"transitAgency": { "...": "full transit agency object from search" }
}
}
The response keeps the selected trip information and adds detailed train data.
{
"meta": {
"provider": "TRC_SGR"
},
"data": {
"provider": "TRC_SGR",
"trip": {
"id": 98,
"name": "DAR - DOM \\ EXPRESS",
"number": "11902"
},
"train": {
"id": 8537,
"type": "High Speed Train",
"name": "EXPRESS TRAIN",
"cars": [
{
"id": 417,
"name": "ROYAL CLASS KIOSK",
"carType": "Royal Class",
"emptySeats": 0,
"prices": [
{
"ticketTypeName": "Adult (13 and Above)",
"pricingContext": "ONLINE",
"total": "45500",
"bookingFeeTotal": "2000",
"grandTotal": "47500",
"fees": [
{ "type": "TRANSPORTER", "amount": "45500", "recipient": "TRC" },
{ "type": "FACILITATION", "amount": "0", "recipient": "SAFIRI_AGENT" },
{ "type": "SAFIRI_COMMISSION", "amount": "2000", "recipient": "SAFIRI" }
],
"formattedPrice": "TZS 45,500.00",
"currency": "TZS"
}
],
"seats": [
{
"id": "1020",
"label": "23",
"positionY": 6,
"positionX": 1,
"key": "1020",
"type": "SEAT",
"class": "ROYAL_CLASS",
"orientation": "DOWN",
"available": true
}
]
}
]
},
"bookingRequirements": {
"travelerRequirements": {
"travelerId": true,
"genderRequired": true,
"ageCategory": true,
"seatRequired": true,
"nationality": true,
"luggageRequired": false,
"emailAddressRequired": false,
"mobilePhoneNumberRequired": true
}
}
}
}
Step 4: Choose a Train Option, Cabin, and Seat
Present each item from the search data as a train option, then use the details response to show cabins, fares, and seats. Useful fields for the selection UI include:
| Field | How to use it |
|---|---|
trip.name and trip.number | Show the train service name and train number. |
departure.dateTime and arrival.dateTime | Show the local departure and arrival date-time. |
route.name and trip.headsign | Show the route name and major intermediate stops. |
price.total, price.bookingFeeTotal, price.grandTotal, price.fees, price.currency, and price.otherCurrencies | Show the TRC fare, Safiri fee breakdown, Safiri wallet debit amount, and supported converted currencies. Add any customer-facing markup in your own system. |
stopTimes | Show the full route, intermediate stops, and stop-level timing. |
train.cars | Show available cabins such as Royal Class, Business, and Economy, plus capacity and available seats. |
train.cars[].prices | Show passenger-type prices for the selected cabin, for example adult and child fares. |
train.cars[].seats | Render the seat map and show which seats are available. |
bookingRequirements | Capture the traveler fields and options needed by the reservation flow. |
transitAgency | Show the train operator details when needed. |
Step 5: Hold and Release Seats
When a customer selects a seat, hold it immediately.
POST /api/trip/train/seat/hold
{
"departureStopPartnerId": 1,
"arrivalStopPartnerId": 12,
"timeTableTrainSetId": 8710,
"carId": 497,
"seat": 2119,
"luggageId": 5
}
The response returns a hold.key. Store that key on the selected passenger/seat in your UI.
{
"data": {
"provider": "TRC_SGR",
"hold": {
"key": "f70c1e0e-9849-41dd-b3b7-949eac159dc2",
"status": "HELD"
}
}
}
If the customer unselects the seat, release it.
GET /api/trip/train/seat/release?key=f70c1e0e-9849-41dd-b3b7-949eac159dc2
Step 6: Reserve Held Seats
After passenger details are submitted, send the selected details object and passenger-held-seat mapping to the reservation endpoint.
POST /api/trip/train/seat/reserve
For one-way bookings, send return: null or omit return.
{
"outbound": { "...": "selected outbound train details object" },
"return": null,
"passengers": [
{
"passengerName": "ITULE",
"identityNo": "",
"phone": "0757713888",
"countryCodeName": "TZ",
"email": "abraham@itule.me",
"nationalityTypeId": 19,
"isDisabledPassenger": false,
"ticketTypeId": 4,
"outboundSeatHoldKey": "f70c1e0e-9849-41dd-b3b7-949eac159dc2",
"outboundCarId": 497,
"outboundCarTypeId": 151,
"outboundSeatId": 2119
}
],
"notifications": {
"ticketPurchase": false
}
}
notifications is optional. notifications.ticketPurchase defaults to
true; set it to false when Safiri should not deliver the final issued
ticket to the customer through email, SMS, WhatsApp, or push. This does not
disable the optional agent callback or issuance-status polling.
For a third-party agent reservation, Safiri never sends customer reservation
or reservation-cancellation notifications on any supported channel. Final
issued-ticket notifications are the only customer notifications sent and stay
enabled by default unless ticketPurchase is false.
For return journeys, use a separate selected return train and separate return seat hold key.
{
"outbound": { "...": "selected outbound train details object" },
"return": { "...": "selected return train details object" },
"passengers": [
{
"passengerName": "ITULE",
"identityNo": "",
"phone": "0757713888",
"countryCodeName": "TZ",
"email": "abraham@itule.me",
"nationalityTypeId": 19,
"isDisabledPassenger": false,
"ticketTypeId": 4,
"outboundSeatHoldKey": "outbound-seat-hold-key",
"outboundCarId": 497,
"outboundCarTypeId": 151,
"outboundSeatId": 2119,
"returnSeatHoldKey": "return-seat-hold-key",
"returnCarId": 501,
"returnCarTypeId": 151,
"returnSeatId": 2120
}
]
}
Reservations require a bearer token. The authenticated account is attached
automatically. Include issuerDeviceInfo only when the issuing app has device
metadata to record.
For third-party agents, make sure the authenticated user's Safiri wallet can cover the selected tickets' price.grandTotal values. Your own customer markup should stay outside the reservation payload.
The reservation response includes the booking reference, ticket numbers, bill details, a 25-minute payment window, payment items, and ticket identifiers.
{
"data": {
"provider": "TRC_SGR",
"journeyType": "ONE_WAY",
"reservation": {
"pnr": "PNR00019e8aa4492c00",
"ticketNumbers": ["TRC00019e8aa449a500"],
"passengerCount": 1,
"segmentCount": 1
},
"bill": {
"id": "TRC-Pay062604545d07cf93a6ce4878913fb6fff8db2f76",
"expireDate": "2026-06-12T10:30:00.000+03:00",
"paymentRequired": true,
"controlNumber": "987264686974"
},
"paymentReference": "SGR1234567890123456",
"paymentWindow": {
"expiresInMinutes": 25,
"expiresAt": "2026-06-12T10:20:00.000Z"
},
"trainTicketUuids": ["c66b52a9-d5d3-4e58-bc15-7f2d91da18f9"]
}
}
The reservation endpoint is safe to retry with the same request. If the
reservation already exists, it returns the existing booking and payment details
instead of creating a duplicate. On retries, paymentWindow.expiresInMinutes
reports the whole minutes remaining at response time and never goes below 0;
use paymentWindow.expiresAt as the authoritative deadline. Keep the effective
notifications.ticketPurchase value unchanged on every retry; changing it for
an existing reservation returns 409 SGR_RESERVATION_RETRY_CONFLICT.
Step 7: Issue the Ticket (Agents)
After creating an agent reservation, call the issue endpoint with the returned
paymentReference:
POST /api/trip/train/ticket/SGR1234567890123456/issue
Send the same bearer token used to create the reservation. The agent must own
the reservation and have enough Safiri wallet balance, including any permitted
overdraft. The endpoint returns 202 Accepted while Safiri issues the ticket.
If the projected wallet balance would fall below the permitted overdraft limit,
Safiri returns 402 Payment Required with error code
SGR_AGENT_INSUFFICIENT_BALANCE. Top up the wallet and retry the same endpoint
before the reservation expires.
You may optionally ask Safiri to notify your system when issuance finishes:
{
"callbackUrl": "https://agent.example.com/webhooks/sgr"
}
Without a callback, poll the issuance status endpoint:
GET /api/trip/train/ticket/SGR1234567890123456/issuance
The ticket is ready when issuance.status is ISSUED. Safiri then
automatically generates the ticket and its PDF. Safiri sends it through the
customer delivery channels unless the reservation set
notifications.ticketPurchase to false; agents do not need to call a
document-generation endpoint as part of this flow.
Step 8: Payment Window for Customer Bookings
Use paymentReference to collect payment through Safiri. After reservation, a
25-minute payment window opens. If payment is not confirmed within the window,
the tickets are marked CANCELLED. If payment is confirmed, they are marked
PAID.
Related Reference
See the Train Booking API reference for full endpoint details, response field descriptions, and larger examples.