Skip to main content

Train Booking

The Train Booking API lets you discover train stations, search available SGR train trips, retrieve cabin, fare, and seat information, hold selected seats, and create SGR train reservations.

Overview

The Train Booking API currently provides:

  • SGR station discovery for origin and destination selection
  • Train search between two stations on a travel date
  • Trip details including route, departure and arrival times, price, stop times, railway cars, and operator details
  • Cabin, passenger-type pricing, seat map, and booking requirement details for a selected train
  • Seat hold and release for seat-selection flows
  • Reservation creation for one-way and return SGR journeys, with a Safiri payment window of up to 25 minutes and always before LATRA expiry
  • Ticket issuance for agent reservations using the agent's Safiri wallet
  • Per-reservation control over final customer ticket notifications

Authentication

Train search and train details 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.

The train station list is served by the train GTFS API and can be used to populate station pickers before calling the protected booking API.

Headers

Required for train search, train details, seat hold/release, reservation, and ticket issuance:

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

Optional:

  • Content-Language: <language_code> - Language for responses (default: en)
  • Content-Currency: <currency_code> - Preferred display currency where supported. Train responses return the base price.currency and the price restated per currency in price.otherCurrencies.

Wallet Charges for Third-Party Agents

Train booking integrations are intended for third-party agents using a pre-funded Safiri wallet. The API returns Safiri's payable amount separately from whatever retail amount you decide to charge your own customer.

Safiri deducts the returned train payable amount from your wallet:

ComponentWhere to find itDescription
Transporter feeprice.fees[] where type is TRANSPORTER; also price.totalThe TRC train fare payable to the transporter.
Safiri feeprice.fees[] where type is SAFIRI_COMMISSIONSafiri's fee for the booking.
Wallet debitprice.grandTotalThe amount Safiri charges to your wallet for that train ticket. This is the transporter fee plus Safiri-controlled fees returned in price.fees.

You can charge your customer any additional agency, service, or handling fee on top of price.grandTotal. That extra customer-facing markup is managed by your own system; do not send it in the train booking request, and Safiri does not deduct it from your wallet.

For example, if the API returns price.total of 22500, SAFIRI_COMMISSION of 2000, and price.grandTotal of 24500, Safiri will deduct 24500 from your wallet. If you charge your customer 26000, the extra 1500 is your own markup outside the Safiri wallet debit.

Endpoints

Get Train Stations

Retrieve SGR stations and their Safiri stop IDs. Use the returned stop_id values as the from and to query parameters when searching for trains.

Endpoint: GET https://train-gtfs-api.safiri.app/api/stops?mini=true

Example Request:

GET https://train-gtfs-api.safiri.app/api/stops?mini=true

Query Parameters

ParameterTypeRequiredDescription
minibooleanNoWhen true, returns the compact station payload used by search and autocomplete flows.

Response Fields

FieldTypeDescription
stop_idstringSafiri stop ID. Use this exact value in train search, for example DARESALAAM:SGR:0.
stop_latnumberStation latitude.
stop_lonnumberStation longitude.
stop_namestringHuman-readable station name.
stop_descstringStation location or address description.
stop_timezonestringIANA timezone for the station.
city_geohashstringGeohash used for station clustering and spatial search.
popularitynumberRanking value used by search and selection UIs.
partnersarrayProvider metadata. For SGR train search, use stops with a TRC partner whose service is SGR.
Example response
[
{
"stop_id": "DARESALAAM:SGR:0",
"stop_lat": -6.82488714,
"stop_lon": 39.2830351,
"stop_name": "Dar es Salaam (John Magufuli SGR Station)",
"stop_desc": "Kisutu, Ilala, Dar es Salaam, Tanzania",
"stop_timezone": "Africa/Dar_es_Salaam",
"city_geohash": "kygcjv",
"popularity": 30,
"partners": [
{
"provider": "TRC",
"service": "SGR",
"id": 1,
"name": "Dar Es Salaam"
}
]
},
{
"stop_id": "DODOMA:SGR:0",
"stop_lat": -6.21138079,
"stop_lon": 35.73413998,
"stop_name": "Dodoma (Samia SGR Station)",
"stop_desc": "Dodoma, Tanzania",
"stop_timezone": "Africa/Dar_es_Salaam",
"city_geohash": "kycktj",
"popularity": 30,
"partners": [
{
"provider": "TRC",
"service": "SGR",
"id": 12,
"name": "Dodoma"
}
]
}
]

Search for Trains

Search for available SGR train trips between two train stations on a travel date.

Endpoint: GET /api/trip/train/search

Example Request:

GET /api/trip/train/search?from=DARESALAAM:SGR:0&to=MOROGORO:SGR:0&date=2026-06-10

Query Parameters

