Skip to main content

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:

EventDescription
safety_eventSafety event occurred — data.subType carries the specific canonical event type (harsh_braking, driving_impact, …)
geofence_enterGeofence entered (data.fenceId / data.fenceName)
geofence_exitGeofence exited (data.fenceId / data.fenceName)
device_disconnectedDevice disconnected for an extended period
export_completedGPS 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 exposed exactly once, in the creation response

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 / pathDescription
GET /v1/webhooksList subscriptions (secret not exposed)
GET /v1/webhooks/{webhookId}Get a single subscription
DELETE /v1/webhooks/{webhookId}Delete a subscription
POST /v1/webhooks/{webhookId}/testSend 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}/deliveriesDelivery history for the last 30 days (see Delivery history)
POST /v1/webhooks/{webhookId}/reactivateResume a subscription that was suspended automatically (see Automatic suspension)
POST /v1/webhooks/{webhookId}/rotate-secretIssue 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.

One exception: test deliveries

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.

Two kinds of speeding

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, so GET /v1/events/{eventId}/video returns 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 in data.speedLimit instead. This is the same judgement the Fleeta web viewer shows, and it is the same speed_limit type GET /v1/events returns.

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"
}
}
}
FieldMeaning
speedLimit.limitKmhPosted limit on the last road segment of the violation
speedLimit.overKmhPeak excess over that limit
speedLimit.durationSecHow long the vehicle stayed above the limit
speedLimit.roadNameRoad name, when the map data has one
location.speedKmhPeak 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: true means some cameras/chunks failed and the artifact covers only part of the requested range.
  • downloadUrl (with downloadFileName / 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:

  1. Check that X-Fleeta-Timestamp is within an allowed tolerance of the current time (5 minutes recommended) — prevents replay attacks.
  2. Recompute the signature the same way and compare each part in constant time.
  3. 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-After header (delay in seconds, or an HTTP-date), the next redelivery waits that long — capped at 5 minutes. Retry-After only 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 on eventKey first; data.eventId remains a secondary key. Implement your receive handler idempotently.
  • data.eventId is issued by the event producer and lives in a different namespace from the eventId of GET /v1/events — do not pass it to GET /v1/events/{eventId}. Locate the matching event by psn and 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:

  1. 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.
  2. 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 (200 with the subscription; 409 webhook_not_suspended if it is already active). No need to delete and re-register. Reactivating also resets the failure counter and the streak clock.
  • Check the current status and suspendedAt with GET /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 on GET /v1/webhooks/{webhookId}) tells until when the previous secret still co-signs; it is null outside 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. A POST /v1/webhooks/{webhookId}/test call writes one record (single attempt, status code and network error included) so you can confirm the sample reached your receiver. Filter on test when 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: false with a statusCode means your endpoint responded non-2xx. When no HTTP response was received at all, statusCode is null and error carries the network-level reason (e.g. timeout (5s)).
  • eventKey matches the X-Fleeta-Event-Key header — join it against your dedupe table; null on records older than the field.
  • eventId matches data.eventId of 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}/test to 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 with test: 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.
  • GPS Export — the export_completed workflow end to end
  • Pagination — feed cursor polling as an alternative to webhooks
  • Rate Limits — the quota advantage of webhooks over polling
  • Authentication — the webhooks:manage scope