Knowledge Base · API Reference

The RailState API

This is the complete reference to the RailState API: how to authenticate, the two data families of endpoints (Sync for keeping a full copy of the data current, and Search for targeted, filtered queries), the reference routes for sensor and region metadata, and worked examples from your first call to advanced route and identifier searches.

  1. The API at a glance
  2. Get your API token
  3. Store the token securely
  4. API basics & conventions
  5. Sync endpoints export & keep current
  6. Search endpoints targeted, filtered queries beta
  7. Reference endpoints sensors & regions
  8. De-duplication critical · read this Skip this step and any count you produce from the Sync routes could be inflated.
  9. Field & join reference
  10. Make your first call
  11. Worked recipes
  12. Tips & gotchas
  13. Other documentation
  14. 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.

Each route checks a permission. A single token covers every route, but your account must hold the right permission for each one: 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.
Syncexport & keep current

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

Which one should I use?

If you want to…Use
Load all data into your own database and keep it currentSyncfull_sightings + deleted_sightings
Answer one question ("tank cars westbound at sensor 35 last week")Searchcars/search with filters
Find every sighting of a reporting mark or container numberSearchcar_id / container_id
Which cars carried a given UN hazmat numberSearchhazmat_un / hazard_class
Corridor volume or cycle time on a sensor pairSearchsite_route
Pull the complete consist of specific train tripsEither — trips/sighting_ids then full_sightings, or cars/search?train_trip_id=
Look up sensor names, locations, timezones, or region idsReferencesensors/overview, regions/overview
Search is in beta. The three Search routes are flagged Under development — the interface can change. They are live on production and available for initial use, but avoid building hard production dependencies on their exact shape until the flag is removed. We want your feedback: see Getting help & feedback.

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

  1. Sign in to RailState with your normal credentials.
  2. Click the profile icon in the top-right to open the User account & settings menu, then choose Tokens.
  3. Click Add new token.
  4. 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.
  5. Press Submit to generate the token. Its value is shown once, right after you submit.
  6. 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.
No UI login? Have your primary account contact support+API@railstate.com and permissions can be added to your credentials.

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.

Never commit tokens to source control, paste them into a shared chat, or include them in screenshots. If you suspect a token has been exposed, disable it immediately and email support+API@railstate.com.

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.

Whichever OS you use, the rule is the same: store the token in a credential manager, expose it as an environment variable (we recommend RAILSTATE_API_TOKEN), and read it from there in your code.

API basics & conventions

Base URL
https://api.railstate.com/api/v3
Auth
Header: Authorization: Bearer <token>
Format
JSON only (request and response)
Sync page size
response_size — default 200, max 1000
Search page size
limit — default 200, soft (see paging)
Pagination
Sync: nextRequestLink · Search: nextPageUrl
Backend timeout
30 seconds per query, returned as 408
Retryable status codes
408, 500, 502, 503, 504 — use exponential backoff
Search condition cap
250 filter values per request, counted across all filters
Request length
The request line must stay under ~4096 bytes

Two hard limits on how much you can ask for at once

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.

The two families spell the sensor and region filters differently, and the wrong spelling fails silently. Sync uses the plurals 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:

Query parameter encoding

Time format

Date and time parameters (such as detection_time_from) accept the ISO-8601 Instant format:

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.

The one rule that matters most here: every Sync result set must be de-duplicated by 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

GET /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.

ParameterRequiredDescription
sensorsRecommendedComma-separated sensor IDs. Strongly recommended for performance.
detection_time_fromRecommendedEarliest detection time (ISO-8601 Instant).
detection_time_toRecommendedLatest detection time (ISO-8601 Instant).
last_modification_time_fromOptionalReturns sightings created or modified after this time. The key to incremental sync — catches both new and updated records. Defaults to 1970-01-01T00:00:00Z, so omitting it starts from the beginning of the record.
regionsOptionalComma-separated region ids in {shortId}@{partitionId} form, from regions/overview. An alternative to listing sensors.
response_sizeOptionalPage size, default 200. Values above 1000 are clamped to 1000.

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.

There is no 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.
GET /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.

ParameterRequiredDescription
idsRequired in practiceComma-separated train sighting IDs. The parameter is named ids, not sighting_ids or train_sighting_ids. Keep batches modest so the URL stays under ~4096 bytes.

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.

