Skip to main content

Step 7: Send your first batch

Post your batch to the telemetry gateway with your access token:

POST https://gw.kairosinnovations.dev/api/v1/ingest/telemetry
Authorization: Bearer <access_token>
Content-Type: application/json
Post to the gateway host only

Send telemetry to gw.kairosinnovations.dev, never to apim.kairosinnovations.dev. The apim host is only the Developer Portal and the token endpoint. A batch posted there does not fail with a JSON error: it redirects (HTTP 302) into the portal and returns an HTML page. If you get a redirect or HTML instead of JSON, check the host first.

Use the unversioned address above. Some Developer Portal screens show a versioned address, https://gw.kairosinnovations.dev/api/v1/1.0.0/ingest/telemetry. It reaches the same API today, but it is tied to one API version and can stop working when a new version is published.

A minimal batch

The smallest batch has one position with a plate number, a timestamp and coordinates:

{"data":{"positions":[
{"vehicle_plate_number":"AA-12345","recorded_at":"2026-08-05T20:04:00Z",
"latitude":9.005401,"longitude":38.763611}]}}
  • Replace recorded_at with a current time: the real time your device recorded the position. The example time is too old, so IFMS would reject it with RECORDED_AT_OUT_OF_RANGE. IFMS accepts times from 30 days ago up to 5 minutes ahead of server time.
  • To print the current UTC time in this format, run date -u +%Y-%m-%dT%H:%M:%SZ.
  • Do not send your provider identity (provider_code or provider_name). The gateway adds it from your approved application.

Sending telemetry lists every field and its limits.

The response

A successful call returns HTTP 202 Accepted:

{
"data": {
"provider_code": "<your provider code>",
"provider_name": "<your organisation name>",
"accepted": 1,
"duplicates": 0,
"rejected": 0,
"failures": []
}
}

The full response also has a header object. Sending telemetry shows it.

A batch returns 202 even when some positions were not stored. Always check that accepted + duplicates + rejected equals the number of positions you sent. Then handle every entry in failures. Sending telemetry explains each field of failures and every failure reason.

Pitfalls on your first call

What you seeWhyWhat to do
202 with duplicates or rejected above 0Some positions were not stored as new positions.Check the counts add up to the positions you sent, then read each entry in failures.
401 Unauthorized, code 900902Missing or expired token.Get a new token and retry.
403 Forbidden, code 900901You used a token from Sandbox Keys.Generate Production Keys and get a new token.
302 or an HTML bodyYou posted to apim.kairosinnovations.dev.Post to https://gw.kairosinnovations.dev/api/v1/ingest/telemetry.

For every other response, such as 400, 5xx, duplicates or TLS errors, see Troubleshooting and FAQ.

Sample code

Each sample gets a token, sends a batch of two positions timestamped with the current time and prints what happened to each failed position.

Before you run a sample:

  1. Set the four environment variables, as shown in Set the environment variables. IFMS_GATEWAY_URL is the base address, without /ingest/telemetry.
  2. Replace the example plates, AA-TEST-001 and AA-TEST-002, with your own registered plates.
Use your own registered plates

A sample posts its positions to the Integration environment, where IFMS stores them. IFMS also registers a plate it has not seen before as an unverified vehicle. So never run a sample with the example plates.

Save the sample under the file name shown on its tab, then run it:

SampleNeedsRun it
cURLPOSIX sh, curl and sedsh send-batch.sh
PythonPython 3.12, standard library onlypython3 ifms_client.py
JavaScriptNode.js 22, no npm packagesnode ifms-client.mjs
JavaJava 21, no build stepjava IfmsClient.java
send-batch.sh
#!/bin/sh
# Sends a batch of GPS positions to IFMS and reports what happened to each.
#
# Integration environment. Set these four variables before running (the
# Environments page lists the values; never hard-code credentials here):
# IFMS_TOKEN_URL token endpoint, https://apim.kairosinnovations.dev/oauth2/token
# IFMS_GATEWAY_URL gateway base URL, https://gw.kairosinnovations.dev/api/v1
# IFMS_CLIENT_ID your application's consumer key
# IFMS_CLIENT_SECRET your application's consumer secret
#
# Requires only POSIX sh, curl and sed. Run it: sh send-batch.sh
# The plates below are made up: replace them with your own registered plates
# before you run it against the Integration environment.

