Searching and booking trains

Look up stations, search trains, pick a fare, and create a train booking.

Trains follow the standard four-step flow: search → fares → build → book.
Search and fares stream results as Server-Sent Events (SSE); build and book
return JSON. Every request below carries the full consumer-HMAC and
traveler-bearer headers — see Authentication for how
$SIGNATURE is computed from $METHOD, the request path, $TIMESTAMP, and
the body.

1. Look up stations

Resolve free-text input to station identifiers.

curl -G "$TRAVELTECH_BASE_URL/v1/trains/stations" \
  --data-urlencode "query=milano" \
  -H "TravelTech-Consumer: $CONSUMER" \
  -H "TravelTech-Timestamp: $TIMESTAMP" \
  -H "TravelTech-Signature: $SIGNATURE" \
  -H "Authorization: Bearer $TRAVELER_JWT"

(Sign with METHOD=GET, PATH=/v1/trains/stations, and the empty-body hash
from Authentication.)

Response — an array of TrainStationOut:

[
  {
    "code": "830000219",
    "displayName": "Milano Centrale",
    "city": "Milan",
    "country": "IT"
  }
]

2. Search

POST /v1/trains/search streams train solutions as SSE.

curl -N -X POST "$TRAVELTECH_BASE_URL/v1/trains/search" \
  -H "TravelTech-Consumer: $CONSUMER" \
  -H "TravelTech-Timestamp: $TIMESTAMP" \
  -H "TravelTech-Signature: $SIGNATURE" \
  -H "Authorization: Bearer $TRAVELER_JWT" \
  -H "Content-Type: application/json" \
  -d '{
    "itinerary": [
      { "origin": "830000219", "destination": "830008409", "date": "2026-02-10" }
    ],
    "passengers": { "adults": 1 },
    "travelClass": "economy"
  }'

Request body (TrainSearchRequest) — one leg per itinerary entry:

{
  "itinerary": [
    { "origin": "830000219", "destination": "830008409", "date": "2026-02-10" }
  ],
  "passengers": { "adults": 1 },
  "travelClass": "economy"
}

travelClass is one of economy, premium_economy, business, or first
(carrier-specific classes are mapped onto these). Each TrainLeg also accepts
departureAfter / departureBefore as HH:MM local-time windows. Each streamed
solution's id (used as the searchSolutionId in the next step) and its
solutionKey are both required to list fares.

A full trace of the response stream — two solutions, then done:

event: solution
data: {"id":"018f2c3e-1234-7abc-8def-000000000131","solutionKey":"7d4f2b81...","departureStation":"Milano Centrale","arrivalStation":"Roma Termini","operatorName":"Example Rail","totalPrice":{"amount":"59.90","currency":"EUR"},"stops":0}

event: solution
data: {"id":"018f2c3e-1234-7abc-8def-000000000132","solutionKey":"e02a9c46...","departureStation":"Milano Centrale","arrivalStation":"Roma Termini","operatorName":"Another Rail","totalPrice":{"amount":"44.50","currency":"EUR"},"stops":1}

event: done
data: {}

A provider failure ends the stream with a terminal event: error frame
instead of done:

event: error
data: {"code":"PROVIDER_ERROR","message":"Search temporarily unavailable."}

3. List fares

POST /v1/trains/fares streams the bookable fares for a chosen solution. Both
searchSolutionId and solutionKey are required for trains.

curl -N -X POST "$TRAVELTECH_BASE_URL/v1/trains/fares" \
  -H "TravelTech-Consumer: $CONSUMER" \
  -H "TravelTech-Timestamp: $TIMESTAMP" \
  -H "TravelTech-Signature: $SIGNATURE" \
  -H "Authorization: Bearer $TRAVELER_JWT" \
  -H "Content-Type: application/json" \
  -d '{
    "searchSolutionId": "018f2c3e-1234-7abc-8def-000000000131",
    "solutionKey": "7d4f2b81...",
    "travelClass": "economy"
  }'

Request body (TrainFaresBody):

{
  "searchSolutionId": "018f...",
  "solutionKey": "abc...",
  "travelClass": "economy"
}

Omitting solutionKey — required for trains, unlike the other verticals —
returns 422:

{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Request validation failed.",
    "details": [
      { "field": "solutionKey", "code": "missing", "message": "Field required" }
    ],
    "recoveryAction": "FIX_INPUT",
    "retryAfterSeconds": null
  }
}

4. Build the fare

POST /v1/trains/fares/build locks a specific fare and returns it as JSON
(201).

curl -X POST "$TRAVELTECH_BASE_URL/v1/trains/fares/build" \
  -H "TravelTech-Consumer: $CONSUMER" \
  -H "TravelTech-Timestamp: $TIMESTAMP" \
  -H "TravelTech-Signature: $SIGNATURE" \
  -H "Authorization: Bearer $TRAVELER_JWT" \
  -H "Content-Type: application/json" \
  -d '{ "fareId": "018f2c3e-1234-7abc-8def-000000000231", "solutionKey": "7d4f2b81...", "searchSolutionId": "018f2c3e-1234-7abc-8def-000000000131" }'

Request body (TrainBuildFareBody):

{ "fareId": "018f...", "solutionKey": "abc...", "searchSolutionId": "018f..." }

Response (TrainFareOut):

{
  "fareId": "018f2c3e-1234-7abc-8def-000000000231",
  "solutionKey": "7d4f2b81...",
  "providerName": "example-rail",
  "totalPrice": { "amount": "59.90", "currency": "EUR" },
  "travelClass": "economy",
  "fareClass": "Standard",
  "fareName": "Standard - Base",
  "refundability": "non_refundable",
  "isRefundable": false,
  "isChangeable": true,
  "conditions": ["Non-refundable", "Changeable for a fee before departure"],
  "includedServices": ["Seat reservation"],
  "changes": 0,
  "legs": [
    {
      "fromStation": "Milano Centrale",
      "toStation": "Roma Termini",
      "departureAt": { "localAt": "2026-02-10T08:00:00", "timezone": "Europe/Rome", "source": "provider" },
      "arrivalAt": { "localAt": "2026-02-10T11:15:00", "timezone": "Europe/Rome", "source": "provider" },
      "operatorName": "Example Rail",
      "vehicleNumber": "9941",
      "trainName": "Frecciarossa",
      "durationMinutes": 195
    }
  ],
  "expiresAt": "2026-02-09T18:36:00Z"
}

expiresAt is the UTC instant the rail provider's hold on the built fare ends.
It is the provider's own deadline, never a travel.tech default, and is null
when the provider gives none. A round-trip fare is a single lock, so one
expiresAt covers both legs.

If the fare is no longer available (fare locks are time-limited), the build
call returns 410 with the standard
error envelope:

{
  "error": {
    "code": "OFFER_EXPIRED",
    "message": "This fare is no longer available. Search again to get a current price.",
    "details": [],
    "recoveryAction": "CHANGE_SELECTION",
    "retryAfterSeconds": null
  }
}

5. (Optional) Protection plans

GET /v1/trains/fares/{fare_id}/protection-plans

6. Book

POST /v1/trains/bookings creates the booking from the built fareId. Send a
unique Idempotency-Key header (required — see
Bookings and trips).

curl -X POST "$TRAVELTECH_BASE_URL/v1/trains/bookings" \
  -H "TravelTech-Consumer: $CONSUMER" \
  -H "TravelTech-Timestamp: $TIMESTAMP" \
  -H "TravelTech-Signature: $SIGNATURE" \
  -H "Authorization: Bearer $TRAVELER_JWT" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 018f2c3e-1234-7abc-8def-000000000431" \
  -d '{
    "fareId": "018f2c3e-1234-7abc-8def-000000000231",
    "passengers": [
      { "firstName": "Ada", "lastName": "Lovelace", "type": "adult", "isHolder": true }
    ],
    "isPersonal": false,
    "tripId": null
  }'

Request body (TrainCreateBookingRequest):

{
  "fareId": "018f...",
  "passengers": [
    {
      "firstName": "Ada",
      "lastName": "Lovelace",
      "type": "adult",
      "isHolder": true
    }
  ],
  "isPersonal": false,
  "tripId": null
}

Response (BookingDetailOut, 201):

{
  "bookingId": "018f2c3e-1234-7abc-8def-000000000431",
  "vertical": "train",
  "status": "pending",
  "providerName": null,
  "needsApproval": false,
  "totalPrice": { "amount": "59.90", "currency": "EUR" },
  "availableOperations": ["cancel_booking"],
  "providerData": {},
  "legs": [
    {
      "legId": "018f2c3e-1234-7abc-8def-000000000531",
      "originAirportCode": "830000219",
      "destinationAirportCode": "830008409",
      "originDisplayName": "Milano Centrale",
      "destinationDisplayName": "Roma Termini",
      "departureAt": { "localAt": "2026-02-10T08:00:00", "timezone": "Europe/Rome", "source": "provider" },
      "arrivalAt": { "localAt": "2026-02-10T11:15:00", "timezone": "Europe/Rome", "source": "provider" },
      "duration": "PT3H15M",
      "airlineName": "Example Rail",
      "flightNumber": "9941",
      "platform": null,
      "coach": null,
      "seat": null
    }
  ],
  "travelers": [
    {
      "travelerId": "018f2c3e-1234-7abc-8def-000000000631",
      "displayName": "Ada Lovelace",
      "legAssignments": [
        { "legId": "018f2c3e-1234-7abc-8def-000000000531", "seat": null, "coach": null }
      ]
    }
  ],
  "bizAwayCode": "BA-183044",
  "providerConfirmationReferences": [],
  "tripId": null
}

legs reuses the same field names as flight bookings (originAirportCode,
airlineName, flightNumber) for the station code, operator name, and train
number respectively; platform and coach replace terminal and gate.

Manage the booking lifecycle with the shared endpoints in
Bookings and trips. Once a booking is confirmed
or issued, subsequent schedule changes and disruptions push a notification
to the traveler — see Notification severity tiers
for how to interpret and render the severity field on those notifications.

7. Change the date or time of a booking

Check GET /v1/trains/bookings/{booking_id}/operations first: a
change_datetime operation with isAllowed: true lists the changeableLegIds
groups you may move. A group is moved as a whole, and when needTripSplit is
not false, one trip at a time.

  1. Search new departures (SSE) — POST /v1/trains/bookings/{booking_id}/change-search
    with the legs to move and the new local departure. Booked stations are kept;
    only the date and time change.

    {
      "journeys": [
        { "legIds": ["018f2c3e-1234-7abc-8def-000000000531"], "departureDateTime": "2026-02-12T09:00:00" }
      ]
    }

    Solutions stream in the same shape as POST /v1/trains/search.

  2. List fares (SSE) — POST /v1/trains/bookings/{booking_id}/change-fares
    with searchSolutionId and solutionKey, as for POST /v1/trains/fares.

  3. Quote — POST /v1/trains/bookings/{booking_id}/operation-quotes:

    {
      "operationType": "change_datetime",
      "fareId": "018f2c3e-1234-7abc-8def-000000000777",
      "legIds": ["018f2c3e-1234-7abc-8def-000000000531"]
    }

    The response carries oldTotal, newTotal, priceDifference and
    estimatedTotalAmount. 409 INSUFFICIENT_CREDIT_LINE means the company
    credit line cannot cover the new total.

  4. Confirm — POST /v1/trains/bookings/{booking_id}/operations with the
    same body plus estimatedTotalAmount copied from the quote, and an
    Idempotency-Key. 201 means the change is done; 202 means it is still
    being confirmed (status: "processing") — read the booking until it
    settles. 409 CHANGE_PRICE_CHANGED returns the new total in
    details[0].finalPrice: quote again and confirm with a new key.

A fareId is accepted only for the booking and legs it was searched for;
a stale or foreign one returns 410 (expired) or 422 INVALID_CHANGE_SELECTION,
with details[0].reason naming the cause, and nothing is changed.

8. Change the stations of a booking

When GET /v1/trains/bookings/{booking_id}/operations lists change_itinerary
with isAllowed: true (some operators never do), the booking can be moved to
a different origin or destination. It works like section 7, through the same
four endpoints; a booking that does not list the operation returns
409 BOOKING_CONFLICT with details[0].reason: "operation_not_available"
and nothing is called.

  1. Search — POST /v1/trains/bookings/{booking_id}/change-search. Name both
    new stations on each journey; at least one must differ from the booked
    journey, and they must differ from each other. operationType is optional
    (change_itinerary is inferred from the stations). When one journey names
    stations, every journey in the request must; otherwise the search returns
    422 INVALID_CHANGE_SELECTION (stations_required).

    {
      "journeys": [
        {
          "legIds": ["018f2c3e-1234-7abc-8def-000000000531"],
          "departureDateTime": "2026-02-12T09:00:00",
          "departureStationId": "IT-NAP",
          "arrivalStationId": "IT-ROM"
        }
      ]
    }
  2. List fares, quote and confirm exactly as in section 7, with
    operationType: "change_itinerary" on the quote and the confirmation. A fare
    is accepted only for the operation it was searched for.

9. Remove part of a booking

When GET /v1/trains/bookings/{booking_id}/operations lists remove_legs
with isAllowed: true, the traveller can drop legs and keep the rest. The
operation lists removableLegIds as groups: a group is removed whole or not at
all. It uses the same two endpoints as sections 7 and 8, with no search step.

  1. Quote — POST /v1/trains/bookings/{booking_id}/operation-quotes:

    {
      "operationType": "remove_legs",
      "legIds": ["018f2c3e-1234-7abc-8def-000000000531"]
    }

    The response carries totalPaid (the booking total today), amountWithheld
    (the total the booking keeps), refundAmount and estimatedTotalAmount.

  2. Confirm — POST /v1/trains/bookings/{booking_id}/operations with the
    same body plus estimatedTotalAmount copied from the quote, and an
    Idempotency-Key. 201, 202 and 409 CHANGE_PRICE_CHANGED mean what they
    mean in section 7. Once the operator has settled, the booking lists only the
    remaining legs and the new total.

A selection that is empty, repeats a leg, names a leg that is not on the
booking or not removable, splits a group, or would remove every leg returns
422 INVALID_CHANGE_SELECTION. details[0].reason is one of empty_selection,
duplicate_leg, unknown_leg, leg_not_removable, incomplete_leg_group or
all_legs_selected, and nothing is changed.

To cancel through the same endpoints, send operationType: "cancellation"
(quote with no other field; confirm with estimatedTotalAmount).

10. Remove or replace travellers

When GET /v1/trains/bookings/{booking_id}/operations lists
remove_passengers or change_travellers with isAllowed: true, the
traveller can drop people from the booking or swap named passengers one for one.
The party never grows, and adding a person to the booking is not offered. Both
operations use the same two endpoints as sections 7 to 9, with no search step.

Each lists constraints.travelerIds, the travellers it may apply to, and
constraints.lockedTravelers, the travellers on the booking it may not. A
locked traveller stays visible with reason: "provider_restriction"; the
operator gives no other reason, so show a generic "locked" state.

Remove travellers

  1. Quote — POST /v1/trains/bookings/{booking_id}/operation-quotes:

    {
      "operationType": "remove_passengers",
      "travelerIds": ["018f2c3e-1234-7abc-8def-000000000611"]
    }

    The response carries oldTotal, newTotal, priceDifference and
    estimatedTotalAmount. At least one traveller must stay.

  2. Confirm — POST /v1/trains/bookings/{booking_id}/operations with the
    same body plus estimatedTotalAmount copied from the quote, and an
    Idempotency-Key. 201, 202 and 409 CHANGE_PRICE_CHANGED mean what they
    mean in section 7.

When the person being removed is the booking holder, the traveller must choose
who stays as the new holder: add "newHolderTravelerId" to the confirmation.
A quote may leave it out and is priced with the first remaining traveller; a
confirmation without it returns 422 INVALID_CHANGE_SELECTION with
new_holder_required.

Replace travellers

Replacements come from the traveller's own account. List them with
GET /v1/profile/colleagues?bookingId={booking_id}: people already on the
booking are left out. A page can hold fewer items than limit while
meta.nextOffset still points at the next page. For a booking paid with a
prepaid carnet, only people matching the carnet's invoice profiles are listed
and personCreationAvailable is false.

A person who is not in the list can be added first with
POST /v1/profile/colleagues:

{
  "firstName": "Anna",
  "lastName": "Verdi",
  "dateOfBirth": "1990-04-21",
  "bookingId": "018f2c3e-1234-7abc-8def-000000000601"
}

The person has no login and receives no invitation. Use the returned id as
newTravelerId. All three name and date fields are required, and a carnet
booking answers 422 COLLEAGUE_CREATION_UNAVAILABLE. Creation is not repeated
for you after a timeout: list the colleagues before creating the person again.

  1. Quote — POST /v1/trains/bookings/{booking_id}/operation-quotes:

    {
      "operationType": "change_travellers",
      "travellers": [
        {
          "currentTravelerId": "018f2c3e-1234-7abc-8def-000000000611",
          "newTravelerId": "018f2c3e-1234-7abc-8def-000000000621"
        }
      ]
    }
  2. Confirm — the same body plus estimatedTotalAmount, as above.

A selection that is empty, repeats a traveller, names a traveller who is not on
the booking or is locked, would leave the booking with nobody, names a
replacement who is already travelling, is not in the traveller's account or has
no date of birth returns 422 INVALID_CHANGE_SELECTION. details[0].reason is
one of empty_selection, duplicate_traveler, unknown_traveler,
traveler_not_eligible, all_travelers_selected, new_holder_required,
new_holder_not_remaining, new_holder_not_needed,
replacement_already_travelling, replacement_unknown or
replacement_birthday_missing, and nothing is changed.

See the API Reference for the complete request and
response schemas.

Reporting issues

This page is maintained by the BizAway integration team. To request changes or
report an error, contact your BizAway integration contact. Edits made directly
in the ReadMe dashboard are overwritten on the next publish.


Did this page help you?