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:
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:
| Field | Meaning |
|---|---|
status | available | sold_out | unavailable — the resolved state including staff overrides |
remaining | Bookable units left (null = unlimited) |
salesCloseAt / cutoffPassed | Sales close time derived from the trip day and the supplier's cut-off hours |
whenSoldOut | Configured policy once the allotment is used: block or on_request |
onRequestAllowed | true 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_payment → partially_paid → confirmed 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"
}
amountis optional — omit it to pay the outstanding balance in full. Providing a partialamountwith apayerNameis a split payment: different party members each pay their share, and every transaction records who paid.- Declines return
402with the gateway's reason code. - With
payment.gateway: simulated, deterministic test cards drive approve/decline/insufficient-funds outcomes; withedge_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" }