- The API at a glance
- Get your API token
- Store the token securely
- API basics & conventions
- Sync endpoints export & keep current
- full_sightings — the primary route
- train_sightings — fetch by ID
- trips/sighting_ids — trip to sightings
- active_sighting_ids — what is moving now
- deleted_sightings — reconcile removals
- Search endpoints targeted, filtered queries beta
- cars/search — one row per car
- containers/search — one row per container
- trains/search — one row per train, nested consist
- Building a query — the same on all three routes
- Reference endpoints sensors & regions
- De-duplication critical · read this Skip this step and any count you produce from the Sync routes could be inflated.
- Field & join reference
- Make your first call
- Worked recipes
- Tips & gotchas
- Other documentation
- Getting help & feedback
The API at a glance
RailState exposes a single REST API, rooted at https://api.railstate.com/api/v3. Every route is a GET request, authenticated with the same bearer token, documented in one generated specification, and returns JSON.
The routes fall into two data families, distinguished by what you are trying to do, plus a small set of reference routes for sensor and region metadata. One token works for all of them.
DATA_EXPORT for all data routes, plus TRAINS_VIEWER for the /trains/* routes, TRAIN_CARS_VIEWER for cars/search, CONTAINERS_VIEWER for containers/search, and REGIONS_USER for regions/overview. If a route you expect to work returns 403, that permission is missing rather than the token being wrong — email support+API@railstate.com.
Pull complete sighting records and keep a local copy in step with RailState over time. This is the path behind data warehouses and BI pipelines: a first full pull, then incremental top-ups by modification time, plus a deletion feed to reconcile removals.
- Returns full records (whole nested consists)
- Paginate with
nextRequestLink - Requires client-side de-duplication
Ask a precise question and get back only the matching rows, with only the fields you request. Filter by car type, direction, reporting mark, container number, hazmat placard, route, owner, and more. RailState evaluates the filters server-side.
- Returns filtered, projected rows, already de-duplicated
- Paginate with
nextPageUrl - Under active development — see the beta note below
Which one should I use?
| If you want to… | Use |
|---|---|
| Load all data into your own database and keep it current | Sync — full_sightings + deleted_sightings |
| Answer one question ("tank cars westbound at sensor 35 last week") | Search — cars/search with filters |
| Find every sighting of a reporting mark or container number | Search — car_id / container_id |
| Which cars carried a given UN hazmat number | Search — hazmat_un / hazard_class |
| Corridor volume or cycle time on a sensor pair | Search — site_route |
| Pull the complete consist of specific train trips | Either — trips/sighting_ids then full_sightings, or cars/search?train_trip_id= |
| Look up sensor names, locations, timezones, or region ids | Reference — sensors/overview, regions/overview |
Get your API token
The RailState API uses bearer token authentication. Every request must include an Authorization: Bearer <your-token> header. Tokens are tied to your account and inherit your account's data permissions. A single token works for every route, in both endpoint families.
Generate a token in the RailState UI
- Sign in to RailState with your normal credentials.
- Click the profile icon in the top-right to open the User account & settings menu, then choose Tokens.
- Click Add new token.
- On the Create token screen, enter a unique ID for this token (for example, "powerbi-ingestion" or "postman-testing").
- The optional fields (Description, Not before date, Validity seconds — leave empty for a token that never expires — and Disabled, which defaults to off) are explained in the blue question-mark tooltips beside each field.
- Press Submit to generate the token. Its value is shown once, right after you submit.
- Copy the token immediately and store it in a password manager or secret store. You will not be able to view the token value again once you close the dialog.
Rotating or revoking a token
Treat tokens like passwords. Rotate them at least annually, after staff changes, or any time a token may have been exposed. To rotate: generate a new token first, switch your scripts and tools over to it, verify everything works, then disable the old one from the same Tokens page.
Store the token securely
Hard-coding the token in a script, or pasting it into a terminal command where it lands in your shell history, is the most common way tokens leak. Use your operating system's credential store and read it from an environment variable at runtime.
# 1) Save the token in Keychain (silent prompt, no shell-history exposure):
read -rs NEW_TOKEN
# Paste the token, press Enter
security add-generic-password -a "$USER" -s "RAILSTATE_API_TOKEN" -U -w "$NEW_TOKEN"
unset NEW_TOKEN
# 2) Add this to ~/.zprofile so every new shell exports the token:
cat >> ~/.zprofile <<'EOF'
# RailState API token sourced from macOS Keychain
export RAILSTATE_API_TOKEN="$(security find-generic-password -a "$USER" -s 'RAILSTATE_API_TOKEN' -w 2>/dev/null)"
EOF
# 3) Open a new Terminal window. macOS prompts once - click "Always Allow".
# 4) Verify it's loaded:
echo "${#RAILSTATE_API_TOKEN}" # should print a number around 200-300
On GNOME-based desktops use secret-tool (libsecret). For headless servers or portable setups, use pass (password-store).
# With secret-tool (interactive prompt):
secret-tool store --label="RailState API" service railstate user "$USER"
# Paste the token at the prompt.
# Read it back in your shell rc (~/.profile or ~/.bashrc):
export RAILSTATE_API_TOKEN="$(secret-tool lookup service railstate user "$USER")"
# PowerShell — save once:
$token = Read-Host -AsSecureString "Paste RailState API token"
$plain = [Runtime.InteropServices.Marshal]::PtrToStringAuto(
[Runtime.InteropServices.Marshal]::SecureStringToBSTR($token))
cmdkey /generic:RailState /user:$env:USERNAME /pass:$plain
Remove-Variable plain, token
# Read it back in a PowerShell profile or session:
$cred = Get-StoredCredential -Target RailState # requires CredentialManager module
$env:RAILSTATE_API_TOKEN = $cred.GetNetworkCredential().Password
If the CredentialManager module is not installed, run Install-Module CredentialManager -Scope CurrentUser first.
RAILSTATE_API_TOKEN), and read it from there in your code.
API basics & conventions
https://api.railstate.com/api/v3Authorization: Bearer <token>response_size — default 200, max 1000limit — default 200, soft (see paging)nextRequestLink · Search: nextPageUrl408408, 500, 502, 503, 504 — use exponential backoffTwo hard limits on how much you can ask for at once
- Search: 250 filter values per request. The cap counts every value across every filter, not per filter, so 250 car ids leaves no room for a sensor list. Exceeding it returns
400 "Search error: Too many search conditions were specified (N). The limit is set to 250." - Any route: the URL must stay under roughly 4096 bytes. Long identifier lists hit this before you expect. 400 eight-digit ids is about 4000 bytes and works; 500 does not, returning
400 "An HTTP line is larger than 4096 bytes."Batch long lists rather than sending one enormous request.
A mistyped filter name returns unfiltered data
An unrecognized parameter name is ignored rather than rejected, so a typo returns 200 with results that were never filtered. An unrecognized value is properly rejected with a 400. Sanity-check the row count whenever you add a filter for the first time: if it does not change, the filter is not being applied.
sensors and regions; Search uses the singulars sensor and region. Sending sensors to a Search route, or sensor to a Sync route, is treated as an unknown parameter: you get a 200 and the full unfiltered result set.
Pagination: follow the link until it is empty
Responses come back in blocks. Each block carries a link to the next block; when that link is null, the data is complete. The two families use different field names, and Search has one extra behavior worth knowing:
- Sync routes return
nextRequestLink. Follow it until it is absent. Once you have the link, drop your original query parameters — the link already carries the full query string. - Search routes return
nextPageUrl, and results are ordered by ascending detection time. Because a page is never split across sightings that share a detection time (all cars of one train sighting share one), a page can come back empty whilenextPageUrlis still present. Do not stop on an empty results page; page untilnextPageUrlisnull. Starting with a largerlimit(for example 1000) reduces empty pages, but it is not required: even with a small limit, thelimitparameter is automatically increased in thenextPageUrl.
Query parameter encoding
- Text strings are not enclosed in quotes.
- Lists are comma-separated with no brackets and no spaces (for example
car_type=Tank Car,Locomotive, URL-encoded). - Composite values are JSON-encoded strings.
- URL-encode all special characters.
- Values are case-insensitive unless stated otherwise. The exceptions are
regionids,region_route, andtrain_tag, which are case-sensitive.
Time format
Date and time parameters (such as detection_time_from) accept the ISO-8601 Instant format:
- UTC with
Zsuffix —2026-05-09T00:00:00Z - UTC offset —
2026-05-09T00:00:00-07:00 - Offset with IANA tag —
2026-05-09T00:00:00-06:00[America/Regina] - Unix epoch seconds —
1746748800
Use an explicit offset (or named timezone) when you need a precise day boundary in a non-UTC zone, for example to match a "Monday in Pacific Time" report.
Retry pattern
Transient 5xx errors and connection resets do happen. Wrap calls in a retry loop with exponential backoff:
import time, requests
def get_with_retry(url, headers, params=None, max_retries=10):
for attempt in range(max_retries):
try:
r = requests.get(url, headers=headers, params=params, timeout=60)
r.raise_for_status()
return r
except requests.exceptions.HTTPError as e:
code = getattr(e.response, "status_code", None)
if code in (500, 502, 503, 504) and attempt < max_retries - 1:
time.sleep(min(30 * (2 ** attempt), 300))
continue
raise
except (requests.exceptions.ConnectionError,
requests.exceptions.Timeout,
requests.exceptions.ChunkedEncodingError):
if attempt < max_retries - 1:
time.sleep(min(15 * (2 ** attempt), 300))
else:
raise
Sync endpoints export & keep current
These routes return complete sighting records. The design intent is that you keep a local copy of RailState's data and patch it over time: do one initial full pull, then on each run fetch everything created or modified since your last run, and reconcile deletions. Everything here paginates with nextRequestLink.
sightingId, keeping the row with the newest lastModified. The same sighting can be returned more than once, and skipping this can quietly inflate every downstream count. Search results, by contrast, come back already de-duplicated.
Train data
/trains/full_sightings
The primary Sync endpoint. Returns train sightings with nested cars, containers, equipment parameters, and hazmat data. Sorted by ascending last-modification time so you can patch a local database incrementally.
Response: A JSON object { "sightings": [...], "nextRequestLink": "..." }. Each sighting contains a nested cars array in train order, and the containers for each car nested under that car. Alongside the fields in the field reference, each sighting also carries trainSetId, trainTripActive, detectionEndUTC and detectionEndSensorLocal, and each car carries carImageUrl.
Good for: All train, car, and container analysis; building and maintaining a local data warehouse.
sighting_ids parameter on this route. To fetch specific sightings by ID, use train_sightings?ids=. Sending sighting_ids here is treated as an unknown parameter: the request succeeds and returns unfiltered data from the start of the record, which looks like a working call but is not the data you asked for.
/trains/train_sightings
Fetch the complete data of specific train sightings by ID. Returns them in an arbitrary order; IDs that do not exist are silently skipped rather than raising an error.
Response: The same { "sightings": [...] } objects as full_sightings, for the IDs you asked for.
Good for: Re-fetching a known set of sightings without a time-range scan, including refreshing expired car image URLs.
/trains/trips/sighting_ids
Maps train trip IDs (train profiles) to the sighting IDs belonging to each trip, in arbitrary order. A trip is the API's grouping of sightings believed to be the same physical train. Trip IDs that do not exist are silently skipped.
Response: A JSON object { "sightings": [ { "sightingId", "trainId" } ] } — one flat pair per sighting, which you group by trainId yourself.
Good for: Route analysis: take a trip ID, get its sighting IDs, then call train_sightings?ids= for the details. (The Search family can do the same in one call with train_trip_id=.)
/trains/active_sighting_ids
The sighting IDs of the last known sighting of each currently active train trip. A trip counts as active when its last sighting is under 5 days old and the majority of its cars and locomotives have not since turned up in a more recent trip — so when a unit train's return working is detected, the original trip stops being active.
Response: A JSON object { "sightingIds": [ ... ] } — not a bare array. Pass the IDs to train_sightings?ids= for the full consists.
Good for: A snapshot of what is currently moving; a starting point for live position views.
/trains/deleted_sightings
The sightings removed since a given time, sorted by ascending deletion time. Sightings are deleted when several partial sightings are merged into one, and also when a sighting is later judged a false-positive detection. Only relevant if you keep a local copy — use it together with full_sightings to keep that copy correct.
Response: A JSON object { "sightings": [...], "nextRequestLink": "..." }. Each row carries sightingId, deletedRecordId, deletionTime, and the original sensorId, detectionTime and speedMph. Page through nextRequestLink.
Good for: Maintaining a local cache: purge these IDs, otherwise merged or removed sightings linger in your reports forever.
full_sightings — what each column means and whether it is available in the API, UI, or both — lives in the RailState Data Dictionary article.
Search endpoints targeted, filtered queries beta
Where the Sync family hands you whole records to filter yourself, the Search family does the filtering on RailState's side and returns only what matches. Two ideas make it powerful:
- Field selection (projection). You list the groups of fields you want with the
attributesparameter, and the response contains only those. This can shrink a payload by one to two orders of magnitude versus a full consist. - Server-side filtering. One parameter per condition: car type, direction, load status, reporting mark, container number, hazmat, route, owner, and more. Conditions combine with AND. Results come back already de-duplicated and ordered by ascending detection time.
The three routes
All three take the same parameters and the same filters. They differ only in what a “row” is, so pick the route that matches the unit you want to count.
/cars/searchBeta
One row per rail car sighting. The workhorse Search route. Every vehicle detected, locomotives included, one row each, filtered server-side and projected to the fields you ask for.
Response: A JSON object { "results": [...], "nextPageUrl": "..." } of ResultsPage<RailCarSightingModular>, already de-duplicated and ordered by ascending detection time.
Good for: Car-type mixes, hazmat exposure, tracing a reporting mark, keeping a fleet of cars current, corridor volume counted by car.
/containers/searchBeta
One row per shipping container sighting. One row per container, including both slots of a double-stack. The car carrying it comes back too, if you request the car attribute groups.
Response: A JSON object { "results": [...], "nextPageUrl": "..." } of ResultsPage<ContainerSightingModular>, already de-duplicated and ordered by ascending detection time.
Good for: Intermodal box counts, container-number tracing, stack-slot analysis, keeping a container fleet current.
/trains/searchBeta
One row per train sighting. The train-level view, with pre-computed locomotive, car, platform, and container counts. Request the car and container attribute groups and each row carries its full nested consist — every car, with its containers nested under it — the Search equivalent of a full_sightings record, filtered and projected to the fields you ask for.
Response: A JSON object { "results": [...], "nextPageUrl": "..." } of ResultsPage<TrainSightingModular>, already de-duplicated and ordered by ascending detection time.
Good for: Train counts and composition, latest position per trip, filtered replacements for a full_sightings pull.
Attributes: choose the fields you get back
attributes is required. It is a comma-separated list of data-element groups; the response populates only the groups you request. An attribute that does not apply to a route is rejected (for example CAR_POSITION on trains/search, or TRAIN_DIMENSIONS on cars/search).
| Attribute | Adds to the response |
|---|---|
TRAIN_SIGHTING_ID | The parent train sighting ID |
DETECTION | Trip ID, sensor, detection time, direction, train tag |
TRAIN_SIGHTING_PARAMS | Train-level parameters (type, operator, speed) |
TRAIN_COMPOSITION | Locomotive / car / platform / container counts |
TRAIN_WARNINGS | Sighting warnings (partial sighting, switch operation, …) |
TRAIN_DIMENSIONS | Estimated train dimensions (trains/search only) |
SENSOR_LOCAL_TIMES | Detection start / end in sensor-local time |
CAR_SIGHTING_ID | The car sighting ID |
RAIL_CAR | Car id, type, equipment lists |
CAR_PARAMS | Observed car parameters (owner, loaded state) |
REGISTERED_CAR_PARAMS | Umler-derived equipment data (type code, tier, HP, model, dims) |
CAR_POSITION | Position in the train, 1-based (1 = first vehicle detected, usually but not always a locomotive) (not on trains/search — there, a car's position is its index in the nested cars array) |
CAR_HAZMATS | Hazmat placards on the car (not on containers/search) |
CAR_IMAGE_URL | Signed car image URL (expires; re-fetch as needed) |
CONTAINER_SIGHTING_ID | The container sighting ID |
CONTAINER_PARAMS | Container id, type, incomplete-id flag |
CONTAINER_POSITION | Container position on the car |
CONTAINER_HAZMATS | Hazmat placards on the container |
LAST_MODIFIED | Last-modified timestamp of the record |
The filter catalog
All filters are optional; combine any number and they apply together (AND). Each is a comma-separated list, and matching within a single filter is OR. Filters marked train also work on cars/containers search: a car matches if its train sighting matches.
| Parameter | Type | Scope | What it matches |
|---|---|---|---|
detection_time_from / _to | Instant | all | Detection-time window. |
sensor | SensorId[] | all | Sightings at these sensors. Translated to site-route conditions, so it OR-combines with site_route. |
region | RegionAtPartitionId[] | all | Sightings inside these regions. Format {region_id}@{partition_id} (e.g. CA@geo_countries). Case-sensitive. |
car_type | CarType[] | car | Cars of these types (or trains containing one). |
container_type | ContainerType[] | container | Containers of these types (or cars/trains carrying one). |
train_type | TrainType[] | train | Train sightings of these types. |
direction | CardinalDirection[] | train | Sightings traveling in these directions. |
load_status | LoadStatus[] | car | Cars in these load states. |
car_id | CarIdWildcard[] | car | Cars whose reporting mark + number matches a wildcard. See identifier search. |
container_id | ContainerIdWildcard[] | container | Containers whose id matches a wildcard. See identifier search. |
container_id_status | ContainerIdStatus[] | container | Complete IDs or Incomplete IDs — whether the container number was read in full. Use it to isolate, or exclude, partially-read containers. |
hazmat_un | HazmatUnNumber[] | car/container | Placards by UN number (e.g. un1203) or a special constant (any, empty, unreadable, not_detected). |
hazard_class | HazardClassCode[] | car/container | Placards by hazard class. Top-level (3) or sub-class (4.2). |
equipment_owner | RailId[] | car | Cars/locomotives owned by these railroads. |
equipment_lessee | RailId[] | car | Cars/locomotives leased to these railroads. |
train_operator | RailId[] | train | Trains operated by these railroads. |
rail_line_owner | RailId[] | train | Sightings on rail lines owned by these railroads. |
equipment_type_code | wildcard[] | car | Umler equipment type code (e.g. C###). Recognized car ids only. |
emission_tier | EPAEmissionsTier[] | loco | Locomotives of these EPA emission tiers. |
propelled_by | LocomotivePropelledBy[] | loco | Locomotives with these propulsion types. |
stenciled_shipping_spec | wildcard[] | car | Umler stenciled shipping spec. Recognized car ids only. |
locomotive_count / car_count | interval[] | train | Trains whose loco/car count falls in a range, e.g. 100-200. |
platform_count / container_count | interval[] | train | Trains whose platform/container count falls in a range. |
site_route | SiteRouteWildcard[] | all | An ordered route across sensors. See route search. |
region_route | RegionRouteWildcard[] | all | An ordered route across regions. Case-sensitive. |
train_tag | TrainTag[] | train | Tagged trains (case-sensitive; * = any tagged). |
train_warning | warning[] | train | Sightings carrying a given warning (e.g. Sensor under evaluation). |
train_trip_status | TrainTripStatus[] | train | active or inactive trips. |
equipment_list | EquipmentListId[] | car | Rolling stock in a saved equipment list (* = any list). |
train_trip_id / train_set_id | id[] | train | Specific trips or train sets. |
train_sighting_id / car_sighting_id / container_sighting_id | id[] | by level | Specific sighting ids. |
Identifier & wildcard search
This is the capability the Sync family cannot offer: find equipment by its printed identity. Both car_id and container_id accept wildcards and are case-insensitive.
car_id — reporting mark + numberA car id is 2 to 4 letters followed by up to 6 digits. (Some locomotives have no letter prefix; those are stored as digits only.) The parser normalizes spacing and leading zeros, so CN 003846, CN003846, and cn 3846 all match the same car.
#— any single digit*— any number of trailing digits (only at the end)
CP1234 | exact match |
DTTX* | all DTTX cars (starts-with) |
BNSF1### | BNSF1 + exactly 3 digits |
TTLX####* | TTLX + at least 4 digits |
container_id — owner + serialA container id is 4 letters followed by 4 to 6 digits (typically 6). Do not include the ISO checksum digit — RailState stores the 10-character form, so the 11-character number printed on the box is rejected.
#— any single digit*— any number of trailing digits (only at the end)
TCLU123456 | exact match |
JBHU* | all JBHU containers (starts-with) |
ANZU1##### | ANZU1 + exactly 5 digits |
* only trails, so you can search "all DTTX cars" (DTTX*) but not "any id ending 543" or "any id containing X". For substring or suffix matching, pull with a broader filter and match locally. Also note: containers whose number was read only partially (incompleteId: true) are returned in results but cannot be matched by container_id, because there is no complete id to match against. You can still select or exclude them as a group with container_id_status.
equipment_type_code (Umler, 4 chars: a letter then 3 digits, e.g. C113 or B###) uses # only. stenciled_shipping_spec additionally supports ? for any single letter and a trailing *. Both match only cars whose equipment id is recognized.
Route search
site_route encodes an ordered path across sensors, with a suffix that controls which sightings come back. This is how you express corridor volume, origin detection, and cycle-time queries server-side.
#{id}{dir}— a detection at a specific sensor, direction one ofN W S Eor?(any)?— exactly one detection at any sensor*— any number of detections (including zero) at any sensors- suffix
~F(full, default) returns all sightings between the first and last named sensor;~E(end) returns only the last named sensor;~S(start) returns only the first. Use~E/~Sfor unique counting.
*#8?* | trains sighted at sensor 8 (anything before or after) |
*#12W*#13N*~E | passed sensor 12 westbound then 13 northbound; return only the sensor-13 sightings |
#6S#8?*~F | originated at sensor 6 southbound, then immediately seen by sensor 8 |
region_route is the same idea over regions instead of sensors (and is case-sensitive). tracking_mode decides whether a route condition must be satisfied by a whole train trip (Trains, default) or by an individual car/locomotive (Cars).
Result shaping & paging
All sightings (default) or Last sightings. With Last sightings: the last matching train sighting per trip (Trains) or the last matching car sighting per equipment id (Cars). Applied after the search conditions.Trains (default) or Cars. Controls how route conditions apply. Car-tracking supports car routes under 10 days, ignores unknown equipment ids, and cannot search train sightings.nextPageUrl when a page returns empty, so a small limit still works.nextPageUrl until null. Empty pages are normal; never stop on one.Value reference
emission_tier=0+ is rejected)Worked examples
All manifest and petroleum-unit trains in Alberta over a window that carried at least one hazmat:
GET /api/v3/trains/search
?detection_time_from=2026-06-20T00:00:00Z
&detection_time_to=2026-06-25T12:00:00Z
&train_type=manifest,petroleum%20unit
&hazmat_un=any
®ion=CA%2FAB@geo_states
&limit=100
&attributes=train_sighting_id,detection,rail_car,car_hazmats
Tank cars carrying UN1202 or UN1203 that crossed from the US into Canada (car-tracking, return the Canadian sighting):
GET /api/v3/cars/search
?tracking_mode=cars
&detection_time_from=2026-06-20T00:00:00Z
&detection_time_to=2026-06-25T12:00:00Z
&car_type=tank%20car
&hazmat_un=un1202,un1203
®ion_route=%2A%3CUS%3E%3CCA%23F%3E%2A@geo_countries~E
&limit=500
&attributes=train_sighting_id,detection,rail_car,car_hazmats
Containers at five sensors, de-duplicated to the last sighting per trip:
GET /api/v3/containers/search
?sightings_filter=last%20sightings
&detection_time_from=2026-06-20T00:00:00Z
&detection_time_to=2026-06-25T12:00:00Z
&sensor=8,315,24,15,39
&limit=500
&attributes=train_sighting_id,detection,rail_car,container_params
Latest position of every train across a corridor — give a list of sensors and collapse each train trip to its single most recent sighting among them. A train seen at four of the sensors returns one row, the newest; widen or narrow the corridor by editing the sensor list:
GET /api/v3/trains/search
?sensor=500,496,494,392,420,422
&detection_time_from=2026-07-05T00:00:00Z
&detection_time_to=2026-07-09T00:00:00Z
&sightings_filter=last%20sightings
&attributes=train_sighting_id,detection,train_sighting_params
One row per train trip (trainTripId). “Last” is the most recent sighting among the sensors you list, within the time window — so the date range bounds it: a historical window gives each train's last position as of the window's end, while ending the window at the current time (or omitting detection_time_to) gives positions as of now. A trip that ran twice in the window returns each run's last sighting.
Keep a fleet current: fetch only what is new
A common pattern is tracking a fixed list of cars or containers and, on a schedule, pulling only what has moved since last time — instead of re-pulling the whole fleet on every call. The mechanism is a single timestamp: the moment of your last poll.
- Hold one record per identifier locally — each car or container and its latest sighting.
- On each poll, send your list with
detection_time_fromset to the time of your previous call. Send up to 250 identifiers per request and page larger fleets in batches. - Merge the response into your store, keeping the newest sighting per
carId(or container ID). This is the same group-by-newest step described under De-duplication.
Everything you already hold is older than your last poll, so any row that comes back is genuinely new for that identifier. Cars with no activity since the last call are simply absent from the response — a quiet interval returns little or nothing, and you never receive a record you already have. Set the next poll's detection_time_from to the time of this call and repeat.
GET /api/v3/cars/search
?car_id=CN003846,UP512340,TILX290117 (your fleet — up to 250 per call)
&detection_time_from=2026-07-08T12:00:00Z (the time of your last poll)
&limit=1000
&attributes=car_sighting_id,detection,rail_car,sensor_local_times
The same call works on /containers/search with container_id. Add sightings_filter=last%20sightings to return one row per train trip when a tracked unit was seen by several sensors in the interval.
Reference endpoints sensors & regions
These routes return the metadata behind the sighting data: the sensor sites themselves, their uptime, and the region hierarchy. They take no time range and change slowly, so fetch them once and cache, then refresh occasionally. Use them to translate sensor IDs into names and timezones, and to build the region ids the Search family accepts.
/sensors/overview
Every sensor visible to your account — name, coordinates, timezone, owning and operating railways, country and region, active/retired status, and launch date. The lookup table behind almost every report.
Response: A JSON object { "sensors": [ { "sensorId", "name", "lat", "lng", "railways", "operatingRailways", "timezone", "country", "region", "isActive", "launchDate", "retiredDate" } ] }. railways is the owner(s) of the track at the site; operatingRailways lists every railroad observed running there. retiredDate is null for active sensors.
Good for: Translating sensor IDs into names, locations, and timezones; deciding which sensors to query.
/sensors/status_history
The active time intervals for one or more sensors over a window. Gaps between intervals are outages or maintenance windows. Note that a sensor running in offline mode after losing internet is reported as an outage here even though its buffered data uploads once it reconnects.
Response: A JSON object { "sensors": [ { "sensorId", "active": [ { "start", "end" } ] } ], "queryInterval": { "start", "end" } }. Each active entry is an uptime interval; time inside the query window but outside every interval is downtime.
Good for: Uptime overlays; deciding whether a quiet period is a real lull in traffic or a sensor outage.
/regions/overview
The partition and region hierarchy, with the sensors that fall in each region. A partition is a named grouping of regions (for example a corridor or study area) that can be defined and edited in the RailState UI; each region carries its own boundary and member sensors.
Response: A JSON object { "partitions": [ { "id", "name", "description", "regions": [ ... ], "lastModified" } ] }. Each region has fullId (the shortId@partitionId form the Search region filter accepts), shortId, name, description, sensorIds, and a geometry polygon. Region ids are case-sensitive, and partitionId is a UUID, so read fullId from this route rather than constructing it by hand.
Good for: Geographic roll-ups by state, province, or corridor; and building the region ids used by the Search family's region filter.
De-duplication
sightingId — newer lastModified winsThis is the single most common cause of inflated counts. The Sync routes can return the same sightingId more than once in two situations:
- Within a fetch: if a sighting is updated while you are paging, it can appear on more than one page.
- Across fetches with
last_modification_time_from: every time a sighting is updated (a car re-read, equipment enrichment, a trip reassignment) it is returned again. Without de-duplication you accumulate stale copies.
The rule is the same every time: group by sightingId, keep the row with the maximum lastModified, drop the rest — and do it before any aggregation. If you count rows first, the numbers are already wrong.
In Excel: after loading via Power Query, sort descending by lastModified and use Remove Duplicates on sightingId. In SQL: ROW_NUMBER() OVER (PARTITION BY sightingId ORDER BY lastModified DESC) and keep rows where the number is 1. In pandas: df.sort_values("lastModified").drop_duplicates("sightingId", keep="last"). Treat each sighting's cars and containers as fully replaced when the sighting changes.
Field & join reference
A Sync sighting is nested JSON, not a set of joinable tables: cars hang off the sighting and containers hang off their car. There are no foreign-key fields on the child objects. A car has no sightingId and no position field — its position in the train is its index in the cars array, and the containers riding on it are simply the objects in that car's containers array.
{ "sightings": [ {
"sightingId": 2277993, "trainId": 1098616, "sensorId": 270,
"detectionTimeUTC": ..., "lastModified": ...,
"cars": [ <-- array order IS train order
{ "type": "Locomotive", "carId": "CN 008873",
"containers": [ <-- nested under the car, not a sibling table
{ "containerSightingId": ..., "identificationNumber": "TCLU123456",
"position": "Bottom" } ] } ] } ] }
The keys that matter, and what each family calls them:
| What you want | Sync (full_sightings) | Search |
|---|---|---|
| Train sighting id | sightingId (on the sighting only) | trainSightingId (on every row) |
| Train trip / profile id | trainId | detection.trainTripId |
| Car type | cars[].type | railCar.type |
| Reporting mark + number | cars[].carId | railCar.carId |
| Car position in train | the cars array index | carPosition (1 = first vehicle) |
| Container id | identificationNumber | containerParameters.containerId |
| Container stack slot | position — Top/Bottom/Bottom Front/Bottom Back | containerPosition, same values |
| Sensor | sensorId | detection.sensorId |
| Last modified | lastModified | lastModified |
| Region membership | fullId on each region from regions/overview, matched to sensors via that region's sensorIds | |
carId is null when the mark could not be read. Never group by carId without excluding nulls, or every unreadable car collapses into one phantom car. The container equivalent is incompleteId: true.
Flattening it into tables
To land this in a warehouse you invent the join keys as you flatten: copy the parent's sightingId onto each car row and stamp the array index as the car's position. Those columns are yours, not the API's.
# one row per car, with the keys a relational model needs
for s in response["sightings"]:
for i, car in enumerate(s["cars"], start=1): # 1-based, to match Search
rows.append({
"sightingId": s["sightingId"], # copied down from the parent
"carPosition": i, # the array index, not an API field
"carType": car["type"],
"carId": car.get("carId"),
"lastModified": s.get("lastModified"),
})
for ct in car.get("containers") or []:
container_rows.append({
"sightingId": s["sightingId"],
"carPosition": i, # links the container to its car
"containerId": ct.get("identificationNumber"),
"stackSlot": ct.get("position"),
})
Once those columns exist, the usual joins work — but note they are joining your schema, not the API's:
SELECT c.carId, c.carType, ct.containerId, ct.stackSlot
FROM cars c
JOIN containers ct
ON c.sightingId = ct.sightingId
AND c.carPosition = ct.carPosition
Make your first call
The fastest test is to fetch your list of sensors. It is small, fast, requires no parameters, and confirms your token is valid.
https://api.railstate.com/api/v3/sensors/overviewPath A — Excel (Power Query)
- Open Excel and choose Data → Get Data → From Other Sources → From Web, then pick Advanced.
- In URL parts, enter
https://api.railstate.com/api/v3/sensors/overview. - Under HTTP request header parameters, add Name
Authorizationand ValueBearer YOUR_TOKEN_HERE. - Click OK, expand the
sensorscolumn into rows, then Close & Load.
Path B — Postman
- New request: GET
https://api.railstate.com/api/v3/sensors/overview. - On the Authorization tab set type Bearer Token and paste your token.
- Click Send. Expect a 200 with a body that begins
{ "sensors": [ ... ] }.
Insomnia, Bruno, and Hoppscotch follow the same pattern.
Path C — Code (Python, R, Node, Go)
import os, requests
TOKEN = os.environ["RAILSTATE_API_TOKEN"]
headers = {"Authorization": f"Bearer {TOKEN}"}
r = requests.get(
"https://api.railstate.com/api/v3/sensors/overview",
headers=headers, timeout=60)
r.raise_for_status()
print(f"{len(r.json()['sensors'])} sensors")
curl one-liner to sanity-check a token:
curl -H "Authorization: Bearer $RAILSTATE_API_TOKEN" \
https://api.railstate.com/api/v3/sensors/overview | head -c 400
Path D — BI tools
Power BI: use Get Data → Web (Advanced) with an Authorization header, exactly like the Excel steps. Tableau and Looker Studio do not support custom auth headers natively; the stable pattern is a small scheduled script that lands the data in a file or warehouse table, then point the BI tool at that.
Worked recipes
Recipe 1 — Incremental sync into your own database Sync
- Persist the highest
lastModifiedvalue you have ever seen. - Call
full_sightings?last_modification_time_from=<that value>and page untilnextRequestLinkis empty. This catches new and updated sightings. - De-duplicate the batch by
sightingId, newestlastModifiedwins (see De-duplication). - Upsert: replace each sighting and its child cars/containers wholesale.
- Call
deleted_sightingsfor the same window and purge those IDs. - Save the new high-water mark.
# De-dup the batch by sightingId, newest wins:
seen = {}
for s in sightings_batch:
sid = str(s["sightingId"])
if sid not in seen or (s.get("lastModified") or "") > (seen[sid].get("lastModified") or ""):
seen[sid] = s
sightings_batch = list(seen.values())
Recipe 2 — Single-sensor daily traffic, by car type Search
No local filtering, one call per cut:
import os, requests
h = {"Authorization": f"Bearer {os.environ['RAILSTATE_API_TOKEN']}"}
params = {
"sensor": "35",
"detection_time_from": "2026-06-10T00:00:00Z",
"detection_time_to": "2026-06-11T00:00:00Z",
"car_type": "Tank Car",
"attributes": "car_sighting_id",
"limit": 1000,
}
rows, url = [], "https://api.railstate.com/api/v3/cars/search"
while url:
b = requests.get(url, headers=h, params=params, timeout=90).json()
rows += b.get("results", [])
url, params = b.get("nextPageUrl"), None # never stop on an empty page
print(len(rows), "tank-car sightings")
Recipe 3 — Trace one car across the network Search
Find every sighting of a reporting mark over a quarter, in one query:
params = {
"car_id": "CN 003846", # spacing/case/leading-zeros are normalized
"tracking_mode": "Cars",
"sightings_filter": "All sightings",
"detection_time_from": "2026-04-01T00:00:00Z",
"detection_time_to": "2026-07-01T00:00:00Z",
"attributes": "car_sighting_id,detection,rail_car",
"limit": 1000,
}
# page cars/search as in Recipe 2, then sort by detection.detectionTime
Recipe 4 — Corridor volume on a sensor pair Search
Trains that ran sensor 35 then sensor 49, counted once each (~E suffix):
params = {
"site_route": "*#35?*#49?*~E",
"tracking_mode": "Trains",
"detection_time_from": "2026-06-08T00:00:00Z",
"detection_time_to": "2026-06-15T00:00:00Z",
"attributes": "train_sighting_id,train_composition",
"limit": 1000,
}
# page trains/search; sum trainComposition.carCount.cars for segment weight
Tips & gotchas
Filter by detectionTimeSensorLocal for a single sensor (geographically correct day). For multi-sensor analysis (routes, trip tracing, cross-timezone counts) filter and order by detectionTimeUTC. Sensor-local times can go backwards when a train crosses a timezone.
Search orders results by ascending detection time and will not split a page across sightings that share a detection time, so a page can return empty while nextPageUrl is still set. Page until nextPageUrl is null. A larger limit (for example 1000) reduces empty pages, but even a small limit works — the limit is raised automatically in the nextPageUrl.
One intermodal stack car maps to multiple platform rows (a five-pack appears as five rows with the same carId). Report cars by distinct carId, platforms by row. On Search, car_count counts whole cars and platform_count counts platforms separately.
Invalid values are rejected with a 400 (for example a hazmat_un without the un prefix, or an 11-digit container id with the checksum). Note that an unrecognized parameter name is currently ignored rather than rejected, so a typo'd filter can silently return unfiltered data. Sanity-check counts when building a new query.
Neither family returns a match count without paging. To count matches, page to the end and count rows (Search returns already-deduplicated rows; Sync needs de-duplication first).
Image URLs expire about seven days after generation. Re-fetch them on demand (Sync: train_sightings?ids= — not full_sightings, which has no id parameter; Search: request CAR_IMAGE_URL again).
nextRequestLink and nextPageUrl arrive fully encoded. Request them as-is and do not add your own parameters. Some HTTP clients encode the URL a second time, which produces a valid-looking request that returns the wrong page: Python's requests does this, so call urllib.parse.unquote() on the link before passing it to requests.get().
issues fieldSync sightings carry an issues array of plain strings — Unverified detection, Partial sighting, Switch operation, Sensor under evaluation. Skip any sighting whose issues include Unverified detection: those records are usually incomplete or contain errors, and loading them inflates your counts. The other flags are worth reviewing against your own data rather than dropping outright.
Use issues and ignore the neighbouring warnings field: on the Sync routes warnings is deprecated and now returns only placeholder objects whose description reads "DEPRECATED, switch to the 'issues' field." On the Search routes the live values arrive in a field called warnings, and train_warning filters on them server-side.
Other documentation
This page is the practical guide: how to authenticate, which family of endpoints to use, and worked examples. Two other references sit alongside it.
The generated endpoint specification
Every route, every query parameter, and every response data model is published in a specification generated from the API code itself, so it never drifts from what the API actually does. Reach for it when you need the exact type of a parameter or the complete field list of a response model. Two ways to open it:
- In any browser, no token required: api.railstate.com/api/v3/documentation
- Inside RailState: click the profile icon in the top-right, then Api documentation.
A third address, https://api.railstate.com/api/v3/documentation/html, serves identical content but requires the Authorization: Bearer header. Use it only if you are already sending that header.
The Data Dictionary
Field-level definitions for everything the API returns, including whether each field is available in the API, the UI, or both. Read it in the Knowledge Base: RailState Data Dictionary.
Getting help & feedback
- Knowledge Base: support.railstate.com — release notes, data dictionary, dashboard guides
- API help & Search beta feedback: support+API@railstate.com for tokens, integration questions, and anything about the API. Search is under active development, so tell us what worked, what was missing, and what you would change (or reply to the release announcement).
- UI and account questions: support+UI@railstate.com
When emailing about an API problem, include: the full URL you called, the timestamp, the HTTP status code and response body, and the email address tied to the token (never paste the token itself).