37 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 is 10 minutes for every grant.
Do not hardcode it — read
expires_infrom the token response, it is authoritative and may be tuned. A token can therefore expire part-way through a long page walk ofGET /api/exports/*; re-request one on 401 and resume at the page you were on. At the defaultperPage=500either export is a single page today. - 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/exports/*, /api/newsletter-subscriptions) return 400 with Symfony's constraint-violation body. Missing/invalid token yields 401; insufficient scope yields 403.
GET /api/exports/* deliberately uses real status codes throughout and never the 200-with-an-error-key shape: a failure there is a 5xx, so retry logic can key on the status code alone.
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,
costUnit, countryCode.
countryis the raw upstream token, unnormalised, and is the join key intoGET /api/countries[].token. It mixes conventions: ISO-2 (CH,NL), European vehicle registration codes (D,A,F,S,FL) and ISO-3 (AUT,PRT,HRV), sometimes several for the same country.countryCode(string|null) is that token mapped to a lowercase ISO 3166-1 alpha-2 code — use this one to pick a flag. A token with no confident mapping yieldsnull, never a guess and never"", so treat null as "unknown" rather than as an error. New tokens appear upstream from time to time; they surface as null here until the mapping table is extended.costUnit(string|null) is the raw upstream cost centre. It is a string, not a number: most hotels have none, and some carry a space-separated list such as"84 85". Store it as text and do not parse it.
GET /api/hotels/{id} — scope api
Single hotel by numeric id, same field set as the list, including costUnit and countryCode.
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"} in Europe/Berlin local time.
If the export marker is missing or unreadable (for instance mid-sync), the answer is {"timestamp": null} with
status 200 — treat null as "unknown", not as "changed".
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.
Bulk exports
GET /api/exports/travels — scope api
Bulk export of every travel date × hotel pair with its boarding points resolved, for clients that mirror the
disposition locally. One request replaces a walk over GET /api/travels plus a per-pair detail call.
The two /api/exports/* endpoints are the only paginated ones and the only ones with a meta envelope.
It sits under /api/exports, not /api/travels, on purpose: /api/travels/{dateCode} accepts an
arbitrary string, so an export hung off that collection would share a namespace with every date code.
/api/exports never takes a parameterised segment, so there is nothing for it to collide with.
Query parameters:
| Parameter | Type | Default | Notes |
|---|---|---|---|
page |
int ≥ 1 | 1 |
Out of range is not clamped — see below |
perPage |
int 1–1000 | 500 |
|
changedSince |
— | — | Reserved, not implemented. Sending it today is ignored; it is named here only so it is not claimed for anything else. |
A page/perPage outside those bounds returns 400 with Symfony's constraint-violation body (send
Accept: application/json to get it as JSON rather than an HTML error page). A page past the last one is not
an error: it returns 200 with an empty data array and the usual meta.
{
"data": [
{
"busProId": 12794,
"code": "PCJSER261226",
"product": "Semesterende - Portes du Soleil - Joliment",
"dateFrom": "2026-12-26",
"dateTo": "2026-12-30",
"hotel": {
"busProId": 225444,
"code": "PCJSER1",
"name": "NEU SER Jolimont 1",
"costUnit": "863",
"country": "CH",
"countryCode": "ch"
},
"pickups": [
{
"busProId": 18,
"code": "ESS-Route",
"city": "Essen",
"street": "Am Hauptbahnhof (Südausgang Fernbushaltestelle)",
"time": "20:15",
"datetime": "2026-12-25T20:15:00"
}
]
}
],
"meta": {
"page": 1,
"perPage": 500,
"total": 415,
"totalPages": 1,
"skipped": 0,
"pickupsWithoutMasterData": 0,
"sourceGeneratedAt": "2026-09-29T11:11:01+02:00",
"generatedAt": "2026-09-29T12:10:06+02:00"
}
}
Row identity. (busProId, hotel.busProId) is unique across the whole export — upsert on that pair. busProId
is always an int and never null; hotel is always an object and never null; pickups is always an array, possibly
empty, never null.
meta describes the whole export, not the page. total, totalPages, skipped, pickupsWithoutMasterData,
sourceGeneratedAt and generatedAt are identical on every page, so you can read them once on page 1 and decide whether to walk the
rest. total is the row count after skips, and totalPages is ceil(total / perPage).
skipped— (travel, hotel) pairs dropped as unusable: a travel date that could not be parsed, or a hotel absent from the hotel master data. Expect0. A non-zero value means upstream data is inconsistent, not that you have lost rows you previously had.pickupsWithoutMasterData— count of distinct pickup ids referenced by a travel but absent fromGET /api/pickups. Those entries are still emitted, withcode,cityandstreetnull: a departure point with no name is more useful to a disposition view than one that silently vanished.
Pickup times. pickups is in source order — the order the boarding list is driven in — not sorted by time.
timeis"HH:MM"for display;datetimeis the same reading with its date resolved, asYYYY-MM-DDTHH:MM:SSwithout an offset. Preferdatetime.- Both are upstream's local clock readings, not instants: coaches are timetabled in plain clock times. A stop at
02:45on the night the clocks go forward comes back as…T02:45:00, although that hour does not exist inEurope/Berlin, and a stop in the hour repeated when the clocks go back passes through unchanged. Do not attach an offset to compare or sort them; they already sort correctly as strings. - Upstream states a boarding time either as a full date and time, taken verbatim, or as a bare time. A bare time
takes the departure date (
dateFrom), and moves to the following day if it would otherwise fall before the latest full date and time anywhere in the same boarding list, whatever its position. So on a departure dated 26.12. whose list also dates a stop26.12. 23:59, a bare02:15comes back as…12-27T02:15:00.datetimecarries that resolution for bare-time sources too. - A boarding point with no time upstream comes back with both
timeanddatetimenull. They are always null together. - Caveat: where upstream states full datetimes for a whole boarding list, those dates are taken verbatim. Some lists date every stop to the evening before departure, including stops after midnight, so an entry can appear a day earlier than the running order implies. This is upstream data, not a transformation applied here.
Consistency. The export is built from the same cached master data as GET /api/hotels and GET /api/pickups,
under one cache tag, so all three move together on an upstream sync rather than drifting between your calls.
It is cached for 3 hours; generatedAt is when the cached copy was assembled, not when you requested it.
Page consistency. sourceGeneratedAt is the timestamp of the upstream export the rows were built from — the
same value GET /api/last-update reports, as ISO-8601 — or null if it could not be read. An upstream sync can
replace the cached copy while you are walking the pages; if sourceGeneratedAt differs from the value you saw on
page 1, you have stitched two exports together. Discard what you have and restart from page 1.
GET /api/exports/products — scope api
Bulk export of every travel date × hotel pair with its whole record, for clients that import the product
catalogue: rooms with prices and availability, the pseudo price, all selection groups, the three service lists,
boarding points with prices, and the guide. Same grain, paging, query parameters, meta envelope, status codes and
page-consistency rule as GET /api/exports/travels above; only the rows differ.
Unfiltered on purpose. Nothing is dropped or rewritten for you. Return-leg (RUECK) services, EIN services,
Buchungsstop rooms and services, rooms without a price and boarding points with id 0 are all emitted. Which of
them a website shows, and how, is the client's rule, not this endpoint's.
At current volumes the export is ~420 rows and ~3.8 MB of JSON (a row is 4–24 kB), so the default perPage=500
returns it in one page.
{
"data": [
{
"product": { "code": "DKIWE", "busProId": 1127 },
"date": {
"code": "DKIWE041226",
"busProId": 12437,
"from": "2026-12-04",
"to": "2026-12-06",
"type": "F"
},
"hotel": { "code": "DKIWEKT", "busProId": 222931 },
"pseudoPrice": null,
"selectionGroups": [
{
"busProId": 7,
"label": "Leistungen Skipass",
"selections": [ { "busProId": 92, "label": "3 Tage Skipass" } ]
}
],
"transportServices": [
{ "busProId": 193804, "subType": "PKW", "label": "Eigene Anreise mit Rabatt", "price": -50, "status": "Frei", "direction": "HIN" }
],
"dateServices": [
{ "busProId": 193805, "subType": "SON", "label": "Keycard-Pfand (Pflichtabgabe)", "price": 5.4, "status": "Frei", "direction": null }
],
"hotelServices": [
{ "busProId": 193805, "subType": "SON", "label": "Keycard-Pfand (Pflichtabgabe)", "price": 5.4, "status": "Frei", "direction": null }
],
"rooms": [
{
"busProId": 74,
"code": "MBZET",
"label": "Bett im Mehrbettzimmer Etagendusche/-WC",
"category": "GR",
"minPax": 1,
"maxPax": 1,
"nights": 2,
"price": 229,
"available": 24,
"status": "Frei",
"boardCode": "Lt. Auss.",
"boardBusProId": 2
}
],
"pickups": [
{
"busProId": 4,
"code": "FRA-Route",
"city": "Frankfurt",
"street": "Flughafen (P36 zwischen Terminal 1 und 2)",
"price": 5.9,
"returnLeg": true,
"returnPrice": 5.9,
"datetime": "2026-12-03T23:30:00"
}
],
"guide": null
}
],
"meta": {
"page": 1,
"perPage": 500,
"total": 419,
"totalPages": 1,
"skipped": 0,
"pickupsWithoutMasterData": 0,
"sourceGeneratedAt": "2026-09-30T11:11:01+02:00",
"generatedAt": "2026-09-30T14:05:00+02:00"
}
}
Row identity. (date.busProId, hotel.busProId) is unique across the whole export — upsert on that pair.
product.code and hotel.code are verbatim and suitable for matching against your own records. No row depends on
hotel master data, so skipped counts only travel dates whose dates could not be parsed.
Types.
- Every price is a JSON number in euros, never a formatted string:
229,5.4,-50. A price the source does not state isnull, and0means a stated zero ("included"). Do not treat the two alike. selectionGroups,selections,transportServices,dateServices,hotelServices,roomsandpickupsare always arrays, possibly empty, never null.pseudoPriceandguideare nullable.- All arrays are in source order. Sort for display yourself; do not depend on the order.
Rooms. One entry per room offered on the date at that hotel.
codeis trimmed.availableis a count of rooms, not persons — person capacity isavailable × maxPax.statusis passed through verbatim:Frei,AnfrageorBuchungsstop.available,price,minPax,maxPax,nightsandboardBusProIdarenullwhere the source leaves them empty.
pseudoPrice. A struck-through comparison price for the date, carried upstream by a pseudo room with code
PDGS. It is lifted onto the row and never appears in rooms. It is null in most rows.
Services. Three lists, deliberately kept apart:
transportServices— the date's transport services (bus, own arrival, train …). Both directions.dateServices— the date's general services.hotelServices— the services listed on this hotel for this date. Upstream repeats most date-level services here, possibly with hotel-specific prices, so expect overlap withdateServices. If you merge the two, key bybusProId: it identifies a service across both lists.
subType is the upstream service subtype (BUS, PKW, BAH, SON, SPA, VER, EIN, …), passed through.
direction is HIN or RUECK on transport services and null on general ones. A negative price is a discount,
such as for arriving by car.
Selection groups. Every group on the date, not only the Leistungen * ones. Match groups by busProId, not by
label: labels are editorial and can be renamed upstream. The ids of the included-services groups are stable:
busProId |
Label |
|---|---|
| 7 | Leistungen Skipass |
| 8 | Leistungen Sonstige |
| 71 | Leistungen Verpflegung |
| 85 | Leistungen Übernachtung |
| 86 | Leistungen Programm |
| 87 | Leistungen Kurse |
| 89 | Leistungen Events |
| 90 | Leistungen Transport |
Pickups. Outbound boarding points only, in source order. datetime is resolved and formatted exactly as in
GET /api/exports/travels — a clock reading without an offset, null where upstream states no time — including
rolling a bare time past midnight onto the next day. There is no separate time field; format datetime for
display. price is the outbound boarding surcharge exactly as upstream states it: null where upstream states
none. The surcharge is charged per leg: returnLeg says whether the point is also served on the way back, and
returnPrice is its return surcharge, again verbatim and null where upstream states none. Neither price is levelled
against the other. A point served on the return leg only is not emitted. An id absent from
GET /api/pickups is emitted with code, city and street null and counted in pickupsWithoutMasterData. An id
of 0 is emitted as-is but not counted.
guide. { "name": …, "phone": … } for the first outbound bus service that has a guide assigned, else null.
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.
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 (
priceslikewise 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
BLOCKEDdays 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:
- Confirm
BPN_CONNECT_API_KEYis valid for the target environment before deploying. If it is rejected the sync cannot populate anything and the endpoint stays at502. - Deploy;
database:migratecreates the empty tables. - Run
bin/console app:bpn:sync-contingentson the host, promptly and ideally off-peak — a consumer that page-caches whatever it renders from a502may hold that for the length of its own cache period. - Verify:
contingent_sync_state.synced_atpopulated andfailure_countat 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,
"totalDiscountAmount": null,
"totalDiscountLabel": null,
"totalPrice": 123456,
"pricingCurrency": "EUR",
"pricingVersion": 2,
"priceBreakdown": {}
}
totalPrice and totalDiscountAmount are integer minor units (cents) — unlike the contingent endpoints. The three *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, running costs and the lines of split-off periods sit in total but in no section, so the section discounts never reach them; only totalDiscountAmount does.
dateFrom/dateTo/nights span the whole stay. The office can split nights off its start or end, for example for an advance team arriving before the group. Such a split-off period has no headcount, board or services of its own; it is priced as free-form lines instead. paxCount, minorsCount, childrenCount, boardService and additionalServices describe the group, which stays for what remains between the split-off periods. The split-off periods are listed in priceBreakdown.periods, below. A booking nobody split has an empty list there, and its group stays for the whole stay.
totalDiscountAmount is a freely named discount on the whole price, labelled by totalDiscountLabel. It is a fixed sum, not a percentage, and is subtracted after the three section discounts have reduced total. It is capped at what is left of that subtotal, so discountedTotal is never negative and the amount shown in the breakdown may be smaller than totalDiscountAmount itself. The section discounts are each rounded to the nearest cent as they are applied.
priceBreakdown carries the resulting rows, all in minor units:
| field | meaning |
|---|---|
nights |
the group's nights, which are fewer than the booking's nights when periods were split off |
periods |
list of {position, label, dateFrom, dateTo, nights, lines, total}, at most one per side, in date order; position is leading (before the group) or trailing (after it), label is the office's name for the period or null, lines is a list of {label, amount}, and total is their sum |
periodsTotal |
the sum of all periods[].total |
total |
the price before discounts, the group's rows plus periodsTotal |
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 totalDiscountAmount applies and at least one section discount did, otherwise null |
totalDiscountDetails |
{label, amount} for the total discount, or null; amount is the capped figure actually subtracted |
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):
{
"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:
sub(UUID string, see below),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*_PENDINGmarkers of roles the BusPro CRM claims but nobody has approved yet, seedocs/buspronet-schema/crm-selection-queries.md#from-claim-to-role) - 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": "..."
}
Identifying the account: match on sub
A BusPro address record holds several email addresses (private and business, say), and BusPro
accepts any of them as a login. Each one is a separate MyE&P account with its own roles —
that is intended, not a duplicate: administrative roles are reserved for the company address, so
the same person signing in privately is a Teamer:in or Kund:in and signing in with their
@ep-reisen.de address is an administrator.
All of those accounts report the same person_id and address_id, and the same
profile.communication.email — that value is the first contact address on the BusPro record and
is not necessarily the one signed in with. So:
sub— a UUID (v4, RFC 4122, 36 characters), stable for the life of the account and unique across accounts. The only claim that identifies an account. Match your local user on it and store it. Size the column at 36 characters and treat the value as opaque: it is random, it carries no timestamp and no ordering, and nothing about it should be parsed or derived from.email— the address this session actually authenticated with. Distinct per account, but treat it as a display and contact value; it is not the account key.person_id/address_id— the BusPro person behind the account. Shared between that person's accounts, so they identify a human, never an account. Do not match on them.
Do not derive staff status from an email domain. MyE&P decides who is a member of staff and
exports the result as ROLE_EMPLOYEE in the roles claim; re-deriving it from email or from
profile.communication.email will disagree with MyE&P in both directions.
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 comes from
expires_in(currently 1 hour for client_credentials, 10 minutes for authorization_code) — cache the token for that long, refresh 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) 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.
- 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.