ParameterTypeRequiredDescription
fromstringYesOrigin station stop_id from the train stops endpoint. Example: DARESALAAM:SGR:0.
tostringYesDestination station stop_id from the train stops endpoint. Example: MOROGORO:SGR:0.
datestringYesTravel date in YYYY-MM-DD format.
lookAheadDaysstringNoOpt in to checking later dates when the requested date has no results. Accepted values are exactly 1, 2, or 3.
Example response
{
"meta": {
"count": 6,
"provider": "TRC_SGR",
"links": {
"self": "https://booking-api.safiri.app/api/trip/train/search?from=DARESALAAM%3ASGR%3A0&to=MOROGORO%3ASGR%3A0&date=2026-06-10"
},
"createdAt": "2026-06-10T01:29:53.925Z"
},
"data": [
{
"provider": "TRC_SGR",
"trip": {
"id": 98,
"name": "DAR - DOM \\ EXPRESS",
"number": "11902",
"trainSetId": 20,
"timeTableId": 299,
"timeTableTrainSetId": 8537,
"gtfsTripId": "SASGR002:O",
"headsign": "Dodoma via Pugu, Soga, Ruvu, Ngerengere, Morogoro, Mkata, Kilosa, Kidete, Gulwe, Igandu",
"wheelchairAccessible": 1,
"bikesAllowed": null
},
"route": {
"id": 68,
"gtfsRouteId": "SASGR002:SER002",
"name": "DAR - DOM"
},
"departure": {
"date": "2026-06-10",
"time": "06:00:00",
"dateTime": "2026-06-10T06:00:00+03:00",
"stop": {
"stop_id": "DARESALAAM:SGR:0",
"stop_lat": -6.82488714,
"stop_lon": 39.2830351,
"stop_name": "Dar es Salaam (John Magufuli SGR Station)",
"stop_desc": "Kisutu, Ilala, Dar es Salaam, Tanzania",
"stop_timezone": "Africa/Dar_es_Salaam",
"city_geohash": "kygcjv",
"popularity": 30,
"partners": [
{
"provider": "TRC",
"service": "SGR",
"id": 1,
"name": "Dar Es Salaam"
}
]
}
},
"arrival": {
"date": "2026-06-10",
"time": "07:40:00",
"dateTime": "2026-06-10T07:40:00+03:00",
"stop": {
"stop_id": "MOROGORO:SGR:0",
"stop_lat": -6.75460532,
"stop_lon": 37.66439974,
"stop_name": "Morogoro (Jakaya Kikwete SGR Station)",
"stop_desc": "Morogoro, Tanzania",
"stop_timezone": "Africa/Dar_es_Salaam",
"city_geohash": "kyfc94",
"popularity": 30,
"partners": [
{
"provider": "TRC",
"service": "SGR",
"id": 6,
"name": "Morogoro"
}
]
}
},
"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"
}
],
"baseCurrency": "TZS",
"basePrice": "22500",
"otherCurrencies": {
"TZS": {
"amount": 22500,
"formatted": "TZS 22,500.00"
},
"USD": {
"amount": 8.57,
"formatted": "USD 8.57",
"currency": "USD",
"total": "8.57",
"bookingFeeTotal": "0.76",
"grandTotal": "9.33",
"fees": [
{
"type": "TRANSPORTER",
"amount": "8.57",
"recipient": "TRC"
},
{
"type": "FACILITATION",
"amount": "0",
"recipient": "SAFIRI_AGENT"
},
{
"type": "SAFIRI_COMMISSION",
"amount": "0.76",
"recipient": "SAFIRI"
}
]
},
"KES": {
"amount": 1107.74,
"formatted": "KES 1,107.74"
},
"UGX": {
"amount": 32274.83,
"formatted": "UGX 32,274.83"
},
"ZMW": {
"amount": 150.59,
"formatted": "ZMW 150.59"
}
}
},
"stopTimes": [
{
"stopId": "DARESALAAM:SGR:0",
"stopSequence": 0,
"arrivalTime": "05:55:00",
"departureTime": "06:00:00",
"stopName": "Dar es Salaam (John Magufuli SGR Station)",
"stopDescription": "Kisutu, Ilala, Dar es Salaam, Tanzania",
"latitude": -6.82488714,
"longitude": 39.2830351
},
{
"stopId": "MOROGORO:SGR:0",
"stopSequence": 5,
"arrivalTime": "08:35:33",
"departureTime": "08:40:33",
"stopName": "Morogoro (Jakaya Kikwete SGR Station)",
"stopDescription": "Morogoro, Tanzania",
"latitude": -6.75460532,
"longitude": 37.66439974
}
],
"cars": [
{
"id": 417,
"carId": 19,
"orderNo": 1,
"typeId": 151,
"typeName": "Royal Class",
"capacity": 44,
"standardSeatCapacity": 44,
"disabledSeatCapacity": 0,
"emptySeats": 0,
"emptyStandardSeats": 0,
"emptyDisabledSeats": 0,
"hasSeat": true,
"bookedSeats": []
},
{
"id": 420,
"carId": 15,
"orderNo": 4,
"typeId": 12,
"typeName": "Economy",
"capacity": 76,
"standardSeatCapacity": 74,
"disabledSeatCapacity": 2,
"emptySeats": 0,
"emptyStandardSeats": 0,
"emptyDisabledSeats": 0,
"hasSeat": true,
"bookedSeats": []
}
],
"transitAgency": {
"name": "Tanzania Railways Corporation",
"tradingName": "Tanzania Railways Corporation",
"tinNumber": "000000001",
"uuid": "204ea6a5-e374-4a22-a8b0-7a1d406c0514",
"phoneNumber": "+255800110042",
"email": "info@trc.co.tz",
"website": "https://sgrticket.trc.co.tz",
"type": "TRAIN",
"logos": []
}
}
]
}
note

Use one item from data as the selected train option in your application UI. Pass that full selected item to Get Train Details to retrieve cabins, passenger-type prices, seat maps, and booking requirements.

Add lookAheadDays when the UI should offer a nearby date after a successful search returns no trains for the requested date:

GET /api/trip/train/search?from=MOROGORO:SGR:0&to=DODOMA:SGR:0&date=2026-08-25&lookAheadDays=3

The requested-date result remains in data. A train found on a later date is described in meta.availability; it is not inserted into data, so the client can explicitly ask the customer before searching the suggested date.

{
"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": []
}

The outer availability.status always describes the requested date. The nested lookAhead.status describes only the additional dates that were checked.

Requested-date outcomeavailability.statuslookAhead.statusDate fields
lookAheadDays omittedField omittedField omittedThe existing response envelope is unchanged.
Requested date has trainsRESULTS_FOUNDNOT_NEEDEDfirstDate, confirmedThroughDate, and next are null.
Earliest confirmed candidate has trainsNO_RESULTSFOUNDconfirmedThroughDate and next.date are the found date.
Every candidate is confirmed emptyNO_RESULTSNO_RESULTSconfirmedThroughDate is the final checked date and next is null.
A required candidate could not be checked reliablyNO_RESULTSINCOMPLETEconfirmedThroughDate is the last consecutively confirmed-empty date, or null; next is null.

For example, an opted-in search for 2026-08-27 that confirms all three later dates are also empty returns lookAhead.status: "NO_RESULTS", firstDate: "2026-08-28", confirmedThroughDate: "2026-08-30", and next: null. The response still has 200, count: 0, and data: [].

NO_RESULTS in lookAhead.status means only that no trains were found in the requested look-ahead window. It does not mean the route never operates. Similarly, a listed train does not guarantee that seats are available.