set -eu

: "${IFMS_TOKEN_URL:?IFMS_TOKEN_URL is not set}"
: "${IFMS_GATEWAY_URL:?IFMS_GATEWAY_URL is not set}"
: "${IFMS_CLIENT_ID:?IFMS_CLIENT_ID is not set}"
: "${IFMS_CLIENT_SECRET:?IFMS_CLIENT_SECRET is not set}"

telemetry_url="${IFMS_GATEWAY_URL%/}/ingest/telemetry"

# A wrong host, or http:// instead of https://, typically answers with a
# redirect or an HTML sign-in page instead of JSON. curl never follows the
# redirect here (no --location): stop and say so instead.
wrong_url_hint="check the URL: a wrong host, or http instead of https, answers with a redirect or an HTML page instead of JSON"

# IFMS may send its JSON indented over many lines. sed reads one line at a
# time, so join all lines into one before pulling values out.
one_line() {
sed -e :a -e '$!N' -e '$!ba' -e 's/\n/ /g'
}

# --- Access token -----------------------------------------------------------
# The token is kept in access_token and reused until a minute before it
# expires. A long-running sender keeps doing this; it never needs a new token
# per request.
access_token=""
renew_at=0

# Sets access_token, fetching a new one only when there is none or it is
# about to expire. The client id and secret go to curl on standard input
# (sent as HTTP Basic auth), so the secret never shows in the process list.
# If your secret contains a double quote or a backslash, put a backslash in
# front of it.
ensure_token() {
now=$(date +%s)
if [ -n "$access_token" ] && [ "$now" -lt "$renew_at" ]; then
return 0
fi
token_response=$(curl --silent --show-error --fail \
--config - \
--data-urlencode "grant_type=client_credentials" \
--data-urlencode "scope=default" \
"$IFMS_TOKEN_URL" <<EOF
user = "${IFMS_CLIENT_ID}:${IFMS_CLIENT_SECRET}"
EOF
) || {
echo "send-batch: the token request failed; check IFMS_CLIENT_ID and IFMS_CLIENT_SECRET" >&2
exit 1
}
token_response=$(printf '%s\n' "$token_response" | one_line)
access_token=$(printf '%s' "$token_response" |
sed -n 's/.*"access_token"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')
expires_in=$(printf '%s' "$token_response" |
sed -n 's/.*"expires_in"[[:space:]]*:[[:space:]]*\([0-9][0-9]*\).*/\1/p')
if [ -z "$access_token" ] || [ -z "$expires_in" ]; then
echo "send-batch: the token response has no access_token or expires_in; $wrong_url_hint (IFMS_TOKEN_URL)" >&2
exit 1
fi
renew_at=$((now + expires_in - 60))
}

# --- The batch --------------------------------------------------------------
# Positions go inside the {"data": {"positions": [...]}} envelope. recorded_at
# is when the device took the fix, with an explicit offset; it must be within
# the last 30 days and at most 5 minutes ahead of IFMS's clock. Build the body
# once: a retry must resend the same recorded_at, which is part of the
# duplicate key.
recorded_at=$(date -u +%Y-%m-%dT%H:%M:%SZ)
batch='{"data":{"positions":[
{"vehicle_plate_number":"AA-TEST-001","recorded_at":"'"$recorded_at"'","latitude":9.005401,"longitude":38.763611,"ignition_on":true},
{"vehicle_plate_number":"AA-TEST-002","recorded_at":"'"$recorded_at"'","latitude":9.010000,"longitude":38.770000,"ignition_on":false}
]}}'

