Bookings and trips

Manage the booking lifecycle and organise bookings into trips.

Once you have built a fare (see the per-vertical guides), you create and manage
bookings through a shared set of endpoints, and organise them into trips.
All requests require the consumer HMAC and a traveler bearer
token
.

Throughout, {vertical} is one of flights, stays, trains, or cars.

Idempotency

Every booking mutation and cost-calculation endpoint — create, confirm,
cancel, calculate-costs, and calculate-cancel-costs — requires a unique
Idempotency-Key request header. Reusing the same key for a retried request
returns the original result instead of performing the action twice; omitting the
header returns 422. Generate a fresh key (for example a UUID) per logical
operation and reuse it only when retrying that same operation.

Idempotency-Key: 018f2c3e-1234-7abc-8def-000000000001

Omitting the header returns a 422 error envelope:

{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "IDEMPOTENCY_KEY_REQUIRED: The Idempotency-Key header is required for booking mutations.",
    "details": [],
    "recoveryAction": "FIX_INPUT"
  }
}

See Errors for the full error
envelope shape and the complete recoveryAction vocabulary.

Booking lifecycle

Create

POST /v1/{vertical}/bookings creates a booking from a built fareId. See the
per-vertical guides for the request body (flights,
stays, trains,
cars).

Calculate costs

POST /v1/{vertical}/bookings/calculate-costs returns the priced breakdown for
a fare before you commit — including any selected ancillaries, extras, seats,
and payment method (see Payment methods and 3D
Secure
for how to obtain a saved payment method's
id).

{ "fareId": "018f...", "locale": "en_us", "isPersonal": false }

billingAddressId and invoiceProfileId on a booking's create body reference
records from Billing addresses and invoice
profiles
. Omitting either key and sending
it as an explicit null are equivalent — travel.tech resolves both to the
account's default invoice profile (and, from it, the default billing
address) before the request ever reaches the upstream booking provider;
neither shape is forwarded upstream as a null or missing value. A client
that cannot omit an already-defaulted key (for example a generated
TypeScript client) still gets this resolution by sending null; it does not
need to strip the key. If the account has no invoice profile at all, booking
creation fails with 422 NO_INVOICE_PROFILE before any provider call,
rather than creating a booking with no fiscal documents. Do not assume a
default invoice profile exists immediately after guest-checkout account
creation — travel.tech's own account provisioning does not create one, so a
guest's first booking should be expected to hit this error unless an
invoiceProfileId is supplied explicitly.

Confirm

POST /v1/{vertical}/bookings/{booking_id}/confirm confirms a pending booking.
The request body is optional.

Get and list

Retrieve a single booking:

GET /v1/{vertical}/bookings/{booking_id}

List bookings with filters:

GET /v1/bookings?vertical=flights&status=confirmed&limit=20&offset=0

This always returns the legacy JSON array. For an offset-paginated response,
use the additive page operation, which returns an items envelope with
meta.totalCount and meta.nextOffset:

GET /v1/bookings/page?limit=20&offset=0

meta.totalCount counts rows in travel.tech's booking mirror — it is not a
live upstream total. A booking made outside travel.tech appears once the
mirror receives it (change feed, or the hourly background sync at the
latest).

Supported query parameters include vertical, status (repeatable),
tripId, createdBy, createdByMe, travelers (repeatable),
travelingOnly, sort (ASC/DESC by departure), departureDateFrom,
departureDateTo, limit, and offset. Both list operations accept the
same filters.

createdByMe and travelingOnly are self-scoped booleans. They take no
user id; when true, travel.tech resolves the filter from the authenticated
caller's Core user id. createdByMe=true keeps bookings the caller booked.
travelingOnly=true keeps bookings the caller travels on, using the local
traveler projection (passengers, stay guests, or car drivers). The two flags
compose with AND, and with every other list filter, before count and
pagination. Combining createdByMe=true with createdBy, or
travelingOnly=true with travelers, returns HTTP 422.

createdBy and travelers expect a public profile id — the
GET /v1/profile identity.id (and the booking travelerId), which is the
only user id travel.tech ever exposes. Send that value directly. A raw internal
user id is not obtainable through the public API and is rejected with a
field-level HTTP 422.

createdBy matches the public id of the booking's owner (normally the booker;
when the provider omitted that field, travel.tech's deterministic
syncing-user fallback). travelers matches the public id of any traveler in
the locally projected passengers, stay guests, or car drivers. Traveler values
use OR semantics; all filter dimensions
compose with AND semantics before counting and pagination. These party
filters are local-only and are not forwarded to the provider; unknown,
well-formed ids return an empty result. Traveler membership is projected
only from complete snapshots travel.tech receives after this feature is
deployed.
Historical rows without a later complete snapshot remain excluded
indefinitely, so traveler-filter items and counts cover only known
projections.