Calendar behavior:

  • serviceDate is the railway's current calendar date when the request starts, in serviceTimeZone.
  • Candidate checking begins on the later of the day after requestedDate and serviceDate. This prevents suggestions from beginning in the past.
  • lookAheadDays counts consecutive calendar dates beginning with that first candidate.
  • Query dates and returned departure and arrival dates/times are railway-local values. The API does not shift these raw values through UTC.
  • Clients should compare and add days to YYYY-MM-DD values as plain calendar dates. Do not use new Date("YYYY-MM-DD") to produce Today or Tomorrow labels; localize the final weekday and month text in the UI.

In the response example above, the railway serviceDate is 2026-08-26, so the suggested date is Today, Wednesday 26 August 2026, even though the requested date was 2026-08-25.

meta.count always counts only the requested date's data. links.nextAvailable is present only when the nested status is FOUND. It changes the date to the suggested date, preserves canonical from, to, and lookAheadDays values, and excludes unrelated query parameters.

An empty data array with a 200 response means the search completed but no trains were returned for the requested date. Invalid input, unknown stations, and provider failures remain non-2xx errors. UI messages should stay neutral:

  • FOUND: “No trains are available for [requested date]. Trains were found for [next date].” Offer an explicit action to search that date.
  • Nested NO_RESULTS: “No trains were found in the dates checked.”
  • INCOMPLETE: “No trains are available for [requested date]. We couldn’t finish checking nearby dates.”
  • Non-2xx response: “We couldn’t search for trains. Try again.”

Do not infer or display “sold out,” “all trains departed,” or “route impossible” from these statuses.

Response Structure

FieldTypeDescription
metaobjectSearch metadata including result count, provider, links, response timestamp, and optional look-ahead availability.
dataarrayList of train options matching the requested origin, destination, and date.

Meta Object

FieldTypeDescription
countnumberNumber of train options in data for the requested date.
providerstringTrain provider key. Current value is TRC_SGR.
links.selfstringFully qualified URL for the current search request.
links.nextAvailablestringFully qualified URL for the suggested date. Present only when opted-in look-ahead has status FOUND.
availabilityobjectRequested-date and look-ahead outcome. Present only when lookAheadDays was supplied.
createdAtstringISO timestamp for when the search response was generated.

Train Result Object

Each item in data represents one train option.

FieldTypeDescription
providerstringProvider key for the train option. Current value is TRC_SGR.
tripobjectTrain identity, timetable IDs, GTFS trip ID, headsign, and accessibility flags.
routeobjectRoute identity and GTFS route metadata.
departureobjectDeparture date, time, ISO date-time, and origin stop.
arrivalobjectArrival date, time, ISO date-time, and destination stop.
priceobjectTRC fare, Safiri fee breakdown, collected/debited total, base currency, and converted otherCurrencies.
stopTimesarray or nullOrdered route stops with arrival and departure times. May be null if GTFS stop-time enrichment is unavailable.
carsarrayRailway car classes, capacities, seat availability, and booked seat metadata.
carTypesarrayDeduplicated cabin/class list derived from cars. Useful for cabin filters.
transitAgencyobjectOperator information for Tanzania Railways Corporation.

Trip Object

FieldTypeDescription
idnumberTRC trip identifier.
namestringTrain trip name.
numberstringTrain number.
trainSetIdnumberTRC train set identifier.
timeTableIdnumberTRC timetable identifier.
timeTableTrainSetIdnumberTRC timetable train set identifier.
gtfsTripIdstring or nullMatching GTFS trip ID when available.
headsignstring or nullRoute headsign showing the destination and major intermediate stops.
wheelchairAccessiblenumber or nullGTFS wheelchair accessibility flag when available.
bikesAllowednumber or nullGTFS bike allowance flag when available.

Price Object

FieldTypeDescription
pricingContextstringPricing mode used for display and booking: ONLINE, SAFIRI_AGENT, or THIRD_PARTY_AGENT.
currencystringBase fare currency. Current SGR fares are returned in TZS.
totalstringDynamic TRC fare amount. This is the transporter ticket cost and is included in the wallet debit.
bookingFeeTotalstringSafiri-controlled fees above the TRC fare. For third-party agents, this is Safiri's booking fee unless another fee component is explicitly returned.
grandTotalstringAmount Safiri debits from the third-party agent wallet for this ticket. It does not include any additional markup you charge your customer outside Safiri.
feesarrayWallet-debit component breakdown. Includes TRANSPORTER, FACILITATION, and SAFIRI_COMMISSION; zero-value components are included as "0".
baseCurrencystringThe operator's own currency. Always the currency basePrice and the fees amounts are denominated in.
basePricestringThe TRC fare in baseCurrency. This is the settlement figure; otherCurrencies never changes it.
otherCurrenciesobjectThe price restated in each supported currency, keyed by currency code. See the Converted Price Object below.

Price Fee Object

FieldTypeDescription
typestringFee component type: TRANSPORTER, FACILITATION, or SAFIRI_COMMISSION.
amountstringComponent amount in the price currency. The returned fee amounts sum to grandTotal.
recipientstringComponent recipient, for example TRC, SAFIRI_AGENT, or SAFIRI.

Converted Price Object

One currency's view of the price. Convert nothing yourself: display these amounts as returned.

amount and formatted are always present and describe the fare only. The remaining fields restate the whole price and are present whenever an exchange rate was available for that currency; an entry carrying only amount and formatted could not be fully converted, and clients should fall back to showing baseCurrency rather than pairing a converted fare with an unconverted fee.

FieldTypeDescription
amountnumberNumeric fare in the target currency.
formattedstringDisplay-ready fare with currency code and separators.
currencystringThe target currency code.
totalstringThe TRC fare in the target currency.
bookingFeeTotalstringSafiri fees above the fare, in the target currency.
grandTotalstringtotal plus bookingFeeTotal, in the target currency.
feesarrayThe same component breakdown as the base price, converted.

Each component is converted and rounded individually, then the totals are summed from those rounded components. total + bookingFeeTotal therefore equals grandTotal exactly, and the fees amounts sum to grandTotal — an invariant a client cannot guarantee by multiplying a repeating rate row by row itself. Rounding uses the precision the payment rail can collect (0 decimals for TZS, KES, UGX, XAF and RWF; 2 otherwise), matching the precision the issuance quote is frozen at.

