Paging
Lists come back in one of three ways.
Pages
Vehicles, trips, events and sensors take page (from 1) and limit (1 to 200, default 50).
meta has page, pages, count and next_page, which is
null on the last page. Asking for a page past the end is 400.
Syncing events with after_id
Send after_id to GET /events to read every event exactly once, oldest first.
meta then has next_after_id and has_more. See the
guide below.
Time windows
Positions and sensor readings take a from and to window, oldest first by recorded_at.
Times are ISO 8601 with a time zone, like 2026-10-11T09:00:00Z; a time without one is 400.
Leave out to for now, and from for the default window before to.
- Positions: 1 hour by default, at most 24 hours, up to 5,000 per call.
- Sensor readings: 24 hours by default, at most 7 days, up to 10,000 per call.
When a window holds more than one call returns, meta.truncated is true. Call again with
meta.next_from as from and the same to. next_from is inclusive,
so rows at that second come back again: dedupe on recorded_at.
Link header
As a convenience, lists also send a Link header with the full URL of the next call, keeping every
other parameter you sent. Pages link first, prev, next and last.
No more to fetch means no Link header. meta stays the source of truth.
Growth
Within v1 we only add things: new fields, endpoints, event types and enum values. Ignore anything you
do not know, and your integration keeps working. Every documented field is always present, and
null when unknown. Times are UTC ending in Z, ids are integers, and units are in the
field name, like speed_kmh.
Webhooks
A webhook sends events to your server as they happen, so you do not have to poll. Each account has one
webhook. Set it up in Developers, under Webhook: give an HTTPS address, choose the event
types you want (or all of them), and copy the signing secret. Send test sends a
test.ping so you can check your receiver.
What we send
A POST with a JSON body and these headers:
| Header | Meaning |
Lokkate-Event-Id | The payload id. |
Lokkate-Event-Type | The payload type, like trip.completed. |
Lokkate-Signature | t=<unix seconds>,v1=<hex signature>. Check it before you trust the body. |
The body is always { id, type, created_at, data }. For events, data.event is the event
in the same shape as GET /events, and data.trip is the trip summary on
trip.completed and null otherwise.
{
"id": 55204,
"type": "trip.completed",
"created_at": "2026-10-11T07:42:56Z",
"data": {
"event": {
"id": 55204,
"type": "trip.completed",
"occurred_at": "2026-10-11T07:42:55Z",
"vehicle_id": 1042,
"driver": {
"id": 311,
"name": "Ama Owusu"
},
"location": {
"latitude": 5.6698,
"longitude": -0.0166
},
"data": {
"trip_id": 9120
}
},
"trip": {
"id": 9120,
"vehicle_id": 1042,
"status": "completed",
"started_at": "2026-10-11T06:58:10Z",
"ended_at": "2026-10-11T07:42:55Z",
"duration_seconds": 2685,
"distance_km": 28.4,
"max_speed_kmh": 78,
"moving_seconds": 2110,
"idle_seconds": 575,
"stops": 3,
"start": {
"latitude": 5.6037,
"longitude": -0.187
},
"end": {
"latitude": 5.6698,
"longitude": -0.0166
},
"driver": {
"id": 311,
"name": "Ama Owusu"
}
}
}
}
Verifying the signature
v1 is the HMAC-SHA256 of the timestamp, a dot and the raw body (t.body), in hex, keyed
with your whole signing secret, whsec_ included. Use the raw bytes you received, before any JSON
parsing. Reject the request when the signature does not match or t is more than 5 minutes from
your clock.
require "openssl"
def valid_signature?(header, raw_body, secret)
parts = header.to_s.split(",").filter_map { |pair| pair.split("=", 2) if pair.include?("=") }.to_h
timestamp = parts["t"].to_i
return false if (Time.now.to_i - timestamp).abs > 300
expected = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{timestamp}.#{raw_body}")
OpenSSL.secure_compare(expected, parts["v1"].to_s)
end
# Rails: valid_signature?(request.headers["Lokkate-Signature"], request.raw_post, ENV["LOKKATE_WEBHOOK_SECRET"])
const crypto = require("node:crypto");
function validSignature(header, rawBody, secret) {
const parts = Object.fromEntries(
String(header).split(",").filter((pair) => pair.includes("=")).map((pair) => {
const index = pair.indexOf("=");
return [pair.slice(0, index), pair.slice(index + 1)];
})
);
const timestamp = Number(parts.t);
if (!Number.isInteger(timestamp) || Math.abs(Date.now() / 1000 - timestamp) > 300) return false;
const expected = crypto.createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex");
const given = String(parts.v1 || "");
return given.length === expected.length && crypto.timingSafeEqual(Buffer.from(given), Buffer.from(expected));
}
// Express: app.post("/lokkate", express.raw({ type: "application/json" }), (req, res) => {
// if (!validSignature(req.get("Lokkate-Signature"), req.body, process.env.LOKKATE_WEBHOOK_SECRET)) return res.sendStatus(400);
// res.sendStatus(204);
// });
import hashlib
import hmac
import time
def valid_signature(header: str, raw_body: bytes, secret: str) -> bool:
parts = dict(pair.split("=", 1) for pair in (header or "").split(",") if "=" in pair)
try:
timestamp = int(parts.get("t", ""))
except ValueError:
return False
if abs(time.time() - timestamp) > 300:
return False
signed = f"{timestamp}.".encode() + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts.get("v1", ""))
Answering, retries and pausing
- Answer with any 2xx within 10 seconds. The 10 seconds cover the whole exchange, so do the work later, after you answer. We do not follow redirects.
- Anything else is retried after about 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 12 hours.
- While your server is failing, new events wait too, instead of arriving all at once.
- A webhook that has failed for 24 hours straight is turned off and the account owner gets an email. Turn it back on in Developers once your server is fixed.
- Every delivery and every attempt, with the status and the start of your answer, is listed on the webhook's page in Developers.
Duplicates and order
Delivery is at least once and in no fixed order. Dedupe on type and id together, and use
occurred_at, not arrival order, to know what happened first. The only exception is
test.ping, whose id is always 0.
Live positions
positions.batch pushes positions so you do not have to poll. It is off by default and is not part of
"all events": turn on Live positions on your webhook. Every 30 seconds you get one
POST with every new position of your vehicles since the last one, oldest first, up to 2,000 at a time.
Nothing is sent when nothing is new, and nothing from before you turned it on is sent. The payload
id is the last position id in the batch.
{
"id": 8812045,
"type": "positions.batch",
"created_at": "2026-10-11T09:00:35Z",
"data": {
"from": "2026-10-11T09:00:04Z",
"to": "2026-10-11T09:00:28Z",
"count": 2,
"positions": [
{
"vehicle_id": 1042,
"recorded_at": "2026-10-11T09:00:04Z",
"latitude": 5.6698,
"longitude": -0.0166,
"speed_kmh": 46,
"course": 92.0,
"state": "moving",
"ignition": true
},
{
"vehicle_id": 1043,
"recorded_at": "2026-10-11T09:00:28Z",
"latitude": 5.556,
"longitude": -0.1969,
"speed_kmh": 0,
"course": 0.0,
"state": "parked",
"ignition": false
}
]
}
}
Event types
These are the values of type, in webhooks and in GET /events. New types may be added:
ignore the ones you do not know. Engine events are only sent when Lokkate staff turn them on for you.
| Type | When it fires |
trip.started | The engine went on and a trip began.data: trip_id |
trip.completed | The engine went off and the trip ended. In webhooks, data.trip carries the trip summary.data: trip_id |
geofence.exited | A vehicle left a geofence it is assigned to.data: geofence_id geofence_name speed_kmh reason_text |
geofence.entered | A vehicle came into a geofence it was outside of. outside_seconds says how long it was outside.data: geofence_id geofence_name speed_kmh outside_seconds reason_text |
sensor.out_of_range | A sensor read a temperature outside its target range. Sent on the first reading out of range, once per breach.data: sensor_id sensor_label celsius target_min target_max reason_text |
sensor.back_in_range | A sensor that was out of range is reading inside its target range again.data: sensor_id sensor_label celsius target_min target_max reason_text |
sensor.offline | A sensor stopped reporting.data: sensor_id sensor_label |
sensor.online | A sensor that was offline is reporting again.data: sensor_id sensor_label |
vehicle.offline | A vehicle's tracker stopped reporting. |
vehicle.online | A vehicle's tracker is reporting again. Says how long it was offline.data: offline_seconds |
speeding.started | A vehicle went over a speed limit set for it.data: speed_limit_id speed_limit_name max_kmh speed_kmh peak_kmh reason_text |
speeding.ended | A vehicle is back under the speed limit. Says how long it was over and its top speed.data: speed_limit_id speed_limit_name max_kmh speed_kmh peak_kmh over_seconds reason_text |
service.due_soon | A vehicle's service is coming up.data: service_tracker_id reason_text |
service.overdue | A vehicle's service is overdue.data: service_tracker_id reason_text |
driver.missing | A pool vehicle moved with no driver checked in. |
engine.immobilized | The engine was cut remotely. Only sent when Lokkate staff turn engine events on. |
engine.released | The engine was released remotely. Only sent when Lokkate staff turn engine events on. |
engine.immobilize_failed | A remote engine cut did not go through. Only sent when Lokkate staff turn engine events on.data: reason_text |
engine.release_failed | A remote engine release did not go through. Only sent when Lokkate staff turn engine events on.data: reason_text |
positions.batch | Every new position of your visible vehicles, pushed every 30 seconds. Off by default; turn on Live positions. |
test.ping | The test event sent from the Developers page. Its id is always 0; do not dedupe on it. |