Webhooks
Events such as safety events, geofence enter/exit, long device disconnection, and GPS Export completion are pushed to a URL you specify. You receive events in near real time without polling — especially valuable for large fleets.
Webhooks are the canonical channel for discrete events. For continuous, high-volume data (live GPS locations), use cursor tail polling on the feed endpoints instead — see Pagination. There is no WebSocket/streaming API, by design.
Every delivery includes an HMAC-SHA256 signature and a timestamp, so the
receiver can block forgery and replay, plus a stable X-Fleeta-Event-Key so
you can dedupe redeliveries. Managing subscriptions requires the
webhooks:manage scope (pro tier or above — see
Authentication).
Event types
Five event types can be subscribed to:
| Event | Description |
|---|---|
safety_event | Safety event occurred — data.subType carries the specific canonical event type (harsh_braking, driving_impact, …) |
geofence_enter | Geofence entered (data.fenceId / data.fenceName) |
geofence_exit | Geofence exited (data.fenceId / data.fenceName) |
device_disconnected | Device disconnected for an extended period |
export_completed | GPS Export job completed — includes a presigned downloadUrl (see GPS Export) |
Registering a subscription
Register with POST /v1/webhooks. The url must be https://.
curl -X POST "https://openapi.fleeta.io/v1/webhooks" \
-H "Authorization: Bearer flt_live_..." \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/hooks/fleeta",
"events": ["safety_event", "geofence_enter"]
}'
{
"data": {
"webhookId": "wh_a1b2c3d4",
"url": "https://example.com/hooks/fleeta",
"events": ["safety_event", "geofence_enter"],
"status": "active",
"createdAt": "2026-07-13T04:00:00.000Z",
"suspendedAt": null,
"previousSecretExpiresAt": null,
"secret": "3f9c…(64 hex chars)"
}
}
The secret is the signature verification key and cannot be retrieved
again after this response (it is excluded from list/detail lookups).
Store it in a secrets manager immediately. If lost, rotate it with
POST /v1/webhooks/{webhookId}/rotate-secret — see
Rotating the secret.
Subscription management endpoints:
| Method / path | Description |
|---|---|
GET /v1/webhooks | List subscriptions (secret not exposed) |
GET /v1/webhooks/{webhookId} | Get a single subscription |
DELETE /v1/webhooks/{webhookId} | Delete a subscription |
POST /v1/webhooks/{webhookId}/test | Send a real sample payload (for verifying your receiver; recorded in delivery history with test: true, never suspends). Sandbox keys do not send |
GET /v1/webhooks/{webhookId}/deliveries | Delivery history for the last 30 days (see Delivery history) |
POST /v1/webhooks/{webhookId}/reactivate | Resume a subscription that was suspended automatically (see Automatic suspension) |
POST /v1/webhooks/{webhookId}/rotate-secret | Issue a new signing secret with a co-signing grace window (see Rotating the secret) |
Delivery format
When an event occurs, the subscription URL receives a POST like this.
POST /hooks/fleeta HTTP/1.1
Content-Type: application/json
X-Fleeta-Event: safety_event
X-Fleeta-Delivery: 6d1e2f3a-... # delivery ID — new for every delivery burst
X-Fleeta-Event-Key: 9f2c4b7a1d0e... # stable across redeliveries and replays — your dedupe key
X-Fleeta-Timestamp: 1783742400 # unix seconds — included in the signed payload
X-Fleeta-Signature: v1=5257a869e7... # HMAC-SHA256 hex — "v1=<new>,v1=<previous>" for 24 h after a secret rotation
{ "event": "safety_event", "deliveryId": "6d1e2f3a-...", "eventKey": "9f2c4b7a1d0e...", "at": "2026-07-13T04:00:00.000Z", "data": { ... } }
The body envelope is always { event, deliveryId, eventKey, at, data } —
event matches the X-Fleeta-Event header, eventKey matches
X-Fleeta-Event-Key, at is the delivery time, and the event-specific
fields are inside data.
POST /v1/webhooks/{webhookId}/test sends X-Fleeta-Event: test while the
body keeps "event": "safety_event" — the only case where the header and the
body disagree. If you route on the header, add test to your router (or route
on the body's event instead) so verification deliveries do not land in your
unknown-event branch.
Event payloads
Fields in data are additive — implement your receiver to ignore unknown
fields. Optional fields (e.g. location when no fix is available) may be
omitted rather than sent as null.
safety_event
data.type is the subscription event name; data.subType carries the
specific safety event as a
canonical event type value
(driving_impact, overspeed, harsh_braking, drowsy, …), drawn from the
same vocabulary as GET /v1/events.
subType currently uses 12 of those values — driving_impact ·
parking_impact · overspeed · speed_limit · harsh_braking ·
harsh_acceleration · sharp_turn · drowsy · distracted · seatbelt ·
undetected · manual. The geofence types arrive as their own geofence_enter /
geofence_exit events rather than as a subType, and calling / smoking are not yet published
over webhooks — they appear only in the insights aggregations. Treat the
list as additive and ignore values you do not recognise.
overspeed and speed_limit are different events and both can arrive.
overspeed— the dashcam's own fixed threshold fired (for example "over 100 km/h"). It has a recording, soGET /v1/events/{eventId}/videoreturns a clip.speed_limit— the server compared the vehicle's GPS track against the posted limit for the road it was on and found a violation. There is no recording; the detail arrives indata.speedLimitinstead. This is the same judgement the Fleeta web viewer shows, and it is the samespeed_limittypeGET /v1/eventsreturns.
A vehicle can trigger one, the other, or both for the same drive. Route them separately if you act on them — "over the posted limit" is a compliance signal, "over 100 km/h" is not.
{
"event": "safety_event",
"deliveryId": "6d1e2f3a-...",
"eventKey": "9f2c4b7a1d0e...",
"at": "2026-07-16T04:00:01.000Z",
"data": {
"eventId": "evt_a1b2c3d4e5f6",
"type": "safety_event",
"subType": "harsh_braking",
"psn": "7XBPK0BE00000001",
"deviceName": "Truck-01",
"groupId": "grp_12",
"occurredAt": "2026-07-16T04:00:00.000Z",
"location": { "lat": 37.5172, "lng": 127.0473, "speedKmh": 62 }
}
}
When subType is speed_limit, data carries a speedLimit object with the violation
detail, and location.speedKmh is the peak speed reached. The field names match
Event.speedLimit on GET /v1/events, so a value you receive here compares directly with
what the read API returns. Fields whose value could not be determined are omitted rather
than sent as null — a road with no posted limit in the map data has no limitKmh, and
without it there is no overKmh either.
{
"event": "safety_event",
"deliveryId": "0f3a7c91-...",
"eventKey": "4e8b0c2f9a17...",
"at": "2026-09-19T08:12:04.000Z",
"data": {
"eventId": "evt_c3d4e5f6a1b2",
"type": "safety_event",
"subType": "speed_limit",
"psn": "7XBPK0BE00000001",
"deviceName": "Truck-01",
"occurredAt": "2026-09-19T08:11:17.000Z",
"location": { "speedKmh": 112.4 },
"speedLimit": {
"limitKmh": 80,
"overKmh": 32.4,
"durationSec": 47,
"roadName": "Gyeongbu Expressway"
}
}
}
| Field | Meaning |
|---|---|
speedLimit.limitKmh | Posted limit on the last road segment of the violation |
speedLimit.overKmh | Peak excess over that limit |
speedLimit.durationSec | How long the vehicle stayed above the limit |
speedLimit.roadName | Road name, when the map data has one |
location.speedKmh | Peak speed reached during the violation |
Coordinates are not included on this subType — use GET /v1/events for the violation's
location, endedAt, peakAt, country and schoolZone.
geofence_enter / geofence_exit
Geofence deliveries add the fence identity (fenceId / fenceName).
{
"event": "geofence_enter",
"deliveryId": "9c4b1a20-...",
"eventKey": "9f2c4b7a1d0e...",
"at": "2026-07-16T04:05:01.000Z",
"data": {
"eventId": "evt_b2c3d4e5f6a1",
"type": "geofence_enter",
"psn": "7XBPK0BE00000001",
"deviceName": "Truck-01",
"occurredAt": "2026-07-16T04:05:00.000Z",
"location": { "lat": 37.4981, "lng": 127.0292, "speedKmh": 38 },
"fenceId": "gf_0001",
"fenceName": "Seoul Depot"
}
}
fenceId corresponds to the geofenceId of GET /v1/geofences — use it to
join the delivery against your geofence inventory.
device_disconnected
{
"event": "device_disconnected",
"deliveryId": "1f2e3d4c-...",
"eventKey": "9f2c4b7a1d0e...",
"at": "2026-07-16T05:00:01.000Z",
"data": {
"eventId": "evt_c3d4e5f6a1b2",
"type": "device_disconnected",
"psn": "7XBPK0BE00000003",
"deviceName": "Van-01",
"occurredAt": "2026-07-16T05:00:00.000Z"
}
}
export_completed
Fires when a GPS Export job finishes. The payload includes a presigned
downloadUrl (valid for downloadUrlExpiresIn seconds — 3600 = 1 hour),
so a server integration can download the artifact directly from the webhook
without polling. If the URL has expired, re-request
GET /v1/gps/export-jobs/{jobId} — a fresh URL is generated on each lookup.
See the GPS Export guide for the full workflow.
{
"event": "export_completed",
"deliveryId": "7a8b9c0d-...",
"eventKey": "9f2c4b7a1d0e...",
"at": "2026-07-16T06:00:01.000Z",
"data": {
"eventId": "evt_d4e5f6a1b2c3",
"jobId": "gpsexp_a1b2c3d4e5f6",
"occurredAt": "2026-07-16T06:00:00.000Z",
"partial": false,
"format": "csv",
"packaging": "single",
"cameraCount": 3,
"rangeStart": "2026-06-01",
"rangeEnd": "2026-07-01",
"sizeBytes": 1834021,
"downloadUrl": "https://…s3….amazonaws.com/gps-export/…/output.zip?X-Amz-Signature=…",
"downloadFileName": "gps-export_20260601_20260701_csv.zip",
"downloadUrlExpiresIn": 3600
}
}
partial: truemeans some cameras/chunks failed and the artifact covers only part of the requested range.downloadUrl(withdownloadFileName/downloadUrlExpiresIn) is omitted when the job produced no data — treat it as optional.
Verifying the signature
The signed payload is the string v1:{timestamp}:{raw body}; the
X-Fleeta-Signature header carries one v1=<hex> HMAC-SHA256 digest per
valid secret, comma-separated — normally one, two during a
secret-rotation grace window (new secret first).
Accept the request if any part matches. The receiver must:
- Check that
X-Fleeta-Timestampis within an allowed tolerance of the current time (5 minutes recommended) — prevents replay attacks. - Recompute the signature the same way and compare each part in constant time.
- Reject requests that fail verification (4xx).
const crypto = require('crypto');
const TOLERANCE_SEC = 300; // 5 minutes — replay-prevention window
function verifyWebhook(secret, rawBody, headers) {
const ts = headers['x-fleeta-timestamp'];
const header = headers['x-fleeta-signature']; // "v1=<hex>" — or "v1=<hex>,v1=<hex>" during a secret rotation
if (!ts || !header) return false;
// 1) validate the timestamp window
if (Math.abs(Date.now() / 1000 - Number(ts)) > TOLERANCE_SEC) return false;
// 2) recompute the signature — payload is "v1:{timestamp}:{raw body}"
const expected = crypto
.createHmac('sha256', secret)
.update(`v1:${ts}:${rawBody}`)
.digest('hex');
// 3) constant-time comparison — any matching part is enough.
// Validate the hex shape first and compare decoded bytes: a forged value of the same
// character length but a different byte length would otherwise throw inside timingSafeEqual.
return header.split(',').some((part) => {
const [scheme, sig] = part.trim().split('=');
if (scheme !== 'v1' || !/^[0-9a-f]{64}$/.test(sig || '')) return false;
return crypto.timingSafeEqual(Buffer.from(sig, 'hex'), Buffer.from(expected, 'hex'));
});
}
Express receiver example — the signature covers the raw body, so you must use the raw body before JSON parsing:
app.post('/hooks/fleeta', express.raw({ type: 'application/json' }), (req, res) => {
const rawBody = req.body.toString('utf8');
if (!verifyWebhook(process.env.WEBHOOK_SECRET, rawBody, req.headers)) {
return res.status(401).end(); // always reject verification failures
}
const payload = JSON.parse(rawBody);
// dedupe on the stable event key — deliveryId changes on every redelivery
if (alreadyProcessed(req.get('X-Fleeta-Event-Key') || payload.eventKey)) return res.status(200).end();
enqueue(payload); // hand heavy work off to a queue
res.status(200).end(); // respond 2xx immediately
});
Checking your verifier against ours
POST /v1/webhooks/{webhookId}/test returns a signatureHeader field. It is
the signature value we sent with that test — v1=<hex>, or
v1=<new>,v1=<previous> inside a rotation grace window — not the header name.
Compare it with the value your endpoint computed for the same request: if the
two agree, your payload assembly (v1:{timestamp}:{raw body}), your secret and
your digest are all correct; if they differ while the delivery still succeeded,
your receiver is accepting requests it should have rejected.
Retries and idempotency
- Only 2xx counts as success. 5xx, network errors and the 5-second timeout are retried inside the burst. 408 and 429 ("later, not never") are retried too, but by the queue rather than inside the burst — see below. Every other 4xx is final — the burst stops at the first 4xx and the event is not queued for redelivery on its own. While your receiver is deploying, answer 5xx (not 4xx) so the event comes back; an outage shorter than 15 minutes cannot on its own trigger automatic suspension, however many events it fails — the 15-minute clock starts at the first failure of the current streak, which may predate the outage if no delivery has succeeded since.
- In-burst: up to 3 attempts with backoff (immediately, +0.5 s, +1.5 s; 5-second timeout each) for 5xx, network errors and timeouts.
- 408 / 429: no in-burst retry — the burst ends after a single attempt
and the event goes straight back to the queue. If the response carries a
Retry-Afterheader (delay in seconds, or an HTTP-date), the next redelivery waits that long — capped at 5 minutes.Retry-Afteronly ever lengthens the wait: a value of 60 seconds or less falls back to the default 60-second queue delay below, so the 5-burst retry window is never shortened; without the header the default queue delay applies. - Queue-level: a burst that ends with a retryable failure returns the event to the delivery queue; it is redelivered about 60 seconds later as a new burst, at most 5 bursts per event (the first plus 4 redeliveries) — roughly a 5-minute window. Events still failing after that are parked in a dead-letter store for 14 days, where Fleeta operations can replay them; there is no self-service replay yet.
- A queue-level redelivery goes to every subscription of your
organization that received the event — including ones that already
answered 2xx or a final 4xx. A 4xx subscription is never re-queued on
its own, but it rides along when another subscription of the same
organization still needs a redelivery. Dedupe on
eventKey. deliveryId(X-Fleeta-Delivery) is per burst — new on every redelivery.eventKey(X-Fleeta-Event-Key, also in the body) is stable across redeliveries and across operator replays from the dead-letter store — it is derived from the event itself (data.eventId), not from the queue message. Dedupe oneventKeyfirst;data.eventIdremains a secondary key. Implement your receive handler idempotently.data.eventIdis issued by the event producer and lives in a different namespace from theeventIdofGET /v1/events— do not pass it toGET /v1/events/{eventId}. Locate the matching event bypsnand time.- An event whose first delivery burst fails — including a 4xx — counts once towards automatic suspension, however many times it is redelivered afterwards.
Automatic suspension
If deliveries to a subscription keep failing, the subscription is suspended automatically to protect both sides from pointless traffic. Two conditions must both hold:
- At least 10 consecutive events failed. Each event counts once — on its first delivery burst — however many times the queue redelivers it, and a 4xx counts as a failure just like a 5xx or a timeout. Any successful delivery in between resets the counter to zero.
- The failures have been going on for at least 15 minutes, measured from the first failure of the current streak. A short outage — a deploy, a restart, a brief rate limit — therefore cannot suspend you on its own, even if it fails many events; only a receiver that stays broken does. Note that a streak stays open until a delivery succeeds: one earlier failure with no successful delivery after it (even with no events in between) is still part of the streak, so its clock may already be running when an outage begins.
Only real event deliveries are counted. A test delivery
(POST /v1/webhooks/{webhookId}/test) is a diagnostic that runs outside the
event pipeline: it never touches the failure counter and never suspends a
subscription, however many times it fails. Ten failed test calls in a row
therefore change nothing — verify the rule with real events, or read the
counter's effect from status / suspendedAt on
GET /v1/webhooks/{webhookId}.
When both hold, the subscription's status changes from active to
suspended, deliveries stop, and a webhook.suspended entry is written to
your organization's audit log.
- A suspended subscription receives no further events — including queue-level redeliveries. Events that arrive while it is suspended are not dropped: when no other active subscription of yours received them, they are parked in the dead-letter store (14 days), where Fleeta operations can replay them once you have reactivated. Ask support if you need that replay; there is no self-service replay yet.
- To resume deliveries, fix your receiver first, then call
POST /v1/webhooks/{webhookId}/reactivate— the same URL, events and secret resume (200with the subscription;409 webhook_not_suspendedif it is already active). No need to delete and re-register. Reactivating also resets the failure counter and the streak clock. - Check the current
statusandsuspendedAtwithGET /v1/webhooks/{webhookId}, and use Delivery history to find out why deliveries were failing (receiver 5xx, timeouts, TLS errors, …).
curl -X POST "https://openapi.fleeta.io/v1/webhooks/wh_a1b2c3d4/reactivate" \
-H "Authorization: Bearer flt_live_..."
Rotating the secret
POST /v1/webhooks/{webhookId}/rotate-secret issues a new signing secret
and returns it once. For graceSeconds (default 86400 = 24 hours)
every delivery is signed with both secrets —
X-Fleeta-Signature: v1=<new>,v1=<previous> — so your receiver can switch
at any moment inside the window; afterwards only the new secret signs.
curl -X POST "https://openapi.fleeta.io/v1/webhooks/wh_a1b2c3d4/rotate-secret" \
-H "Authorization: Bearer flt_live_..." \
-H "Content-Type: application/json" \
-d '{ "graceSeconds": 86400 }' # body optional — omit it for the 24-hour default
{
"data": {
"webhookId": "wh_a1b2c3d4",
"url": "https://example.com/hooks/fleeta",
"events": ["safety_event", "geofence_enter"],
"status": "active",
"createdAt": "2026-07-13T04:00:00.000Z",
"suspendedAt": null,
"previousSecretExpiresAt": "2026-09-02T04:00:00.000Z",
"secret": "a7c1…(64 hex chars — the new secret)"
}
}
- Update your verifier first. The sample above accepts any matching part; a verifier that compares the whole header against a single value will reject every delivery during the window.
previousSecretExpiresAt(also onGET /v1/webhooks/{webhookId}) tells until when the previous secret still co-signs; it isnulloutside a grace window.- Compromised secret: send
{ "graceSeconds": 0 }to revoke the previous secret immediately — switch the receiver to the new secret first. - Rotating again inside the window discards the previous secret at once: at most two secrets are ever valid.
- Subscriptions that never rotate keep the single-signature header — nothing changes for them.
Delivery history
GET /v1/webhooks/{webhookId}/deliveries returns the subscription's
delivery records for the last 30 days (older records are purged
automatically), latest first, with offset pagination (page / perPage —
see Pagination). Each record is one delivery burst;
attempts is the number of in-burst tries (1–3; always 1 after a final 4xx
or a 408/429, which end the burst at once). Use it to debug your
receiver — e.g. after a subscription got suspended, or when events seem to
be missing.
curl "https://openapi.fleeta.io/v1/webhooks/wh_a1b2c3d4/deliveries?page=1&perPage=20" \
-H "Authorization: Bearer flt_live_..."
{
"data": [
{
"deliveryId": "6d1e2f3a-...",
"eventKey": "9f2c4b7a1d0e...",
"event": "safety_event",
"eventId": "evt_a1b2c3d4e5f6",
"delivered": false,
"statusCode": 500,
"attempts": 3,
"error": null,
"at": "2026-07-16T04:00:01.000Z"
}
],
"pagination": { "page": 1, "perPage": 20, "total": 1, "totalPages": 1 }
}
- Test deliveries are recorded too, flagged
test: true. APOST /v1/webhooks/{webhookId}/testcall writes one record (single attempt, status code and network error included) so you can confirm the sample reached your receiver. Filter ontestwhen you only want real events. A subscription that has never been tested and whose events have not occurred yet returns an empty list — the expected state right after registering, not a broken endpoint. delivered: falsewith astatusCodemeans your endpoint responded non-2xx. When no HTTP response was received at all,statusCodeisnullanderrorcarries the network-level reason (e.g.timeout (5s)).eventKeymatches theX-Fleeta-Event-Keyheader — join it against your dedupe table;nullon records older than the field.eventIdmatchesdata.eventIdof the delivered payload — join it against your receiver's logs to spot gaps.- The target URL is not part of the record — look it up with
GET /v1/webhooks/{webhookId}.
Best practices
- Respond 2xx immediately; process asynchronously. Heavy work inside the receive handler triggers the timeout (5 seconds) and causes retries. Enqueue and respond right away.
Sandbox keys do not send
A sandbox key (flt_test_…) performs no outbound requests, so a test delivery
never reaches your URL. The call still answers 200, but the body says what
actually happened:
{
"data": {
"delivered": false,
"sandbox": true,
"error": "The sandbox does not send outbound requests — nothing was delivered to your URL. The signature above was computed for real: verify it against your own HMAC, then re-test with a live key to exercise your receiver end to end.",
"signatureHeader": "v1=3f8a1c9e…"
}
}
The signature is computed exactly as a live delivery computes it, so the sandbox
is still the right place to check that your verification code agrees with ours.
What it cannot prove is that your endpoint is reachable, that its TLS chain
validates, or that it answers 2xx — issue a live key and re-run the test for
that. The attempt is recorded in Delivery history with
test: true so you can see it happened.
- Right after registering, use
POST /v1/webhooks/{webhookId}/testto verify end to end, including your signature verification code. Test deliveries use the same signing contract as real deliveries and are recorded in Delivery history withtest: true, but they are diagnostics only: they never count towards automatic suspension, however many of them fail. - Event ordering is not guaranteed. If order matters, process by the body's
at(occurrence time). - Manage the secret only via environment variables / a secrets manager, and
rotate it periodically with
POST /v1/webhooks/{webhookId}/rotate-secret— see Rotating the secret.
Related links
- GPS Export — the
export_completedworkflow end to end - Pagination — feed cursor polling as an alternative to webhooks
- Rate Limits — the quota advantage of webhooks over polling
- Authentication — the
webhooks:managescope