A booking's status is one of
pending, confirming, confirmed, issued, cancelled, failed, or
voided.

Itinerary legs on list rows and trip stages

Flight and train items on GET /v1/bookings/page and every booking embedded in
a trip stage carry a legs array. Each leg names itself, says when it arrives,
and reports its own phase, so a client can show a completed outbound and a
remaining return as separate rows (London–Amsterdam round trip, read at
2026-08-21T01:00Z):

"legs": [
  {
    "legId": "1d771894-5f45-5aeb-86a1-77af07ee2c89",
    "journeyUnitIndex": 0,
    "originAirportCode": "LHR",
    "destinationAirportCode": "AMS",
    "departureAt": { "localAt": "2026-08-20T20:55:00", "timezone": "Europe/London", "source": "provider" },
    "arrivalAt":   { "localAt": "2026-08-20T23:05:00", "timezone": "Europe/Amsterdam", "source": "provider" },
    "timelinePhase": "completed"
  },
  {
    "legId": "8a47c297-07c8-5bcf-91d0-dfde9d838934",
    "journeyUnitIndex": 1,
    "originAirportCode": "AMS",
    "destinationAirportCode": "LHR",
    "departureAt": { "localAt": "2026-08-21T07:20:00", "timezone": "Europe/Amsterdam", "source": "provider" },
    "arrivalAt":   { "localAt": "2026-08-21T07:40:00", "timezone": "Europe/London", "source": "provider" },
    "timelinePhase": "imminent"
  }
]
  • legId equals legId on the booking detail leg; use it for leg-scoped
    resources and to open the detail on that leg.
  • legs is null for stays and cars and [] for a flight or train whose
    itinerary has not been mirrored yet; the booking itself still returns.
  • departureAt / arrivalAt are null when the location's zone is unresolved
    and fill in on the next sync (same localAt / timezone / source shape as
    the real-time section below).
  • timelinePhase uses the same ladder as the booking and is derived from the
    leg's clock anchors only, so it does not reflect cancellation; a flight leg
    enters imminent 48h before departure (its check-in window), so it can lead
    the booking's phase by a day.
  • The same leg shape is embedded on every trip-stage booking, so a trip read
    carries per-leg phases without a detail fetch.

When the account has custom checkout fields configured, the single-booking
response carries the stored answers under customFields, joined with the
account's definitions at read time — labels, type (the
GET /v1/custom-fields vocabulary), and definition ordering come from the
definitions; answers themselves are returned as stored:

"customFields": [
  {
    "key": "cost_center",
    "label": "Cost Center",
    "type": "single_select",
    "values": [{ "key": null, "label": "Milan HQ" }]
  }
]

Each value's key is present only when the source can vouch for it
(multi-select answers always; text and date answers as their own value;
single-select only when the stored string matches an option key). The block is
omitted entirely when the feature is disabled, the booking has no answers, or
on create responses and outage fallbacks — re-fetch the booking detail for the
joined block. If the definitions cannot be read, answers still render with the
key doubling as the label and type: null; an answer is never dropped.

Full booking object

A response is pruned to the fields that apply to its own vertical — a flight
booking never carries stayDetail or carDetail, and a stay or car booking
carries an empty legs array instead of flight/train leg data. This is a
complete GET /v1/flights/bookings/{booking_id} response for a confirmed,
non-approval-gated flight booking with one leg:

