Find relevant connections between an origin and destination for a requested departure or arrival date, including daytime services, overnight services, and mixed public-transport itineraries. Report departure and arrival times, journey details, bookable fares, and the cheapest displayed one-way option under the caller's assumptions.
Use Cases
Use for questions such as “what public transport can get me from {origin} to {destination} on {date}, what does it cost, and what is the cheapest option?” Also use for overnight-train searches, arrival-constrained journeys, comparisons of transport modes, and questions about reservations or booking requirements.
Automation Flow
Resolve each supplied station or place name through Bahn's same-origin location endpoint; never invent opaque station IDs. Use one evaluate call such as:
(async () => { const find = async query => { const r = await fetch('/web/api/reiseloesung/orte?suchbegriff=' + encodeURIComponent(query) + '&typ=ALL&limit=10'); if (!r.ok) throw new Error('location lookup failed: ' + r.status); return (await r.json()).map(x => ({name: x.name, id: x.id, type: x.type})); }; return {origin: await find('{origin}'), destination: await find('{destination}')}; })()Select the matching station from the returned name/type, then use its returned
idas{origin-station-id}or{destination-station-id}.Prefer the direct same-origin journey API, but expect to fall back. A same-origin POST from the loaded booking page was refused with
403 {"status":"ERROR","code":"OPS_BLOCKED"}from both a datacenter and a German residential IP, while the station-lookup API on the same origin answered normally. Treat anOPS_BLOCKEDresponse as "use the rendered results URL of step 3", not as "no connections exist". Each call searches ONE direction; run a second call with the stations swapped for a return leg and report the two legs separately. POST to/web/api/angebote/fahrplanwith the resolved IDs and generalized body below, replacing placeholders:{ abfahrtsHalt: '{origin-station-id}', ankunftsHalt: '{destination-station-id}', anfrageZeitpunkt: '{date}T{time}', ankunftSuche: '{search-mode}', klasse: '{class}', produktgattungen: ['ICE','EC_IC','IR','REGIONAL','SBAHN','BUS','SCHIFF','UBAHN','TRAM','ANRUFPFLICHTIG'], reisende: [{typ:'ERWACHSENER', ermaessigungen:[{art:'{discount-card}', klasse:'{discount-class}'}], alter:[], anzahl:{passenger-count}}], schnelleVerbindungen: true, sitzplatzOnly: false, bikeCarriage: false, reservierungsKontingenteVorhanden: false, nurDeutschlandTicketVerbindungen: false, deutschlandTicketVorhanden: false }{passenger-count}is the number of travellers.{class}is the API enumKLASSE_1orKLASSE_2, while the rendered URL of step 3 wants the numerickl=1orkl=2— the same choice in two spellings, so map it rather than pasting the enum intokl, or the rendered search silently runs in the other class and its fares do not match the API's.{discount-card}and{discount-class}carry a BahnCard:KEINE_ERMAESSIGUNGwithKLASSENLOSmeans no card, and that is the default when the caller says nothing. Bahn's own discount codes were not observed here, so do not guess them: for a BahnCard fare, set the card in Bahn's traveller control and reuse the URL Bahn generates, and say which discount the returned prices assume. The rendered URL encodes the same triple inr=13:16:KLASSENLOS:{passenger-count}, whose numeric codes are likewise unobserved.A group, a class, or a BahnCard left at the default returns single-adult second-class undiscounted prices, so the "cheapest option" answer is then wrong for that party.
Set
{search-mode}toABFAHRTwhenanfrageZeitpunktis the earliest departure, and toANKUNFTwhen the caller constrains arrival ("arrive by"). Bahn treats these as different searches, so leaving it onABFAHRTfor an arrival request returns connections departing at that time instead. Step 3'shza=Dandhza=Aare the rendered-URL equivalents.Extract
verbindungenfrom the JSON and, for each connection, readangebotsPreis,verbindungsDauerInSeconds, and everyverbindungsAbschnitt's origin, destination, departure/arrival timestamps, andverkehrsmittelname/type. Sort or compare displayed prices to identify the cheapest valid one-way option. Do not claim an option is available unless its price or fare state is returned.Alternatively navigate directly to the rendered results URL, URL-encoding all values:
https://int.bahn.de/en/buchung/fahrplan/suche#sts=true&so={origin}&zo={destination}&kl={class-number}&r=13:16:KLASSENLOS:{passenger-count}&soid={origin-station-id}&zoid={destination-station-id}&sot=ST&zot=ST&hd={date}T{time}&hza={D-or-A}&ar=false&s=true&d=false&vm=00,01,02,03,04,05,06,07,08,09&fm=false&bp=trueUse
hza=Dfor departure constraints andhza=Afor arrival constraints.{class-number}is1or2, matching{class}; keep second class unless another class is requested. This URL carries one date, one time, and one direction, so it is a one-way search: navigate it once per leg rather than expecting a round trip from it.On the loaded rendered results page, run this concrete extractor once:
(() => { const clean = v => (v || '').replace(/\s+/g, ' ').trim(); const text = el => clean(el?.innerText || el?.textContent || ''); const money = s => s.match(/(?:€|EUR)\s*\d+[\d.,]*|\d+[\d.,]*\s*(?:€|EUR)/gi) || []; const time = s => s.match(/\b\d{1,2}:\d{2}\b/g) || []; const candidates = [...document.querySelectorAll('[data-testid*="result" i], [data-testid*="connection" i], [class*="result" i], [class*="connection" i], article, li')]; const results = [], seen = new Set(); for (const el of candidates) { const value = text(el), times = time(value), prices = money(value); if (!value || value.length < 20 || value.length > 2500 || (times.length < 2 && !prices.length)) continue; const key = value.slice(0, 120); if (seen.has(key)) continue; seen.add(key); results.push({text:value, times:times.slice(0,6), prices, links:[...el.querySelectorAll('a[href]')].map(a=>({text:text(a),href:a.href})).filter(x=>x.text||x.href)}); } return {url:location.href, title:clean(document.title), results, pageText:clean(document.body.innerText || '').slice(0,12000)}; })()Include every visible connection, not only the first. For each option report departure and arrival date/time and stations, duration, transfers, service/train names or numbers, transport modes, displayed fare(s), currency, class, and reservation or supplement requirements. For cheapest-option questions, compare fares under identical passenger, date, class, discount, and flexibility assumptions and state those assumptions.
For arrival-date searches, use
hza=Aand an appropriate arrival time; inspect the preceding evening when an overnight service could otherwise be omitted. Distinguish journey tickets from optional or required seat reservations, sleeper/couchette supplements, passenger data, and train-specific restrictions.
Possible Friction Points
- Bahn station identity is carried by opaque
soidandzoidvalues such as the IDs returned by/web/api/reiseloesung/orte; visible names alone are not reliable identifiers. - The internal journey endpoint is
/web/api/angebote/fahrplan, accepts a JSON POST, and returns structuredverbindungenwith prices, durations, sections, timestamps, and transport modes. It is the shortest route to structured results when same-origin fetch is available. - The rendered planner uses
hdfor the date/time,hza=Dfor departure searches,hza=Afor arrival searches,sot=STandzot=STfor station endpoints, andvm=00,01,02,03,04,05,06,07,08,09for the visible transport modes. - Results may mix ordinary daytime connections, regional trains, buses, S-Bahn, and overnight services. Identify night options from service names, overnight spans, sleeper/couchette terminology, or a departure on the preceding evening.
- Prices vary with passenger count, discount card, flexibility, class, and availability. Report the exact displayed conditions and do not present the cheapest displayed fare as universal.
- Result-card selectors can change. Prefer a stable
data-testidfound on the current page; otherwise use the fallback selectors in the extractor and deduplicate nested cards. - The planner may need time to render after navigation; if the rendered page is empty, wait for results before running the extractor. The API path avoids this rendering delay.