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 six-minute payment window and an exact remaining-time deadline
- 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.
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.
Issuance-status polling also requires a JWT, but resolves by payment reference rather than reservation ownership and exposes status and payable amounts only. Ticket document endpoints additionally require the reservation owner or authorized Safiri staff.
Headers
Required for train search, train details, seat hold/release, reservation, ticket issuance, issuance status, ticket documents, and ticket notification resends:
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 baseprice.currencyand the price restated per currency inprice.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:
| Component | Where to find it | Description |
|---|---|---|
| Transporter fee | price.fees[] where type is TRANSPORTER; also price.total | The TRC train fare payable to the transporter. |
| Safiri fee | price.fees[] where type is SAFIRI_COMMISSION | Safiri's fee for the booking. |
| Wallet debit | price.grandTotal | The 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
| Parameter | Type | Required | Description |
|---|---|---|---|
mini | boolean | No | When true, returns the compact station payload used by search and autocomplete flows. |
Response Fields
| Field | Type | Description |
|---|---|---|
stop_id | string | Safiri stop ID. Use this exact value in train search, for example DARESALAAM:SGR:0. |
stop_lat | number | Station latitude. |
stop_lon | number | Station longitude. |
stop_name | string | Human-readable station name. |
stop_desc | string | Station location or address description. |
stop_timezone | string | IANA timezone for the station. |
city_geohash | string | Geohash used for station clustering and spatial search. |
popularity | number | Ranking value used by search and selection UIs. |
partners | array | Provider 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
| Parameter | Type | Required | Description |
|---|---|---|---|
from | string | Yes | Origin station stop_id from the train stops endpoint. Example: DARESALAAM:SGR:0. |
to | string | Yes | Destination station stop_id from the train stops endpoint. Example: MOROGORO:SGR:0. |
date | string | Yes | Travel date in YYYY-MM-DD format. |
lookAheadDays | string | No | Opt 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": []
}
}
]
}
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.
Optional Next-Date Search
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 outcome | availability.status | lookAhead.status | Date fields |
|---|---|---|---|
lookAheadDays omitted | Field omitted | Field omitted | The existing response envelope is unchanged. |
| Requested date has trains | RESULTS_FOUND | NOT_NEEDED | firstDate, confirmedThroughDate, and next are null. |
| Earliest confirmed candidate has trains | NO_RESULTS | FOUND | confirmedThroughDate and next.date are the found date. |
| Every candidate is confirmed empty | NO_RESULTS | NO_RESULTS | confirmedThroughDate is the final checked date and next is null. |
| A required candidate could not be checked reliably | NO_RESULTS | INCOMPLETE | confirmedThroughDate 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.
Checking additional dates can take longer than a single-date search. If a
required date cannot be checked reliably, the response uses
lookAhead.status: INCOMPLETE; do not present unchecked dates as unavailable.
Calendar behavior:
serviceDateis the railway's current calendar date when the request starts, inserviceTimeZone.- Candidate checking begins on the later of the day after
requestedDateandserviceDate. This prevents suggestions from beginning in the past. lookAheadDayscounts 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-DDvalues as plain calendar dates. Do not usenew 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
| Field | Type | Description |
|---|---|---|
meta | object | Search metadata including result count, provider, links, response timestamp, and optional look-ahead availability. |
data | array | List of train options matching the requested origin, destination, and date. |
Meta Object
| Field | Type | Description |
|---|---|---|
count | number | Number of train options in data for the requested date. |
provider | string | Train provider key. Current value is TRC_SGR. |
links.self | string | Fully qualified URL for the current search request. |
links.nextAvailable | string | Fully qualified URL for the suggested date. Present only when opted-in look-ahead has status FOUND. |
availability | object | Requested-date and look-ahead outcome. Present only when lookAheadDays was supplied. |
createdAt | string | ISO timestamp for when the search response was generated. |
Train Result Object
Each item in data represents one train option.
| Field | Type | Description |
|---|---|---|
provider | string | Provider key for the train option. Current value is TRC_SGR. |
trip | object | Train identity, timetable IDs, GTFS trip ID, headsign, and accessibility flags. |
route | object | Route identity and GTFS route metadata. |
departure | object | Departure date, time, ISO date-time, and origin stop. |
arrival | object | Arrival date, time, ISO date-time, and destination stop. |
price | object | TRC fare, Safiri fee breakdown, collected/debited total, base currency, and converted otherCurrencies. |
stopTimes | array or null | Ordered route stops with arrival and departure times. May be null if GTFS stop-time enrichment is unavailable. |
cars | array | Railway car classes, capacities, seat availability, and booked seat metadata. |
carTypes | array | Deduplicated cabin/class list derived from cars, each { id, name, luggage? } (see Luggage Allowance Object). Useful for cabin filters. |
transitAgency | object | Operator information for Tanzania Railways Corporation. |
Trip Object
| Field | Type | Description |
|---|---|---|
id | number | TRC trip identifier. |
name | string | Train trip name. |
number | string | Train number. |
trainSetId | number | TRC train set identifier. |
timeTableId | number | TRC timetable identifier. |
timeTableTrainSetId | number | TRC timetable train set identifier. |
gtfsTripId | string or null | Matching GTFS trip ID when available. |
headsign | string or null | Route headsign showing the destination and major intermediate stops. |
wheelchairAccessible | number or null | GTFS wheelchair accessibility flag when available. |
bikesAllowed | number or null | GTFS bike allowance flag when available. |
Price Object
| Field | Type | Description |
|---|---|---|
pricingContext | string | Pricing mode used for display and booking: ONLINE, SAFIRI_AGENT, or THIRD_PARTY_AGENT. |
currency | string | Base fare currency. Current SGR fares are returned in TZS. |
total | string | Dynamic TRC fare amount. This is the transporter ticket cost and is included in the wallet debit. |
bookingFeeTotal | string | Safiri-controlled fees above the TRC fare. For third-party agents, this is Safiri's booking fee unless another fee component is explicitly returned. |
grandTotal | string | Amount Safiri debits from the third-party agent wallet for this ticket. It does not include any additional markup you charge your customer outside Safiri. |
fees | array | Wallet-debit component breakdown. Includes TRANSPORTER, FACILITATION, and SAFIRI_COMMISSION; zero-value components are included as "0". |
baseCurrency | string | The operator's own currency. Always the currency basePrice and the fees amounts are denominated in. |
basePrice | string | The TRC fare in baseCurrency. otherCurrencies provides converted display amounts. |
otherCurrencies | object | The price restated in each supported currency, keyed by currency code. See the Converted Price Object below. |
Price Fee Object
| Field | Type | Description |
|---|---|---|
type | string | Fee component type: TRANSPORTER, FACILITATION, or SAFIRI_COMMISSION. |
amount | string | Component amount in the price currency. The returned fee amounts sum to grandTotal. |
recipient | string | Component 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.
| Field | Type | Description |
|---|---|---|
amount | number | Numeric fare in the target currency. |
formatted | string | Display-ready fare with currency code and separators. |
currency | string | The target currency code. |
total | string | The TRC fare in the target currency. |
bookingFeeTotal | string | Safiri fees above the fare, in the target currency. |
grandTotal | string | total plus bookingFeeTotal, in the target currency. |
fees | array | The 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
| Field | Type | Description |
|---|---|---|
id | number | Timetable railway car identifier. |
carId | number | Railway car type identifier. |
orderNo | number | Car order in the train. |
typeId | number | TRC class/type identifier. |
typeName | string | Passenger class name, for example Royal Class, Business, or Economy. |
capacity | number | Total car capacity. |
standardSeatCapacity | number | Standard seat capacity. |
disabledSeatCapacity | number | Accessible seat capacity. |
emptySeats | number | Currently available seats for the car. |
emptyStandardSeats | number | Currently available standard seats. |
emptyDisabledSeats | number | Currently available accessible seats. |
hasSeat | boolean | Whether the car has reserved seats. |
bookedSeats | array | Seat 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
| Field | Type | Description |
|---|---|---|
meta | object | Details metadata including provider, self link, and response timestamp. |
data | object | The 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.
| Field | Type | Description |
|---|---|---|
provider | string | Provider key for the train option. Current value is TRC_SGR. |
trip | object | Train identity, timetable IDs, GTFS trip ID, headsign, and accessibility flags. |
route | object | Route identity and GTFS route metadata. |
departure | object | Departure date, time, ISO date-time, and origin stop. |
arrival | object | Arrival date, time, ISO date-time, and destination stop. |
price | object | Base amount, base currency, and converted otherCurrencies. |
stopTimes | array or null | Ordered route stops with arrival and departure times. |
train | object | Detailed train-set information and cabin/car layout. |
bookingRequirements | object | Passenger details and option lists required for seat reservation. |
transitAgency | object | Operator information for Tanzania Railways Corporation. |
Train Object
| Field | Type | Description |
|---|---|---|
id | number | Timetable train set identifier. |
typeId | number | Train type identifier. |
type | string | Train type name, for example High Speed Train. |
name | string | Train set name. |
number | number | Train set number. |
carTypes | array | Deduplicated cabin/class list derived from cars, each { id, name, luggage? } (see Luggage Allowance Object). |
cars | array | Cabin/car details, including prices and seats. |
Train Car Object
| Field | Type | Description |
|---|---|---|
id | number | Timetable car identifier. |
name | string | Car name from the provider. |
orderNo | number | Car order in the train. |
carId | number | Railway car identifier. |
carTypeId | number | Railway car class/type identifier. |
carType | string | Passenger class name, for example Royal Class, Business, or Economy. |
capacity | number | Total car capacity. |
standardCapacity | number | Standard seat capacity. |
disabledCapacity | number | Accessible seat capacity. |
emptySeats | number | Currently available seats in this car. |
rowCount | number | Number of seat-layout rows. |
columnCount | number | Number of seat-layout columns. |
hasSeat | boolean | Whether the car has selectable seats. |
isActive | boolean | Whether the car is active. |
isDisabled | boolean | Whether the car is disabled/unavailable. |
prices | array | Passenger-type prices for this car. |
luggage | object | Optional. What one passenger may bring in this car's class. See Luggage Allowance Object. |
seats | array | Seat map for this car. |
Luggage Allowance Object
TRC SGR's luggage allowance for one passenger: two bags, together at most
20 kg in Economy and 30 kg in Business or Royal Class. The optional luggage
field appears on search carTypes, on details train.carTypes and
train.cars[], and on tickets as seat.luggage. The ticket's allowance is the
one applicable at booking time.
| Field | Type | Description |
|---|---|---|
maxBags | number | Bags one passenger may bring. |
maxTotalWeightKg | number | The most those bags may weigh together, in kilograms. |
{
"luggage": { "maxBags": 2, "maxTotalWeightKg": 20 }
}
New train tickets also keep seat.carOrderNo: the car's 1-based place in the
train (its orderNo in details), which is the coach number the traveller
looks for on the platform. Tickets booked before either field existed have
neither.
Car Price Object
| Field | Type | Description |
|---|---|---|
ticketTypeId | number | Passenger ticket type identifier. |
ticketTypeCode | number | Passenger ticket type code. |
ticketTypeName | string | Passenger type, for example Adult (13 and Above) or Child (4 - 12 Years). |
pricingContext | string | Pricing mode used for this fare: ONLINE, SAFIRI_AGENT, or THIRD_PARTY_AGENT. |
total | string | Dynamic TRC fare amount for this passenger type and car. This is the transporter ticket cost and is included in the wallet debit. |
bookingFeeTotal | string | Safiri-controlled fees above the TRC fare. For third-party agents, this is Safiri's booking fee unless another fee component is explicitly returned. |
grandTotal | string | Amount Safiri debits from the third-party agent wallet for this ticket. It does not include any additional markup you charge your customer outside Safiri. |
fees | array | Wallet-debit component breakdown with TRANSPORTER, FACILITATION, and SAFIRI_COMMISSION. |
formattedPrice | string | Display-ready fare amount. |
currency | string | Fare currency. |
otherCurrencies | object | Converted fare amounts keyed by currency code. |
luggageTypeId | number | Selected/default luggage type identifier. |
luggageTypeName | string | Selected/default luggage type name. |
luggagePrice | number | Luggage price amount. |
Seat Object
| Field | Type | Description |
|---|---|---|
id | string | Seat identifier. |
label | string | Seat label shown to passengers. Empty for non-seat layout cells. |
positionY | number | Zero-based display row. |
positionX | number | Zero-based display column within the row. |
key | string | Stable frontend key for the layout cell. |
type | string | Layout cell type, for example SEAT, DISABLED_SEAT, AISLE, WC, LUGGAGE, or BED. |
class | string | Cabin/class for the car, for example ECONOMY, BUSINESS, or ROYAL_CLASS. |
orientation | string | Seat orientation for layout rendering: UP, DOWN, LEFT, or RIGHT. |
available | boolean | Whether the seat can be selected. |
Booking Requirements Object
| Field | Type | Description |
|---|---|---|
travelerRequirements | object | Traveler 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. Collect gender when genderRequired: true. |
nationalityOptions | array | Valid passenger nationality/residency options. |
luggageOptions | array | Valid luggage options. |
ticketTypeOptions | array | Valid 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,
"luggage": { "maxBags": 2, "maxTotalWeightKg": 30 },
"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
| Field | Type | Required | Description |
|---|---|---|---|
departureStopPartnerId | number | Yes | TRC station ID from departure.stop.partners[].id. |
arrivalStopPartnerId | number | Yes | TRC station ID from arrival.stop.partners[].id. |
timeTableTrainSetId | number | Yes | trip.timeTableTrainSetId from train search/details. |
carId | number | Yes | Selected details car id. |
seat | number | Yes | Selected seat id. |
luggageId | number | No | Luggage option ID. Defaults to 5 (None). |
{
"departureStopPartnerId": 1,
"arrivalStopPartnerId": 12,
"timeTableTrainSetId": 8710,
"carId": 497,
"seat": 2119,
"luggageId": 5
}
Success Response
{
"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
| Parameter | Type | Required | Description |
|---|---|---|---|
key | string | Yes | Hold 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"
}
}
}
Responses include Cache-Control: no-store.
Reserve Held Train Seats
Convert held seats into a reservation with a six-minute payment window.
Complete payment before the returned paymentWindow.expiresAt deadline. The
remaining time may be shorter when the response arrives; refreshing or retrying
does not restart the countdown.
Endpoint: POST /api/trip/train/seat/reserve
For authenticated Safiri agent integrations, the agent account is attached as
the reservation owner automatically when boughtByUser is omitted. Public
customer bookings resolve the booking account from the passenger's phone
details. Agent integrations should not send boughtByUser or bookingUserType.
Example Request:
POST /api/trip/train/seat/reserve
Headers
| Header | Required | Description |
|---|---|---|
Authorization | Yes | Bearer JWT for the user creating the reservation. |
x-authentication-context | No | Booking channel saved on the train ticket. Defaults to MOBILE_APP when omitted, preserving agent integration pricing. Explicit web-app requests use customer pricing, including when an agent is signed in. |
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
outbound | object | Yes | Full selected train details object from Get Train Details, containing only the selected cars/seats. |
return | object or null | No | Full selected return train details object. Use null or omit for one-way bookings. |
passengers | array | Yes | Passenger and held-seat details. Supports multiple passengers. |
notifications | object | No | Final customer ticket notification preferences for this reservation. Omit it to keep notifications enabled. |
issuerDeviceInfo | object | No | Device/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 Field | Type | Required | Description |
|---|---|---|---|
passengerName | string | Yes | Passenger name sent to TRC. |
identityNo | string | No | Passenger Passport, NIDA, or other identity number. Send an empty string when the passenger leaves the requested field blank. |
gender | Male or Female | No | Required by current apps when genderRequired is true. Temporarily optional at the API boundary for older clients; when supplied it is validated and stored. |
ageCategory | Adult or Child | No | Safiri derives and stores this from ticketTypeId (4 is Adult and 3 is Child). When supplied, it must match that ticket type. |
phone | string | Yes | Passenger phone number in international E.164 format, including + and the country calling code, for example +255757713888. |
email | string | No | Passenger email address. Omit or use an empty string when not available. When provided, it must be a valid email format. |
nationalityTypeId | number | Yes | Value from bookingRequirements.nationalityOptions[].id. |
isDisabledPassenger | boolean | No | Whether the passenger is disabled. |
ticketTypeId | number | Yes | Value from bookingRequirements.ticketTypeOptions[].id, for example adult or child. |
outboundSeatHoldKey | string | Yes | Hold key returned by the outbound seat hold call. |
outboundCarId | number | Yes | Selected outbound details car id. |
outboundCarTypeId | number | Yes | Selected outbound details car carTypeId. |
outboundSeatId | number | Yes | Selected outbound seat id. |
returnSeatHoldKey | string | Return only | Hold key returned by the return seat hold call. |
returnCarId | number | Return only | Selected return details car id. |
returnCarTypeId | number | Return only | Selected return details car carTypeId. |
returnSeatId | number | Return only | Selected return seat id. |
Do not send payer information or luggage details; they are not accepted by this request.
Phone Number Format
Send phone in international E.164 format, including + and the country
calling code:
{
"phone": "+255757713888"
}
Safiri validates the number and determines its country automatically. No separate country fields are needed.
Spaces and the Tanzanian local leading 0 are normalized: +255 0757713888
is accepted as +255757713888.
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 an agent, third-party or in-house Safiri 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.
Send the passenger's actual phone number in E.164 format, including the country code. Safiri uses this contact information for the reservation and eligible ticket notifications.
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": "+255757713888",
"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": "+255757713888",
"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
journeyType is ONE_WAY for a one-way reservation and ROUND_TRIP for a
return reservation. Individual paymentItems[].journey values remain
OUTBOUND and RETURN.
Selected response fields are shown below. At the response time of 10:05:30Z,
five whole minutes remain before the payment deadline at 10:11:00Z.
{
"meta": {
"provider": "TRC_SGR",
"links": {
"self": "https://booking-api.safiri.app/api/trip/train/seat/reserve"
},
"createdAt": "2026-06-12T10:05:30.000Z"
},
"data": {
"provider": "TRC_SGR",
"journeyType": "ONE_WAY",
"reservation": {
"pnr": "PNR00019e8aa4492c00",
"ticketNumbers": ["TRC00019e8aa449a500"],
"passengerCount": 1,
"segmentCount": 1
},
"bill": {
"id": "TRC-Pay062604545d07cf93a6ce4878913fb6fff8db2f76",
"paymentRequired": true,
"controlNumber": "987264686974"
},
"paymentReference": "SGR1234567890123456",
"paymentWindow": {
"expiresInMinutes": 5,
"expiresAt": "2026-06-12T10:11:00.000Z"
},
"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"]
}
}
A 200 OK response confirms the reservation. Keep the paymentReference and
returned ticket identifiers for issuance, status checks and support. A reserved
seat is not yet an issued ticket.
Reservation Requires Support
A 503 SGR_WORKFLOW_START_UNCONFIRMED response with retryAllowed: false
requires Safiri support. Keep the existing reservation identifiers and do not
submit a replacement booking. Selected response fields:
{
"errorCode": "SGR_WORKFLOW_START_UNCONFIRMED",
"data": {
"paymentReference": "SGR1234567890123456",
"retryAllowed": false
},
"status": 503
}
Passenger Validation Error
Safiri validates the passenger details and selected seats. 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"
}
]
}
}
Other correctable booking errors may also return 422. Use the returned code
and message to help the customer correct their request.
Response Time
Reservation requests can take time. Allow up to 180 seconds in your client and
handle 409 SGR_RESERVATION_IN_PROGRESS with retryAllowed: true by waiting a
few seconds and resending the identical request. A client timeout or disconnect
does not prove that the reservation failed; do not create a replacement booking
solely because the connection ended.
A 503 SGR_PROVIDER_UNAVAILABLE response with retryAllowed: true means the
service is temporarily unavailable. Wait briefly and retry the identical request.
Reservation Not Confirmed
If Safiri cannot confirm the selected seats, the endpoint returns
409 SGR_RESERVATION_RECONCILIATION_REQUIRED with retryAllowed: false. Follow
the response instructions to select different seats; do not reuse the hold keys.
{
"error": {
"name": "modal"
},
"errorCode": "SGR_RESERVATION_RECONCILIATION_REQUIRED",
"errorMessage": "We could not confirm this seat with the railway operator. You have not been charged. Please choose a different seat and try again.",
"data": {
"paymentReference": "SGR1234567890123456",
"retryAllowed": false
},
"status": 409
}
The seat holds in this request are spent: resending them always returns this same response. Do not release them either. Send the customer back to seat selection, hold different seats and reserve with the new hold keys.
Other responses with retryAllowed: false (SGR_WORKFLOW_START_UNCONFIRMED,
SGR_RESERVATION_CHECKPOINT_UNCONFIRMED, SGR_WORKFLOW_LINKAGE_INCOMPLETE,
SGR_WORKFLOW_IDENTITY_CONFLICT and SGR_BILLING_RECONCILIATION_REQUIRED)
require Safiri support before another booking is attempted. Preserve the
reservation identifiers and do not reuse or release those seat holds.
Reservation Still Processing
While a reservation is processing, Safiri may return
409 SGR_RESERVATION_IN_PROGRESS or 409 SGR_BILLING_ALREADY_IN_PROGRESS
with retryAllowed: true. Wait a few seconds and resend the identical request;
do not release the holds or create a replacement booking.
Limit repeated client requests. If the outcome remains unresolved, preserve the returned identifiers and contact Safiri support.
{
"error": {
"name": "modal"
},
"errorCode": "SGR_RESERVATION_IN_PROGRESS",
"errorMessage": "This reservation is still being processed. Please wait a few seconds and try again.",
"data": {
"paymentReference": "SGR1234567890123456",
"retryAllowed": true
},
"status": 409
}
Reservation Retry Behavior
Except for a response that says retryAllowed: false (such as
SGR_WORKFLOW_START_UNCONFIRMED and SGR_RESERVATION_RECONCILIATION_REQUIRED),
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.
Payment Deadlines and Timezones
The payment window is six minutes. Use paymentWindow.expiresAt as the exact
payment deadline and calculate the countdown from that timestamp. Time already
elapsed is included, so the remaining time can be shorter when the reservation
response arrives. Refreshing or retrying does not extend the deadline.
paymentWindow.expiresInMinutes gives the whole remaining minutes, rounded
down to a minimum of zero. A value of 0 can still mean less than a minute
remains; use expiresAt for the exact countdown. Treat the returned timestamp
as an absolute instant, respecting its Z or timezone offset, and convert it
to the customer's timezone only for display.
Submit payment or agent issuance before the deadline. If a payment is already processing when the countdown ends, keep checking issuance status. Do not ask the customer to pay again or assume that the booking was cancelled.
Keep the returned identifiers. If the booking 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
Your available wallet balance, including any approved overdraft, must cover the
returned booking amount. Otherwise, the request returns 402 Payment Required:
{
"error": {
"name": "modal"
},
"errorCode": "SGR_AGENT_INSUFFICIENT_BALANCE",
"errorMessage": "Insufficient agent wallet balance to issue this SGR ticket",
"status": 402
}
Your wallet is not charged 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
Send a valid bearer token. The same status endpoint supports online and agent
reservations and returns statuses and payable amounts without passenger details.
The ticket is issued only when issuance.status is ISSUED. A confirmed payment
alone does not mean the ticket is ready.
While issuance is processing, continue checking the same reservation. If
failure.action is CONTACT_SUPPORT, contact Safiri with the payment reference.
A RECONCILIATION_REQUIRED status needs Safiri's attention and can subsequently
change to ISSUED; it is not an instruction to pay again or create another
booking.
An issued ticket's PDF may still be preparing. document.status: SENT means
the document is ready to download. Safiri sends eligible email, SMS, WhatsApp
and push notifications when notifications.ticketPurchase is true or omitted.
Setting it to false suppresses those customer messages. A delivery problem
does not invalidate an issued ticket, and no 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.
Selected status response fields for a booking awaiting confirmation:
{
"paymentReference": "SGR1234567890123456",
"funding": {
"source": "AGENT_WALLET",
"status": "AGENT_WALLET_ADMITTED",
"walletDebited": false
},
"issuance": {
"status": "PROVIDER_CONFIRMING"
}
}
Paying in Another Currency
Train fares are always denominated in TZS, but an online reservation can be
paid for in any currency offered by Safiri with an available exchange rate,
including GBP, EUR, AUD, CNY, NGN and ZAR for card payments. Customer payment
currencies are independent of wallet currencies. Mobile money collects in its
correspondent's local 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 feeas the charge amount (totalon/credit/charge/cardand/credit/charge/mobile,amounton/credit/pawapay/deposits). Safiri derives the processing fee as charged amount − quote and stores that; it does not read yourfee/transactionFeefield 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 withSGR_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.
- On this status endpoint, an unavailable currency leaves the poll successful
and adds
payable.quoteErrorwith a code and message; the existingpayable.quotesremain available. Charge admission can instead reject withSGR_PAYMENT_CURRENCY_UNAVAILABLEand adata.payableCurrenciesfallback list. Mobile money can only collect its own country's currency, so a mismatched payer number is rejected withSGR_PAYMENT_CURRENCY_UNSUPPORTED_BY_MNO. - The status endpoint freezes new quotes only for online reservations still awaiting funds and before the customer deadline. It does not require the reservation owner's identity. Charge admission and funding verification enforce the payment's ownership and exact reference, amount, and currency.
- While
payable.paymentProcessingis true, an existing customer payment is still awaiting an outcome. Keep polling the same reservation. BeforepaymentWindow.expiresAt, the customer may choose a fresh payment attempt using the same or a different method; do not disable checkout solely because this flag is true. A fresh attempt does not cancel the earlier one, and both can complete. Do not automatically submit additional charges while polling, or start another charge after payment has been confirmed.
Download the Generated Ticket PDF
Once document.status is SENT, the reservation owner can fetch the stored
PDF URL using a bearer token:
GET /api/trip/train/ticket/SGR1234567890123456/document
The response is { "url": "..." }. Before the group document is ready, this
endpoint returns 409 SGR_DOCUMENT_NOT_READY. You must own the reservation to
download its document. Status polling does not itself expose the document URL
or passenger data.
Optional: Download a Fresh Ticket PDF
After the ticket reaches ISSUED, the authenticated reservation owner can
request a fresh copy of the ticket PDF:
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 or resend customer
messages. You may download only tickets you own. The PDF opens without a
password and supports printing.
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
Endpoint: POST /api/trip/train/send-notification
Use the JWT for your own active Safiri account. The person who booked a ticket, including a third-party agent, can resend that ticket. A passenger can also resend their own ticket when their verified Safiri account phone number or email address matches the contact details on the ticket. Authorized Safiri staff can resend tickets on behalf of customers. Shared website credentials cannot be used for this endpoint.
Select the tickets with paymentReference, pnr, ticketNumber, or
trainTicketUuids. A payment reference or PNR selects only tickets you are
allowed to resend. If you use ticketNumber, that ticket must be yours. If you
provide trainTicketUuids as a customer or agent, every listed ticket must exist
and be yours; otherwise the entire request is rejected before any messages are
sent.
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). Customer and agent
resends support only TRAIN_TICKET. Authorized staff may also select
TRAIN_TICKET_RESERVATION or TRAIN_TICKET_RESERVATION_CANCELLATION. The legacy
emailType field remains supported when messageType is omitted.
Messages use the recipients already associated with the tickets; this endpoint does not accept replacement recipients. A passenger resending their own ticket resends only their own ticket documents to the contact details saved on their ticket. A matching verified phone number or email establishes ownership; the selected delivery channel uses that ticket's saved phone number or email. They cannot resend other passengers' tickets in the booking.
For a resend by the booker or authorized staff, existing booking recipients are used. For third-party agent bookings, only passengers receive WhatsApp, with their own ticket PDFs; the agent receives no WhatsApp message. For other bookings, the buyer receives the group PDF and other passenger recipients receive their own PDFs. Already-sent tickets can be resent.
For TRAIN_TICKET, every selected ticket must be paid, and ticket-purchase
notifications must remain enabled. WhatsApp additionally requires the booking to be issued and the
corresponding ticket PDFs to be ready. A missing buyer phone does not prevent
WhatsApp 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.
For email, check value.email.success and value.email.message, including
partial delivery failures. Email success remains false if any requested
booking is skipped or a recipient's email fails, even if other emails succeed.
For any request covering several bookings, including requests by Safiri staff,
each booking's recipients receive only that booking's ticket details.
HTTP 401 with PROTECTED_RESOURCE means you must sign in. HTTP 403 with
FORBIDDEN_ACCESS_PRIVILEGE means the account is not eligible, the
selected tickets are not authorized for that account, or the requested message
type is not permitted. Sign in with the booking account or a passenger account
with matching verified contact details, and select only eligible tickets.
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, ordate - A stop ID that does not exist in the train GTFS data
- A stop that is not supported for SGR bookings
- 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