{
  "bookingId": "018f2c1a-9b3e-7c4d-8a1f-3e5b7c9d1a2f",
  "vertical": "flight",
  "status": "confirmed",
  "bizAwayCode": "BA-0001234",
  "providerReference": "BA-0001234",
  "providerConfirmationReferences": [
    { "type": "pnr", "value": "XJ4KLM", "providerName": "example-airline", "legIds": ["1d771894-5f45-5aeb-86a1-77af07ee2c89"] }
  ],
  "providerName": null,
  "needsApproval": false,
  "approvalStatus": null,
  "totalPrice": { "amount": "149.90", "currency": "EUR" },
  "paymentStatus": "not_required",
  "availableOperations": ["cancel_booking", "post_booking_changes"],
  "providerData": {},
  "legs": [
    {
      "legId": "1d771894-5f45-5aeb-86a1-77af07ee2c89",
      "originAirportCode": "MXP",
      "destinationAirportCode": "LHR",
      "originDisplayName": "Milan Malpensa",
      "destinationDisplayName": "London Heathrow",
      "departureAt": { "localAt": "2026-02-10T09:15:00", "timezone": "Europe/Rome", "source": "provider" },
      "arrivalAt": { "localAt": "2026-02-10T10:35:00", "timezone": "Europe/London", "source": "provider" },
      "duration": "PT1H20M",
      "airlineName": "example-airline",
      "carrierLogo": "https://cdn.example.com/logos/example-airline.png",
      "flightNumber": "EA 918",
      "fareName": "Economy Flex",
      "terminal": "1",
      "gate": null,
      "seat": "14C",
      "checkInLink": null,
      "cabinClass": "economy",
      "layoverMinutes": null,
      "connection": null,
      "timelinePhase": "upcoming"
    }
  ],
  "travelers": [
    {
      "travelerId": "018f2c1b-1a2b-7c3d-9e4f-5a6b7c8d9e0f",
      "displayName": "Ada Lovelace",
      "legAssignments": [
        { "legId": "1d771894-5f45-5aeb-86a1-77af07ee2c89", "seat": "14C", "coach": null, "eTicketNumber": "1769012345678", "baggageAllowance": "1 x 23kg checked bag" }
      ]
    }
  ],
  "policySnapshot": { "wasWithinPolicy": true, "rationale": null, "capturedAt": "2026-02-08T09:00:00Z" },
  "holdExpiresAt": null,
  "calendarFileUrl": "https://provider.example.com/bookings/018f2c1a.../calendar.ics",
  "customFields": [
    { "key": "cost_center", "label": "Cost Center", "type": "single_select", "values": [{ "key": "milan-hq", "label": "Milan HQ" }] }
  ],
  "extraBaggages": [
    {
      "quantity": 1,
      "pieces": 1,
      "weight": 25.0,
      "unit": "kg",
      "price": { "amount": "39.00", "currency": "EUR" },
      "legIds": ["1d771894-5f45-5aeb-86a1-77af07ee2c89"],
      "travelerId": "018f2c1b-1a2b-7c3d-9e4f-5a6b7c8d9e0f"
    }
  ],
  "tripId": "018f2c1d-2b3c-7d4e-8f5a-6b7c8d9e0f1a"
}

extraBaggages lists the bags bought on a flight booking, on top of
the fare's included baggageAllowance. price is the price of one unit, so a
line costs price × quantity. legIds and travelerId point at entries in
legs and travelers on the same response; an empty legIds means the bag's
trip could not be matched to a leg, and a null travelerId means the bag
names no listed traveler or the airline hands bags out by order rather than by
traveler. A weight of 1 may mean the airline stated no weight. The list is
empty when no bags were bought, and the key is omitted for other verticals and
on create responses.

disruption is omitted here because none has been observed; see
Real-time disruptions. needsApproval and
approvalStatus describe policy-gated bookings — see
Booking approval requests for the full request →
decision → exception flow. A travelers entry may be a saved companion
managed through Managing traveler profiles rather than
the authenticated traveler.

paymentStatus reports what happened to the booking's charge, read from the
provider's booking and payment state rather than inferred from status, so a
confirming booking can already show captured. not_required means the
booking settles on the account's credit line after travel and no card is
charged at booking; captured means the provider reports it paid by a charged
method (withheld once the booking is cancelled or failed, when a refund may
apply). processing (a charge started with no result yet) and failed
(declined or errored) are part of the contract but are not returned yet: the
provider's booking read carries no signal for them, and a decline is reported
on the confirm call itself. The field is always present and null when the
payment state is unknown — hide any payment step rather than assume an outcome.

Cancel

Preview the refund first:

POST /v1/{vertical}/bookings/{booking_id}/calculate-cancel-costs

Then cancel, echoing the amount you expect to be charged/refunded so the server
can guard against a stale quote:

{ "estimatedTotalAmount": "149.90" }
POST /v1/{vertical}/bookings/{booking_id}/cancel

For trains, these two calls are deprecated (responses carry Deprecation and
Link headers) in favour of POST /v1/trains/bookings/{booking_id}/operation-quotes
and .../operations with operationType: "cancellation"; they keep working
unchanged. See Trains.

Trips

A trip groups related bookings (for example an outbound flight, a stay, and
a return flight) into one itinerary the traveler can browse.

Create

POST /v1/trips — supply either purpose (to name a fresh trip) or
bookingId (to seed the trip from an existing booking, deriving the name from
its destination). At least one is required; startsAt / endsAt are optional.

