Ingest reference
Send BMS and GPS telemetry from any vendor to one endpoint. This page is the contract: how you authenticate, the vendor-neutral generic_json payload, batching, limits, and every response code. For a device already speaking a binary dialect, see the protocol catalog.
Overview
Ingest is the one-way door for raw readings. Every reading enters through a single endpoint, POST /v1/ingest/{protocol}, where the {protocol} path segment selects the codec that decodes your wire format. The simplest path is generic_json, a vendor-neutral JSON shape any backend can produce, and it is what this page documents in full. Devices that already speak a binary dialect (JT/T 808, GT06, Modbus, OCPP, …) post their native bytes to the same endpoint; the protocol catalog lists all eleven.
Writes are idempotent on (tenant, device, timestamp): a replayed frame is a no-op, so a device can retry freely after a connectivity gap without double-counting.
Authentication
Ingest requires a bearer token that carries the ingest role. You mint one from a normal operator session: two calls, then you're sending data. You can also create one in the dashboard under Settings → API tokens.
1. Log in to obtain a session token (skip this if you already have a dashboard session):
01# 1: Log in once to get a session token (or copy your dashboard session).02curl -X POST https://api.rootd.cc/v1/auth/login \03 -H "Content-Type: application/json" \04 -d '{"email":"you@operator.com","password":"••••••••"}'05# → { "token": "<SESSION_TOKEN>", "user": { "tenant": "...", "roles": [...] } }2. Mint an ingest token. Machine-to-machine tokens always carry the ingest role on top of your account's roles, scoped to your tenant. Hand this token to the device or gateway that pushes telemetry, not your login password.
01# 2: Mint a long-lived ingest token. It carries the `ingest` role and is02# bound to your tenant. Treat it as a device secret. (Dashboard: Settings →03# API tokens does the same thing.)04curl -X POST https://api.rootd.cc/v1/settings/tokens \05 -H "Authorization: Bearer $SESSION_TOKEN" \06 -H "Content-Type: application/json" \07 -d '{"scopes":[],"ttlHours":720}'08# → { "token": "<INGEST_TOKEN>" }ttlHours for short-lived deployments.The endpoint
Base URL: https://api.rootd.cc. Send the native payload as the raw request body: a JSON document for generic_json and ocpp, or raw protocol bytes for the binary codecs.
| Field | Type | Description |
|---|---|---|
| {protocol} req | path | Codec name. generic_json for JSON; see the catalog for binary dialects. |
| Authorization req | header | Bearer token with the ingest role (see above). |
| Content-Type | header | application/json for generic_json; binary protocols post raw bytes. |
| device | query | Device id for protocols whose wire carries none (e.g. modbus, can). For generic_json it is only a fallback when a message omits device. |
| body req | raw | The native payload. For generic_json, one JSON object or an array of them. Max 1 MiB. |
generic_json schema
A generic_json payload is one object, or a JSON array of objects. Each object describes one device at one instant. Optional numeric fields are omitted when absent: a GPS-only ping carries no voltage; a battery-only ping carries no coordinates.
| Field | Type | Description |
|---|---|---|
| device req | string | Device id. Also the battery id unless battery is set. Falls back to ?device= if omitted. |
| battery | string | Explicit battery id: set it when one device hosts more than one pack. |
| timestamp | string (RFC 3339) | Sample time in UTC. Omitted → the server's arrival time is used. |
| v | number | Pack voltage, volts. |
| i | number | Pack current, amps. Negative = discharge. |
| temp | number | Pack temperature, °C. |
| soc | number | Vendor-reported state of charge, 0..1 (raw). Rootd derives its own estimate on read; this is a hint. |
| events | string[] | Markers: swap, fault, or connectivity_transition. |
| lat / lon | number | GPS fix in decimal degrees. Both required to record a location. |
| speed | number | Ground speed, km/h. |
| heading | number | Heading in degrees; 0 = north. |
| altitude | number | Altitude in metres. |
v / i / temp / soc is present. A location is recorded only when both lat and lon are present. A message may carry both, either, or just a heartbeat (accepted with zero counts).01# 3: Push a reading. Tenant comes from the token, never the body.02curl -X POST https://api.rootd.cc/v1/ingest/generic_json \03 -H "Authorization: Bearer $INGEST_TOKEN" \04 -H "Content-Type: application/json" \05 -d '{"device":"BIKE-001","timestamp":"2026-06-24T09:00:00Z",06 "v":63.2,"i":-4.5,"temp":29,"soc":0.71}'07# → 202 { "acceptedReadings": 1, "acceptedLocations": 0 }Batches & limits
To send many readings in one request, post a JSON array instead of a single object:
01# Send many at once: post a JSON array. Each element is one message.02curl -X POST https://api.rootd.cc/v1/ingest/generic_json \03 -H "Authorization: Bearer $INGEST_TOKEN" \04 -H "Content-Type: application/json" \05 -d '[{"device":"BIKE-001","timestamp":"2026-06-24T09:00:00Z","v":63.2,"i":-4.5,"temp":29,"soc":0.71},06 {"device":"BIKE-002","timestamp":"2026-06-24T09:00:05Z","v":61.8,"i":2.0,"temp":33,"soc":0.40,07 "lat":-1.286,"lon":36.817,"speed":18}]'08# → 202 { "acceptedReadings": 2, "acceptedLocations": 1 }| Field | Type | Description |
|---|---|---|
| Body size | limit | 1 MiB per request. Larger → 413. |
| Readings | limit | Up to 1000 battery readings decoded per request. |
| Locations | limit | Up to 1000 GPS fixes decoded per request. |
| Retention | policy | Raw readings are retained 90 days; older data is served from hourly rollups via the Twin API. |
Responses & errors
A successful POST returns 202 Accepted with the counts decoded from the payload. The whole request is atomic per message: the first invalid reading rejects the request and dead-letters the offending payload for inspection, so nothing is silently dropped.
01{ "acceptedReadings": 2, "acceptedLocations": 1 }Errors return a JSON body naming the reason, the protocol, and the offending field:
01{02 "error": "reading_out_of_bounds",03 "protocol": "generic_json",04 "field": "temperature"05}| Field | Type | Description |
|---|---|---|
| 400 | bad_request | Empty body (empty_payload), unreadable, or malformed JSON. |
| 401 | unauthorized | Missing or invalid bearer token. |
| 403 | forbidden | Token lacks the ingest role. |
| 404 | not_found | Unknown {protocol} (unknown_protocol) or unknown tenant (unknown_tenant). |
| 413 | payload_too_large | Body over 1 MiB, or over 1000 readings/locations. |
| 422 | unprocessable | Decode failed (decode_failed) or a reading failed validation (invalid_reading / reading_out_of_bounds, with field). Payload is dead-lettered. |
| 429 | rate_limited | Per-tenant rate limit. Honor Retry-After. |
| 503 | broker_unavailable | Ingest queue temporarily unavailable. Honor Retry-After and retry; the write is idempotent. |
Connectivity & freshness
Rootd models connectivity from the gap between now and a battery's latest reading. A dropped feed is a state, never an error or a deleted twin:
| Field | Type | Description |
|---|---|---|
| online | state | A recent reading within the expected interval. |
| stale | state | No reading for a configurable window; the twin holds last-known values. |
| offline | state | Gap exceeds the offline threshold. Still queryable at last-known state. |
stale or offline and keeps serving last-known values with an honest freshness flag.Where to next
- Protocols: the eleven codecs (telematics, BMS/bus, charging) and a curl example for each binary dialect.
- Twin API: read the twin back to confirm your readings landed, then consume health, forecasts, and rider projections.
- Security: how tenant isolation and audited reads work.
- Talk to us: to provision a tenant or add a protocol.