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.
| Status | Code | When |
|---|---|---|
| 400 | VALIDATION_ERROR | Malformed request (path or body fails validation) |
| 401 | UNAUTHORIZED, INVALID_TOKEN, EXPIRED, ALREADY_USED | Missing/invalid session token, or a failed magic-link verification |
| 404 | PORTAL_NOT_FOUND, TRIP_NOT_FOUND, FORM_NOT_FOUND, NOT_FOUND | Portal not declared in portal.yaml, or resource not accessible to this traveler |
| 409 | ALREADY_SUBMITTED | Re-submitting an already-submitted form |
| 422 | VALIDATION_ERROR | Form answers failed field validation (fields[] lists them) |
| 501 | NOT_IMPLEMENTED | Capability not wired for this provider yet |
| 502 | UPSTREAM_ERROR, UPSTREAM_UNREACHABLE | An upstream dependency failed; upstream detail is never leaked to the client |
| 500 | INTERNAL_ERROR | Unexpected 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
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /health-check | None | Router liveness: { "status": "ok", "api": "portal-v1" } |
| POST | /auth/magic-link | None | Request 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/verify | None | Exchange 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/validate | None | Validate 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-token | None | Development only — mints a session from caller-supplied claims; 404 outside development |
Portal config (public)
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /{tenantId}/{portalId}/config | None | The 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.
| Method | Path | Feature flag | Response data |
|---|---|---|---|
| GET | /trips | — | Array 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-days | itinerary_days | Array of days: dayNumber, date, title, location, summary, highlights[] |
| GET | /trips/{tripId}/payment-schedules | payment_schedule | Array of instalments: id, label, dueOn, amount, currency, status (paid | due | overdue) |
| GET | /trips/{tripId}/payment-transactions | payment_schedule | Array of payments received: id, capturedOn, amount, currency, status (succeeded | pending | failed), methodLabel, reference |
| GET | /trips/{tripId}/documents | documents | Array of documents: id, category (free string — tenant docType vocabularies are config-owned), title, url, createdAt |
| GET | /trips/{tripId}/pre-departure-forms | pre_departure_forms | Array 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_forms | A 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_forms | Upsert 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-extras | optional_extras | Array 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.