{
  "purpose": "Rome client visit",
  "bookingId": "018f...",
  "startsAt": "2026-02-10T08:00:00Z",
  "endsAt": "2026-02-14T20:00:00Z"
}

The response (TripOut) includes the trip id, status (one of planning,
booked, active, disrupted, completed, cancelled), and its segments.

List, get, and rename

GET   /v1/trips
GET   /v1/trips/{trip_id}
PATCH /v1/trips/{trip_id}

GET /v1/trips streams the traveler's trips as Server-Sent Events by default —
one event: trip frame per trip (each data: payload is a TripOut), closing
with event: done. Consume it like the
notification stream. In environments where
streaming is disabled it instead returns a plain JSON array of TripOut, so
branch on the response Content-Type (text/event-stream vs
application/json) and parse accordingly.

Optional query flags bookedByMe and travelingOnly are booleans resolved to
the authenticated caller — do not send a user id. bookedByMe=true keeps trips
that have at least one trip-visible member booking this caller booked.
travelingOnly=true keeps trips that have at least one trip-visible member
booking this caller travels on, using the local traveler projection
(bookings without a later complete snapshot may be omitted). Both true apply
independently: a trip matches if it has a booked-by-me member booking and a
traveled-by-me member booking, not necessarily the same booking. Absent or
false leaves the list unfiltered on that axis. Manually created planning
trips with no bookings match neither flag. status still composes with these
flags. Both the SSE path and the JSON fallback apply the same filters in SQL
before serializing.

GET /v1/trips/{trip_id} always
returns a single TripOut as JSON. Each stage booking embeds the per-leg
legId, arrivalAt, and timelinePhase described under
Itinerary legs on list rows and trip stages.

Full trip object

A trip groups its bookings into stages (segments) — a stage is a
destination and date window; bookings attach to a stage under the ingress,
stays, cars, or egress role depending on how they serve that stage. This
is a complete GET /v1/trips/{trip_id} response for a two-stage trip (an
outbound flight, a stay, and a return flight):

{
  "id": "018f2c1d-2b3c-7d4e-8f5a-6b7c8d9e0f1a",
  "defaultPurpose": "Rome client visit",
  "purpose": "Rome client visit",
  "status": "booked",
  "startsAt": "2026-02-10T08:00:00Z",
  "endsAt": "2026-02-14T20:00:00Z",
  "createdAt": "2026-02-08T09:00:00Z",
  "segments": [
    {
      "id": "018f2c1e-3c4d-7e5f-9a6b-7c8d9e0f1a2b",
      "name": "Rome",
      "location": "Rome",
      "status": "scheduled",
      "startsAt": "2026-02-10T09:15:00Z",
      "endsAt": "2026-02-14T18:00:00Z",
      "createdAt": "2026-02-08T09:00:00Z",
      "ingress": [
        {
          "bookingId": "018f2c1a-9b3e-7c4d-8a1f-3e5b7c9d1a2f",
          "vertical": "flight",
          "status": "confirmed",
          "bizAwayCode": "BA-0001234",
          "providerReference": "BA-0001234",
          "providerConfirmationReferences": [],
          "totalPrice": { "amount": "149.90", "currency": "EUR" },
          "displayName": "Milan Malpensa → Rome Fiumicino",
          "startsAt": "2026-02-10T09:15:00Z",
          "endsAt": "2026-02-10T10:35:00Z",
          "holdExpiresAt": null,
          "timelinePhase": "upcoming",
          "statusTier": "default",
          "activeTask": { "kind": "check_in", "id": "018f2c1a-9b3e-7c4d-8a1f-3e5b7c9d1a2f", "disruption": null },
          "riskScore": 0.0,
          "isRoundTrip": false,
          "legs": null
        }
      ],
      "stays": [
        {
          "bookingId": "018f2c1f-4d5e-7f6a-8b7c-8d9e0f1a2b3c",
          "vertical": "stay",
          "status": "confirmed",
          "bizAwayCode": "BA-0001235",
          "providerReference": "BA-0001235",
          "providerConfirmationReferences": [],
          "totalPrice": { "amount": "640.00", "currency": "EUR" },
          "displayName": "Grand Hotel Roma",
          "propertyName": "Grand Hotel Roma",
          "startsAt": "2026-02-10T00:00:00Z",
          "endsAt": "2026-02-14T00:00:00Z",
          "timelinePhase": "upcoming",
          "statusTier": "default",
          "riskScore": 0.0
        }
      ],
      "cars": [],
      "egress": [],
      "timelinePhase": "upcoming",
      "statusTier": "default",
      "riskScore": 0.0
    }
  ],
  "bookingStatusCounts": { "confirmed": 2, "awaitingApproval": 0, "pending": 0, "confirming": 0, "rejected": 0, "visibleTotal": 2 },
  "timelinePhase": "upcoming",
  "statusTier": "default",
  "activeTask": { "kind": "check_in", "id": "018f2c1a-9b3e-7c4d-8a1f-3e5b7c9d1a2f", "disruption": null },
  "riskScore": 0.0,
  "reassuranceState": null,
  "safetyActive": false,
  "accessibilityOverride": null,
  "outstandingExpenseCount": 0,
  "destinationCity": "Rome",
  "destinationImageUrl": "https://d2x7k9example.cloudfront.net/destinations/rome-it/c85aeadb32af040cfe5599781b68ff2bd0571274230350e99f808fd83be4484f.webp"
}