Railway Car Object

FieldTypeDescription
idnumberTimetable railway car identifier.
carIdnumberRailway car type identifier.
orderNonumberCar order in the train.
typeIdnumberTRC class/type identifier.
typeNamestringPassenger class name, for example Royal Class, Business, or Economy.
capacitynumberTotal car capacity.
standardSeatCapacitynumberStandard seat capacity.
disabledSeatCapacitynumberAccessible seat capacity.
emptySeatsnumberCurrently available seats for the car.
emptyStandardSeatsnumberCurrently available standard seats.
emptyDisabledSeatsnumberCurrently available accessible seats.
hasSeatbooleanWhether the car has reserved seats.
bookedSeatsarraySeat booking metadata when returned by the provider.

Get Train Details

Retrieve detailed cabin, passenger-type fare, seat map, and booking requirement information for one selected train option.

Endpoint: POST /api/trip/train/details

Example Request:

POST /api/trip/train/details

Request Body

Pass the full train option selected from the Search for Trains response.

{
"trip": {
"provider": "TRC_SGR",
"trip": {
"id": 98,
"name": "DAR - DOM \\ EXPRESS",
"number": "11902",
"trainSetId": 20,
"timeTableId": 299,
"timeTableTrainSetId": 8537
},
"route": {
"id": 68,
"gtfsRouteId": "SASGR002:SER002",
"name": "DAR - DOM"
},
"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" }
}
}

Response Structure

FieldTypeDescription
metaobjectDetails metadata including provider, self link, and response timestamp.
dataobjectThe selected train option enriched with train-set, cabin, seat, fare, and booking requirement details.

Details Data Object

The details response returns the same selected trip fields from search, plus train and bookingRequirements.

FieldTypeDescription
providerstringProvider key for the train option. Current value is TRC_SGR.
tripobjectTrain identity, timetable IDs, GTFS trip ID, headsign, and accessibility flags.
routeobjectRoute identity and GTFS route metadata.
departureobjectDeparture date, time, ISO date-time, and origin stop.
arrivalobjectArrival date, time, ISO date-time, and destination stop.
priceobjectBase amount, base currency, and converted otherCurrencies.
stopTimesarray or nullOrdered route stops with arrival and departure times.
trainobjectDetailed train-set information and cabin/car layout.
bookingRequirementsobjectPassenger details and option lists required for seat reservation.
transitAgencyobjectOperator information for Tanzania Railways Corporation.

Train Object

FieldTypeDescription
idnumberTimetable train set identifier.
typeIdnumberTrain type identifier.
typestringTrain type name, for example High Speed Train.
namestringTrain set name.
numbernumberTrain set number.
carTypesarrayDeduplicated cabin/class list derived from cars.
carsarrayCabin/car details, including prices and seats.

Train Car Object

FieldTypeDescription
idnumberTimetable car identifier.
namestringCar name from the provider.
orderNonumberCar order in the train.
carIdnumberRailway car identifier.
carTypeIdnumberRailway car class/type identifier.
carTypestringPassenger class name, for example Royal Class, Business, or Economy.
capacitynumberTotal car capacity.
standardCapacitynumberStandard seat capacity.
disabledCapacitynumberAccessible seat capacity.
emptySeatsnumberCurrently available seats in this car.
rowCountnumberNumber of seat-layout rows.
columnCountnumberNumber of seat-layout columns.
hasSeatbooleanWhether the car has selectable seats.
isActivebooleanWhether the car is active.
isDisabledbooleanWhether the car is disabled/unavailable.
pricesarrayPassenger-type prices for this car.
seatsarraySeat map for this car.

Car Price Object

FieldTypeDescription
ticketTypeIdnumberPassenger ticket type identifier.
ticketTypeCodenumberPassenger ticket type code.
ticketTypeNamestringPassenger type, for example Adult (13 and Above) or Child (4 - 12 Years).
pricingContextstringPricing mode used for this fare: ONLINE, SAFIRI_AGENT, or THIRD_PARTY_AGENT.
totalstringDynamic TRC fare amount for this passenger type and car. This is the transporter ticket cost and is included in the wallet debit.
bookingFeeTotalstringSafiri-controlled fees above the TRC fare. For third-party agents, this is Safiri's booking fee unless another fee component is explicitly returned.
grandTotalstringAmount Safiri debits from the third-party agent wallet for this ticket. It does not include any additional markup you charge your customer outside Safiri.
feesarrayWallet-debit component breakdown with TRANSPORTER, FACILITATION, and SAFIRI_COMMISSION.
formattedPricestringDisplay-ready fare amount.
currencystringFare currency.
otherCurrenciesobjectConverted fare amounts keyed by currency code.
luggageTypeIdnumberSelected/default luggage type identifier.
luggageTypeNamestringSelected/default luggage type name.
luggagePricenumberLuggage price amount.

Seat Object

FieldTypeDescription
idstringSeat identifier.
labelstringSeat label shown to passengers. Empty for non-seat layout cells.
positionYnumberZero-based display row.
positionXnumberZero-based display column within the row.
keystringStable frontend key for the layout cell.
typestringLayout cell type, for example SEAT, DISABLED_SEAT, AISLE, WC, LUGGAGE, or BED.
classstringCabin/class for the car, for example ECONOMY, BUSINESS, or ROYAL_CLASS.
orientationstringSeat orientation for layout rendering: UP, DOWN, LEFT, or RIGHT.
availablebooleanWhether the seat can be selected.

Booking Requirements Object

