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
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_atwith a current time: the real time your device recorded the position. The example time is too old, so IFMS would reject it withRECORDED_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_codeorprovider_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 see | Why | What to do |
|---|---|---|
202 with duplicates or rejected above 0 | Some 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 900902 | Missing or expired token. | Get a new token and retry. |
403 Forbidden, code 900901 | You used a token from Sandbox Keys. | Generate Production Keys and get a new token. |
| 302 or an HTML body | You 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:
- Set the four environment variables, as shown in Set the environment variables.
IFMS_GATEWAY_URLis the base address, without/ingest/telemetry. - Replace the example plates,
AA-TEST-001andAA-TEST-002, with 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:
| Sample | Needs | Run it |
|---|---|---|
| cURL | POSIX sh, curl and sed | sh send-batch.sh |
| Python | Python 3.12, standard library only | python3 ifms_client.py |
| JavaScript | Node.js 22, no npm packages | node ifms-client.mjs |
| Java | Java 21, no build step | java IfmsClient.java |
- cURL
- Python
- JavaScript
- Java
#!/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
"""Send a batch of GPS positions to IFMS and report 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
Python 3.12, standard library only. Run it: python3 ifms_client.py
The plates below are made up: replace them with your own registered plates
before you run it against the Integration environment.
"""
import base64
import json
import os
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
from datetime import datetime, timezone
# Renew the token this many seconds before it expires, so a request never
# goes out with a token about to lapse.
RENEW_EARLY_SECONDS = 60
TIMEOUT_SECONDS = 30
# A wrong host, or http:// instead of https://, typically answers with a
# redirect or an HTML sign-in page instead of JSON.
WRONG_URL_HINT = (
"a wrong host, or http instead of https, answers with a redirect "
"or an HTML page instead of JSON"
)
class _NoRedirects(urllib.request.HTTPRedirectHandler):
"""Never follow a redirect: a 3xx surfaces as an HTTPError instead."""
def redirect_request(self, req, fp, code, msg, headers, newurl):
return None
# urllib follows redirects by default; this opener does not, so a request
# never silently goes somewhere other than the URL you configured.
OPENER = urllib.request.build_opener(_NoRedirects)
def parse_json(text, variable):
"""Parses a JSON answer, or stops with the likely cause when it is not JSON."""
try:
return json.loads(text)
except json.JSONDecodeError:
sys.exit(f"the answer is not JSON; check {variable}: {WRONG_URL_HINT}")
def require_env(name):
value = os.environ.get(name)
if not value:
sys.exit(f"{name} is not set")
return value
class TokenCache:
"""Fetches an access token once and reuses it until shortly before it expires."""
def __init__(self, token_url, client_id, client_secret):
self._token_url = token_url
# HTTP Basic auth: base64 of "client_id:client_secret".
pair = f"{client_id}:{client_secret}".encode()
self._basic = base64.b64encode(pair).decode()
self._token = None
self._renew_at = 0.0
def get(self):
if self._token is None or time.monotonic() >= self._renew_at:
self._fetch()
return self._token
def forget(self):
"""Drops the cached token, so the next get() fetches a fresh one."""
self._token = None
def _fetch(self):
form = urllib.parse.urlencode(
{"grant_type": "client_credentials", "scope": "default"}
).encode()
request = urllib.request.Request(
self._token_url,
data=form,
method="POST",
headers={
"Authorization": f"Basic {self._basic}",
"Content-Type": "application/x-www-form-urlencoded",
},
)
try:
with OPENER.open(request, timeout=TIMEOUT_SECONDS) as response:
text = response.read().decode()
except urllib.error.HTTPError as error:
if 300 <= error.code < 400:
sys.exit(
f"token request answered HTTP {error.code}; "
f"check IFMS_TOKEN_URL: {WRONG_URL_HINT}"
)
sys.exit(
f"token request failed with HTTP {error.code}; "
"check IFMS_CLIENT_ID and IFMS_CLIENT_SECRET"
)
body = parse_json(text, "IFMS_TOKEN_URL")
self._token = body["access_token"]
self._renew_at = time.monotonic() + body["expires_in"] - RENEW_EARLY_SECONDS
def post_batch(telemetry_url, token, batch):
"""POSTs the batch; returns (HTTP status, response body text)."""
request = urllib.request.Request(
telemetry_url,
data=json.dumps(batch).encode(),
method="POST",
headers={
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
},
)
try:
with OPENER.open(request, timeout=TIMEOUT_SECONDS) as response:
return response.status, response.read().decode()
except urllib.error.HTTPError as error:
# urllib raises for 4xx/5xx; the status and body are still useful.
return error.code, error.read().decode()
def send_batch(tokens, telemetry_url, batch):
status, text = post_batch(telemetry_url, tokens.get(), batch)
if status == 401:
# The gateway no longer accepts this token (it expired early or was
# revoked). Forget it, get a fresh one and try once more.
tokens.forget()
status, text = post_batch(telemetry_url, tokens.get(), batch)
return status, text
def report(result):
"""Prints the counts and one line per position that was not stored."""
data = result["data"]
print(
f"accepted={data['accepted']} duplicates={data['duplicates']} "
f"rejected={data['rejected']}"
)
for failure in data["failures"]:
# The plate is absent when the position was too malformed to read one.
plate = failure.get("vehicle_plate_number") or "no readable plate"
# outcome is REJECTED or DUPLICATE, reason a code such as
# INVALID_FIELDS. New codes can appear at any time: show them as they are.
line = (
f"position {failure['index']} ({plate}): "
f"{failure.get('outcome') or 'NOT STORED'} "
f"{failure.get('reason') or 'no reason given'}"
)
details = [
f"{error.get('field')} {error.get('message')}"
for error in failure.get("errors") or []
]
if failure.get("message"):
details.append(failure["message"])
if details:
line += " - " + "; ".join(details)
print(line)
def main():
token_url = require_env("IFMS_TOKEN_URL")
gateway_url = require_env("IFMS_GATEWAY_URL")
tokens = TokenCache(
token_url, require_env("IFMS_CLIENT_ID"), require_env("IFMS_CLIENT_SECRET")
)
telemetry_url = gateway_url.rstrip("/") + "/ingest/telemetry"
# 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. A retry must resend the same value: it is part of the duplicate key.
recorded_at = datetime.now(timezone.utc).isoformat(timespec="seconds")
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,
},
]
}
}
status, text = send_batch(tokens, telemetry_url, batch)
if status != 202:
print(f"IFMS answered HTTP {status}, so the batch was not processed", file=sys.stderr)
if status == 400:
print(f"the request itself is invalid; details: {text}", file=sys.stderr)
elif status < 400:
print(f"check IFMS_GATEWAY_URL: {WRONG_URL_HINT}", file=sys.stderr)
sys.exit(1)
# HTTP 202 does not mean every position was stored: read the counts and
# every entry in data.failures.
report(parse_json(text, "IFMS_GATEWAY_URL"))
if __name__ == "__main__":
main()
// 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
//
// Node.js 22, built-in fetch, no npm packages. Run it: node ifms-client.mjs
// The plates below are made up: replace them with your own registered plates
// before you run it against the Integration environment.
// Renew the token this long before it expires, so a request never goes out
// with a token about to lapse.
const RENEW_EARLY_MS = 60_000;
// A wrong host, or http:// instead of https://, typically answers with a
// redirect or an HTML sign-in page instead of JSON. Every request below uses
// redirect: "manual", so a redirect is reported instead of silently followed.
const WRONG_URL_HINT =
"a wrong host, or http instead of https, answers with a redirect or an HTML page instead of JSON";
function fail(message) {
console.error(message);
process.exit(1);
}
/** Parses a JSON answer, or stops with the likely cause when it is not JSON. */
async function parseJson(response, variable) {
const text = await response.text();
try {
return JSON.parse(text);
} catch {
return fail(`the answer is not JSON; check ${variable}: ${WRONG_URL_HINT}`);
}
}
function requireEnv(name) {
const value = process.env[name];
if (!value) fail(`${name} is not set`);
return value;
}
/** Fetches an access token once and reuses it until shortly before it expires. */
function createTokenCache(tokenUrl, clientId, clientSecret) {
// HTTP Basic auth: base64 of "clientId:clientSecret".
const basic = Buffer.from(`${clientId}:${clientSecret}`).toString("base64");
let token;
let renewAt = 0;
async function fetchToken() {
const response = await fetch(tokenUrl, {
method: "POST",
redirect: "manual",
headers: {
Authorization: `Basic ${basic}`,
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
grant_type: "client_credentials",
scope: "default",
}),
});
if (response.status >= 300 && response.status < 400) {
fail(
`token request answered HTTP ${response.status}; check IFMS_TOKEN_URL: ${WRONG_URL_HINT}`,
);
}
if (!response.ok) {
fail(
`token request failed with HTTP ${response.status}; check IFMS_CLIENT_ID and IFMS_CLIENT_SECRET`,
);
}
const body = await parseJson(response, "IFMS_TOKEN_URL");
token = body.access_token;
renewAt = Date.now() + body.expires_in * 1000 - RENEW_EARLY_MS;
}
return {
async get() {
if (token === undefined || Date.now() >= renewAt) await fetchToken();
return token;
},
/** Drops the cached token, so the next get() fetches a fresh one. */
forget() {
token = undefined;
},
};
}
function postBatch(telemetryUrl, token, batch) {
return fetch(telemetryUrl, {
method: "POST",
redirect: "manual",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(batch),
});
}
async function sendBatch(tokens, telemetryUrl, batch) {
let response = await postBatch(telemetryUrl, await tokens.get(), batch);
if (response.status === 401) {
// The gateway no longer accepts this token (it expired early or was
// revoked). Forget it, get a fresh one and try once more.
tokens.forget();
response = await postBatch(telemetryUrl, await tokens.get(), batch);
}
return response;
}
/** Prints the counts and one line per position that was not stored. */
function report(result) {
const { accepted, duplicates, rejected, failures } = result.data;
console.log(
`accepted=${accepted} duplicates=${duplicates} rejected=${rejected}`,
);
for (const failure of failures) {
// The plate is absent when the position was too malformed to read one.
const plate = failure.vehicle_plate_number || "no readable plate";
// outcome is REJECTED or DUPLICATE, reason a code such as INVALID_FIELDS.
// New codes can appear at any time: show them as they are.
let line = `position ${failure.index} (${plate}): ${failure.outcome || "NOT STORED"} ${failure.reason || "no reason given"}`;
const details = (failure.errors ?? []).map(
(error) => `${error.field} ${error.message}`,
);
if (failure.message) details.push(failure.message);
if (details.length > 0) line += ` - ${details.join("; ")}`;
console.log(line);
}
}
const tokens = createTokenCache(
requireEnv("IFMS_TOKEN_URL"),
requireEnv("IFMS_CLIENT_ID"),
requireEnv("IFMS_CLIENT_SECRET"),
);
const telemetryUrl = `${requireEnv("IFMS_GATEWAY_URL").replace(/\/$/, "")}/ingest/telemetry`;
// 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.
// A retry must resend the same value: it is part of the duplicate key.
const recordedAt = new Date().toISOString();
const batch = {
data: {
positions: [
{
vehicle_plate_number: "AA-TEST-001",
recorded_at: recordedAt,
latitude: 9.005401,
longitude: 38.763611,
ignition_on: true,
},
{
vehicle_plate_number: "AA-TEST-002",
recorded_at: recordedAt,
latitude: 9.01,
longitude: 38.77,
ignition_on: false,
},
],
},
};
const response = await sendBatch(tokens, telemetryUrl, batch);
if (response.status !== 202) {
console.error(
`IFMS answered HTTP ${response.status}, so the batch was not processed`,
);
if (response.status === 400) {
console.error(
`the request itself is invalid; details: ${await response.text()}`,
);
} else if (response.status < 400) {
console.error(`check IFMS_GATEWAY_URL: ${WRONG_URL_HINT}`);
}
process.exit(1);
}
// HTTP 202 does not mean every position was stored: read the counts and every
// entry in data.failures.
report(await parseJson(response, "IFMS_GATEWAY_URL"));
// 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
//
// Java 21, standard library only (java.net.http.HttpClient). Run it as a
// single file, no build step: java IfmsClient.java
// The plates below are made up: replace them with your own registered plates
// before you run it against the Integration environment.
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
public class IfmsClient {
// Renew the token this long before it expires, so a request never goes out
// with a token about to lapse.
static final Duration RENEW_EARLY = Duration.ofSeconds(60);
static final Duration TIMEOUT = Duration.ofSeconds(30);
// A wrong host, or http:// instead of https://, typically answers with a
// redirect or an HTML sign-in page instead of JSON.
static final String WRONG_URL_HINT =
"a wrong host, or http instead of https, answers with a redirect or an HTML page instead of JSON";
// Redirect.NEVER (also HttpClient's default): a redirect is reported, never
// silently followed somewhere other than the URL you configured.
static final HttpClient HTTP =
HttpClient.newBuilder()
.connectTimeout(TIMEOUT)
.followRedirects(HttpClient.Redirect.NEVER)
.build();
public static void main(String[] args) throws IOException, InterruptedException {
TokenCache tokens =
new TokenCache(
requireEnv("IFMS_TOKEN_URL"),
requireEnv("IFMS_CLIENT_ID"),
requireEnv("IFMS_CLIENT_SECRET"));
URI telemetryUrl =
URI.create(requireEnv("IFMS_GATEWAY_URL").replaceAll("/$", "") + "/ingest/telemetry");
// 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. A retry must resend the same value: it is part of the duplicate key.
String recordedAt = Instant.now().truncatedTo(ChronoUnit.SECONDS).toString();
String batch =
"""
{"data": {"positions": [
{"vehicle_plate_number": "AA-TEST-001", "recorded_at": "%s",
"latitude": 9.005401, "longitude": 38.763611, "ignition_on": true},
{"vehicle_plate_number": "AA-TEST-002", "recorded_at": "%s",
"latitude": 9.010000, "longitude": 38.770000, "ignition_on": false}
]}}
"""
.formatted(recordedAt, recordedAt);
HttpResponse<String> response = postBatch(telemetryUrl, tokens.get(), batch);
if (response.statusCode() == 401) {
// The gateway no longer accepts this token (it expired early or was
// revoked). Forget it, get a fresh one and try once more.
tokens.forget();
response = postBatch(telemetryUrl, tokens.get(), batch);
}
if (response.statusCode() != 202) {
System.err.println(
"IFMS answered HTTP " + response.statusCode() + ", so the batch was not processed");
if (response.statusCode() == 400) {
System.err.println("the request itself is invalid; details: " + response.body());
} else if (response.statusCode() < 400) {
System.err.println("check IFMS_GATEWAY_URL: " + WRONG_URL_HINT);
}
System.exit(1);
}
// HTTP 202 does not mean every position was stored: read the counts and
// every entry in data.failures.
report(parseJson(response.body(), "IFMS_GATEWAY_URL"));
}
/** Parses a JSON answer, or stops with the likely cause when it is not JSON. */
static Object parseJson(String text, String variable) {
try {
return MiniJson.parse(text);
} catch (IllegalArgumentException | IndexOutOfBoundsException notJson) {
System.err.println("the answer is not JSON; check " + variable + ": " + WRONG_URL_HINT);
System.exit(1);
return null;
}
}
static String requireEnv(String name) {
String value = System.getenv(name);
if (value == null || value.isEmpty()) {
System.err.println(name + " is not set");
System.exit(1);
}
return value;
}
static HttpResponse<String> postBatch(URI telemetryUrl, String token, String batch)
throws IOException, InterruptedException {
HttpRequest request =
HttpRequest.newBuilder(telemetryUrl)
.timeout(TIMEOUT)
.header("Authorization", "Bearer " + token)
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(batch))
.build();
return HTTP.send(request, HttpResponse.BodyHandlers.ofString());
}
/** Prints the counts and one line per position that was not stored. */
static void report(Object result) {
Map<String, Object> data = MiniJson.object(MiniJson.object(result).get("data"));
System.out.println(
"accepted=" + MiniJson.integer(data.get("accepted"))
+ " duplicates=" + MiniJson.integer(data.get("duplicates"))
+ " rejected=" + MiniJson.integer(data.get("rejected")));
for (Object entry : MiniJson.array(data.get("failures"))) {
Map<String, Object> failure = MiniJson.object(entry);
// The plate is absent when the position was too malformed to read one.
String plate = orElse(failure.get("vehicle_plate_number"), "no readable plate");
// outcome is REJECTED or DUPLICATE, reason a code such as INVALID_FIELDS.
// New codes can appear at any time: show them as they are.
StringBuilder line =
new StringBuilder("position " + MiniJson.integer(failure.get("index")))
.append(" (").append(plate).append("): ")
.append(orElse(failure.get("outcome"), "NOT STORED")).append(' ')
.append(orElse(failure.get("reason"), "no reason given"));
List<String> details = new ArrayList<>();
if (failure.get("errors") != null) {
for (Object fieldError : MiniJson.array(failure.get("errors"))) {
Map<String, Object> error = MiniJson.object(fieldError);
details.add(error.get("field") + " " + error.get("message"));
}
}
if (failure.get("message") != null) details.add((String) failure.get("message"));
if (!details.isEmpty()) line.append(" - ").append(String.join("; ", details));
System.out.println(line);
}
}
static String orElse(Object value, String fallback) {
return value instanceof String text && !text.isEmpty() ? text : fallback;
}
/** Fetches an access token once and reuses it until shortly before it expires. */
static final class TokenCache {
private final URI tokenUrl;
private final String basic;
private String token;
private Instant renewAt = Instant.MIN;
TokenCache(String tokenUrl, String clientId, String clientSecret) {
this.tokenUrl = URI.create(tokenUrl);
// HTTP Basic auth: base64 of "clientId:clientSecret".
this.basic =
Base64.getEncoder()
.encodeToString((clientId + ":" + clientSecret).getBytes(StandardCharsets.UTF_8));
}
String get() throws IOException, InterruptedException {
if (token == null || !Instant.now().isBefore(renewAt)) fetch();
return token;
}
/** Drops the cached token, so the next get() fetches a fresh one. */
void forget() {
token = null;
}
private void fetch() throws IOException, InterruptedException {
String form = "grant_type=client_credentials&scope=default";
HttpRequest request =
HttpRequest.newBuilder(tokenUrl)
.timeout(TIMEOUT)
.header("Authorization", "Basic " + basic)
.header("Content-Type", "application/x-www-form-urlencoded")
.POST(HttpRequest.BodyPublishers.ofString(form))
.build();
HttpResponse<String> response = HTTP.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() >= 300 && response.statusCode() < 400) {
System.err.println(
"token request answered HTTP " + response.statusCode()
+ "; check IFMS_TOKEN_URL: " + WRONG_URL_HINT);
System.exit(1);
}
if (response.statusCode() != 200) {
System.err.println(
"token request failed with HTTP " + response.statusCode()
+ "; check IFMS_CLIENT_ID and IFMS_CLIENT_SECRET");
System.exit(1);
}
Map<String, Object> body =
MiniJson.object(parseJson(response.body(), "IFMS_TOKEN_URL"));
token = (String) body.get("access_token");
long expiresIn = MiniJson.integer(body.get("expires_in"));
renewAt = Instant.now().plusSeconds(expiresIn).minus(RENEW_EARLY);
}
}
/**
* A minimal JSON reader, just enough for this sample: Java has no built-in JSON API and a
* single-file program takes no dependencies. Objects become maps, arrays lists, numbers
* doubles. In production, use the JSON library your application already has (for example
* Jackson or Gson) instead of this.
*/
static final class MiniJson {
private final String text;
private int pos;
private MiniJson(String text) {
this.text = text;
}
static Object parse(String text) {
MiniJson reader = new MiniJson(text);
Object value = reader.value();
reader.skipWhitespace();
if (reader.pos != text.length()) throw reader.error("unexpected trailing content");
return value;
}
@SuppressWarnings("unchecked")
static Map<String, Object> object(Object value) {
if (value instanceof Map<?, ?> map) return (Map<String, Object>) map;
throw new IllegalArgumentException("expected a JSON object");
}
static List<?> array(Object value) {
if (value instanceof List<?> list) return list;
throw new IllegalArgumentException("expected a JSON array");
}
static long integer(Object value) {
if (value instanceof Double number) return number.longValue();
throw new IllegalArgumentException("expected a JSON number");
}
private Object value() {
skipWhitespace();
if (pos >= text.length()) throw error("unexpected end of input");
char c = text.charAt(pos);
return switch (c) {
case '{' -> readObject();
case '[' -> readArray();
case '"' -> readString();
case 't' -> literal("true", Boolean.TRUE);
case 'f' -> literal("false", Boolean.FALSE);
case 'n' -> literal("null", null);
default -> readNumber();
};
}
private Map<String, Object> readObject() {
Map<String, Object> map = new LinkedHashMap<>();
pos++; // {
skipWhitespace();
if (consume('}')) return map;
do {
skipWhitespace();
String key = readString();
skipWhitespace();
expect(':');
map.put(key, value());
skipWhitespace();
} while (consume(','));
expect('}');
return map;
}
private List<Object> readArray() {
List<Object> list = new ArrayList<>();
pos++; // [
skipWhitespace();
if (consume(']')) return list;
do {
list.add(value());
skipWhitespace();
} while (consume(','));
expect(']');
return list;
}
private String readString() {
expect('"');
StringBuilder out = new StringBuilder();
while (pos < text.length()) {
char c = text.charAt(pos++);
if (c == '"') return out.toString();
if (c != '\\') {
out.append(c);
continue;
}
char escaped = text.charAt(pos++);
switch (escaped) {
case 'b' -> out.append('\b');
case 'f' -> out.append('\f');
case 'n' -> out.append('\n');
case 'r' -> out.append('\r');
case 't' -> out.append('\t');
case 'u' -> {
out.append((char) Integer.parseInt(text.substring(pos, pos + 4), 16));
pos += 4;
}
default -> out.append(escaped); // \" \\ \/
}
}
throw error("unterminated string");
}
private Double readNumber() {
int start = pos;
while (pos < text.length() && "+-0123456789.eE".indexOf(text.charAt(pos)) >= 0) pos++;
if (start == pos) throw error("unexpected character");
return Double.valueOf(text.substring(start, pos));
}
private Object literal(String word, Object value) {
if (!text.startsWith(word, pos)) throw error("unexpected character");
pos += word.length();
return value;
}
private void skipWhitespace() {
while (pos < text.length() && Character.isWhitespace(text.charAt(pos))) pos++;
}
/** Consumes {@code c} if it is next, after whitespace. */
private boolean consume(char c) {
skipWhitespace();
if (pos < text.length() && text.charAt(pos) == c) {
pos++;
return true;
}
return false;
}
private void expect(char c) {
if (!consume(c)) throw error("expected '" + c + "'");
}
private IllegalArgumentException error(String message) {
return new IllegalArgumentException("invalid JSON at position " + pos + ": " + message);
}
}
}
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:
- Access tokens: cache and renew tokens, and protect your credentials.
- Sending telemetry: every field and limit, and how to read the 202 response.
- Troubleshooting and FAQ: what each error means and what to do.