Skip to main content

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"
}
]
}
tip

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:

ParameterRequiredDescription
fromYesOrigin station stop_id, for example DARESALAAM:SGR:0.
toYesDestination station stop_id, for example MOROGORO:SGR:0.
dateYesTravel date in YYYY-MM-DD format.
lookAheadDaysNoCheck 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:

SituationRequested dateLook-aheadUI behavior
Requested date has trainsRESULTS_FOUNDNOT_NEEDEDRender data normally.
A later checked date has trainsNO_RESULTSFOUNDExplain that the requested date is empty and offer an explicit search action for next.date.
All requested candidate dates are emptyNO_RESULTSNO_RESULTSSay 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 validatedNO_RESULTSINCOMPLETEExplain 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:

ComponentFieldMeaning
Transporter feeprice.fees[] where type is TRANSPORTER; also price.totalThe TRC fare payable to the transporter.
Safiri feeprice.fees[] where type is SAFIRI_COMMISSIONSafiri's fee for processing the booking.
Wallet debitprice.grandTotalThe 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:

FieldHow to use it
trip.name and trip.numberShow the train service name and train number.
departure.dateTime and arrival.dateTimeShow the local departure and arrival date-time.
route.name and trip.headsignShow the route name and major intermediate stops.
price.total, price.bookingFeeTotal, price.grandTotal, price.fees, price.currency, and price.otherCurrenciesShow the TRC fare, Safiri fee breakdown, Safiri wallet debit amount, and supported converted currencies. Add any customer-facing markup in your own system.
stopTimesShow the full route, intermediate stops, and stop-level timing.
train.carsShow available cabins such as Royal Class, Business, and Economy, plus capacity and available seats.
train.cars[].pricesShow passenger-type prices for the selected cabin, for example adult and child fares.
train.cars[].seatsRender the seat map and show which seats are available.
bookingRequirementsCapture the traveler fields and options needed by the reservation flow.
transitAgencyShow 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"]
}
}
note

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.

See the Train Booking API reference for full endpoint details, response field descriptions, and larger examples.