# Sets http_status and response_body. No --fail here: a 401 or 400 must stay
# readable. The bearer token also goes on standard input, not the command line.
send_batch() {
raw=$(curl --silent --show-error \
--config - \
--header "Content-Type: application/json" \
--data-binary "$batch" \
--write-out '\nHTTP_STATUS:%{http_code}' \
"$telemetry_url" <<EOF
header = "Authorization: Bearer ${access_token}"
EOF
) || {
echo "send-batch: could not reach $telemetry_url" >&2
exit 1
}
http_status=$(printf '%s\n' "$raw" | sed -n 's/^HTTP_STATUS://p')
response_body=$(printf '%s\n' "$raw" | sed '$d' | one_line)
}

ensure_token
send_batch

# 401: the gateway no longer accepts this token (it expired early or was
# revoked). Forget it, get a fresh one and try once more.
if [ "$http_status" = "401" ]; then
access_token=""
ensure_token
send_batch
fi

if [ "$http_status" != "202" ]; then
echo "send-batch: IFMS answered HTTP $http_status, so the batch was not processed" >&2
case "$http_status" in
400) echo "send-batch: the request itself is invalid; details: $response_body" >&2 ;;
2* | 3*) echo "send-batch: $wrong_url_hint (IFMS_GATEWAY_URL)" >&2 ;;
esac
exit 1
fi

# --- The result -------------------------------------------------------------
# HTTP 202 does not mean every position was stored: read the counts and every
# entry in data.failures. sed can do this for a sample because response_body
# is now one line, but it is fragile: in a real integration, use a JSON parser
# (such as jq) instead.
count() {
printf '%s' "$response_body" |
sed -n "s/.*\"$1\"[[:space:]]*:[[:space:]]*\\([0-9][0-9]*\\).*/\\1/p"
}
accepted=$(count accepted)
duplicates=$(count duplicates)
rejected=$(count rejected)
if [ -z "$accepted" ] || [ -z "$duplicates" ] || [ -z "$rejected" ]; then
echo "send-batch: HTTP 202, but the answer is not the JSON IFMS sends; $wrong_url_hint (IFMS_GATEWAY_URL)" >&2
exit 1
fi
echo "send-batch: accepted=$accepted duplicates=$duplicates rejected=$rejected"

# One line per failure entry: each entry starts with its "index" field, the
# position's place in the batch you sent.
failures=$(printf '%s' "$response_body" |
sed -n 's/.*"failures"[[:space:]]*:[[:space:]]*\[\(.*\)\].*/\1/p' |
sed 's/"index"/\
"index"/g')

field() {
printf '%s' "$2" |
sed -n "s/.*\"$1\"[[:space:]]*:[[:space:]]*\"\\([^\"]*\\)\".*/\\1/p"
}

# Prints one line per failure entry it can read.
print_failures() {
printf '%s\n' "$failures" | while IFS= read -r entry; do
case "$entry" in
'"index"'*) ;;
*) continue ;;
esac
index=$(printf '%s' "$entry" |
sed -n 's/^"index"[[:space:]]*:[[:space:]]*\([0-9][0-9]*\).*/\1/p')
# The plate is absent when the position was too malformed to read one.
plate=$(field vehicle_plate_number "$entry")
# outcome is REJECTED or DUPLICATE, reason a code such as INVALID_FIELDS.
# New codes can appear at any time: show them as they are.
outcome=$(field outcome "$entry")
reason=$(field reason "$entry")
echo "send-batch: position $index (${plate:-no readable plate}): ${outcome:-NOT STORED} ${reason:-no reason given}"
done
}

failure_lines=$(print_failures)

if [ -n "$failure_lines" ]; then
printf '%s\n' "$failure_lines"
elif [ $((duplicates + rejected)) -gt 0 ]; then
# Never report success silently when positions were not stored.
echo "send-batch: WARNING: $((duplicates + rejected)) positions were not stored, but this script could not read the failures in the response; parse it with a JSON parser to see which and why" >&2
fi

Try it in the Developer Portal

You can also try the API in the IFMS-Telemetry-Ingest API console. Sign in first.

The telemetry API reference shows the published contract.

Next steps

Onboarding is complete. Read these guides as you build your integration: