# BusProNet XML API — working reference Condensed from the vendor PDF `docs/bpn_docs.pdf` (41 pages, German), keeping what this project uses or plausibly could. The PDF stays authoritative; a few of its sentences are mangled by a broken font encoding, so anything unclear there is flagged rather than guessed at. > **The PDF lags the live API.** The vendor has shipped elements since it was written — > `` (return boarding points) is one confirmed example. So *absent from the PDF* means > "not in this snapshot", never "unsupported". Treat the document as a floor on what the API > accepts, not a ceiling, and prefer a live `buchungsart=Anfrage` probe over an argument from > silence. Read this alongside `buspro-database-reference.md` (what BusPro actually stores) — the two answer different questions, and where they disagree the schema is the record of what happened. **Use it for:** what an element is called and what it may contain; which codes a failure can return; which documented capabilities we are not using; and which elements we send that the vendor never documented — those are the ones that can break without warning. ## Envelope Every request is `` with ``, `` and ``. ``` key = md5( user + password + date("YYYYMMDD") + satz-typ ) ``` Per satz type and per day. `ApiClient::createKey()` implements it, and `sendRawXml()` regenerates the key before sending, so a hand-built payload can carry a placeholder. ## Satz types **Documented:** `ADRESSFELDER`, `ADRESSFELDERGUTSCHEIN`, `ADRESSFELDERKATALOGANFRAGE`, `AGENTUREN`, `AGENTURKONTO`, `AKTIONSGUTSCHEIN`, `BUCHUNG`, `BUCHUNGAENDERUNG`, `CRM`, `CRMKATALOGANFRAGE`, `GUTSCHEINBUCHUNG`, `GUTSCHEINDRUCK`, `GUTSCHEINPRUEFUNGEINLOESUNG`, `GUTSCHEINVORLAGEN`, `GUTSCHEINVORSCHAU`, `GUTSCHEINZAHLUNGSARTEN`, `KATALOGANFRAGE`, `KUNDENKONTO`, `KUNDENLISTE`, `MOEGLICHEAENDERUNGEN`, `PRODUKTDATEN`, `PRODUKTE`, `SITZPLATZ`, `STAMMHOTELS`, `STAMMLAENDER`, `STAMMVERSICHERUNGEN`, `STAMMZUSTIEGE`, `VERFUEGBARKEIT`, `VERFUEGBARKEITHOTEL`, `ZAHLUNG`, `ZAHLUNG2`, `ZAHLUNGSARTEN`, `ZAHLUNGSARTEN2`. `SITZPLATZAUSWAHL` and `ZAHLUNG4` are named in prose without their own section. **Used here but undocumented:** `VERFUEGBARKEIT2` (`ApiClient::TYPE_AVAILABILITY_EXTENDED`) — the extended availability call returning `buchungstatusmoeglich` and per-service `pflicht`/`frei`. Its contract is observed, not specified. **Documented but unused here:** `SITZPLATZ`/`SITZPLATZAUSWAHL`, `KUNDENLISTE`, `ZAHLUNG2`, `ADRESSFELDER`, `STAMMHOTELS`, `STAMMVERSICHERUNGEN`, `STAMMZUSTIEGE`, `KATALOGANFRAGE`. ## BUCHUNG — the create contract `` decides what the call does: | Value | Effect | |---|---| | `Anfrage` | Enquiry. Validates and **returns the full price breakdown**. Nothing is stored. | | `Buchung` | Stores the booking. | `` at the root: `F` Festbuchung · `O` Optionsbuchung · `A` Anfragebuchung. Gated by error `962` ("Buchungsstatus #1# kann nicht verarbeitet werden, nur Status #2# möglich"), and by `982`/`983` when the travel forbids options or enquiries outright. ### Two accommodation models The base payload books accommodation as an ordinary service: ```xml ``` For the **Ferienziel** domain — what this app books — `unterbringungen` is *replaced* by `ferienzielunterbringungen`, **and** a root `` added: ```xml 78687 ``` > `` is introduced as the element you add when switching to the Ferienziel model, and > `914 Partner-ID fehlt` guards it. Note it is **not** confined to bookings that have a room: a > teamer booking with zero `BuchungTNFerienziel` rows still returns `222925` > from `Vorgang_Details`. Where that comes from is **unresolved** — `Buchung` has no partner FK, so > either it is stored on `Buchung.IDAdresse_FS_ExtraFerienzielPartner`, or it is derived from the > travel. That booking's product has exactly one hotel, so the response cannot distinguish the two; > one read-only query settles it. Accommodation is **mandatory** — `913 Unterbringungen fehlen` / `916 Ferienziel-Unterbringungen fehlen`. Confirmed empirically on the test system 2026-09-02, not just documented: see "Validation gate order" below. Room dates are cross-checked against the transport legs (`921`, `923`). ### Full documented request shape ```xml Anfrage|Buchung F|O|A name, vorname, anrede, titel, geschlecht, nationalitaet, geburtsdatum, strasse, plz, ort, ortsteil, land telefonmobil, email, telefonprivat …same personal fields… ``` `zuordnung` is a comma-joined list of 1-based `teilnehmer/@id` values throughout. ### Responses `buchungsart=Anfrage` → `möglich` plus ``: one `` per line with `position`, `art`, `unterart`, `bezeichnung`, `terminvon`/`terminbis`, `anzahl`, `zuordnung`, `preis`, `gesamtpreis`, `id`; then `` and `` with `` and `` (each `betrag` + `termin`). `art` values seen: `BEF` transport, `UNT` accommodation, `SON` other, `ERM` discount, `VER` insurance. `buchungsart=Buchung` → `erfolgt`, ``, ``, ``, ``, ``. ### Validation gate order (measured) Established with `buchungsart=Anfrage` against the test system on 2026-09-02, by mirroring a real room-less teamer booking (Vorgang 110628) element for element: 1. **Accommodation.** No `ferienzielunterbringungen` → `916`, regardless of everything else being valid. Nothing further is evaluated. 2. **Personal data**, once a room is present → `650` with the field list *"Fehlende Anmelder/Teilnehmer Daten … Nachname, Vorname, Strasse, Plz, Ort, Telefon-Mobil, Mail-Adresse, Geburtsdatum, Nationalität"*, for the Anmelder **and every participant**. So **sending `idadresse`/`idadresseperson` alone is not enough on create** — BusPro validates the personal fields whether or not it could resolve them from the ids. Echoing the record back (`idadresse` + `idadresseperson` + full data) narrows the complaint to whatever that record is genuinely missing, which is a useful way to audit an address: the placeholder account 19465:41003 fails on `Telefon-Mobil` and `Nationalität` because its `Vorgang_Details` record has only `telefonprivat` and no `nationalitaet`. Note `650` again: both of those are hard rejections delivered under the code `Notification::isError()` treats as success. ## BUCHUNGAENDERUNG — the edit contract Structurally identical to `BUCHUNG` plus ``, and the responses follow `buchungsart` the same way. Ask `MOEGLICHEAENDERUNGEN` first (``), which answers: ```xml <änderungen> <änderung art="anzahl_teilnehmer" möglich="False"/> <änderung art="beförderung" möglich="False"/> <änderung art="zustieg" möglich="False"/> <änderung art="sitztplatz" möglich="False"/> <änderung art="unterbringung" möglich="False"/> <änderung art="zusatzleistung" möglich="True" möglichbiszum="23.02.2020"/> <änderung art="teilnehmerdaten" möglich="False"/> ``` ## How this app maps onto the contract `BookingPayloadBuilder::buildCreatePayload()` builds create; `buildBasePayload()` + the `addTo…Payload()` helpers build edit; `ApiClient::createBooking()`/`updateBooking()` wrap them. | Documented element | Sent on create | Sent on edit | Notes | |---|---|---|---| | `buchungsart` | yes | yes | Edit **hardcodes `Buchung`** (`ApiClient.php:233`) | | `status` | yes | yes | Edit replays the value read back from BusPro — see the caveat below | | `idreise`, `idpartner`, `idagentur` | yes | yes | `idpartner` sent unconditionally, even with no room | | `anmelder` | yes | yes | Create builds it from participant[0]; edit uses `PersonalData::toPayload()` | | `anmelder/bemerkung` | — | yes | Via `PersonalData::toPayload()` | | `teilnehmerliste` | yes | yes | | | `wünsche` (`unterbringungswunsch`, `beförderungswunsch`) | yes | yes | | | `aktionscode` | yes | yes | Promo code, paired with `AKTIONSGUTSCHEIN` | | `beförderungen`, `zusatzleistungen`, `zustiege` | yes | yes | Built by `ServiceMappingCollector` | | `ferienzielunterbringungen` | yes | yes | Create omits the node entirely when no room maps; edit always emits the empty skeleton | | `versicherungen` | yes | yes | | | `gutscheine` | yes | yes | Paired with `GUTSCHEINPRUEFUNGEINLOESUNG` | | `zahlung` + `bankverbindung` | yes | yes | | | `idcrm` | no | no | | | `fremdvorgang` | no | no | Would give our own bookings a stable external reference | | `datenschutztfmp` | no | no | Advertising consent — we track newsletter consent locally instead | | `sitzpläne` | no | no | See Seats | | `zuschlaege` | no | no | Free-text manual surcharge or discount with its own amount | ### Newer than the PDF These are absent from the document but work against the live API — vendor additions that post-date it. Listed so nobody mistakes the PDF's silence for a contract violation, and so the gaps in this reference are known. | Element | Where | Note | |---|---|---| | `` | create and edit | Return boarding points. Confirmed as a recent vendor addition; the PDF documents only ``. | | `` inside `` | create (hardcoded `F`) and edit | Not in the PDF in either direction, though `Vorgang_Details` returns `status_teilnehmer`. | | `J` on `KUNDENKONTO` | probe command | Passwordless read escape hatch; see `bpn-login-j-passwordless-reads`. | | `VERFUEGBARKEIT2` | availability path | Whole satz type post-dates the PDF. Returns `buchungstatusmoeglich` and per-service `pflicht`. | When one of these needs pinning down, the authoritative move is a live `Anfrage` call, not a reading of this file. ### Caveat: the edit flow replays a derived status `buildBasePayload()` sends `'status' => $bookingData->status`, and `Booking::$status` is parsed from the response's root `` (`BookingParser.php:38`). But `Buchung` has **no status column** — the root value is an aggregate over `BuchungTN.Status`, the only real store (`F` beside `F/F/…/F` in `tests/Resources/booking_data.xml`). So on a booking whose participants do *not* all share one status, the edit flow sends an aggregate back as a directive. Worth confirming against a mixed booking before relying on it. ### Gap: edit never dry-runs Create is two-phase — `Anfrage` to validate and price, then `Buchung`. Edit sends `buchungsart=Buchung` directly (`ApiClient.php:233`), even though `BUCHUNGAENDERUNG` supports `Anfrage` identically. An enquiry pass would surface `913`/`924`/`962` and return the recalculated price *before* mutating a live booking. ## Seats `SITZPLATZ` asks BusPro to pick seats: ```xml V|M|H L|R F|G ``` Response: one `` per direction with `sitzplaetze="0D;1D;1C"`, `idsitzplan`, `richtung="H|R"`, `bezeichnung`, `idleistung`, and `grafik` (base64 PNG of the seat map). Where seats carry surcharges the response adds `` (`id`, `bezeichnung`, `preis`, colours) and the request must then carry `idzuschlag`. `SITZPLATZAUSWAHL` returns the raw plan structure instead of a picture. Chosen seats go back into a booking via ``, landing in `BuchungTNLeistungZusatz.Sitzplatz` / `IDSitzplan_FS`. Guarded by `933 Sitzplan mit ID #1# bei Leistung #2# nicht freigegeben` and `304 Es konnte nicht für alle Teilnehmer ein Sitzplatz ermittelt werden`. ## KUNDENKONTO ``: `Adressdaten`, `Adressdaten_Ändern`, `Adresse_Neu`, `SelektionCRM`, `SelektionCRM_Ändern`, `Vorgänge`, `Vorgangdruck`, `Voucherdruck`, `Dokumentdruck`, `Passwort_Anfrage`, `Newsletter`, `Adressdokument`, `Vorgang_Details`, `Prüfung`, `PrüfungEmail`, `Ausweisdaten_Ändern`, `Stornoanfrage`, `Storno`, `Versicherungspolice`. `Stornoanfrage` answers `stornierung_moeglich` + `stornopreis`; `Storno` answers `storniert` — the documented way to cancel, including cleaning up a test booking. `Ausweisdaten_Ändern` takes a `` list with `` data and answers `änderung`. `Versicherungspolice` returns per-policy document URLs. ## AGENTURKONTO ``: `Vorgänge`, `Vorgangdruck`, `Rechnungdruck`, `Hotelgutscheine`, `Hotelgutscheindruck`, `OptionInFest` — the last converts a held option into a firm booking, answering `true`. `Vorgangdruck` takes `idbuchung`, `Rechnungdruck` takes `idrechnung`, `Hotelgutscheindruck` takes `idgutschein`; print arts return base64 PDFs. ## Availability and payment `VERFUEGBARKEIT` takes `` plus a `` filter and answers `` plus per-service `status` ("buchbar"), `frei`, `preis`, `preisfaellig`. `VERFUEGBARKEIT2`, which we actually use, additionally reports `buchungstatusmoeglich` and `pflicht` but is undocumented. `ZAHLUNGSARTEN2` lists payment methods; `ZAHLUNG2` records a payment against a booking (`vorgang`, `idbuchung`, `datum`, `betrag`, `idzahlungsart`, `zahlungstext` — all mandatory). `ZAHLUNG` reports a credit-card authorisation after booking; the amount must equal the confirmed `anzahlung` or errors `1011`-`1018` follow. ## Error codes Returned as ``. `#1#`/`#2#` are substituted at runtime. > **`650` is not a success code.** Its documented text is a bare `#1#` — a generic message > carrier whose entire content is substituted. `Notification::isError()` > (`src/BusProNet/Model/Notification.php:33-36`) treats it as success, which happens to hold on > the `KUNDENKONTO` paths but not on `BUCHUNG`/`BUCHUNGAENDERUNG`, where the same code delivers > real rejections such as *"Es wurde nicht jedem Teilnehmer eine Pflichtleistung zugeordnet"*. > `BookingEditSubmitter.php:81` therefore reports some genuine failures as INFO. **Envelope / auth** — 100 Anfrageknoten fehlt · 101 Satzknoten fehlt · 102 Ungültiger Satztyp · 103/104 User fehlt/fehlerhaft · 105/106 Key fehlt/fehlerhaft · 999 Systemfehler **Lookup** — 200 Keine Einträge · 201/202 Partner · 203 Ungültiger Termin · 300 IDReise fehlt · 301 Reise nicht gefunden · 302 IDLeistung (Hin & Rück) fehlt · 303 IDLeistung passt nicht zu IDReise · 304 Kein Sitzplatz für alle Teilnehmer ermittelbar · 305 Anzahl Personen fehlt · 306/307 Leistungen · 308 Keine Zahlungsarten · 400 IDPartner fehlt · 401 Partner nicht gefunden · 402 Bis-Termin fehlt · 403 Keine Unterbringungsleistungen gefunden **Customer / address** — 500 Art fehlt · 502 Keine Kunden · 840-842 Personen · 850 IDAdresse und EMail fehlen · 851 Passwort fehlt · 852 Art fehlt · 853 ID, EMail oder Passwort falsch · 855 Keine Daten für Adresse · 860 Adressdaten fehlen · 862-867 Selektionsmerkmale / CRM-Aktionen · 870-875 Newsletter · 880-882 Dokumente · 885-891 Pflichtfelder (Name, Vorname, Geschlecht, EMail, PLZ, Straße) · 889 Kunde bereits angelegt (ID #1#) **Booking (create and edit)** — 900 Buchungsart fehlt · 901 Buchungsart falsch · 902 Status fehlt · 903 Status falsch · 904/905 Agentur · 906 Reise-ID fehlt · 907 Reise nicht gefunden · 908 Reise ist storniert · **909 Beförderungen fehlen** · 910 Leistung gehört nicht zur Reise · 911 Leistung gehört nicht zum Produkt · 912 Beförderungsleistung fehlt · **913 Unterbringungen fehlen** · **914 Partner-ID fehlt** · 915 Partner nicht gefunden · **916 Ferienziel-Unterbringungen fehlen** · 917 Zimmer-IDZ fehlt · 918 Kategorie fehlt · 919 Verpflegungs-ID fehlt · 920 Anreise fehlt · **921 Anreise passt nicht zur Beförderungsleistung (Hinfahrt)** · 922 Abreise fehlt · **923 Abreise passt nicht zur Beförderungsleistung (Rückfahrt)** · **924 Ferienziel: Keine Preise zu den Daten gefunden** · 925 Preis zu den Daten nicht gefunden · 930 Zustiege fehlen · 931 Zustieg nicht gefunden · 932 Zustieg bei Leistung nicht freigegeben · 933 Sitzplan bei Leistung nicht freigegeben · 935-938 Versicherungen · 940-942 Zahlungsart · 950 Anmelder fehlt · 951 Teilnehmerliste fehlt · 952 Teilnehmer-ID fehlt · 953 Keine Teilnehmer übergeben · 960 Preisfehler · 961 Buchung konnte nicht gespeichert werden · **962 Buchungsstatus #1# kann nicht verarbeitet werden, nur Status #2# möglich** · 970 CRM-Merkmal nicht gültig · 980 Reise ist fürs Internet gesperrt · 981 Reise ist nicht mehr buchbar (#1#) · **982 Optionsbuchungen nicht zugelassen** · 983 Anfragebuchung nicht zugelassen · 984 Leistung nicht fürs Internet buchbar · 985 Zustieg nicht fürs Internet buchbar · 986/987 Rechnung · 990-994 Agenturkonto · 995/996 Gutschein · 997/998 Buchung-ID · 1001 Reisedatum fehlerhaft · 1002 Buchungsdatum fehlerhaft · 1003 Status im Vorgang nicht korrekt · 1011-1018 Zahlungsabgleich **Vouchers** — 600-608 Gutscheine · 610/611 Buchungsart · 612-621 Gutscheinart / Stamm / Kapazität · 622 Einlösecode fehlt · 630-632 Aktionsgutschein ## Applying this: the teamer placeholder booking **Answered 2026-09-02: `BUCHUNG` cannot create it.** A payload mirroring the real teamer booking (travel 12429, hotel 222925, agency 77876, both bus legs, ski pass, Ortstaxe, Keycard-Pfand, pickup and drop-off, root and participant status `O`, ids-only identity, no room) returns `916 Ferienziel-Unterbringungen fehlen`. Adding a room to the identical payload clears that gate and moves on to `650`, which proves accommodation was the only structural objection. Two blockers that were *not* the problem: - **Status `O` is fine here.** Travel 12429 reports `buchungstatusmoeglich="F, O, A"`. The `982` refusal seen earlier is per travel, not a global setting. - **`NurFahrt` never came up** — the request never got far enough for it to matter. What the real booking shows about how the office builds one, worth reproducing if the work moves to a direct write: - `` **is** present despite there being no accommodation — 222925 is the ordinary hotel of the travel (`Adresse.IDAdresse`, "NEU Sportclub Weissfluh Midweeks und Wochenreisen", the sole hotel of product 1429, with real bookable rooms), not a special no-accommodation partner. Whether the booking stores it or the response derives it is still open. - Each teamer seat is its **own `AdressePerson`** (407725, 407726) under the shared address 41003, with an empty ``. - `` carries the role label — "Reiseleitung + Busbegleitung". - Per participant, `` sets the option expiry. - The total is driven to zero with a **manual ``**: Ortstaxe 44,00 + Keycard-Pfand 10,80 + Abfahrtsortzuschlag 25,80 = 80,60, offset by an unnamed −80,60 line. `` is part of the documented `BUCHUNG` payload, so this part *is* expressible over XML. See `.ddev/plans/teamer-placeholder-booking-plan.md`.