Skip to main content

Guest API

The guest surface: sign in with a booking, browse the trip's catalog with live availability, order optional experiences and gratuities, pay by card or voucher (including split payments across the party), and review orders.

All endpoints are under /api/optionals/v1 and — except login — require a guest bearer token (see Authentication). Guest sessions are trip-scoped: every endpoint operates on the trip and party from the session, and ownership is enforced server-side. All amounts are integer minor units.

Sign in

Guests never register. A tenant activates one or both entry points:

  • Magic link — a link delivered by email that opens the guest's trip-scoped session. Your messaging platform (for example a marketing-cloud journey) sends it at the moments you choose — pre-trip, at tour start, or on demand; the embed steps are provided at onboarding.
  • Booking reference + surname — an interactive sign-in form backed by:
POST /auth/guest
{ "tenantId": "meridian", "bookingReference": "MT-882401", "lastName": "Carter" }

Matches the booking reference + surname against the booking records for the tenant. Both entry points land in the same session. On success:

How magic links work

The emailed URL carries an opaque, single-use code — never the session token itself, so no credential appears in browser history, referrer headers, or proxy logs. Opening the link lands on a branded continue page, and the code is exchanged server-side for the trip-scoped session token only on the guest's explicit tap — automated email-security scanners that prefetch URLs therefore cannot consume the link. Codes expire (validity is configured per tenant), and each code is redeemable once. An expired, consumed, or invalid link never dead-ends: the guest sees why and falls back to the booking reference + surname form above. The formal payload and error contract for link issuance is part of the interface contract agreed at onboarding.

{
"token": "<bearer token>",
"guest": {
"id": "g_014", "tripId": "trip_301",
"bookingReference": "MT-882401",
"firstName": "Elena", "lastName": "Carter",
"email": "elena.carter@example.com",
"party": { "size": 2, "memberNames": ["Elena Carter", "James Carter"] }
},
"trip": { "id": "trip_301", "code": "MERBOI-260812", "name": "Best of Italy", "currency": "GBP", "...": "..." }
}

A failed match returns 401 booking_not_found with a deliberately uniform message that does not reveal which field was wrong.

Trip context

GET /guest/trip?tenantId=meridian

The session's trip, guest record, resolved brand (with theme), the assigned Tour Director's display name, and the driver's name (for gratuities):

{
"trip": { "id": "trip_301", "code": "MERBOI-260812", "name": "Best of Italy", "startDate": "2026-08-12", "endDate": "2026-08-20", "currency": "GBP", "status": "upcoming", "...": "..." },
"guest": { "...": "..." },
"brand": { "id": "meridian_uk", "name": "Meridian Travel UK", "theme": { "...": "..." } },
"tourDirector": { "id": "td_07", "name": "Marco Bellini" },
"driverName": "Stefan Novak"
}

Catalog

GET /guest/catalog?tenantId=meridian
GET /guest/catalog/{productId}?tenantId=meridian

The trip-specific catalog with availability resolved per product:

{
"tripId": "trip_301",
"currency": "GBP",
"items": [
{
"product": {
"id": "opt_venice_gondola",
"title": "Venice by Gondola at Dusk",
"description": "Private gondola circuit through the quiet back canals.",
"category": "sightseeing",
"price": 8500,
"currency": "GBP",
"durationMinutes": 90,
"supplierName": "Laguna Experiences",
"supplierCutoffHours": 48,
"capacity": 24,
"tripDay": 4,
"startTime": "19:30",
"meetingPoint": "Hotel lobby",
"imageUrl": "https://cdn.example.com/experiences/gondola.jpg"
},
"status": "available",
"remaining": 9,
"salesCloseAt": "2026-08-13T17:30:00.000Z",
"cutoffPassed": false,
"whenSoldOut": "block",
"onRequestAllowed": false
}
]
}

Availability fields per item:

FieldMeaning
statusavailable | sold_out | unavailable — the resolved state including staff overrides
remainingBookable units left (null = unlimited)
salesCloseAt / cutoffPassedSales close time derived from the trip day and the supplier's cut-off hours
whenSoldOutConfigured policy once the allotment is used: block or on_request
onRequestAllowedtrue when sold out but staff may still take bookings subject to supplier confirmation (guest self-serve always respects block)