GET /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.

ParameterRequiredDescription
trip_idsRequired in practiceComma-separated trip IDs. Maximum 500 per request; beyond that you get 400 "Too many train trip ids specified. The upper limit is 500." With realistic 7 to 8 digit IDs you will hit the ~4096-byte URL limit before 500, so batch around 400.

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=.)

GET /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.

ParameterRequiredDescription
sensorsOptionalRestrict to trains whose last known sighting is at one of these sensors. Trains later seen elsewhere are excluded.
regionsOptionalSame idea by region. Combined with sensors it is treated as a union, not an intersection.
detection_time_fromOptionalEarliest detection time to consider.

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.

GET /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.

ParameterRequiredDescription
deletion_timeRecommendedReturn sightings deleted after this time (ISO-8601 Instant). Defaults to 1970-01-01T00:00:00Z.
sensorsOptionalRestrict to these sensors.
regionsOptionalRestrict to these regions.
detection_time_from / _toOptionalBound by the sighting's original detection time.
response_sizeOptionalPage size, default 200. Values above 1000 are clamped to 1000.

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.

Field-level details. The complete list of fields returned by full_sightings — what each column means and whether it is available in the API, UI, or both — lives in the RailState Data Dictionary article.

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.

GET /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.

ParameterRequiredDescription
sensorsOptionalComma-separated sensor IDs. Omit it to get every sensor your account can see.

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.

GET /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.

ParameterRequiredDescription
sensorsOptionalSensor ID (comma-separated for several). Omitting it returns every sensor your account can see, which is a large response.
startOptionalWindow start (ISO-8601 Instant). Defaults to 30 days ago.
endOptionalWindow end. Defaults to now.

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.

GET /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.

ParameterRequiredDescription
(none)No parameters.

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

De-duplicate Sync results by sightingId — newer lastModified wins

This 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.

Search results are already de-duplicated. The Search family returns clean, pre-flattened rows, so no client-side de-duplication is needed there. This rule applies to the Sync family.

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 wantSync (full_sightings)Search
Train sighting idsightingId (on the sighting only)trainSightingId (on every row)
Train trip / profile idtrainIddetection.trainTripId
Car typecars[].typerailCar.type
Reporting mark + numbercars[].carIdrailCar.carId
Car position in trainthe cars array indexcarPosition (1 = first vehicle)
Container ididentificationNumbercontainerParameters.containerId
Container stack slotpositionTop/Bottom/Bottom Front/Bottom BackcontainerPosition, same values
SensorsensorIddetection.sensorId
Last modifiedlastModifiedlastModified
Region membershipfullId 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
Every field the API returns is described in the RailState Data Dictionary article, including whether it is available in the API, the UI, or both. It names fields the way the UI labels them ("Train Sighting Last Modified", "Car Position"), so use it for what a field means and this page or the generated specification for the exact JSON key.

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.

GEThttps://api.railstate.com/api/v3/sensors/overview

Path A — Excel (Power Query)

  1. Open Excel and choose Data → Get Data → From Other Sources → From Web, then pick Advanced.
  2. In URL parts, enter https://api.railstate.com/api/v3/sensors/overview.
  3. Under HTTP request header parameters, add Name Authorization and Value Bearer YOUR_TOKEN_HERE.
  4. Click OK, expand the sensors column into rows, then Close & Load.

Path B — Postman

  1. New request: GET https://api.railstate.com/api/v3/sensors/overview.
  2. On the Authorization tab set type Bearer Token and paste your token.
  3. 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

  1. Persist the highest lastModified value you have ever seen.
  2. Call full_sightings?last_modification_time_from=<that value> and page until nextRequestLink is empty. This catches new and updated sightings.
  3. De-duplicate the batch by sightingId, newest lastModified wins (see De-duplication).
  4. Upsert: replace each sighting and its child cars/containers wholesale.
  5. Call deleted_sightings for the same window and purge those IDs.
  6. 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

Sensor-local time for one sensor, UTC for many

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 paging: an empty page is not the end

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.

Stack cars: car count ≠ platform count

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.

Search input validation

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.

No total-count field

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).

Car image URLs are signed and expire

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).

Paging links come back already URL-encoded

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().

Screen Sync results on the issues field

Sync 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:

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

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).