14 KiB
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. scopeapi→ROLE_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/productsand/api/travels/{dateId}/{hotelId}/{dateTo}/availabilitythis shape is returned with HTTP 200, so clients must check for the presence of anerrorkey, 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 whensourceis 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:
{
"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. Both dates are Y-m-d and inclusive (dateTo is the last night, not the checkout day); the range may not exceed 366 days and dateTo must not precede dateFrom. Upstream contingent data is cached 1 hour per (hotelCode, dateFrom, dateTo).
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.
Errors: 400 {"error":"Hotel not found for hotelCode."}, 502 {"error":"Failed to fetch contingent data."} when the upstream contingent service fails, 400 with violations for invalid parameters.
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; the implicit baseline role is stripped, only explicitly assigned roles are exported) - scope
profile:profileobject:
{
"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
- Token lifetime is 10 minutes — implement refresh/re-fetch with a safety margin and retry once on 401.
- Check for an
errorkey even on 200 responses for/api/productsand the travel availability endpoint. - Money units are inconsistent across endpoints: contingent endpoints return decimal major units; accommodation-booking fields return integer cents. Normalize at the client boundary.
- Dates:
Y-m-deverywhere exceptacceptedAt(ISO-8601 datetime) and pickup planning (Y-m-d H:i). ContingentdateFrom/dateToare inclusive night boundaries. - Server-side caching (1 h on products and the contingent calendar) means upstream changes may not be visible immediately.
- 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.