FieldTypeDescription
travelerRequirementsobjectTraveler fields the form should request for seat reservation. travelerId: true means the form should display Passport, NIDA, or another identity field, but the SGR API currently permits it to be left blank. Current clients must also collect gender when genderRequired: true; the backend temporarily accepts it as optional for older clients.
nationalityOptionsarrayValid passenger nationality/residency options.
luggageOptionsarrayValid luggage options.
ticketTypeOptionsarrayValid passenger ticket type options.
Example response
{
"meta": {
"provider": "TRC_SGR",
"links": {
"self": "https://booking-api.safiri.app/api/trip/train/details"
},
"createdAt": "2026-06-10T01:30:01.015Z"
},
"data": {
"provider": "TRC_SGR",
"trip": {
"id": 98,
"name": "DAR - DOM \\ EXPRESS",
"number": "11902",
"trainSetId": 20,
"timeTableId": 299,
"timeTableTrainSetId": 8537,
"gtfsTripId": "SASGR002:O",
"headsign": "Dodoma via Pugu, Soga, Ruvu, Ngerengere, Morogoro, Mkata, Kilosa, Kidete, Gulwe, Igandu",
"wheelchairAccessible": 1,
"bikesAllowed": null
},
"route": {
"id": 68,
"gtfsRouteId": "SASGR002:SER002",
"name": "DAR - DOM"
},
"departure": {
"date": "2026-06-10",
"time": "06:00:00",
"dateTime": "2026-06-10T06:00:00+03:00",
"stop": {
"stop_id": "DARESALAAM:SGR:0",
"stop_name": "Dar es Salaam (John Magufuli SGR Station)"
}
},
"arrival": {
"date": "2026-06-10",
"time": "07:40:00",
"dateTime": "2026-06-10T07:40:00+03:00",
"stop": {
"stop_id": "MOROGORO:SGR:0",
"stop_name": "Morogoro (Jakaya Kikwete SGR Station)"
}
},
"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"
}
}
},
"stopTimes": [
{
"stopId": "DARESALAAM:SGR:0",
"stopSequence": 0,
"arrivalTime": "05:55:00",
"departureTime": "06:00:00"
},
{
"stopId": "MOROGORO:SGR:0",
"stopSequence": 5,
"arrivalTime": "08:35:33",
"departureTime": "08:40:33"
}
],
"train": {
"id": 8537,
"typeId": 13,
"type": "High Speed Train",
"name": "EXPRESS TRAIN",
"number": 1011,
"cars": [
{
"id": 417,
"name": "ROYAL CLASS KIOSK",
"orderNo": 1,
"carId": 19,
"carTypeId": 151,
"carType": "Royal Class",
"capacity": 44,
"standardCapacity": 44,
"disabledCapacity": 0,
"emptySeats": 0,
"rowCount": 12,
"columnCount": 5,
"hasSeat": true,
"isActive": true,
"isDisabled": false,
"prices": [
{
"ticketTypeId": 4,
"ticketTypeCode": 4,
"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",
"otherCurrencies": {
"TZS": {
"amount": 45500,
"formatted": "TZS 45,500.00"
},
"USD": {
"amount": 17.34,
"formatted": "USD 17.34"
}
},
"luggageTypeId": 5,
"luggageTypeName": "None",
"luggagePrice": 0
}
],
"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
},
"nationalityOptions": [
{
"code": 20,
"id": 20,
"title": "Non-Resident",
"price": null
},
{
"code": 19,
"id": 19,
"title": "Resident",
"price": null
}
],
"luggageOptions": [
{
"id": 5,
"name": "None",
"description": "None",
"weight": 0,
"price": 0,
"isActive": true,
"configurationItemId": 98
}
],
"ticketTypeOptions": [
{
"code": 4,
"id": 4,
"title": "Adult (13 and Above)",
"price": 45500
},
{
"code": 3,
"id": 3,
"title": "Child (4 - 12 Years)",
"price": 45500
}
]
},
"transitAgency": {
"name": "Tanzania Railways Corporation",
"tradingName": "Tanzania Railways Corporation",
"tinNumber": "000000001",
"uuid": "204ea6a5-e374-4a22-a8b0-7a1d406c0514",
"phoneNumber": "+255800110042",
"email": "info@trc.co.tz",
"website": "https://sgrticket.trc.co.tz",
"type": "TRAIN",
"logos": []
}
}
}

Hold Train Seat

Hold one selected SGR seat before collecting passenger details. Call this endpoint when a customer selects a seat in the seat map.

Endpoint: POST /api/trip/train/seat/hold

Example Request:

POST /api/trip/train/seat/hold

Request Body

FieldTypeRequiredDescription
departureStopPartnerIdnumberYesTRC station ID from departure.stop.partners[].id.
arrivalStopPartnerIdnumberYesTRC station ID from arrival.stop.partners[].id.
timeTableTrainSetIdnumberYestrip.timeTableTrainSetId from train search/details.
carIdnumberYesSelected details car id.
seatnumberYesSelected seat id.
luggageIdnumberNoLuggage option ID. Defaults to 5 (None).
{
"departureStopPartnerId": 1,
"arrivalStopPartnerId": 12,
"timeTableTrainSetId": 8710,
"carId": 497,
"seat": 2119,
"luggageId": 5
}

Success Response

journeyType is ONE_WAY for a one-way reservation and ROUND_TRIP for a return reservation. Individual paymentItems[].journey values remain OUTBOUND and RETURN.

{
"meta": {
"provider": "TRC_SGR",
"links": {
"self": "https://booking-api.safiri.app/api/trip/train/seat/hold"
},
"createdAt": "2026-06-12T10:00:00.000Z"
},
"data": {
"provider": "TRC_SGR",
"hold": {
"key": "f70c1e0e-9849-41dd-b3b7-949eac159dc2",
"status": "HELD"
}
}
}

Seat Already Taken

If TRC reports that the seat is no longer available, the API returns 400.

{
"error": {
"name": "modal",
"seatId": 2119
},
"errorCode": "SEAT_ALREADY_BOOKED",
"errorMessage": "This seat is taken.",
"status": 400
}

Release Train Seat

Release a previously held SGR seat. Call this endpoint when a customer unselects a seat or abandons seat selection before reservation.

Endpoint: GET /api/trip/train/seat/release

Example Request:

GET /api/trip/train/seat/release?key=f70c1e0e-9849-41dd-b3b7-949eac159dc2

Query Parameters

ParameterTypeRequiredDescription
keystringYesHold key returned by Hold Train Seat.

Success Response

{
"meta": {
"provider": "TRC_SGR",
"links": {
"self": "https://booking-api.safiri.app/api/trip/train/seat/release?key=f70c1e0e-9849-41dd-b3b7-949eac159dc2"
},
"createdAt": "2026-06-12T10:02:00.000Z"
},
"data": {
"provider": "TRC_SGR",
"hold": {
"key": "f70c1e0e-9849-41dd-b3b7-949eac159dc2",
"status": "RELEASED"
}
}
}
note

