What this module covers
Transportation models the physical fleet and the journeys run on it: assets (trains), parts (coaches), compartments (suites and seats), routes and their stoppages, journeys, and the passengers, bookings and crew attached to them - plus the lookups those records depend on.
CompanyId and the X-Source-System header, and
Authentication for tokens. They apply to everything here.Recommended upload order
Some records point at other records (a coach points at a part type, a journey points at a route). Upload in this order and everything will resolve cleanly:
- Lookups - PartCategory → PartType, CompartmentType, WindowType, ToiletType, WheelType, Facility, TripStatus, PromotionalStatus, PackageDepartureStatus, JourneyCode, Stoppage, ItinerarySupplier
- Members (internal users/crew) - only needed if you'll reference them from TripMember
- Passengers (people)
- Assets (trains)
- Parts (coaches) - needs PartType
- PartCompartments (suites/seats in a coach) - needs Part, CompartmentType
- LinkedAssetParts (attach coaches to a train) - needs Asset, Part
- RouteInfo - needs JourneyCode and an existing Client; include
RouteStoppagesif you have them, needs Stoppage saved first - SiteTripInfo (create the journey) - needs a Site that is already linked to your company in Ketendo (set up via the Ketendo portal first - this API can't create that link); include
TripStoppagesif the route has stoppages, needs RouteInfo'sRouteStoppagessaved first - TripParts (attach coaches to the journey)
- TripCompartments (publish a seat/suite to the journey)
- TripPassenger (assign a booking to a seat)
- TripMember (assign crew to the journey)
Master data / lookups
Upload these first - the core entities below reference them by id. All 13 endpoints share the same shape: an ExternalId plus one nested lookup object with Name, Code, Description, Order.
Order is not honoured the very first time you create a record (Ketendo auto-assigns it) - if you send the same ExternalId again to update it, your Order value is then applied.POST/v1/integration/transportation/SaveGenderGender option for passengers
| Field | Type | Required? | Notes |
|---|---|---|---|
ExternalId | string | Required | Your unique key for this gender, e.g. GENDER-F |
CompanyId | int | Required | Which company this record belongs to - see the CompanyId note |
Gender | object | Required | The gender details |
↳ Name | string | Required | Display name, e.g. Female. Max 256 characters |
↳ Code | string | Required | Short code, e.g. F. Must be unique within your company - see the callout below. Max 256 characters |
↳ Description | string | Optional | |
↳ Selectable | bool | Optional | Whether it appears in pick-lists in the Ketendo portal. Send true for anything staff should be able to choose; default false |
↳ Order | int | Optional | Display order. Left out on a create, we append it to the end of the list for you |
↳ Deleted | bool | Optional | Send true to retire an option without removing it from passengers already using it |
↳ Id, ExternalIdentifier | - | Auto-set | Don't send |
Code is unique per company. If you send a Code that another gender on the same company already uses, the call fails on a unique-constraint violation. Because of that, treat Code as fixed once created - to rename an option change Name, not Code. Name and Code are both non-nullable in the database, so neither can be omitted or sent as null.internalId in the response is the value you then send as Passenger.GenderId on SavePassenger. Genders are configured per company, so save them once per company and keep the mapping.Request
POST /v1/integration/transportation/SaveGender
{
"ExternalId": "GENDER-F",
"CompanyId": 101,
"Gender": {
"Name": "Female",
"Code": "F",
"Selectable": true
}
}
Result
{
"externalId": "GENDER-F",
"internalId": "3",
"mappingCreated": true
}
POST/v1/integration/transportation/SavePartCategoryCoach category (e.g. kitchen, bar)
| Field | Type | Required? | Notes |
|---|---|---|---|
ExternalId | string | Required | Your unique key for this category, e.g. CATEGORY-LOCO |
CompanyId | int | Required | Which company this record belongs to - see the CompanyId note |
PartCategory | object | Required | The category details |
↳ Name | string | Optional | Display name |
↳ Code | string | Optional | Short code |
↳ Description | string | Optional | |
↳ Order | int | Optional | Display order, default 0 |
↳ Id | - | Auto-set | Don't send |
Request
POST /v1/integration/transportation/SavePartCategory
{
"ExternalId": "CATEGORY-LOCO",
"CompanyId": 101,
"PartCategory": {
"Name": "Locomotive",
"Code": "LOCO",
"Description": "Locomotive and power car parts",
"Order": 10
}
}
Result
{
"externalId": "CATEGORY-LOCO",
"internalId": "7",
"mappingCreated": true
}
POST/v1/integration/transportation/SavePartTypeCoach type (e.g. loco, sleeper, diner)
| Field | Type | Required? | Notes |
|---|---|---|---|
ExternalId | string | Required | e.g. TYPE-SLEEPER |
CompanyId | int | Required | Which company this record belongs to - see the CompanyId note |
PartType | object | Required | |
↳ Name, Code, Description, Order | - | Optional | Same as PartCategory above |
↳ PartCategoryId | int | Required | Must reference an existing PartCategory - save that first |
↳ Id | - | Auto-set | Don't send |
PartCategoryId is the internal numeric id returned when you saved the PartCategory - not its ExternalId. Keep a lookup table of ExternalId → returned id/InternalId as you go, or re-resolve it from the response of the earlier call.Request
POST /v1/integration/transportation/SavePartType
{
"ExternalId": "TYPE-SLEEPER-6",
"CompanyId": 101,
"PartType": {
"Name": "6-berth Sleeper",
"Code": "SLEEPER6",
"Description": "6-berth sleeper coach",
"Order": 20,
"PartCategoryId": 7
}
}
Result
{
"externalId": "TYPE-SLEEPER-6",
"internalId": "14",
"mappingCreated": true
}
POST/v1/integration/transportation/SaveWindowTypeWindow style
| Field | Type | Required? | Notes |
|---|---|---|---|
ExternalId | string | Required | e.g. WINDOW-LARGE |
CompanyId | int | Required | Which company this record belongs to - see the CompanyId note |
WindowType | object | Required | Name/Code/Description/Order all Optional; Id Auto-set |
Request
POST /v1/integration/transportation/SaveWindowType
{
"ExternalId": "WINDOW-LARGE",
"CompanyId": 101,
"WindowType": {
"Name": "Large picture window",
"Code": "LARGE",
"Description": "Full-height panoramic window",
"Order": 10
}
}
Result
{
"externalId": "WINDOW-LARGE",
"internalId": "3",
"mappingCreated": true
}
POST/v1/integration/transportation/SaveToiletTypeToilet type
| Field | Type | Required? | Notes |
|---|---|---|---|
ExternalId | string | Required | e.g. TOILET-VACUUM |
CompanyId | int | Required | Which company this record belongs to - see the CompanyId note |
ToiletType | object | Required | Name/Code/Description/Order all Optional; Id Auto-set |
Request
POST /v1/integration/transportation/SaveToiletType
{
"ExternalId": "TOILET-VACUUM",
"CompanyId": 101,
"ToiletType": {
"Name": "Vacuum toilet",
"Code": "VACUUM",
"Description": "Vacuum-flush toilet unit",
"Order": 10
}
}
Result
{
"externalId": "TOILET-VACUUM",
"internalId": "2",
"mappingCreated": true
}
POST/v1/integration/transportation/SaveWheelTypeWheel / bogie type
| Field | Type | Required? | Notes |
|---|---|---|---|
ExternalId | string | Required | e.g. WHEEL-BOGIE-4 |
CompanyId | int | Required | Which company this record belongs to - see the CompanyId note |
WheelType | object | Required | Name/Code/Description/Order all Optional; Id Auto-set |
Request
POST /v1/integration/transportation/SaveWheelType
{
"ExternalId": "WHEEL-BOGIE-4",
"CompanyId": 101,
"WheelType": {
"Name": "4-wheel bogie",
"Code": "BOGIE4",
"Description": "Standard 4-wheel bogie",
"Order": 10
}
}
Result
{
"externalId": "WHEEL-BOGIE-4",
"internalId": "5",
"mappingCreated": true
}
POST/v1/integration/transportation/SaveCompartmentTypeSuite / seat type
| Field | Type | Required? | Notes |
|---|---|---|---|
ExternalId | string | Required | e.g. COMP-SUITE-1B |
CompanyId | int | Required | Which company this record belongs to - see the CompanyId note |
CompartmentType | object | Required | Name/Code/Description/Order all Optional; Id Auto-set |
Request
POST /v1/integration/transportation/SaveCompartmentType
{
"ExternalId": "COMP-SUITE-1B",
"CompanyId": 101,
"CompartmentType": {
"Name": "1-Bed Suite",
"Code": "SUITE1B",
"Description": "Single-bed luxury suite",
"Order": 10
}
}
Result
{
"externalId": "COMP-SUITE-1B",
"internalId": "5",
"mappingCreated": true
}
POST/v1/integration/transportation/SaveFacilityFacility / amenity (e.g. WiFi)
| Field | Type | Required? | Notes |
|---|---|---|---|
ExternalId | string | Required | e.g. FAC-WIFI |
CompanyId | int | Required | Which company this record belongs to - see the CompanyId note |
Facility | object | Required | Name/Code/Description/Order all Optional; Id Auto-set |
Request
POST /v1/integration/transportation/SaveFacility
{
"ExternalId": "FAC-WIFI",
"CompanyId": 101,
"Facility": {
"Name": "WiFi",
"Code": "WIFI",
"Description": "Onboard wireless internet",
"Order": 10
}
}
Result
{
"externalId": "FAC-WIFI",
"internalId": "9",
"mappingCreated": true
}
POST/v1/integration/transportation/SaveTripStatusJourney status (e.g. On Time, Delayed)
| Field | Type | Required? | Notes |
|---|---|---|---|
ExternalId | string | Required | e.g. STATUS-ON-TIME |
CompanyId | int | Required | Which company this record belongs to - see the CompanyId note |
TripStatus | object | Required | |
↳ Name, Code, Description, Order | - | Optional | |
↳ Colour, TextColour | string | Optional | Hex colour for UI display, e.g. #00FF00 |
↳ IsDefault | bool | Optional | Marks this as the default status, default false |
↳ Id | - | Auto-set | Don't send |
Request
POST /v1/integration/transportation/SaveTripStatus
{
"ExternalId": "STATUS-ON-TIME",
"CompanyId": 101,
"TripStatus": {
"Name": "On Time",
"Code": "ON-TIME",
"Description": "Journey is running to schedule",
"Order": 10,
"Colour": "#00FF00",
"TextColour": "#000000",
"IsDefault": true
}
}
Result
{
"externalId": "STATUS-ON-TIME",
"internalId": "1",
"mappingCreated": true
}
POST/v1/integration/transportation/SavePromotionalStatusPromo / special status
| Field | Type | Required? | Notes |
|---|---|---|---|
ExternalId | string | Required | e.g. PROMO-FULL |
CompanyId | int | Required | Which company this record belongs to - see the CompanyId note |
PromotionalStatus | object | Required | Name/Code/Description/Order all Optional; Id Auto-set |
Request
POST /v1/integration/transportation/SavePromotionalStatus
{
"ExternalId": "PROMO-FULL",
"CompanyId": 101,
"PromotionalStatus": {
"Name": "Fully Booked",
"Code": "FULL",
"Description": "No remaining availability",
"Order": 10
}
}
Result
{
"externalId": "PROMO-FULL",
"internalId": "4",
"mappingCreated": true
}
POST/v1/integration/transportation/SavePackageDepartureStatusDeparture readiness status
| Field | Type | Required? | Notes |
|---|---|---|---|
ExternalId | string | Required | e.g. DEP-READY |
CompanyId | int | Required | Which company this record belongs to - see the CompanyId note |
PackageDepartureStatus | object | Required | Name/Code/Description/Order all Optional; Id Auto-set |
Request
POST /v1/integration/transportation/SavePackageDepartureStatus
{
"ExternalId": "DEP-READY",
"CompanyId": 101,
"PackageDepartureStatus": {
"Name": "Ready for Departure",
"Code": "READY",
"Description": "All checks complete, cleared to depart",
"Order": 10
}
}
Result
{
"externalId": "DEP-READY",
"internalId": "1",
"mappingCreated": true
}
POST/v1/integration/transportation/SaveJourneyCodeJourney / timetable code
| Field | Type | Required? | Notes |
|---|---|---|---|
ExternalId | string | Required | e.g. JC-0600-JNB-CPT |
CompanyId | int | Required | Which company this record belongs to - see the CompanyId note |
JourneyCode | object | Required | Name/Code/Description/Order all Optional; Id Auto-set |
Request
POST /v1/integration/transportation/SaveJourneyCode
{
"ExternalId": "JC-0600-JNB-CPT",
"CompanyId": 101,
"JourneyCode": {
"Name": "06:00 Johannesburg to Cape Town",
"Code": "JC-0600-JNB-CPT",
"Description": "Daily morning departure to Cape Town",
"Order": 10
}
}
Result
{
"externalId": "JC-0600-JNB-CPT",
"internalId": "4",
"mappingCreated": true
}
POST/v1/integration/transportation/SaveStoppageIntermediate stop on a route
| Field | Type | Required? | Notes |
|---|---|---|---|
ExternalId | string | Required | e.g. STOP-BEAUFORT-WEST |
CompanyId | int | Required | Which company this record belongs to - see the CompanyId note |
Stoppage | object | Required | Name/Code/Description/Order all Optional; Id Auto-set |
RouteStoppages below - save every Stoppage you plan to reference first.Request
POST /v1/integration/transportation/SaveStoppage
{
"ExternalId": "STOP-BEAUFORT-WEST",
"CompanyId": 101,
"Stoppage": {
"Name": "Beaufort West",
"Code": "BFW",
"Description": "Refuelling and crew change stop",
"Order": 10
}
}
Result
{
"externalId": "STOP-BEAUFORT-WEST",
"internalId": "9",
"mappingCreated": true
}
POST/v1/integration/transportation/SaveItinerarySupplierSupplier providing an itinerary activity
| Field | Type | Required? | Notes |
|---|---|---|---|
ExternalId | string | Required | e.g. SUPPLIER-KAROO-TOURS |
CompanyId | int | Required | Which company this record belongs to - see the CompanyId note |
ItinerarySupplier | object | Required | Name/Code/Description/Order all Optional; Id Auto-set |
ExternalId.Request
POST /v1/integration/transportation/SaveItinerarySupplier
{
"ExternalId": "SUPPLIER-KAROO-TOURS",
"CompanyId": 101,
"ItinerarySupplier": {
"Name": "Karoo Tours",
"Code": "KAROO-TOURS",
"Description": "Local sightseeing operator for the Beaufort West stop",
"Order": 10
}
}
Result
{
"externalId": "SUPPLIER-KAROO-TOURS",
"internalId": "3",
"mappingCreated": true
}
Core entities
POST/v1/integration/transportation/SavePassengerCreate/update a passenger
| Field | Type | Required? | Notes |
|---|---|---|---|
ExternalId | string | Required | Your unique key for this person |
CompanyId | int | Required | Which company this record belongs to - see the CompanyId note |
Passenger | object | Required | |
↳ IdentificationNumber | string | Optional | ID/passport number |
↳ PassportNumber | string | Optional | |
↳ IsRepeatGuest, IsSmoker, Visa, AllowSharingOfData | bool | Optional | Default false if omitted |
↳ Occupation | string | Optional | |
↳ DateOfBirth | date? | Optional | Date of birth. Send ISO-8601 - "1985-03-14" or "1985-03-14T00:00:00Z" both work. Omitting it leaves any existing value in place |
↳ GenderId | int? | Optional | The internal id of a Gender configured for your company - not a code or a name. See the callout below for how to fetch the list, or create them yourself with SaveGender |
↳ CompanyCountryId | guid? | Optional | The passenger's country, as configured for your company |
↳ Contact | object | Optional | Name/email/phone for this passenger - see below |
↳ ↳ Name, Email, Website | string | Optional | |
↳ ↳ CompanyTitleId | guid? | Optional | e.g. Mr/Mrs/Dr, as configured for your company |
↳ ↳ ContactNumbers | array | Optional | Phone numbers - see below |
↳ ↳ ↳ Number | string | Optional | |
↳ ↳ ↳ ContactNumberTypeId | int | Optional | e.g. mobile/landline, as configured for your company |
↳ ↳ ↳ IsDefault | bool | Optional | Default false |
↳ PassengerActions | array | Optional | Notes/log entries against this passenger - rarely needed for integration. On updates, only brand-new entries are added; changes to an entry you already sent before are not applied |
↳ ↳ ActionTypeId | int | Optional | As configured for your company |
↳ ↳ Name, Notes, AdditionalNotes | string | Optional | |
↳ Id, ExternalIdentifier | - | Auto-set | Don't send |
GenderId values. Genders are configured per company, so the ids on your company are not the same as anyone else's. Fetch them once with POST /v1/LookupLists/GenderListItems sending { "Identifier": <your CompanyId>, "PageNumber": 1, "PageSize": 100 } - the response gives you each gender's id and name. Cache the mapping in your source system rather than looking it up per passenger. Same idea as CompanyTitleId and ContactNumberTypeId above.Contact the first time you save this passenger. We checked: if the passenger already exists and doesn't have a Contact yet, sending one on a later update call is silently ignored rather than adding it - there's no way to add a Contact after the fact through this endpoint today.Request
POST /v1/integration/transportation/SavePassenger
{
"ExternalId": "PAX-987654",
"CompanyId": 101,
"Passenger": {
"IdentificationNumber": "9901011234567",
"PassportNumber": "A12345678",
"IsRepeatGuest": false,
"IsSmoker": false,
"Visa": false,
"AllowSharingOfData": true,
"Occupation": "Engineer",
"DateOfBirth": "1985-03-14",
"GenderId": 1,
"CompanyCountryId": "7c8d9e0f-1234-4a2b-9c3d-00000000000c",
"Contact": {
"Name": "J. Smith",
"Email": "j.smith@example.com",
"Website": "",
"CompanyTitleId": "8d9e0f1a-1234-4a2b-9c3d-00000000000d",
"ContactNumbers": [
{ "Number": "+27821234567", "ContactNumberTypeId": 1, "IsDefault": true }
]
},
"PassengerActions": [
{ "ActionTypeId": 1, "Name": "Dietary requirement", "Notes": "Vegetarian" }
]
}
}
Result
{
"externalId": "PAX-987654",
"internalId": "b3f1c2a4-9e77-4c1a-8f2d-11a2b3c4d5e6",
"mappingCreated": true
}
POST/v1/integration/transportation/SaveAssetCreate/update a train / asset
| Field | Type | Required? | Notes |
|---|---|---|---|
ExternalId | string | Required | Your unique key, e.g. TRAIN-001-2026 |
CompanyId | int | Required | Which company this record belongs to - see the CompanyId note |
Asset | object | Required | |
↳ Code, Name, Description | string | Optional | |
↳ UsedForTravelPurposes | bool | Optional | Default false |
↳ Id | - | Auto-set | Don't send |
Request
POST /v1/integration/transportation/SaveAsset
{
"ExternalId": "TRAIN-001-2026",
"CompanyId": 101,
"Asset": {
"Code": "N001-2026",
"Name": "Blue Train N001",
"Description": "Premier long-distance passenger train",
"UsedForTravelPurposes": true
}
}
Result
{
"externalId": "TRAIN-001-2026",
"internalId": "a1b2c3d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
"mappingCreated": true
}
POST/v1/integration/transportation/SavePartCreate/update a coach / part
| Field | Type | Required? | Notes |
|---|---|---|---|
ExternalId | string | Required | e.g. COACH-A01 |
CompanyId | int | Required | Which company this record belongs to - see the CompanyId note |
Part | object | Required | |
↳ Code, Name, Description | string | Optional | |
↳ PartTypeId | int | Required | Must reference an existing PartType (internal id, not ExternalId) |
↳ Id | - | Auto-set | Don't send |
WindowTypeId, ToiletTypeId and WheelTypeId exist on the Part object but this endpoint does not save them - we verified the underlying save method never reads them. Use SavePartFacilities to set those three fields on a coach.Request
POST /v1/integration/transportation/SavePart
{
"ExternalId": "COACH-A01",
"CompanyId": 101,
"Part": {
"Code": "A01",
"Name": "Coach A01",
"Description": "Sleeper coach, 6-berth",
"PartTypeId": 14
}
}
Result
{
"externalId": "COACH-A01",
"internalId": "f4e5d6c7-8b9a-4c1d-9e2f-3a4b5c6d7e8f",
"mappingCreated": true
}
POST/v1/integration/transportation/SavePartCompartmentsAdd/update suites/seats inside a coach (batch of many per call)
| Field | Type | Required? | Notes |
|---|---|---|---|
PartExternalId | string | Conditional | The coach these belong to - required unless you pass PartId instead |
PartId | guid | Conditional | Internal id alternative to PartExternalId |
CompanyId | int | Required | Which company these records belong to - see the CompanyId note |
Compartments | array | Required | At least one item |
↳ ExternalId | string | Required | Your unique key for this seat/suite |
↳ Name, Description | string | Optional | |
↳ CompartmentTypeId | int | Required | Must reference an existing CompartmentType |
↳ Deleted | bool | Optional | Set true to remove a previously-synced compartment; default false |
Compartments list on a later call is automatically removed (soft-deleted) - always send the complete current list for this coach, not just new/changed items.Request
POST /v1/integration/transportation/SavePartCompartments
{
"PartExternalId": "COACH-A01",
"CompanyId": 101,
"Compartments": [
{ "ExternalId": "A01-SUITE-1", "Name": "Suite 1", "Description": "Lower deck suite", "CompartmentTypeId": 5, "Deleted": false },
{ "ExternalId": "A01-SUITE-2", "Name": "Suite 2", "Description": "Upper deck suite", "CompartmentTypeId": 5, "Deleted": false }
]
}
To remove a compartment you previously saved, either leave it out of this list entirely, or include it with "Deleted": true.
Result
{
"items": [
{ "externalId": "A01-SUITE-1", "internalId": "4f5b6c7d-1111-4a2b-9c3d-000000000001", "mappingCreated": true },
{ "externalId": "A01-SUITE-2", "internalId": "8c2d3e4f-2222-4a2b-9c3d-000000000002", "mappingCreated": true }
]
}
POST/v1/integration/transportation/SaveLinkedAssetPartsAttach coaches to a train
| Field | Type | Required? | Notes |
|---|---|---|---|
AssetExternalId | string | Conditional | Required unless AssetId is given |
AssetId | guid | Conditional | Internal id alternative to AssetExternalId |
CompanyId | int | Required | Which company this record belongs to - see the CompanyId note |
PartExternalIds | array of string | Conditional | Required (non-empty) unless PartIds is given |
PartIds | array of guid | Conditional | Internal ids alternative to PartExternalIds |
Request
POST /v1/integration/transportation/SaveLinkedAssetParts
{
"AssetExternalId": "TRAIN-001-2026",
"CompanyId": 101,
"PartExternalIds": ["COACH-A01", "COACH-A02"]
}
Result
true
POST/v1/integration/transportation/SaveRouteInfoCreate/update a route
| Field | Type | Required? | Notes |
|---|---|---|---|
ExternalId | string | Required | |
CompanyId | int | Required | Which company this record belongs to - see the CompanyId note |
ClientExternalId | string | Conditional | Required unless RouteInfo.ClientId is already set to a valid guid |
RouteInfo | object | Required | |
↳ RouteName, PackageCode, Colour, TextColour | string | Optional | |
↳ JourneyCodeId | int | Required | Must reference an existing JourneyCode |
↳ Duration | int? | Optional | |
↳ ClientId | guid | Conditional | Alternative to ClientExternalId at the wrapper level |
↳ ExternalIdentifier | - | Auto-set | Don't send |
RouteStoppages | array | Optional | Intermediate stops for this route, in order |
↳ StoppageId | int | Required | Internal id of an existing Stoppage - save that first and use the internalId from its response |
↳ Order, Duration | int | Optional | |
↳ ItineraryItems | array | Optional | Activities at this stop |
↳ ↳ Name, Code, Description | string | Optional | |
↳ ↳ Order | int | Optional | |
↳ ↳ Deleted | bool | Optional | Default false |
↳ ↳ ExternalIdentifier | string | Optional | Your own key for this specific itinerary item, so you can update it later without knowing its internal id |
↳ ↳ Id, RouteStoppageId | - | Auto-set | Don't send |
ExternalId. Calling this again for the same Client (even with a different ExternalId) updates that same route rather than creating a second one. Use one RouteInfo call per Client, and treat JourneyCodeId/RouteName as "the route currently assigned to this client," not as an independent list of routes./v1/integration/ client endpoints, not covered in this Transportation guide).RouteStoppages at all, it replaces the full set of stoppages on the route - any stoppage previously saved whose StoppageId is left out of the list is removed, itinerary items included. Leave RouteStoppages out entirely to update only the route's own fields and leave its stoppages untouched. Give each itinerary item you want to update again later an ExternalIdentifier - items sent without one are always treated as new.Request
POST /v1/integration/transportation/SaveRouteInfo
{
"ExternalId": "ROUTE-JNB-CPT-001",
"CompanyId": 101,
"ClientExternalId": "CLIENT-001",
"RouteInfo": {
"RouteName": "Johannesburg to Cape Town",
"JourneyCodeId": 4,
"PackageCode": "PKG-JNB-CPT",
"Duration": 1560,
"Colour": "#1565C0",
"TextColour": "#FFFFFF"
},
"RouteStoppages": [
{
"StoppageId": 9,
"Order": 10,
"Duration": 45,
"ItineraryItems": [
{
"Name": "Karoo sightseeing walk",
"Code": "KAROO-WALK",
"Description": "Guided walk while the train is stopped",
"Order": 10,
"Deleted": false,
"ExternalIdentifier": "ITIN-BFW-WALK-001"
}
]
}
]
}
Result
{
"externalId": "ROUTE-JNB-CPT-001",
"internalId": "d1e2f3a4-3333-4a2b-9c3d-000000000003",
"mappingCreated": true,
"routeStoppages": [
{
"stoppageId": 9,
"routeStoppageId": "e2f3a4b5-4444-4a2b-9c3d-000000000007",
"itineraryItems": [
{ "externalId": "ITIN-BFW-WALK-001", "internalId": "f3a4b5c6-5555-4a2b-9c3d-000000000008", "mappingCreated": true }
]
}
]
}
routeStoppages is only present in the response when the request included RouteStoppages. itineraryItems within it only lists items that were sent with an ExternalIdentifier.
POST/v1/integration/transportation/SaveSiteTripInfoCreate a journey (trip) at a site
| Field | Type | Required? | Notes |
|---|---|---|---|
SiteExternalId | string | Conditional | Required unless Request.Site.Id is already a valid guid |
TripExternalId | string | Conditional | Required unless Request.TripInfo.Id is already a valid guid |
CompanyId | int | Required | Which company this record belongs to - see the CompanyId note |
Request | object | Required | |
↳ Site | object | Required | The site the journey departs from |
↳ ↳ Name, Code | string | Optional | Must be unique per Client - reusing a Code already used by another Site under the same Client fails |
↳ ↳ ClientId | guid | Required | Existing client this site belongs to - the Client must already have a company relationship set up in Ketendo, or creating a new Site fails outright |
↳ ↳ VATNumber | string | Optional | |
↳ ↳ ShippingAddress, BillingAddress | string | Optional | |
↳ ↳ UseAsBillingAddress | bool | Optional | Default false |
↳ ↳ CountryId, ProvinceId | int? | Optional | |
↳ ↳ SiteTypeId | int? | Optional | Send it to categorize the site for your company |
↳ ↳ DateActioned | datetime? | Optional | |
↳ ↳ ExternalIdentifier, CompanyId | - | Auto-set | Don't send - both are set from the wrapper-level SiteExternalId / CompanyId above |
↳ TripInfo | object | Required | The journey details |
↳ ↳ StartDate | datetime | Required | |
↳ ↳ EndDate | datetime? | Optional | |
↳ ↳ Code | string | Optional | |
↳ ↳ StatusId, PromotionalStatusId, PackageDepartureStatusId | int? | Optional | Reference existing lookups if used |
↳ ↳ AssetId | guid? | Optional | Train assigned to this journey |
↳ ↳ JourneyToFromId | int? | Optional | Direction/leg indicator for the journey |
↳ ↳ OccupationValue | int? | Optional | Occupancy figure for the journey |
↳ TripStoppages | array | Optional | Intermediate stops for this journey |
↳ ↳ RouteStoppageId | guid | Required | Must reference an existing stoppage on this journey's route - the routeStoppageId returned by SaveRouteInfo's RouteStoppages response |
↳ ↳ Date | datetime | Required | Together with RouteStoppageId this is the match key - Id is not used to find an existing trip stoppage |
↳ ↳ ItineraryCustomized | bool | Optional | Default false. We confirmed in the save logic: when false, ItineraryItems below is ignored and the itinerary is auto-cloned from the route stoppage instead; when true, exactly what you send in ItineraryItems is saved |
↳ ↳ ItineraryItems | array | Optional | Only takes effect when ItineraryCustomized is true |
↳ ↳ ↳ Name, Description | string | Optional | No Code field at this level (unlike the route-level itinerary item) |
↳ ↳ ↳ Order | int | Optional | |
↳ ↳ ↳ Deleted | bool | Optional | Default false |
↳ ↳ ↳ ExternalIdentifier | string | Optional | Your own key for this specific itinerary item, so you can update it later without knowing its internal id |
↳ ↳ ↳ Id | - | Auto-set | Don't send |
TripStoppages at all, it replaces the full set of stoppages on the journey - any previously saved for a RouteStoppageId/Date combination left out of the list is removed. When a stoppage is customized, its ItineraryItems follow the same full-replacement rule.Request
POST /v1/integration/transportation/SaveSiteTripInfo
{
"SiteExternalId": "SITE-JNB-STATION",
"TripExternalId": "TRIP-2026-07-06-001",
"CompanyId": 101,
"Request": {
"Site": {
"Name": "Johannesburg Station",
"Code": "JNB-STN",
"ClientId": "5c9e0b3a-1111-4b7a-9c2f-000000000001",
"VATNumber": "4123456789",
"ShippingAddress": "1 Station Rd, Johannesburg",
"BillingAddress": "1 Station Rd, Johannesburg",
"UseAsBillingAddress": true,
"CountryId": 1,
"ProvinceId": 3,
"SiteTypeId": 2,
"DateActioned": "2026-07-01T09:00:00Z"
},
"TripInfo": {
"StartDate": "2026-07-06T06:00:00+02:00",
"EndDate": "2026-07-07T18:00:00+02:00",
"Code": "JC-0600-JNB-CPT",
"StatusId": 1,
"PromotionalStatusId": 4,
"PackageDepartureStatusId": 1,
"AssetId": "a1b2c3d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
"JourneyToFromId": 1,
"OccupationValue": 42
},
"TripStoppages": [
{
"RouteStoppageId": "e2f3a4b5-4444-4a2b-9c3d-000000000007",
"Date": "2026-07-06T14:30:00+02:00",
"ItineraryCustomized": true,
"ItineraryItems": [
{
"Name": "Karoo sightseeing walk (afternoon slot)",
"Description": "Shortened version for this specific departure",
"Order": 10,
"Deleted": false,
"ExternalIdentifier": "ITIN-TRIP-2026-07-06-001-BFW-WALK"
}
]
}
]
}
}
Result
{
"result": {
"siteId": "9a8b7c6d-4444-4a2b-9c3d-000000000004",
"tripInfoId": "1a2b3c4d-5555-4a2b-9c3d-000000000005",
"companySiteId": "2b3c4d5e-6666-4a2b-9c3d-000000000006",
"tripStoppages": [
{ "id": "b4c5d6e7-7777-4a2b-9c3d-000000000009", "routeStoppageId": "e2f3a4b5-4444-4a2b-9c3d-000000000007", "date": "2026-07-06T14:30:00+02:00" }
]
},
"site": { "externalId": "SITE-JNB-STATION", "internalId": "9a8b7c6d-4444-4a2b-9c3d-000000000004", "mappingCreated": false },
"trip": { "externalId": "TRIP-2026-07-06-001", "internalId": "1a2b3c4d-5555-4a2b-9c3d-000000000005", "mappingCreated": true }
}
The itinerary items themselves aren't echoed back in this response - look them up afterwards with the TripStoppageItineraryItem lookup using the ExternalIdentifier you sent.
POST/v1/integration/transportation/SaveTripPartsAttach coaches to a journey
| Field | Type | Required? | Notes |
|---|---|---|---|
TripExternalId | string | Conditional | Required unless TripInfoId is given |
TripInfoId | guid | Conditional | Internal id alternative to TripExternalId |
CompanyId | int | Required | Which company this record belongs to - see the CompanyId note |
PartExternalIds | array of string | Conditional | Required (non-empty) unless PartIds is given |
PartIds | array of guid | Conditional | Internal ids alternative to PartExternalIds |
Request
POST /v1/integration/transportation/SaveTripParts
{
"TripExternalId": "TRIP-2026-07-06-001",
"CompanyId": 101,
"PartExternalIds": ["COACH-A01", "COACH-A02"]
}
Result
The journey's internal TripInfoId (a bare GUID, not wrapped in an object):
"1a2b3c4d-5555-4a2b-9c3d-000000000005"
POST/v1/integration/transportation/SaveTripCompartmentsPublish a suite/seat onto a journey
| Field | Type | Required? | Notes |
|---|---|---|---|
TripExternalId | string | Conditional | Required unless TripInfoId is given |
TripInfoId | guid | Conditional | |
CompartmentExternalId | string | Conditional | Required unless CompartmentId is given |
CompartmentId | guid | Conditional | |
CompanyId | int | Required | Which company this record belongs to - see the CompanyId note |
CompartmentTypeId | int | Optional | |
TelephoneExtensionNumber | string | Optional |
Request
POST /v1/integration/transportation/SaveTripCompartments
{
"TripExternalId": "TRIP-2026-07-06-001",
"CompartmentExternalId": "A01-SUITE-1",
"CompanyId": 101,
"CompartmentTypeId": 5,
"TelephoneExtensionNumber": "101"
}
Result
true
POST/v1/integration/transportation/SaveTripPassengerAssign a passenger/booking to a seat on a journey
| Field | Type | Required? | Notes |
|---|---|---|---|
ExternalId | string | Required | Your unique key for this booking |
TripExternalId | string | Conditional | Required unless TripPassenger.TripInfoId is already set |
CompartmentExternalId | string | Conditional | Required unless TripPassenger.CompartmentId is already set |
PassengerExternalId | string | Conditional | Required unless TripPassenger.PassengerId is already set |
CompanyId | int | Required | Which company this record belongs to - see the CompanyId note. Also used to save the auto-created Passenger below |
TripPassenger | object | Required | |
↳ BookingNumber, Agent | string | Optional | Sending an empty string leaves the existing value unchanged on updates rather than clearing it |
↳ ReservedBy | string | Optional | Email address of the person who reserved the booking. Stored as free text exactly as you send it (trimmed) - it does not need to match a Ketendo user or Member. Behaves like Agent: sending an empty string leaves the existing value unchanged on updates rather than clearing it |
↳ CheckedIn | bool? | Optional | Unlike Agent/ReservedBy, a value you send here does overwrite what's saved. Omitting it leaves the existing value untouched on an update, and leaves it null on a create - null and false both mean "not checked in". Once a passenger is checked in inside Ketendo this field is locked to the integration - see the callout below |
↳ CheckedInDate | - | Auto-set | We verified this is never read from the request - it's always computed from CheckedIn (set to the current time when true, cleared when false) |
↳ HasUpdatedFields | bool? | Optional | Internal sync flag - only relevant if your integration also interacts with the Ketendo mobile app's offline sync for this booking |
↳ Passenger | object | Conditional | Only required if PassengerExternalId hasn't been seen before - used to auto-create the passenger in the same call. Takes the same fields as SavePassenger, including DateOfBirth and GenderId |
↳ TripInfoId, CompartmentId, PassengerId, Id | - | Auto-set | Resolved from the ExternalIds above |
ReservedByEmail field (and TripPassenger.ReservedById) have been removed. Send the email on TripPassenger.ReservedBy instead. If you keep sending ReservedByEmail the call will still succeed but the field will silently stop being saved, because unknown properties are ignored rather than rejected - so please rename it in your payload.ReservedBy does not have to match a Ketendo user, so you do not need to save a Member before saving a trip passenger on account of this field.CheckedIn is already true in Ketendo, this endpoint ignores CheckedIn and CheckedInDate on your payload - a physical check-in is captured on the Portal, and a source system can't undo or re-date it. Everything else in the same payload still saves normally and the call still returns success, so you can keep re-sending a booking unchanged. While the passenger is not checked in, sending true or false works as you'd expect.Passenger object inline here instead - it will be created automatically.Request
POST /v1/integration/transportation/SaveTripPassenger
{
"ExternalId": "BOOKING-55321",
"TripExternalId": "TRIP-2026-07-06-001",
"CompartmentExternalId": "A01-SUITE-1",
"PassengerExternalId": "PAX-987654",
"CompanyId": 101,
"TripPassenger": {
"BookingNumber": "BK-55321",
"Agent": "Fabric Bookings",
"ReservedBy": "agent@example.com",
"CheckedIn": false,
"HasUpdatedFields": false
}
}
Result
{
"externalId": "BOOKING-55321",
"internalId": "3c4d5e6f-7777-4a2b-9c3d-000000000007",
"mappingCreated": true
}
POST/v1/integration/transportation/SaveTripMemberAssign crew/staff to a journey
| Field | Type | Required? | Notes |
|---|---|---|---|
ExternalId | string | Required | Your unique key for this assignment |
TripExternalId | string | Conditional | Required unless TripInfoId is given |
TripInfoId | guid | Conditional | |
MemberExternalId | string | Optional | If provided, must already resolve to an existing Member (crew person) - this API does not create Members |
MemberEmail | string | Optional | Alternative to MemberExternalId/TripMember.MemberId - if you only have the crew Member's email, send it here instead and we resolve it for you, scoped to CompanyId. Ignored if either of the other two is already set. The call fails with a clear error if the email doesn't match a Member on your company |
CompanyId | int | Required | Which company this record belongs to - see the CompanyId note |
TripMember | object | Required | |
↳ BookingNumber | string | Optional | |
↳ IsProposed | bool | Optional | Default false |
↳ ProductId | guid | Required | Must reference an existing Product |
↳ StartDate, EndDate, CheckedIn | - | Optional | |
↳ CheckedInDate | - | Auto-set | We verified this is never read from the request - it's always computed from CheckedIn (set to the current time when true, cleared when false) |
↳ TripInfoId, MemberId, ExternalIdentifier | - | Auto-set |
Request
POST /v1/integration/transportation/SaveTripMember
{
"ExternalId": "CREW-ASSIGN-001",
"TripExternalId": "TRIP-2026-07-06-001",
"MemberExternalId": "MEMBER-JSMITH",
"CompanyId": 101,
"TripMember": {
"BookingNumber": "CREW-BK-001",
"IsProposed": false,
"ProductId": "b4b8c9d0-8888-4a2b-9c3d-000000000008",
"StartDate": "2026-07-06T06:00:00+02:00",
"EndDate": "2026-07-07T18:00:00+02:00",
"CheckedIn": false
}
}
Don't have MemberExternalId mapped yet? Send "MemberEmail": "crew.member@example.com" instead and omit MemberExternalId - we'll resolve the Member by email for your company.
Result
{
"externalId": "CREW-ASSIGN-001",
"internalId": "4d5e6f7a-9999-4a2b-9c3d-000000000009",
"mappingCreated": true
}
Optional endpoints
Only needed if you're tracking part maintenance or want to bulk-assign amenities.
POST/v1/integration/transportation/SavePartOperationStatusMaintenance/operation status for a part
| Field | Type | Required? | Notes |
|---|---|---|---|
ExternalId | string | Required | |
CompanyId | int | Required | Which company this record belongs to - see the CompanyId note |
PartOperationStatus | object | Required | Name/Code/Description/Order/Colour/TextColour all Optional (Order only takes effect from your second call onward - see note on the lookups above); Id Auto-set |
Request
POST /v1/integration/transportation/SavePartOperationStatus
{
"ExternalId": "OPSTATUS-DUE",
"CompanyId": 101,
"PartOperationStatus": {
"Name": "Maintenance Due",
"Code": "MAINT-DUE",
"Description": "Scheduled maintenance is due",
"Order": 10,
"Colour": "#FFA500",
"TextColour": "#000000"
}
}
Result
{
"externalId": "OPSTATUS-DUE",
"internalId": "2",
"mappingCreated": true
}
POST/v1/integration/transportation/SavePartOperationLog a maintenance record/operation against a part
| Field | Type | Required? | Notes |
|---|---|---|---|
ExternalId | string | Required | |
CompanyId | int | Required | Which company this record belongs to - see the CompanyId note |
PartOperation | object | Required | |
↳ PartId | guid? | Optional | Part this operation relates to |
↳ StartTime, EndTime | datetime? | Optional | Leaving these out on an update keeps the existing value |
↳ OperationStatusId | int? | Optional | Leaving this out on an update keeps the existing value |
↳ Id | - | Auto-set | Don't send |
OperationType field here - we checked and it isn't persisted by this endpoint, so it's been removed from this table. Everything else listed above is genuinely saved.Request
POST /v1/integration/transportation/SavePartOperation
{
"ExternalId": "OP-A01-2026-07-01",
"CompanyId": 101,
"PartOperation": {
"PartId": "f4e5d6c7-8b9a-4c1d-9e2f-3a4b5c6d7e8f",
"StartTime": "2026-07-01T08:00:00Z",
"EndTime": "2026-07-01T12:00:00Z",
"OperationStatusId": 2
}
}
Result
{
"externalId": "OP-A01-2026-07-01",
"internalId": "5e6f7a8b-aaaa-4a2b-9c3d-00000000000a",
"mappingCreated": true
}
POST/v1/integration/transportation/SavePartFacilitiesBulk-assign amenities to a coach
| Field | Type | Required? | Notes |
|---|---|---|---|
PartExternalId | string | Required | The coach to update |
CompanyId | int | Required | Which company this record belongs to - see the CompanyId note |
FacilityExternalIds | array of string | Required | At least one facility, all must already exist (save with SaveFacility first) |
WindowTypeId, WheelTypeId, ToiletTypeId | int? | Optional | Also set these on the same coach in one call - leaving one out (or sending null) clears that value on the coach, it does not leave the existing value unchanged |
FacilityExternalIds replaces the full set of facilities on the coach, not just the ones you send - always send the complete current list of facilities for this coach.Request
POST /v1/integration/transportation/SavePartFacilities
{
"PartExternalId": "COACH-A01",
"CompanyId": 101,
"FacilityExternalIds": ["FAC-WIFI", "FAC-AC"],
"WindowTypeId": 3,
"WheelTypeId": 5,
"ToiletTypeId": 2
}
Result
true
Reconciling passengers in bulk
Reconciling in bulk
The lookups above fetch one record at a time. If you need the whole set - for example to work out which of your passengers we already have before running a delta - use the endpoint below instead of the full list endpoints. It returns only the two fields needed to match records up, projected directly in the database, so no related data is loaded and the response stays small.
POST/v1/integration/transportation/PassengerExternalIdsAll passenger ids + your ExternalIds
| Field | Type | Required? | Notes |
|---|---|---|---|
CompanyId | int | Required | Which company's passengers to return |
OnlyMapped | bool | Optional | Defaults to true, returning only passengers that actually carry an ExternalIdentifier - which is what you want for reconciliation. Send false to include passengers created in Ketendo that have never been mapped to your system |
IncludeDeleted | bool | Optional | Defaults to false. Note this is the opposite default to our older list endpoints |
PageNumber, PageSize | int | Optional | Omit both (or send 0) to get every record in one response - see the size note below. Send both to page |
/v1/Transportation/PassengersList for this. That endpoint returns fully-populated passengers including every trip each one has been on, which for a full company can run to well over 100 MB and will fail in Logic Apps with a buffer error. If you do use it, always send PageNumber and PageSize - leaving them out returns every record.ExternalId, so you can simply send a passenger and we'll create or update as appropriate - no existence check needed. This endpoint is for when you want to compute the difference on your side first.Request
POST /v1/integration/transportation/PassengerExternalIds
{
"CompanyId": 101,
"OnlyMapped": true,
"IncludeDeleted": false
}
Result
{
"result": [
{ "internalId": "b3f1c2a4-9e77-4c1a-8f2d-11a2b3c4d5e6", "externalId": "182107" },
{ "internalId": "c4e2d3b5-8f66-4d2b-9e3c-22b3c4d5e6f7", "externalId": "147987" }
],
"totalRecords": 41027
}
totalRecords is the count matching your filters, regardless of paging - use it to size your loop if you do page.