Skip to main content

API reference

The portal API is served under one base path:

/api/portal/v1

It is a versioned, contract-tested surface: the platform repository carries an OpenAPI 3.1 document that CI keeps in lock-step with the route definitions — a route added, removed, or renamed without a matching spec change is a test failure. This page summarizes that contract.

Conventions

These match the data contracts conventions: dates are ISO YYYY-MM-DD, timestamps ISO 8601, currency codes ISO 4217, money is decimal in the booking currency, and identifiers are stable opaque strings.

Envelope. Every successful data response is wrapped in { "data": ... }.

Errors are { "error", "code", "message" }, where error and code are stable machine-readable identifiers (equal today; treat code as canonical). Two errors carry extra fields: a 422 form validation adds fields[] (per-field name + code), and a 501 adds capability and upstreams.

StatusCodeWhen
400VALIDATION_ERRORMalformed request (path or body fails validation)
401UNAUTHORIZED, INVALID_TOKEN, EXPIRED, ALREADY_USEDMissing/invalid session token, or a failed magic-link verification
404PORTAL_NOT_FOUND, TRIP_NOT_FOUND, FORM_NOT_FOUND, NOT_FOUNDPortal not declared in portal.yaml, or resource not accessible to this traveler
409ALREADY_SUBMITTEDRe-submitting an already-submitted form
422VALIDATION_ERRORForm answers failed field validation (fields[] lists them)
501NOT_IMPLEMENTEDCapability not wired for this provider yet
502UPSTREAM_ERROR, UPSTREAM_UNREACHABLEAn upstream dependency failed; upstream detail is never leaked to the client
500INTERNAL_ERRORUnexpected server error

Authentication. Portal-scoped data routes require a portal session token (aud includes edge-portal) as Authorization: Bearer <token> or the x-portal-session-token header. The token's tenantId/portalId claims must match the path, and reads are scoped to the authenticated traveler. The auth endpoints themselves are unauthenticated by design — they exist to obtain a session. See Authentication.

Auth and session endpoints

MethodPathAuthDescription
GET/health-checkNoneRouter liveness: { "status": "ok", "api": "portal-v1" }
POST/auth/magic-linkNoneRequest a sign-in link. Body: { tenantId, portalId, email }. Responds 202 { "status": "accepted" } for known and unknown emails alike (no account enumeration); 404 when the portal is not declared. In development only, responds 201 echoing the raw token
POST/auth/magic-link/verifyNoneExchange a one-time link token for a session. Body: { token }. Responds 201 { token, tokenType: "Bearer", session }; 401 with INVALID_TOKEN / EXPIRED / ALREADY_USED otherwise
POST/session/validateNoneValidate a session token (from the body's token or the auth headers), optionally pinning expected tenantId / portalId. Responds 200 { data: session } or 401
POST/dev/session-tokenNoneDevelopment only — mints a session from caller-supplied claims; 404 outside development

Portal config (public)

MethodPathAuthDescription
GET/{tenantId}/{portalId}/configNoneThe advisory, non-sensitive subset of the portal's configuration: label, type, features, branding, and the tenant ui block. Deliberately unauthenticated — the sign-in screen must render before a session exists. Cached (Cache-Control: public, max-age=60). Sensitive config (auth, live, provider mode) is never exposed

Trip data endpoints

All routes below are scoped /{tenantId}/{portalId}/... and require a session. Responses are 200 with a data envelope unless noted. Endpoints marked with a flag are feature-gated: when the flag is false the list routes return { "data": [] } and the single-form routes return 404, without calling the data provider.

MethodPathFeature flagResponse data
GET/tripsArray of trip summaries: id, bookingReference, title, destination, status (upcoming | in_progress | completed), startsOn, endsOn, currency, totalAmount, balanceDueAmount
GET/trips/{tripId}Trip detail: the summary plus leadTravelerId and travelers[] (id, firstName, lastName, email?). 404 TRIP_NOT_FOUND for trips the traveler is not on
GET/trips/{tripId}/itinerary-daysitinerary_daysArray of days: dayNumber, date, title, location, summary, highlights[]
GET/trips/{tripId}/payment-schedulespayment_scheduleArray of instalments: id, label, dueOn, amount, currency, status (paid | due | overdue)
GET/trips/{tripId}/payment-transactionspayment_scheduleArray of payments received: id, capturedOn, amount, currency, status (succeeded | pending | failed), methodLabel, reference
GET/trips/{tripId}/documentsdocumentsArray of documents: id, category (free string — tenant docType vocabularies are config-owned), title, url, createdAt
GET/trips/{tripId}/pre-departure-formspre_departure_formsArray of forms: id, title, status (not_started | in_progress | submitted), dueOn, url (the in-portal route that renders the form)
GET/trips/{tripId}/forms/{formId}pre_departure_formsA single form for in-tab rendering: { form: { id, title, status, dueOn, fields[] }, answers }. Fields carry name, label, type (text | textarea | email | tel | date | select | checkbox), required, and options[] for select
PUT/trips/{tripId}/forms/{formId}pre_departure_formsUpsert the traveler's answers. Body: { answers, submit }. submit: false saves a draft; submit: true validates and records the submission. Responds { status }; 422 with fields[] on validation failure, 409 when already submitted
GET/trips/{tripId}/optional-extrasoptional_extrasArray of extras: id, name, description, amount, currency, selected

Ownership scoping. A traveler can only read trips they appear on. Requesting a collection sub-resource of a trip the session cannot access returns an empty collection rather than revealing whether the trip exists; single-resource routes return their documented 404 response.

Operational endpoints

/health, /ready, and (on an internal-only port) /metrics are served at the service root, outside /api/portal/v1, and are not part of the traveler-facing contract — see Integration architecture.