Responses include Cache-Control: no-store.

Reserve Held Train Seats

Convert held seats into a confirmed reservation and open a Safiri payment window of up to 25 minutes. The customer deadline is always earlier than LATRA's exact bill expiry.

Endpoint: POST /api/trip/train/seat/reserve

The authenticated account is attached to the reservation automatically. Do not send boughtByUser or bookingUserType.

Example Request:

POST /api/trip/train/seat/reserve

Headers

HeaderRequiredDescription
AuthorizationYesBearer JWT for the user creating the reservation.
x-authentication-contextNoBooking channel saved on the train ticket. Defaults to WEB_APP when omitted.

Request Body

FieldTypeRequiredDescription
outboundobjectYesFull selected train details object from Get Train Details, containing only the selected cars/seats.
returnobject or nullNoFull selected return train details object. Use null or omit for one-way bookings.
passengersarrayYesPassenger and held-seat details. Supports multiple passengers.
notificationsobjectNoFinal customer ticket notification preferences for this reservation. Omit it to keep notifications enabled.
issuerDeviceInfoobjectNoDevice/app metadata for the issuer, using the same shape as bus ticket issuerDeviceInfo.

Each passenger must include the common passenger fields and outbound seat fields. Return seat fields are required only when return is supplied.

Passenger FieldTypeRequiredDescription
passengerNamestringYesPassenger name sent to TRC.
identityNostringNoPassenger Passport, NIDA, or other identity number. Send an empty string when the passenger leaves the requested field blank.
genderMale or FemaleNoRequired by current apps when genderRequired is true. Temporarily optional at the API boundary for older clients; when supplied it is validated and stored.
ageCategoryAdult or ChildNoSafiri derives and stores this from ticketTypeId (4 is Adult and 3 is Child). When supplied, it must match that ticket type.
phonestringYesPassenger phone number saved by Safiri.
countryCodestringNoPassenger phone country calling code, for example 255 or 44. Safiri derives and saves this from the parsed phone when countryCodeName is supplied.
countryCodeNamestringNoPassenger ISO country code, for example TZ or GB. Required for local-format phone numbers when countryCode is omitted. Safiri saves the parsed country in lowercase, for example tz or gb.
emailstringNoPassenger email address. Omit or use an empty string when not available. When provided, it must be a valid email format.
nationalityTypeIdnumberYesValue from bookingRequirements.nationalityOptions[].id.
isDisabledPassengerbooleanNoWhether the passenger is disabled.
ticketTypeIdnumberYesValue from bookingRequirements.ticketTypeOptions[].id, for example adult or child.
outboundSeatHoldKeystringYesHold key returned by the outbound seat hold call.
outboundCarIdnumberYesSelected outbound details car id.
outboundCarTypeIdnumberYesSelected outbound details car carTypeId.
outboundSeatIdnumberYesSelected outbound seat id.
returnSeatHoldKeystringReturn onlyHold key returned by the return seat hold call.
returnCarIdnumberReturn onlySelected return details car id.
returnCarTypeIdnumberReturn onlySelected return details car carTypeId.
returnSeatIdnumberReturn onlySelected return seat id.
note

Do not send payer information or luggage details; they are not accepted by this request.

Final Ticket Notifications

Use the optional notifications.ticketPurchase boolean to control whether Safiri sends the final issued ticket to the customer:

{
"notifications": {
"ticketPurchase": false
}
}

The setting defaults to true when notifications or ticketPurchase is omitted. Set it to false to suppress the final customer ticket notification across email, SMS, WhatsApp, and push for this reservation. This preference does not disable the optional agent callback or issuance-status polling.

For reservations created by a third-party agent, Safiri never sends customer reservation or reservation-cancellation notifications on any supported channel. Final issued-ticket notifications remain enabled by default and are the only customer notifications Safiri sends for these bookings; set ticketPurchase to false to suppress them as well.

note

Send the passenger's actual phone and country information. Safiri keeps that real number on the ticket. Tanzanian numbers are sent to LATRA in national format without the leading zero; international numbers are replaced only in the LATRA request with Safiri's accepted local fallback number.

One-Way Example

{
"outbound": {
"provider": "TRC_SGR",
"trip": {
"id": 98,
"name": "DAR - DOM \\ EXPRESS",
"timeTableTrainSetId": 8710
},
"route": {
"id": 68,
"name": "DAR - DOM"
},
"departure": { "...": "full departure object from details" },
"arrival": { "...": "full arrival object from details" },
"train": {
"cars": [
{
"id": 497,
"carTypeId": 151,
"carType": "Royal Class",
"seats": [
{
"id": "2119",
"label": "23"
}
]
}
]
},
"transitAgency": { "...": "full transit agency object from details" }
},
"return": null,
"passengers": [
{
"passengerName": "ITULE",
"identityNo": "19900101-00001-00001-01",
"gender": "Male",
"ageCategory": "Adult",
"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
},
"issuerDeviceInfo": {
"deviceId": "device-1",
"deviceBrand": "Apple",
"deviceModel": "iPhone",
"operatingSystem": "ios",
"operatingSystemVersion": "18.0",
"appName": "Safiri",
"appVersion": "1.0.0"
}
}

Return Journey Example

Use a separate selected details object and separate held seat for the return train.

{
"outbound": { "...": "selected outbound train details object" },
"return": { "...": "selected return train details object" },
"passengers": [
{
"passengerName": "ITULE",
"identityNo": "19900101-00001-00001-01",
"gender": "Male",
"ageCategory": "Adult",
"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
}
]
}

Success Response

{
"meta": {
"provider": "TRC_SGR",
"links": {
"self": "https://booking-api.safiri.app/api/trip/train/seat/reserve"
},
"createdAt": "2026-06-12T10:05:00.000Z"
},
"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,
"internalExpiresAt": "2026-06-12T10:20:00.000Z",
"expiresAt": "2026-06-12T10:20:00.000Z",
"providerExpiresAt": "2026-06-12T10:30:00.000+03:00"
},
"paymentItems": [
{
"identifier": "TRC00019e8aa449a500",
"journey": "OUTBOUND",
"passengerName": "ITULE",
"price": {
"pricingContext": "ONLINE",
"currency": "TZS",
"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"
}
],
"otherCurrencies": {
"TZS": {
"amount": 45500,
"formatted": "TZS 45,500.00"
}
}
},
"trainTicketUuid": "c66b52a9-d5d3-4e58-bc15-7f2d91da18f9"
}
],
"trainTicketUuids": ["c66b52a9-d5d3-4e58-bc15-7f2d91da18f9"]
}
}

