Files
myep/docs/api-consumer-guide.md
T

19 KiB
Raw Blame History

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_<SCOPE_UPPERCASE> (e.g. scope apiROLE_OAUTH2_API).
  • Send the token as Authorization: Bearer <access_token>.

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:

[{"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:

[
  {
    "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, 10009999.

Each row:

{
  "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:

{
  "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.

{
  "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,
  "totalPrice": 123456,
  "pricingCurrency": "EUR",
  "pricingVersion": 3,
  "priceBreakdown": {}
}

totalPrice and the *Discount fields are integer minor units (cents) — unlike the contingent endpoints. dateFrom/dateTo are Y-m-d; acceptedAt is a full ISO-8601 datetime and is null until accepted. priceBreakdown is a computed nested structure.

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):

{
  "lastName": "...", "firstName": "...", "gender": "...",
  "street": "...", "zipCode": "...", "city": "...",
  "email": "...", "phone": "..."
}
  • 201{"success": true, "addressId": 123, "personId": 456, "isNewRecord": true}
  • 409{"success": false, "message": "<upstream notification>"} (upstream rejected/conflicted)
  • 400{"success": false, "message": "<error>"} (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:

{
  "email": "[email protected]",
  "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:
{
  "success": true,
  "email": "[email protected]",
  "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:
{
  "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.