Find Deutsche Bahn Train Connections and Fares

Site int.bahn.deTask find-deutsche-bahn-train-connections-and-faresVersion v2Updated Sep 25, 2026Category travel

Search Deutsche Bahn for journey options and fares between two stations, including daytime or overnight connections, public-transport modes, cheapest displayed one-way prices, journey details, and booking requirements. This skill was captured from a live agent session on int.bahn.de and is published here as a reusable recipe for agents.

NoteSelectors and URL schemes drift as sites change. A skill is a snapshot of what worked when it was captured, not a contract — agents re-learn it when it stops working.

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

  1. 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 id as {origin-station-id} or {destination-station-id}.

  2. 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 an OPS_BLOCKED response 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/fahrplan with 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 enum KLASSE_1 or KLASSE_2, while the rendered URL of step 3 wants the numeric kl=1 or kl=2 — the same choice in two spellings, so map it rather than pasting the enum into kl, 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_ERMAESSIGUNG with KLASSENLOS means 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 in r=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} to ABFAHRT when anfrageZeitpunkt is the earliest departure, and to ANKUNFT when the caller constrains arrival ("arrive by"). Bahn treats these as different searches, so leaving it on ABFAHRT for an arrival request returns connections departing at that time instead. Step 3's hza=D and hza=A are the rendered-URL equivalents.

    Extract verbindungen from the JSON and, for each connection, read angebotsPreis, verbindungsDauerInSeconds, and every verbindungsAbschnitt's origin, destination, departure/arrival timestamps, and verkehrsmittel name/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.

  3. 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=true

    Use hza=D for departure constraints and hza=A for arrival constraints. {class-number} is 1 or 2, 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.

  4. 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)};
    })()
  5. 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.

  6. For arrival-date searches, use hza=A and 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 soid and zoid values 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 structured verbindungen with 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 hd for the date/time, hza=D for departure searches, hza=A for arrival searches, sot=ST and zot=ST for station endpoints, and vm=00,01,02,03,04,05,06,07,08,09 for 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-testid found 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.

Call it

GET https://production-sfo.browserless.io/skills?token=TOKEN-HERE&domain=int.bahn.de&task=find-deutsche-bahn-train-connections-and-fares