Safiri returns this 200 OK response only after the provider PNR, complete ticket group, bill, control number, payment reference, and provider expiry are stored coherently and the exact stable reservation workflow has been confirmed as started. A local workflow ID by itself is not a successful reservation response.

Workflow Start Unconfirmed

If the provider reservation is durable but Safiri cannot confirm that its workflow started, the endpoint returns 503 SGR_WORKFLOW_START_UNCONFIRMED with the existing reservation identifiers:

{
"error": {
"name": "modal"
},
"errorCode": "SGR_WORKFLOW_START_UNCONFIRMED",
"errorMessage": "The SGR reservation was created but its workflow start could not be confirmed",
"data": {
"paymentReference": "SGR1234567890123456",
"reservation": {
"pnr": "PNR00019e8aa4492c00",
"ticketsNo": ["TRC00019e8aa449a500"]
},
"trainTicketUuids": ["c66b52a9-d5d3-4e58-bc15-7f2d91da18f9"],
"retryAllowed": false,
"workflowId": "SGR_TRAIN_SEAT_RESERVATION-issuance-uuid"
},
"status": 503
}

Do not submit a new or modified reservation after this response: LATRA may already be holding the seats. Preserve the returned identifiers and contact Safiri support. Safiri recovery uses the same stable workflow ID and does not create another provider reservation.

Passenger Validation Error

Safiri validates all passengers and selected seat references before calling LATRA CreateTicket. Invalid requests return 422 SGR_PASSENGER_DATA_INVALID with every detected field error so the client can annotate the matching passenger form.

{
"errorCode": "SGR_PASSENGER_DATA_INVALID",
"errorMessage": "Correct the passenger details before reserving this train",
"status": 422,
"data": {
"fields": [
{
"passengerIndex": 0,
"field": "passengerName",
"message": "passengers.0.passengerName is required"
},
{
"passengerIndex": 1,
"field": "ageCategory",
"message": "passengers.1.ageCategory must match the selected ticket type"
}
]
}
}

Definite customer-correctable LATRA rejections also return 422 with LATRA's exact provider code and message. Timeouts and uncertain submission outcomes do not invite an immediate retry; Safiri reconciles them to prevent duplicate reservations.

Reservation Retry Behavior

Except for a checkpoint response that explicitly says retryAllowed: false, the reservation endpoint is safe to retry with the same request. If the reservation already exists, the API returns the existing reservation and payment details instead of creating a duplicate.

Keep the effective notifications.ticketPurchase value unchanged on every retry. Omitting it is equivalent to true. Changing the value for an existing reservation returns 409 SGR_RESERVATION_RETRY_CONFLICT instead of creating a second provider reservation.

paymentWindow.expiresInMinutes is the number of whole minutes remaining when the response is generated, so it decreases on retries and is 0 after the deadline. paymentWindow.expiresAt is the customer deadline: the earlier of Safiri's 25-minute maximum and the safety margin needed to verify the customer payment and complete payout before LATRA's authoritative bill expiry, paymentWindow.providerExpiresAt.

For online payments, provider confirmation is stored independently from the train workflow. If a process stops after the payment provider confirms money but before the reservation is marked funded, Safiri reattaches only an exact owner, payment-reference, amount, and currency match and wakes the existing workflow. Cancellation also checks this independent confirmed or unresolved payment evidence before deleting an unpaid reservation. A notification failure does not undo confirmed funding or prevent issuance from continuing.

warning

Keep the returned identifiers. If a retry cannot be completed, preserve them and contact Safiri support.

Issue a Reserved Train Ticket (Agents)

After creating an agent reservation, call this endpoint to pay from the agent's Safiri wallet and issue the train ticket.

Endpoint: POST /api/trip/train/ticket/{paymentReference}/issue

Use the paymentReference returned by the reservation endpoint.

Example Request

curl --request POST \
--url https://booking-api.safiri.app/api/trip/train/ticket/SGR1234567890123456/issue \
--header 'Authorization: Bearer <JWT_TOKEN>' \
--header 'Content-Type: application/json' \
--data '{}'

The authenticated agent must own the reservation and have enough available Safiri wallet balance, including any overdraft Safiri has configured for that agent. A successful request returns 202 Accepted because ticket issuance completes asynchronously.

Insufficient Wallet Balance

Safiri calculates the agent's projected balance using the frozen ticket debit. If that balance would fall below the agent's permitted overdraft limit, the request is rejected with 402 Payment Required:

{
"error": {
"name": "modal"
},
"errorCode": "SGR_AGENT_INSUFFICIENT_BALANCE",
"errorMessage": "Insufficient agent wallet balance to issue this SGR ticket",
"status": 402
}

The reservation is not claimed for issuance when this response is returned. Top up the agent's Safiri wallet and retry the same endpoint before the reservation expires.

Optional Issued-Ticket Callback

An agent may provide an HTTPS callback URL when requesting issuance:

{
"callbackUrl": "https://agent.example.com/webhooks/sgr"
}

Safiri calls this URL after the ticket is issued. If no callback URL is provided, use the status endpoint below.

Check Issuance Status

GET /api/trip/train/ticket/SGR1234567890123456/issuance

Use the same bearer token. The ticket is ready when issuance.status is ISSUED. As part of successful issuance, Safiri automatically generates the ticket PDF. Safiri sends the ticket through email, SMS, WhatsApp, and push when notifications.ticketPurchase is true or omitted; it skips those customer deliveries when the reservation set it to false. No separate document-generation request is required.

