SJ Train Tickets & Connections Scraper avatar

SJ Train Tickets & Connections Scraper

Pricing

from $0.40 / 1,000 trips

Go to Apify Store
SJ Train Tickets & Connections Scraper

SJ Train Tickets & Connections Scraper

Scrape live SJ train connections, schedules, ticket prices, transfers, delays, and fares across Sweden and Europe. Extract structured SJ timetable data for travel apps, price monitoring, analytics, and AI agents.

Pricing

from $0.40 / 1,000 trips

Rating

5.0

(3)

Developer

Jindřich Bär

Jindřich Bär

Maintained by Community

Actor stats

0

Bookmarked

2

Total users

1

Monthly active users

7 days ago

Last modified

Share

SJ (Swedish Railways) train tickets & connections scraper

Search SJ train connections and ticket prices across Sweden, and get back a structured dataset of routes, times, changes, and fares — ready to plug into spreadsheets, databases, dashboards, or AI agents.

Pulls live data from the official SJ booking API, so you always get the same trains, prices, and schedules a passenger would see on sj.se or in the SJ app.

What you can do with it

  • Compare ticket prices between any two Swedish stations in real time — Stockholm to Göteborg, Malmö to Lund, Stockholm to Kiruna, and every other route SJ sells (SJ Snabbtåg, SJ InterCity, SJ Regional, SJ Night Train, plus partner traffic such as Öresundståg, Mälartåg, Norrtåg and Snälltåget where SJ sells the ticket).
  • Get every ticket type, not just the headline price — 2 klass, 2 klass Lugn (quiet coach) and 1 klass are each returned in all three flexibility tiers (NOFLEX "kan ej ombokas", SEMIFLEX "kan ombokas", FULLFLEX "kan återbetalas"), so you can see exactly which ticket produced the cheapest result.
  • Price night trains properly — couchettes and sleeper berths (shared, private, first class) come back as their own offers, so a Stockholm → Luleå search shows both a seat price and a bed price.
  • Find direct trains or allow changes, with a minimum transfer time and optional via stations.
  • Search by arrival time — "get me there by 09:00" — not just by departure.
  • Scrape the timetable without prices — set includePrices: false and the whole day costs one request, which is the cheapest way to build a departure board.
  • Monitor fares over time by scheduling the actor to run daily / hourly and writing the results to your own datastore. SJ's cheapest non-rebookable fares move a lot as departure approaches.
  • Build a travel-planning assistant — feed the JSON output directly into an LLM agent that answers "what's the cheapest train from Stockholm to Göteborg next Tuesday morning."
  • See SJ Prio point prices — every offer also carries the points it can be bought with.

Typical use cases: travel comparison sites, price-monitoring tools, business-trip planners, rail enthusiasts, journalists working on transport coverage, and AI agents that need a structured rail-data source for Sweden.

Input

