# API Consumer Guide This document describes the public JSON API exposed under `/api`, written for developers implementing a **client** in another application. It is self-contained: no access to this codebase is required to use it. ## Overview A Symfony application exposing a read-mostly JSON API under the `/api` prefix. All `/api/*` requests are stateless and authenticated via OAuth2 Bearer tokens (League OAuth2 Server, JWT access tokens). Responses are `application/json`. HTTPS is enforced on all routes. ## Authentication - Token endpoint: `POST /token` (unauthenticated). - Enabled grants: **client_credentials**, **authorization_code** (PKCE required for public clients), **refresh_token**. Password and implicit grants are disabled. - Access token TTL: **10 minutes** — clients must refresh/re-request frequently. - Available scopes: `email`, `id`, `profile`, `roles`, `api`. Default scope if none requested: `email`. - Each granted scope maps to a role `ROLE_OAUTH2_` (e.g. scope `api` → `ROLE_OAUTH2_API`). - Send the token as `Authorization: Bearer `. Scope required per endpoint: | Scope needed | Endpoints | |---|---| | `api` | everything except the two below | | `profile` | `GET /api/crm-attributes` | | `email` | `GET /api/userinfo` (optional extra scopes `id`, `profile`, `roles` widen the response) | `/api/userinfo` and `/api/crm-attributes` act on the **authenticated end user**, so they require an authorization_code token issued for a user. All other endpoints are machine-to-machine and work with client_credentials. ### Machine-to-machine example ``` POST /token Content-Type: application/x-www-form-urlencoded grant_type=client_credentials&client_id=...&client_secret=...&scope=api ``` ``` GET /api/hotels Authorization: Bearer eyJ0eXAi... ``` ## Error conventions There is no single error envelope; three shapes occur: - `{"message": "..."}` — commonly with 404 / 400. - `{"error": "...", "type": "api_error"}` — upstream failures on some endpoints. **Caution:** on `/api/products` and `/api/travels/{dateId}/{hotelId}/{dateTo}/availability` this shape is returned with HTTP **200**, so clients must check for the presence of an `error` key, not only the status code. - `{"success": false, "message": "..."}` — the write endpoints (contact form, newsletter). Validation failures on endpoints using mapped query strings / payloads (`/api/contingents/*`, `/api/newsletter-subscriptions`) return **400** with Symfony's constraint-violation body. Missing/invalid token yields **401**; insufficient scope yields **403**. The HTTP methods listed below are the intended ones. Several read routes do not restrict methods server-side; clients should still use `GET` for them. --- ## Endpoints ### Reference data (BusProNet master data) #### `GET /api/hotels` — scope `api` List of hotels. Array of objects: `id` (int), `code` (string), `name`, `city`, `street`, `phone`, `country`. #### `GET /api/hotels/{id}` — scope `api` Single hotel by numeric id, same field set as the list. `404 {"message":"Not found"}` if unknown. #### `GET /api/countries` — scope `api` Array of `{id, name, token, nationality}`. #### `GET /api/products` — scope `api` Product master data from the upstream BusProNet system, cached server-side for 1 hour. On upstream failure returns `{"error": "...", "type": "api_error"}` **with status 200**. #### `GET /api/last-update` — scope `api` Timestamp of the last upstream data export: `{"timestamp": "YYYY-MM-DD HH:MM:SS"}`. Note: if no export marker file exists the current implementation errors (500) rather than returning null — treat a non-JSON/500 response as "unknown". --- ### Pickups (bus boarding points) #### `GET /api/pickups` — scope `api` Array of `{id, code, city, postalCode, street}`. #### `GET /api/pickups/{id}` — scope `api` Single pickup by numeric id, same fields as the list. #### `GET /api/pickups-planning/{travelCode}` — scope `api` Planned outbound (HIN) boarding stops for one travel code. Returns an array, chronologically sorted: ```json [{"city": "...", "location": "...", "datetime": "YYYY-MM-DD HH:MM", "busNumber": "..."}] ``` `404 {"error":"Planning data not available"}` if no planning has been uploaded yet, `404 {"error":"Travel code not found"}` for an unknown code, `500 {"error":"Failed to read planning data"}` on storage errors. #### `POST /api/pickups-planning` — scope `api` Bulk-replaces the planning dataset. Body: a JSON **array** of raw planning rows using the upstream German keys: ```json [ { "PlanungsArt": "HIN", "Reisecode": "ABC123", "Zustieg/Ausstieg Ort": "Köln", "Zustieg/Ausstieg Strasse": "Hauptbahnhof", "Zeit": "24.12.2026 07:30", "BusNummer": "1" } ] ``` Rows without `PlanungsArt === "HIN"` or without `Reisecode` are dropped; duplicates (same city + location + datetime + bus) are de-duplicated; results are grouped by `Reisecode` and sorted by time. Response `{"status":"ok"}` (200). `400 {"error":"Invalid JSON payload"}`, `500 {"error":"Failed to write pickups planning data"}`. This is a full overwrite, not a merge. --- ### Travels #### `GET /api/travels` — scope `api` Index/mapping of all known travel dates. Object keyed by numeric travel id, each value `{id, code, label, dateFrom, dateTo, hotels: [...]}`. #### `GET /api/travels/{dateId}/{hotelId}` — scope `api` Single travel by **numeric** ids (`hotelId` optional). Route matches only when both segments are digits. #### `GET /api/travels/{dateCode}/{hotelCode}` — scope `api` Same, addressed by **string** codes (`hotelCode` optional). The date code is sanitized (separators stripped) before lookup. `404 {"message":"Not found"}` if the date code is unknown, `404 {"message":"Travel not found"}` if no data exists. Both variants accept optional query parameters: - `source=local` — force the local XML/snapshot data. - `source=remote` — force a live upstream call. - `prefer_remote=1` — used only when `source` is absent; prefers upstream with local fallback. Response fields: `id`, `code`, `productCode`, `productId`, `label`, `hotelId`, `hotel` (hotel object), `dateFrom`, `dateTo`, `type`, `status`, `allowedBookingStatus[]`, `priceFrom`, `selectionGroups[]`, `additionalServices[]`, `transportationServices[]`, `pickups[]`, `dropOffs[]`, `rooms[]`, `guide`, `insurances[]`. #### `GET /api/travels/{dateId}/{hotelId}/{dateTo}/availability` — scope `api` Live hotel availability from the upstream system. `dateTo` must be `Y-m-d`. On upstream failure returns `{"error":"...","type":"api_error"}` **with status 200**. --- ### Contingents & accommodation prices #### `GET /api/contingents/prices?hotelCode=…&year=…` — scope `api` Price timeline for one accommodation and calendar year, collapsed into contiguous segments (adjacent days with the same effective price are merged into one row). The timeline never starts before today; if the requested year is entirely in the past the response is `[]`. - `hotelCode`: required, max 16 chars, `[A-Za-z0-9_-]+` (matches the accommodation's calendar code). - `year`: required int, 1000–9999. Each row: ```json { "dateFrom": "2026-06-01", "dateTo": "2026-06-14", "season": "high|low|...|null", "includedPax": 2, "pricePerNight": 89.5, "priceAdditionalPerson": 25.0, "defaultPricePerNight": 99.0, "defaultPriceAdditionalPerson": 30.0, "currency": "EUR", "type": "discount|null" } ``` Prices are **decimal major units** (converted from integer minor units server-side). `default*` fields are non-null only when the winning price is a discount, and then carry the undiscounted price. `400 {"error":"Hotel not found for hotelCode."}` for an unknown code; `400` with violations for invalid parameters. #### `GET /api/contingents/calendar?hotelCode=…&dateFrom=…&dateTo=…` — scope `api` Per-day availability enriched with prices. `dateFrom` and `dateTo` are **optional, and must be supplied together** — one without the other is a `400`. Both are `Y-m-d` and **inclusive** (`dateTo` is the last night, not the checkout day); when supplied, the range may not exceed 366 days and `dateTo` must not precede `dateFrom`. **Omit both to get the full priced span.** The range is then derived from the accommodation's persisted prices, `MIN(dateFrom)` to `MAX(dateTo)`, and never starts before today. This is the way to fetch everything a hotel sells in one call, and it is not subject to the 366-day cap — a hotel priced over two seasons returns well over a year of entries. Full-span responses are contiguous, exactly like ranged ones: every day between the derived bounds gets an entry, including days no price covers. Those come back `BLOCKED` with `null` price fields — a hotel closed over winter reports the whole closure day by day rather than skipping it, so a consumer keying off `status` always finds one. The response is `[]` — a `200`, not a `502` — when the hotel has no prices at all, or when every priced period has already ended. Availability is served from a **local snapshot** refreshed over a 24-month horizon — every 15 minutes during the day, hourly overnight — not fetched from the upstream contingent service per request. The endpoint therefore responds in single-digit milliseconds and stays available during an upstream outage, at the cost of being at most one sync interval stale. The response format is unchanged from the previous upstream-backed implementation: same fields, same order, same types, same `status` values. One behavioural note — the endpoint returns **one entry per day of the range**, whether that range came from `dateFrom`/`dateTo` or was derived from the prices. Previously days the upstream service did not mention were simply absent, so a response may contain days it would not have contained before. It is a superset, never a different shape. Two kinds of day resolve to `BLOCKED` regardless of the underlying contingent: - **days in the past** — the snapshot is maintained from today forward only (`prices` likewise refuses past ranges) - **days beyond the synced horizon** — currently 24 months out. Prices are often maintained further ahead than the contingent is synced, so a full-span response can end in a run of `BLOCKED` days that simply have no availability data yet. Each entry: ```json { "date": "2026-06-01", "status": "Free|Blocked|...", "type": "discount|null", "pricePerNight": 89.5, "defaultPricePerNight": 99.0, "priceAdditionalPerson": 25.0, "defaultPriceAdditionalPerson": 30.0, "currency": "EUR", "includedPax": 2, "minNights": 2 } ``` Important rule: **a day with no maintained price is forced to `Blocked`** regardless of what the upstream contingent says. Price fields may be `null` in that case. This applies to both modes. Errors: `400 {"error":"Hotel not found for hotelCode."}`, `400` with violations for invalid parameters. `502 {"error":"Failed to fetch contingent data."}` is still returned — with the same body as before — but it now means **"no usable local snapshot for this hotel"** rather than "the upstream call failed": either the hotel has never synced successfully, or its last successful sync is more than 6 hours old. Treat it as before: the data is unavailable, not empty. This matters because the alternative would be a `200` reporting every day as `BLOCKED`, which is indistinguishable from a genuinely fully-booked hotel. --- ### Operating the snapshot (deployment) The snapshot is a new table populated by the scheduler, so it is **empty immediately after a deploy that runs the migration**. Until the first successful sync every hotel returns `502` — deliberately, so no consumer caches a fully-blocked calendar as if it were real availability. Rollout order: 1. **Confirm `BPN_CONNECT_API_KEY` is valid for the target environment before deploying.** If it is rejected the sync cannot populate anything and the endpoint stays at `502`. 2. Deploy; `database:migrate` creates the empty tables. 3. Run `bin/console app:bpn:sync-contingents` on the host, promptly and ideally off-peak — a consumer that page-caches whatever it renders from a `502` may hold that for the length of its own cache period. 4. Verify: `contingent_sync_state.synced_at` populated and `failure_count` at 0 for every hotel, then spot-check one calendar response. Thereafter the scheduler keeps it current; a sync failing for one hotel puts only that hotel into `502` and leaves the rest serving normally. ### Freshness and propagation There is deliberately **no change-notification endpoint**. Availability changes slowly, and the measured cost of simply refetching on a TTL is low enough that a polling/revision mechanism was not worth the extra moving parts on either side. That leaves two levers, and consumers should know both: - **Their own cache TTL** bounds how stale a rendered calendar can be. A one-hour TTL is the current agreed budget. - **Direct cache invalidation** is the only fast path when something must propagate sooner — a hotel closing, or bad availability that got cached. Consumers should keep this targeted rather than flushing everything; the TYPO3 site, for example, tags its cached calendar and price entries per hotel, so a single hotel can be flushed and re-warmed on its own. On this side, staleness is bounded by the sync cadence and hard-capped by the 6-hour guard: past that, the endpoint returns `502` rather than presenting stale availability as current. --- ### Accommodation bookings (group bookings) #### `GET /api/accommodation-bookings/{uuid}` — scope `api` Single booking by UUID. `404 {"message":"Not found"}` if unknown. ```json { "uuid": "...", "status": "...", "type": "...", "dateFrom": "2026-06-01", "dateTo": "2026-06-08", "nights": 7, "paxCount": 40, "minorsCount": 12, "childrenCount": 3, "groupName": "...", "acceptedAt": "2026-05-01T10:00:00+00:00", "personalData": {}, "accommodation": {}, "boardService": {}, "additionalServices": [], "accommodationDiscount": null, "boardServiceDiscount": null, "additionalServicesDiscount": null, "totalDiscount": null, "totalDiscountLabel": null, "totalPrice": 123456, "pricingCurrency": "EUR", "pricingVersion": 3, "priceBreakdown": {} } ``` `totalPrice` is in integer **minor units** (cents) — unlike the contingent endpoints. The `*Discount` fields are whole **percentages** (1–100) or `null`, not amounts. `dateFrom`/`dateTo` are `Y-m-d`; `acceptedAt` is a full ISO-8601 datetime and is `null` until accepted. `priceBreakdown` is a computed nested structure. The first three discounts each apply to one section of the breakdown: `accommodationDiscount` to `basePrice + additionalPersonsPrice`, `boardServiceDiscount` to `boardPrice`, `additionalServicesDiscount` to `servicesPrice`. Surcharges and running costs sit in `total` but in no section, so they are never discounted. `totalDiscount` is a freely named discount on the whole price, labelled by `totalDiscountLabel`. **It compounds**: the three section discounts reduce `total` first, and `totalDiscount` then applies to that already-reduced subtotal. Recomputing it against the gross `total` yields a different figure. Each amount is rounded to the nearest cent at the step it is applied. `priceBreakdown` carries the resulting rows, all in minor units: | field | meaning | | --- | --- | | `discounts` | list of `{label, percent, amount}` for the section discounts; entries rounding to `0` are omitted, and `label` already includes its `Rabatt ` prefix | | `discountSubtotal` | `total` minus the section discounts — present only when `totalDiscount` applies *and* at least one section discount did, otherwise `null` | | `totalDiscountDetails` | `{label, percent, amount}` for the total discount, or `null` | | `discountedTotal` | the final price, equal to the booking's `totalPrice` | #### `POST /api/accommodation-bookings/{uuid}/accept` — scope `api` Marks the booking as accepted (server-side side effects: status change, `acceptedAt`, notifications). No request body. Returns the same booking representation as the GET. `404` if unknown. Not idempotent in terms of side effects — call once. --- ### Contact form #### `POST /api/contactform` — scope `api` Creates or matches an address record in the upstream CRM. Request body (all fields optional strings; the upstream system decides what is required): ```json { "lastName": "...", "firstName": "...", "gender": "...", "street": "...", "zipCode": "...", "city": "...", "email": "...", "phone": "..." } ``` - `201` — `{"success": true, "addressId": 123, "personId": 456, "isNewRecord": true}` - `409` — `{"success": false, "message": ""}` (upstream rejected/conflicted) - `400` — `{"success": false, "message": ""}` (upstream call failed) - `422` — malformed payload --- ### Newsletter #### `GET /api/newsletters` — scope `api` The configured newsletter lists as an array of `{id, name, ...}` mapping objects. Use the returned `id` values as `listIds` below. #### `POST /api/newsletter-subscriptions` — scope `api` Request: ```json { "email": "user@example.com", "firstName": "Ada", "lastName": "Lovelace", "listIds": [1, 2] } ``` - `email`: required, strict email validation. - `firstName` / `lastName`: optional, max 255 chars. - `listIds`: required, at least one positive integer; every id must be one of the configured lists. Responses: - `200` — subscription completed, `state: "subscribed"`. - `202` — a double-opt-in confirmation mail was requested, `state: "confirmation_requested"`. - Body in both cases: ```json { "success": true, "email": "user@example.com", "lists": [{"id": 1, "name": "...", "state": "success|pending|already_registered"}], "state": "subscribed|pending_confirmation|confirmation_requested", "confirmationRequested": true } ``` - `400` — unknown or disallowed list ids: `{"success": false, "message": "...", "listIds": [...], "unknownListIds": [...]}`, or validation violations. - `503` — `{"success": false, "email": "...", "listIds": [...], "message": "Newsletter subscription request could not be processed."}` when the newsletter provider is unavailable. Safe to retry. --- ### End-user endpoints (require a user-bound token) #### `GET /api/userinfo` — scope `email` (+ optional `id`, `profile`, `roles`) OIDC-style claims for the authenticated user. The response contains only the claims covered by the granted scopes: - always: `email` - scope `id`: `person_id`, `address_id` - scope `roles`: `roles` (array; only the roles that actually grant something are exported — the implicit baseline role is stripped, and so are the `*_PENDING` markers of roles the BusPro CRM claims but nobody has approved yet, see `docs/user-roles.md`) - scope `profile`: `profile` object: ```json { "first_name": "...", "last_name": "...", "title": "...", "salutation": "...", "gender": "...", "date_of_birth": "1990-01-01", "nationality": "...", "address": {"street": "...", "postcode": "...", "city": "...", "district": "...", "country": "..."}, "communication": {"email": "...", "mobile": "...", "phone": "...", "newsletter": true}, "hotel_codes": ["..."], "height": null, "weight": null, "shoe_size": null, "remarks": "..." } ``` Note: `profile.communication.email` comes from the CRM address record and is **not necessarily** the login email — a single address can hold several contacts. Use the top-level `email` claim for identity. `400 {"message": "...", "code": "..."}` when the upstream lookup fails or rejects the credentials. #### `GET /api/crm-attributes` — scope `profile` CRM selection attributes/segments for the authenticated user. `400 {"message": "...", "code": "..."}` on upstream failure. --- ## Client implementation notes 1. **Token lifetime is 10 minutes** — implement refresh/re-fetch with a safety margin and retry once on 401. 2. **Check for an `error` key even on 200 responses** for `/api/products` and the travel availability endpoint. 3. **Money units are inconsistent across endpoints**: contingent endpoints return decimal major units; accommodation-booking fields return integer cents. Normalize at the client boundary. 4. **Dates**: `Y-m-d` everywhere except `acceptedAt` (ISO-8601 datetime) and pickup planning (`Y-m-d H:i`). Contingent `dateFrom`/`dateTo` are inclusive night boundaries. 5. **Server-side caching** (1 h on products) means upstream changes may not be visible immediately. The contingent calendar is snapshot-backed instead: it is at most one sync interval behind upstream. There is no change-notification endpoint — clients are expected to cache on their own TTL and, when something must propagate sooner, invalidate their cache directly. 6. **Route ambiguity**: `/api/travels/{a}/{b}` resolves to the id-based route when both segments are numeric, otherwise to the code-based route. Non-numeric codes are safe; purely numeric *codes* will hit the id route.