The funding.status field distinguishes admission from an actual wallet charge. AGENT_WALLET_ADMITTED means the balance and overdraft checks passed, but no debit has occurred. The debit is committed only with successful ticket issuance, after which the status becomes AGENT_WALLET_DEBITED and funding.walletDebited is true.

{
"paymentReference": "SGR1234567890123456",
"funding": {
"source": "AGENT_WALLET",
"status": "AGENT_WALLET_ADMITTED",
"walletDebited": false,
"duplicatePaymentRequiresReview": false
},
"payout": { "status": "PAYOUT_REJECTED" },
"issuance": {
"status": "PAYOUT_REJECTED",
"internalExpiresAt": "2026-08-13T11:19:40.643Z",
"providerExpiresAt": "2026-08-13T11:20:40.643Z"
},
"failure": {
"stage": "PAYOUT",
"code": "TEMBO_REQUEST_FAILED",
"retryable": false,
"action": "CONTACT_SUPPORT"
}
}

Paying in Another Currency

Train fares are always denominated in TZS, but an online reservation can be paid for in any supported payment currency. Add a currency query parameter to the status endpoint to freeze and read the exact payable amount:

GET /api/trip/train/ticket/SGR1234567890123456/issuance?currency=ZMW
{
"payable": {
"baseAmount": "3500.00",
"baseCurrency": "TZS",
"paymentRequired": true,
"quotes": [
{ "amount": "3500.00", "currency": "TZS", "exchangeRate": 1 },
{ "amount": "25.20", "currency": "ZMW", "exchangeRate": 0.0072 }
]
}
}

A quote is the fare: the train fare plus Safiri's booking fee. Customers are charged that plus the processing fee for their payment method, so a charge request must send:

amount = quote.amount + transactionFee

Rules that payment integrations must follow:

  • Charge the quote plus your processing fee. Send quote.amount + processing fee as the charge amount (total on /credit/charge/card and /credit/charge/mobile, amount on /credit/pawapay/deposits). Safiri derives the processing fee as charged amount − quote and stores that; it does not read your fee / transactionFee field for this, because that line usually also carries Safiri's booking commission, which the quote already includes.
  • The fee is bounded. Charging less than the quote is rejected with SGR_PAYMENT_AMOUNT_MISMATCH, and a fee above 10% of the quote with SGR_PAYMENT_FEE_TOO_HIGH. Omit the amount entirely and the bare quote is charged. Agent wallet issuance never carries a processing fee.
  • The rate is frozen once per currency and never re-derived, so the amount quoted here is the fare the customer pays for the life of the reservation.
  • A currency that cannot be quoted returns SGR_PAYMENT_CURRENCY_UNAVAILABLE with a data.payableCurrencies list to fall back to. Mobile money can only collect its own country's currency, so a mismatched payer number is rejected with SGR_PAYMENT_CURRENCY_UNSUPPORTED_BY_MNO.
  • Quotes are only frozen while the reservation is still awaiting funds, and only for the reservation's owner.

Optional: Download a Fresh Ticket PDF

This endpoint is a manual download and support utility, not a step in the booking or issuance flow. After the ticket reaches ISSUED, an authenticated owner can download a freshly rendered PDF from the persisted provider ticket data:

GET /api/trip/train/ticket/document/generate?paymentReference=SGR1234567890123456

To generate one ticket instead of the complete payment-reference group, provide its Safiri UUID or full TRC ticket number:

GET /api/trip/train/ticket/document/generate?ticketId=TRC00019e8aa449a500

Provide exactly one of paymentReference or ticketId. The endpoint renders a new PDF on every request and returns it directly to the caller with 200 OK and Content-Type: application/pdf. It does not issue the ticket, resend customer messages, or replace Safiri's automatic delivery. Administrators may download any issued SGR ticket; other callers may download only tickets they own. The generated PDF opens without a password and permits high-resolution printing, but it is owner-password encrypted to prevent editing, annotations, form changes, and page assembly in compliant PDF readers. Text and image copying is permitted.

note

Calling the issue endpoint again for the same reservation is safe. If a callback URL was supplied, use the same URL on repeated requests.

Resend Train Ticket Notifications (Safiri Staff)

Endpoint: POST /api/trip/train/send-notification

Requires a Safiri ADMIN or SUDO JWT. Select the booking with paymentReference, pnr, ticketNumber, or trainTicketUuids.

To resend a paid ticket on WhatsApp and email:

{
"paymentReference": "SGR1234567890123456",
"notificationChannels": ["WHATSAPP", "EMAIL"]
}

Use ["WHATSAPP"] to send only WhatsApp. Paid-ticket manual sends support EMAIL and WHATSAPP; omitting the channel selects email. messageType is optional and defaults to TRAIN_TICKET (ticket purchase). To send a reservation or cancellation notification, explicitly set TRAIN_TICKET_RESERVATION or TRAIN_TICKET_RESERVATION_CANCELLATION. The legacy emailType field remains supported when messageType is omitted.

WhatsApp uses the approved English train_ticket_purchase template with the ticket PDF. For third-party agent bookings, only passengers receive WhatsApp, with their own ticket PDFs. The agent (buyer) receives no WhatsApp message. For other bookings, the buyer receives the group PDF and other passenger recipients receive their own PDFs. Both automatic delivery and manual resends follow this recipient rule, and already-sent tickets can be resent. The booking must be issued, every ticket must be paid, and ticket-purchase notifications must remain enabled. The corresponding ticket documents must be ready. A missing buyer phone does not prevent delivery to passengers with valid phones.

The response is HTTP 200 with one settled result per selected channel, in request order. Check value.whatsapp.success and value.whatsapp.results for WhatsApp delivery outcomes, including partial failures; HTTP 200 alone does not mean every message was sent. Inspect rejected channel results as well.

Error Responses

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

{
"error": {},
"errorCode": "FROM_STOP_ID_REQUIRED",
"errorMessage": "Please provide from, to and date for SGR train search",
"status": 400
}

Common causes include:

  • Missing from, to, or date
  • A stop ID that does not exist in the train GTFS data
  • A stop that is not an SGR stop with TRC partner metadata
  • Temporary search unavailability
  • A selected seat that no longer matches the selected train details payload
  • A seat that has already been booked or held by another customer
  • A reservation retry that cannot be safely completed