destinationImageUrl is a curated 1600x900 WebP photo of destinationCity,
or null when that city has no destination photo (or destinationCity is
null). It is never an error, so render a fallback when it is null.

ingress and egress on a round-trip flight or train both point at the same
booking — check isRoundTrip on the embedded booking rather than treating the
two entries as distinct journeys. legs is null on a stage booking summary
for stays and cars, and an array (possibly empty) for flights and trains, same
as on the full booking object.

Associate and detach bookings

Attach an existing booking to a trip (atomically moving it from any current
trip):

POST /v1/trips/{trip_id}/bookings
{ "bookingId": "018f..." }

Detach a booking (the booking row is preserved; only the trip link is removed —
idempotent, returns 204):

DELETE /v1/trips/{trip_id}/bookings/{booking_id}

Timeline

GET /v1/trips/{trip_id}/timeline returns the ordered sequence of trip events
for rendering an itinerary view.

Real-time disruptions

When live-status coverage is available for a carrier, these endpoints surface
delays, cancellations, gate/platform changes, and equipment swaps:

GET /v1/trips/{trip_id}/disruptions
GET /v1/trips/{trip_id}/segments/{segment_id}/disruptions
GET /v1/trips/{trip_id}/segments/{segment_id}/realtime

Live-status coverage varies by carrier and region. Absence of a disruption
does not guarantee an on-time departure — treat these as best-effort signals.

A segment can hold more than one leg (a round trip or a connection). Every
leg-scoped field on these responses — affectedLegId, scheduledDepartureAt,
liveFields, originCode/destinationCode/flightNumber — describes the
affected leg: the one the latest disruption event names, otherwise the next
leg that has not departed yet.

/realtime also returns the affected leg's schedule as local wall-clock time
with its IANA zone:

"departureAt": { "localAt": "2026-09-22T22:00:00", "timezone": "Europe/Madrid", "source": "provider" },
"arrivalAt":   { "localAt": "2026-09-23T00:20:00", "timezone": "Europe/Rome",   "source": "provider" }

Both are null when the segment has no transport leg or the zone is unknown.
The top-level timezone field is deprecated (sunset 2026-11-30): it is an
alias of departureAt.timezone and will be removed — read the zone from
departureAt instead.

For real-time push of new disruption alerts, see the
notification stream.

Trip expenses

GET /v1/trips/{trip_id}/expenses lists the expense items linked to a trip's
bookings — this is currently a read-only surface; there is no endpoint to
submit or approve an expense through this API.

{
  "outstandingCount": 1,
  "deadline": null,
  "items": [
    {
      "expenseId": "018f2c20-5e6f-7a7b-9c8d-9e0f1a2b3c4d",
      "bookingId": "018f2c1f-4d5e-7f6a-8b7c-8d9e0f1a2b3c",
      "vertical": "stay",
      "status": "submitted",
      "amount": { "amount": "640.00", "currency": "EUR" },
      "incurredAt": "2026-02-14T00:00:00Z",
      "merchantName": "Grand Hotel Roma",
      "receiptCount": 0
    }
  ]
}

status is one of unsubmitted, submitted, approved, rejected.
outstandingCount counts items still unsubmitted; it also drives a trip's
outstandingExpenseCount shown on the full trip object
above. deadline and receiptCount are reserved fields — always null and
0 respectively today.

An unknown or inaccessible trip_id returns 404:

{
  "error": {
    "code": "RESOURCE_NOT_FOUND",
    "message": "Trip not found.",
    "details": [],
    "recoveryAction": "CONTACT_SUPPORT"
  }
}

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?