FieldRequiredDefaultDescription
fromyesOrigin station name or UIC code (e.g. "Stockholm Central", "740000001", or a city like "Stockholm")
toyesDestination station name or UIC code
datenotodayTravel date (date picker, ISO YYYY-MM-DD), Swedish local time
timenowhole dayTravel time (HH:MM, 24-hour), Swedish local time
timeTypeno"DEPART""DEPART" treats the time as the earliest departure, "ARRIVE" as the latest acceptable arrival
adultsno1Adult passengers (26+)
childrenAgesno[]One age per child/youth passenger, 0–25 (e.g. [4, 12]). Max 5 per booking
studentAgesno[]One age per student passenger, 15+
seniorAgesno[]One age per senior passenger, 18+ (SJ's senior discount starts at 65)
onlyDirectJourneysnofalseOnly return journeys with no change of train
maxTransfersnounlimitedDrop journeys with more changes than this
minTransferTimenoSJ defaultMinimum time at every change: "TEN_MINUTES""SIXTY_MINUTES"
viaStationsno[]Station names or UIC codes the journey must pass through
allowedServiceTypesnoallRestrict to SJ_HIGH, SJ_IC, SJ_REG, SJ_NT, X_TRAINOPS, X_PTA, X_EXPBUS
excludedServiceTypesno[]Exclude those same service groups — e.g. ["X_EXPBUS"] to drop bus legs
includePricesnotrueFetch ticket prices. Disable for a pure timetable scrape
includeAllOffersnofalseStore every ticket in offers[], not just the cheapest ones
includeServiceDetailsnofalseList each leg's on-board amenities in amenities[]
maxResultsno20Maximum number of departures to push to the dataset

Ages are mandatory for everyone except adults, because SJ prices child, youth, student, and senior tickets by age — that is why the passenger inputs are age lists rather than plain counts.

Station names are matched against the official SJ station catalogue, which is bundled with the actor and refreshed on every build — so no lookup request is made at run time. You can pass a full name ("Göteborg Central"), a short name ("Malmö C"), a partial name, a name without Swedish diacritics ("Goteborg", "Malmo"), a UIC code ("740000002"), or the short station code from a ticket ("2"). A bare city name resolves to that city's main station ("Uppsala" → Uppsala Central), and the five city groups SJ publishes — Stockholm, Göteborg, Arlanda Flygplats, plus Berlin and Hamburg — search every station in the city at once.

Example input

{
"from": "Stockholm Central",
"to": "Göteborg",
"date": "2026-08-26",
"time": "08:00",
"adults": 1,
"maxResults": 20
}

Output

Each item in the dataset describes one departure, with its legs and the cheapest tickets found for it.

Example item

{
"id": "64bdb83e-091f-395f-a156-dea1e043f433",
"from": "Stockholm Central",
"to": "Göteborg",
"fromStationCode": "740000001",
"toStationCode": "740098002",
"departure": "2026-08-26T08:14:00+02:00",
"arrival": "2026-08-26T13:30:00+02:00",
"durationMinutes": 316,
"durationText": "5h 16m",
"transfers": 0,
"operators": ["SJ"],
"serviceTypes": ["SJREG"],
"serviceNames": ["165"],
"producer": "SJ_ONLY",
"resplus": false,
"nightTrain": false,
"status": "AVAILABLE",
"unavailableReasons": [],
"passengerCount": 1,
"priceAmount": 295,
"priceCurrency": "SEK",
"cheapestOffer": {
"offerType": "SEAT",
"fareClass": "SECOND",
"comfortType": null,
"flexibility": "NOFLEX",
"priceAmount": 295,
"priceCurrency": "SEK",
"originalPriceAmount": 295,
"discounted": false,
"pointPrice": 5100,
"productName": "2 klass, Kan ej ombokas",
"productCode": "JNON_2",
"offerId": "a5fee194-ba4a-3e44-b8bd-41ed9afc2b7a",
"available": true
},
"cheapestSecondClassOffer": { "fareClass": "SECOND", "flexibility": "NOFLEX", "priceAmount": 295 },
"cheapestFirstClassOffer": { "fareClass": "FIRST", "flexibility": "NOFLEX", "priceAmount": 475 },
"cheapestBedOffer": null,
"legs": [
{
"from": "Stockholm Central",
"to": "Göteborg Central",
"fromStationCode": "740000001",
"toStationCode": "740000002",
"departure": "2026-08-26T08:14:00+02:00",
"arrival": "2026-08-26T13:30:00+02:00",
"durationMinutes": 316,
"changeTimeMinutes": null,
"serviceName": "165",
"serviceType": "SJREG",
"serviceTypeName": "SJ Regional",
"modality": "REGIONAL_RAIL_SERVICE",
"operator": "SJ",
"transportMethod": "TRAIN",
"vehicle": "X40",
"nightTrain": false,
"international": false,
"seatMapAvailable": false
}
]
}

Field reference

FieldTypeDescription
idstringSJ's departure id. Stable — the same train on the same date keeps it across runs, so it is safe to deduplicate on
from / tostringStation names
fromStationCode / toStationCodestringUIC station codes (740000001 = Stockholm Central)
departure / arrivalISO 8601 with timezone offsetScheduled times, Swedish local time (+02:00 in summer, +01:00 in winter)
durationMinutesintegerTotal travel time
durationTextstringDuration formatted for reading (e.g. "5h 16m")
transfersintegerNumber of changes (0 = direct)
operatorsstring[]Operators running the legs (SJ, Öresundståg, Mälartåg, …)
serviceTypesstring[]Service codes per leg: SJHIGH (Snabbtåg), SJIC, SJREG, SJNIGHT, XORESUNDSTAG, XMALARTAG, …
serviceNamesstring[]Train/bus numbers in travel order
producerstringSJ_ONLY, SJ_AND_OTHER, or MIX_OF_OTHER — who runs the legs
resplusbooleanTrue for a Resplus ticket, which combines SJ with regional operators
nightTrainbooleanTrue when any leg is an overnight service
statusstring or nullBookability from the ticket response ("AVAILABLE", "SOLD_OUT"). null when includePrices is off
passengerCountintegerHow many passengers the prices cover
priceAmountnumber or nullCheapest available ticket in SEK. null when SJ sells no ticket for it
priceCurrencystring or nullAlways "SEK" when a price is present
cheapestOfferobject or nullThe ticket behind priceAmount, with its class, flexibility tier, product name and code
cheapestSecondClassOffer / cheapestFirstClassOfferobject or nullCheapest ticket per class, so you can compare without re-querying
cheapestBedOfferobject or nullCheapest couchette or sleeper berth on a night train
offers[]arrayEvery ticket on offer, cheapest first. Only present when includeAllOffers is enabled
legs[]arrayPer-leg breakdown: stations and UIC codes, times, duration, wait before the next leg, train number, service type, operator, vehicle type

Prices are totals for the whole passenger list, in SEK — a search with two adults and a child returns the price of all three tickets together, not a per-person price. passengerCount tells you how many people that covers. originalPriceAmount and discounted show when SJ applied a family, campaign, or last-minute reduction.

priceAmount is null when SJ doesn't sell a through-ticket for the journey — common where a leg belongs to a regional operator that only sells its own tickets. The departure is still returned with full timing and leg detail; only the price is missing.

Pricing

Pay-per-event — one search-result event is charged for each departure pushed to the dataset. That means a query with maxResults: 10 charges for at most ten events, regardless of how many API calls the actor makes under the hood.

Using the API

Trigger runs from your own code via the Apify API. With your Apify API token, a POST request runs the actor synchronously and returns the dataset items:

curl -X POST "https://api.apify.com/v2/acts/jindrich.bar~sj-ticket-scraper/run-sync-get-dataset-items?token=<APIFY_TOKEN>" \
-H "Content-Type: application/json" \
-d '{"from": "Stockholm Central", "to": "Göteborg", "maxResults": 10}'

Or run asynchronously and poll for status / dataset items:

# Start a run
curl -X POST "https://api.apify.com/v2/acts/jindrich.bar~sj-ticket-scraper/runs?token=<APIFY_TOKEN>" \
-H "Content-Type: application/json" \
-d '{"from": "Stockholm Central", "to": "Göteborg"}'
# When it's done, read the dataset
curl "https://api.apify.com/v2/datasets/<DATASET_ID>/items?token=<APIFY_TOKEN>"

Official client libraries are available for JavaScript / TypeScript, Python, and via the Apify REST API directly.

Scheduling

Run the actor on a cron schedule from the Schedules tab in the Apify console — daily, hourly, or any custom cron expression. Common patterns:

  • Daily price snapshot at 09:00 — track how the cheapest Stockholm → Göteborg fare moves as the departure date approaches.
  • Hourly refresh during a peak week — keep a live price board for a route over midsummer or a major event.
  • Weekly market scan — compare a basket of routes (Stockholm↔Göteborg, Stockholm↔Malmö, Göteborg↔Malmö) for week-over-week price changes.

Schedules can fan out into multiple datasets, push to a webhook, or trigger downstream actors when the run finishes.

Use with AI Agents (Apify MCP)

This actor is exposed through the Apify Model Context Protocol (MCP) server, so any AI agent that speaks MCP — Claude, ChatGPT custom agents, OpenAI Agents SDK, Cursor, etc. — can call it directly to fetch live SJ ticket prices and connection options.

Once the Apify MCP server is connected, the agent picks up the actor's input schema automatically. Typical prompts that work out of the box:

  • "What's the cheapest train from Stockholm to Göteborg next Tuesday morning?"
  • "Find me a direct train from Malmö C to Stockholm Central on 2026-09-15."
  • "I need to be in Uppsala by 09:00 from Stockholm — what should I catch?"
  • "How much is a sleeper berth on the night train from Stockholm to Kiruna?"
  • "Price a trip for two adults and a 6-year-old from Göteborg to Malmö on Friday."

The agent fills the input, runs the actor, and reads the structured dataset items back — no scraping, no HTML parsing, no scheduling logic on the agent side.

Why this works well for agents

  • Typed input schema — every field has a title, type, default, and validation rules, so an agent can call the actor without trial-and-error prompting.
  • Typed outputdeparture, arrival, durationMinutes, transfers, priceAmount are all numbers / ISO timestamps, ready to be diffed, sorted, or compared directly.
  • Tickets are pre-summarisedcheapestOffer, cheapestSecondClassOffer, cheapestFirstClassOffer and cheapestBedOffer mean the agent doesn't have to reason over a matrix of comfort classes and flexibility tiers to answer "how much?".
  • Whole-day results in chronological order — SJ returns every departure for the date in one response, so the agent never has to reason about paging.
  • Pay-per-event cost control — an agent that asks for five connections pays for five.

Troubleshooting & support

Most issues come from date formatting, station naming, or how far ahead you're searching. Try the fixes below before opening an issue.

Common problems

No results returned (reason: "no-results")

The run's status message spells out what was searched and which of your filters could have excluded everything — read that first. The usual causes:

  • The travel date is in the past, or more than 365 days ahead. SJ's sales window is one year, and the actor rejects dates outside it before making a request.
  • The route genuinely has no service that day, or your time is past the last departure. Try an earlier time or drop time to get the whole day.
  • onlyDirectJourneys is enabled on a route with no through train (e.g. Uppsala → Malmö, where every service changes in Stockholm). Turn it off, or use maxTransfers instead.
  • SJ doesn't sell the route at all. Purely local traffic — a bus stop or a halt served only by a regional authority — is often bookable only through that operator, and SJ returns nothing for it.
  • maxTransfers dropped everything. This filter runs in the actor after the search, so the status message reports exactly how many departures it removed.

The run failed (reason: "error")

The actor fails loudly rather than finishing with an empty dataset. The status message and the log carry the reason — an unknown station name (with suggestions), an unreachable API, or an HTTP error from SJ, including the field SJ itself complained about. Re-running usually fixes transient network and 5xx errors.

The wrong station was picked

  • Station queries are resolved in this order: UIC or short station code, exact name (or short name or synonym), then a name that starts with your query, then one that contains it. Swedish diacritics are optional — "Malmo C" and "Malmö C" both work.
  • A bare city name prefers that city's main station, which SJ names "<City> Central" — so "Luleå" resolves to Luleå Central, not to Luleå Airport. "Stockholm" and "Göteborg" are exceptions: SJ publishes them as city groups, and they search every station in the city.
  • If nothing matches the whole query, trailing words are dropped and the search retried, so "Malmö Centralstation" becomes Malmö Central. This is logged as a warning so you can see which station was actually searched.
  • If you need a specific station, pass its UIC code (740000001 Stockholm Central, 740000002 Göteborg Central, 740000003 Malmö Central). Codes are unambiguous and always win.

priceAmount is null on some departures

  • SJ doesn't sell a through-ticket for that connection — usually a leg run by a regional operator that retails its own tickets. Timing and leg data are still complete.
  • Or includePrices is false, in which case no ticket data is fetched at all and status is null too.

Some departures came back without prices, and the log says so

  • One ticket request failed (typically because the search's ticket session expired mid-run). Those departures are kept with timetable data only rather than being dropped. Re-run to retry them.

Invalid date or Invalid time error

  • date must be YYYY-MM-DD (e.g. 2026-09-15). time must be HH:MM in 24-hour format (e.g. 08:00, not 8 AM). Both are optional.

Age cannot be null / age out of bounds

  • Every non-adult passenger needs an age, because SJ prices those tickets by age. Use childrenAges (0–25), studentAges (15+), or seniorAges (18+) — one entry per passenger, not a count. A booking takes at most 9 passengers, of whom at most 5 may be children/youths.

Different prices than sj.se shows

  • The actor returns the cheapest ticket available at the moment it runs. SJ's cheapest non-rebookable quotas sell out continuously, so a price can change within minutes.
  • Prices cover the whole passenger list. If sj.se shows a lower number, check passengerCount — you may be comparing a per-person price with a group total.
  • Discount cards (SJ Prio, travel passes, corporate contracts, Interrail) are not applied. Season tickets and promo codes aren't either.

No delay or real-time information

  • The booking API is a timetable and sales interface: it publishes scheduled times only. SJ serves live running information from a separate traffic-information service, which this actor does not query.

FAQs

Can I search by station code instead of name? Yes — from and to accept the 9-digit UIC code (740000001) and the short code printed on tickets (1). UIC codes are also returned as fromStationCode / toStationCode.

What are city groups? SJ publishes five "agglomerations" that cover several stations under one code — Stockholm, Göteborg, Arlanda Flygplats, Berlin, Hamburg. Searching from one returns the best journey from any station in that city, which is usually what you want for intercity travel.

Can I search for a return journey? Not in one run — each run searches one direction. Run the actor twice (outbound and return) and merge the datasets. SJ prices singles anyway, so two singles is the normal way to price a return.

Does it cover buses, the metro, and local traffic? It covers what SJ sells. Express buses and replacement buses appear as legs (

transportMethod: "BUS"
, service types X_EXPBUS / X_PTA) and can be excluded with excludedServiceTypes. Local metro and city-bus travel is not sold by SJ and is not returned.

How many requests does a run make? One search, one timetable request for the whole day, and then one ticket request per departure it keeps. With includePrices: false it is just the first two, which makes whole-day timetable scrapes very cheap.

Can I apply an SJ Prio or Interrail discount? No — prices are returned undiscounted. Every offer does carry pointPrice, the SJ Prio points the ticket can be bought with.

Can I get seat availability or book a ticket? No — the actor returns timetable and price data only. seatMapAvailable tells you whether a seat map exists for a leg, but booking requires SJ's own checkout flow.

Support

Open an issue on the actor's Issues tab in the Apify console. Include the full input JSON, the run ID (visible in the run URL, e.g. console.apify.com/actors/runs/<RUN_ID>), and the expected vs. actual output — that lets the maintainer pull the exact logs and reproduce the issue quickly.

Need a different data source?

If you're scraping connections across multiple operators, check our companion actors:

All these actors emit a comparable schema (from, to, departure, arrival, price, leg-level breakdown), so an aggregator agent can merge their outputs into a single multi-modal travel search.

Development

npm install
npm test # vitest, no network access needed
npm run typecheck
npm run main:dev # one live search printed to stdout
npm run start:dev # run the actor locally against storage/key_value_stores/default/INPUT.json
npm run update-stations # refresh stations.json from the SJ booking config

The code is split so that everything except the thin Apify wrapper is testable in isolation:

FileRole
index.tsAPI types and the pure parsing helpers that flatten responses into dataset items
extended.tsHTTP layer, station resolution, and the iterateDepartures generator
actor-logic.tsInput validation, the run loop, and the "why was this empty" reporting
actor.tsApify entry point — wires the SDK to runActor
stations.jsonStation catalogue snapshot, regenerated at build time by the Dockerfile