The single-product variant returns one catalog item or 404.

Itinerary

GET /guest/itinerary?tenantId=meridian

The guest-facing day-by-day plan for the trip's home screen — day number, date, location, and summary only. Operational detail (supplier contacts, confirmation references, duty-of-care data) is never exposed on the guest surface. Trips without a published day plan return an empty list.

Orders

Create an order

POST /guest/orders
{
"tenantId": "meridian",
"items": [
{ "type": "optional", "productId": "opt_venice_gondola", "quantity": 2, "participants": ["Elena Carter", "James Carter"] },
{ "type": "gratuity", "gratuityRecipient": "tour_director", "amount": 6000 },
{ "type": "gratuity", "gratuityRecipient": "driver", "amount": 3000 }
]
}

Items are validated against live availability and cut-offs at creation time (409 unavailable on conflict). Gratuity items carry a free amount and a recipient (tour_director or driver). Returns 201 with the order:

{
"id": "ord_5521",
"reference": "OPT-260712-8341",
"tripId": "trip_301",
"guestId": "g_014",
"channel": "guest_self_serve",
"items": [ { "id": "item_1", "type": "optional", "title": "Venice by Gondola at Dusk", "unitPrice": 8500, "quantity": 2, "participants": ["Elena Carter", "James Carter"] } ],
"currency": "GBP",
"totalAmount": 26000,
"paidAmount": 0,
"status": "pending_payment",
"createdAt": "2026-07-12T08:45:00.000Z"
}

Order status moves pending_paymentpartially_paidconfirmed as payments complete (or cancelled).

List and read orders

GET /guest/orders?tenantId=meridian
GET /guest/orders/{orderId}?tenantId=meridian

The list returns the session guest's orders. The detail adds the order's payment transactions. Another party's order id returns 404 — ownership is checked before anything else.

Request a change

POST /guest/orders/{orderId}/request-change
{ "tenantId": "meridian", "message": "James can no longer make the gondola evening" }

Logs a change request against the order (message optional, max 500 characters). It surfaces as an actionable alert on the Tour Director's overview, who resolves it — typically with remove-participant. Returns 201 { "ok": true }.

Payments

Card

POST /guest/orders/{orderId}/payments/card
{
"tenantId": "meridian",
"card": {
"cardNumber": "4111111111111111",
"expiryMonth": 8,
"expiryYear": 2028,
"cvv": "123",
"cardholderName": "Elena Carter"
},
"amount": 13000,
"payerName": "Elena Carter"
}
  • amount is optional — omit it to pay the outstanding balance in full. Providing a partial amount with a payerName is a split payment: different party members each pay their share, and every transaction records who paid.
  • Declines return 402 with the gateway's reason code.
  • With payment.gateway: simulated, deterministic test cards drive approve/decline/insufficient-funds outcomes; with edge_pay, the card is processed through the production gateway path and card data never touches Experiences servers.

Returns the payment transaction and the updated order:

{
"transaction": { "id": "txn_9912", "type": "purchase", "method": "card", "amount": 13000, "currency": "GBP", "status": "completed", "cardBrand": "visa", "cardLast4": "1111", "payerName": "Elena Carter" },
"order": { "id": "ord_5521", "paidAmount": 13000, "status": "partially_paid", "...": "..." }
}

Voucher

POST /guest/orders/{orderId}/payments/voucher
{ "tenantId": "meridian", "code": "MER-GV-7Q2K9" }

Redeems a voucher against the order. Validation failures return 422 with a specific code (expired, already redeemed, wrong currency, linked to a different booking). Partner vouchers linked to a booking reference are checked against the session guest's booking.

Pre-check a voucher

POST /guest/vouchers/check
{ "tenantId": "meridian", "code": "MER-GV-7Q2K9" }

Real-time validation for checkout UX — no redemption happens:

{ "valid": true, "amount": 5000, "currency": "GBP" }

or, when invalid:

{ "valid": false, "reason": "This voucher expired on 2026-06-30", "reasonCode": "voucher_expired" }