Sending telemetry
This guide describes the telemetry contract in full: what you send, how IFMS processes it and what comes back. For your first call, see Send your first batch. The telemetry API reference shows the published contract.
The request
POST https://gw.kairosinnovations.dev/api/v1/ingest/telemetry
Authorization: Bearer <access_token>
Content-Type: application/json
- The body is UTF-8 JSON. Field names use
snake_case. - Send your token as described in Access tokens.
- Never send your provider identity: no
provider_codeorprovider_namein the body, and no identity headers. The gateway adds your identity from your approved application.
The envelope
Every batch is wrapped in a data object:
{
"data": {
"positions": [
{ "vehicle_plate_number": "AA-12345", "recorded_at": "2026-08-05T20:04:00Z", "latitude": 9.01, "longitude": 38.77 }
]
}
}
| Field | Type | Required | Rule |
|---|---|---|---|
data | object | Yes | Must not be null. |
data.positions | array | Yes | At least one position. Version 1 sets no maximum number of positions. |
A batch has no other fields. Each position is one GPS fix for one vehicle at one moment. You can put several vehicles, or several fixes for one vehicle, in the same batch.
Position fields
| Field | Type | Required | Rule |
|---|---|---|---|
vehicle_plate_number | string | Yes | Not blank. At most 120 characters. IFMS changes it to uppercase and removes all whitespace. |
recorded_at | ISO 8601 date and time | Yes | The time your device recorded the fix, with an explicit offset. UTC (Z) is recommended. Must be inside the observation window below. |
latitude | number | Yes | WGS 84 decimal degrees, from −90 to 90 inclusive. |
longitude | number | Yes | WGS 84 decimal degrees, from −180 to 180 inclusive. |
ignition_on | boolean | No | Engine or ignition state, if your device reports it. It helps IFMS tell idling from parked. |
device_imei | string | No | At most 32 characters. Used for audit and correlation only, never as the vehicle's identity. |
A position with both optional fields:
{"data":{"positions":[
{"vehicle_plate_number":"AA-12345","recorded_at":"2026-08-05T20:04:00Z",
"latitude":9.005401,"longitude":38.763611,"ignition_on":true,
"device_imei":"356938035643809"}]}}
The timestamps in these examples are illustrations. Send the real time your device recorded each position.
What not to send
Do not send any of these as fields:
- provider identity;
- vehicle or driver master data;
- speed, heading or movement state;
- geofence or reverse-geocoding values;
- message identifiers or batch sequence numbers;
- odometer, altitude or accuracy values;
- other vendor-specific values.
IFMS works out speed, heading and movement state itself, from your successive positions. IFMS ignores fields it does not recognise, so never rely on one being processed.
The observation window
IFMS accepts a position only if recorded_at is between:
- IFMS server time minus 30 days, and
- IFMS server time plus 5 minutes.
A position outside the window does not fail the batch. It comes back in failures with the reason RECORDED_AT_OUT_OF_RANGE. Keep your device and server clocks accurate.
How IFMS processes a batch
| Rule | What happens |
|---|---|
| Each position on its own | IFMS checks every position separately. An invalid position is counted and listed in failures. The valid positions in the same batch are still stored. |
| The counts always add up | accepted + duplicates + rejected equals the number of positions you sent. failures has one entry for every duplicate or rejected position. |
| Duplicates | A position is a duplicate when IFMS already stored one with the same provider identity, normalised plate and recorded_at, compared to the microsecond. |
| The same plate from another provider | It counts as a separate vehicle. It is not a duplicate. |
| Order | An older position that is still inside the window is stored as history. It does not move the vehicle's live state backwards. |
| A plate IFMS has not seen before | IFMS registers the vehicle as unverified and checks it against the vehicle registry later, in the background. The check never blocks acceptance: a position is not rejected because its plate is new or not yet verified. |
Retrying safely
To retry, resend the original position with its original recorded_at. recorded_at is part of the duplicate key, so never refresh it on a retry. If IFMS already stored the position, the retry comes back as a DUPLICATE, and nothing is stored twice.
The 202 response
A valid batch returns HTTP 202 Accepted. 202 does not mean every position was stored. Always check the counts and every entry in data.failures.
This batch had two positions. The first was stored and the second was rejected:
{
"header": {
"operation": "POST",
"request_uri": "/api/v1/ingest/telemetry",
"response_code": 202,
"response_message": "SUCCESS",
"additional_details": ""
},
"data": {
"provider_code": "<your provider code>",
"provider_name": "<your organisation name>",
"accepted": 1,
"duplicates": 0,
"rejected": 1,
"failures": [
{
"index": 1,
"vehicle_plate_number": "AA-67890",
"recorded_at": "2026-08-05T20:04:30Z",
"outcome": "REJECTED",
"reason": "INVALID_FIELDS",
"errors": [
{
"field": "latitude",
"message": "must be less than or equal to 90.0",
"rejected_value": "91"
}
]
}
]
}
}
Response fields
| Field | Meaning |
|---|---|
provider_code | Your stable provider identity, taken from your approved application. You never send it. |
provider_name | Your organisation's name. It can be absent. |
accepted | New, valid positions that IFMS stored. |
duplicates | Positions IFMS had already stored. |
rejected | Positions that were not stored because they failed validation, the time window, parsing or storage. |
failures | One entry for every duplicate or rejected position, in the order you sent them. Empty when every position was accepted. |
Failure entry fields
Fields with no value are left out.
| Field | Meaning |
|---|---|
index | The position's place in the positions array you sent, counting from 0. |
vehicle_plate_number | The plate as you sent it. Absent when it could not be read. |
recorded_at | The timestamp as you sent it. Absent when it could not be parsed. |
outcome | REJECTED or DUPLICATE. |
reason | A code you can act on. See the table below. |
message | Optional text for a person to read, for parse, time-window or internal errors. Do not branch on it. |
errors | For INVALID_FIELDS only: one entry per wrong field, with field, message and rejected_value. |
Failure reasons
| Reason | Meaning | What to do |
|---|---|---|
UNPARSEABLE | A value or type could not be read. | Correct the position and resend only that position. |
INVALID_FIELDS | One or more fields broke a rule. | Use errors to correct the fields. Resend only that position. |
RECORDED_AT_OUT_OF_RANGE | The timestamp is more than 30 days old or more than 5 minutes ahead. | Fix your device clock or backlog handling. Resend with the true observation time. |
DUPLICATE | IFMS had already stored this position. | Nothing. The retry was safe. |
INTERNAL_ERROR | IFMS could not store this one position. | Retry the same position with controlled backoff and the same recorded_at. |
The set of failure reasons is additive: new reasons can appear. Treat reason and outcome values as plain strings, and handle a value you do not recognise: log it as it is instead of failing. See Changes to the API.
Reading the counts
| Counts | What happened | What to do |
|---|---|---|
| accepted 2, duplicates 0, rejected 0 | Two new positions stored. failures is empty. | Nothing. |
| accepted 0, duplicates 1, rejected 0 | A safe replay. One DUPLICATE entry. | Nothing. Mark the original as delivered. |
| accepted 1, duplicates 0, rejected 1 | One stored, one rejected and listed. | Follow the reason. Resend only the corrected position. |
| accepted 0, duplicates 0, rejected 1 | Nothing stored. One rejected and listed. | Correct or retry, as the reason says. |
Other responses
Troubleshooting and FAQ lists every other response from the telemetry gateway, such as 400, 401, 403 and 5xx, and what to do about each. For a 401, also see Access tokens.
Handling failures in your client
Your client is ready when it:
- checks that
accepted+duplicates+rejectedequals the number of positions it sent; - handles every entry in
failuresby itsindexandreason; - accepts reasons and response fields it does not recognise;
- retries only the affected positions, with their original
recorded_atand controlled backoff.
The sample clients on Send your first batch print one line for every entry in failures: see print_failures in the cURL sample and report in the Python, JavaScript and Java samples.