Use webhook endpoints to receive real-time EonsFleet events in your own systems, with signed HTTP callbacks, retryable delivery logs, and an event catalog you can query from the API.
Webhook management endpoints are separate from inbound provider webhooks. They live under /api/v1/webhooks and require normal API authentication.
Send either a Bearer token or API key, plus X-Request-Type: client.
Authorization: Bearer <access_token> X-Request-Type: client
The delivery URL must be HTTPS. Redirects are disabled during delivery, and unsafe internal/private network targets are blocked.
url + starts_with:httpsThe signing secret is returned only when you create or regenerate an endpoint. Store it before leaving that response.
whsec_...Set location_throttle_seconds to control how often location.updated fires for this endpoint — as low as 30s. Omit it to use the account-wide default (300s).
30–3600# Create a webhook endpoint POST /api/v1/webhooks Authorization: Bearer <access_token> X-Request-Type: client Content-Type: application/json { "name": "Production operations webhook", "url": "https://example.com/eonsfleet/webhooks", "description": "Receives trip and incident events", "events": [ "trip.started", "trip.completed", "incidents.created" ], // optional — omit both to get today's payload contract with no per-event filtering "payload_version": "2026-01-01", // optional — defaults to the account-wide location.updated cadence when omitted "location_throttle_seconds": 30, "subscriptions": [ { "event_type": "trip.started", "filters": { "vehicle_ids": [101, 102] } } ] }
events gets a subscription automatically. Only add an entry to subscriptions for an event type you want to restrict — here, trip.started only fires for vehicles 101/102, while trip.completed and incidents.created stay unrestricted and fire for every vehicle. vehicle_ids and device_imeis are the two supported filters today — use device_imeis if you're not tracking vehicles, only devices (e.g. {"device_imeis": ["8611002345"]}).
# 201 Created { "message": "Webhook endpoint registered", "data": { "id": 12, "public_id": "wh_01JABCXYZQRS7T8U9V0WXYZ12", "name": "Production operations webhook", "url": "https://example.com/eonsfleet/webhooks", "description": "Receives trip and incident events", "events": ["trip.started", "trip.completed", "incidents.created"], "payload_version": "2026-01-01", "location_throttle_seconds": 30, "status": "active", "last_delivery_at": null, "last_delivery_status": null, "success_rate_30d": null, "failure_count": 0, "last_failure_at": null, "created_at": "2026-08-06T08:00:00.000000Z", "updated_at": "2026-08-06T08:00:00.000000Z", "secret_key": "whsec_STORE_THIS_ONCE" } }
secret_key is shown exactly twice: on POST /webhooks and on POST /webhooks/{id}/regenerate-secret. It is never included in any other response, including GET requests. If you lose it, regenerate — there is no way to retrieve the current one.
Every webhook endpoint is gated by role-based access control. A user or API key without the right permission gets a 403, regardless of authentication being otherwise valid.
| Permission | Grants |
|---|---|
view_webhook | List/view endpoints, deliveries, delivery attempts, the event catalog, org stats, and replay runs. |
manage_webhook | Register, update, delete, test, and regenerate the secret for an endpoint; queue a bulk replay. |
# 403 Forbidden — missing permission { "message": "Unauthorize access" }
Each delivery includes a timestamp, event slug, and HMAC-SHA256 signature. The signature input is timestamp + "." + rawBody.
| Header | Example | Purpose |
|---|---|---|
X-Eonsfleet-Timestamp | 1774526400 | Unix timestamp used in the signature base string. |
X-Eonsfleet-Signature | sha256=... | HMAC-SHA256 digest generated with the endpoint secret. |
X-Eonsfleet-Event | trip.started | The event type for routing before parsing the body. |
// Node.js / Express signature verification
import crypto from "crypto";
function verifyEonsFleetWebhook(req, secret) {
const timestamp = req.header("X-Eonsfleet-Timestamp");
const signature = req.header("X-Eonsfleet-Signature") || "";
const rawBody = req.rawBody;
const expected = "sha256=" + crypto
.createHmac("sha256", secret)
.update(`${timestamp}.${rawBody}`)
.digest("hex");
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expected)
);
}
Webhook payloads use a small stable envelope with event-specific data in data.
{
"event": "trip.started",
"timestamp": "2026-04-03T08:00:00.000000Z",
"organization_id": 42,
"data": {
"id": 1001,
"reference": "TRIP-2026-1001",
"vehicle_id": 3,
"driver_id": 5,
"status": "started"
}
}
payload_version now controls the location.updated payload shape. Every other event type is unaffected regardless of your configured version. The default, 2026-01-01, keeps the original fields. Set 2026-09-01 to additionally receive processed telemetry — odometer, fuel level, altitude, satellites, and GPS confidence — on every location.updated delivery.
If you register a device (by IMEI) without linking it to a Vehicle record in EonsFleet — e.g. you're reselling tracking hardware and your own software is the fleet-management layer — location.updated still fires. vehicle_id is simply null; imei is always present on every location.updated delivery (both payload versions) so you can identify the physical unit either way. driver_id stays null too, since it's resolved from an in-progress Trip, which requires a Vehicle.
# location.updated — payload_version: 2026-01-01 (default) { "event": "location.updated", "timestamp": "2026-08-07T14:22:00.000000Z", "organization_id": "42", "data": { "vehicle_id": null, "imei": "861100234567890", "lat": 6.5244, "lng": 3.3792, "speed": 35, "heading": 180, "timestamp": "2026-08-07T14:22:00.000000Z", "driver_id": null, "ignition": "on", "movement": "moving" } }
# location.updated — payload_version: 2026-09-01 (enriched) { "event": "location.updated", "timestamp": "2026-08-07T14:22:00.000000Z", "organization_id": "42", "data": { "vehicle_id": null, "imei": "861100234567890", "lat": 6.5244, "lng": 3.3792, "speed": 35, "heading": 180, "timestamp": "2026-08-07T14:22:00.000000Z", "driver_id": null, "ignition": "on", "movement": "moving", "odometer_km": 12345.6, "fuel_level_percent": 71.5, "fuel_level_liters": 42.0, "fuel_rate": 2.1, "altitude": 45, "satellites": 9, "gps_confidence": 0.95 } }
Every webhook delivery uses the same envelope. The data object changes by event type, and the examples below show the exact field names client developers should parse.
| Event | When it is sent | data fields |
|---|---|---|
trip.started | A driver has started a trip. | id, reference, vehicle_id, driver_id, status |
trip.completed | A trip has been completed. | id, reference, vehicle_id, driver_id, status |
trip.assigned | A trip has been assigned to a driver. | id, reference, vehicle_id, driver_id, status |
location.updated | A vehicle or device location update was dispatched. Throttled per vehicle/device — cadence is configurable per endpoint via location_throttle_seconds (default 300s). Fires even when the reporting device has no linked vehicle. | vehicle_id, imei, lat, lng, speed, heading, timestamp, driver_id, ignition, movement — see payload versioning above for enriched telemetry fields |
incidents.created | A new incident was reported. | id, reference, type, status, priority, date |
incidents.resolved | An incident was resolved. | id, reference, type, status, priority, date |
incidents.closed | An incident was closed. | id, reference, type, status, priority, date |
maintenance.assigned | A maintenance job was assigned. | id, vehicle_id, status, type |
maintenance.completed | A maintenance job was completed. | id, vehicle_id, status, type |
geofence.alert | A vehicle entered or exited a geofence. | device_id, message, in_geofenced_area |
device.status_changed | A tracking device changed online/offline state. | device_id, imei, is_online, was_online |
alert.speeding | A vehicle exceeded its effective speed limit. | vehicle_id, vehicle_name, speed_kmh, speed_limit, detected_at |
alert.idling | A vehicle was detected idling. | vehicle_id, imei, detected_at |
test.ping | Sent by the test endpoint; not required in the subscription list. | message |
trip.assigned, trip.started, trip.completedAll trip events share the same data shape; use the top-level event value to distinguish assignment, start, and completion.
{
"event": "trip.started",
"timestamp": "2026-04-03T08:00:00.000000Z",
"organization_id": 42,
"data": {
"id": 1001,
"reference": "TRIP-2026-1001",
"vehicle_id": 3,
"driver_id": 5,
"status": "started"
}
}
location.updatedSee Delivery payload shape above for the full location.updated format, including payload_version-gated enriched telemetry and device-only (no linked vehicle) behavior.
incidents.created, incidents.resolved, incidents.closedAll incident lifecycle events share this format, with status indicating the current incident state.
{
"event": "incidents.created",
"timestamp": "2026-04-03T10:00:00.000000Z",
"organization_id": 42,
"data": {
"id": 10,
"reference": "INC-2026-0010",
"type": "collision",
"status": "open",
"priority": "high",
"date": "2026-04-03"
}
}
maintenance.assigned, maintenance.completedMaintenance events carry the maintenance log identifier and current job metadata.
{
"event": "maintenance.assigned",
"timestamp": "2026-04-03T09:00:00.000000Z",
"organization_id": 42,
"data": {
"id": 7,
"vehicle_id": 3,
"status": "assigned",
"type": "preventive"
}
}
geofence.alertGeofence alerts identify the tracker device and whether it is currently inside the geofenced area.
{
"event": "geofence.alert",
"timestamp": "2026-04-03T08:15:00.000000Z",
"organization_id": 42,
"data": {
"device_id": "IMEI123456789",
"message": "Vehicle entered Restricted Zone",
"in_geofenced_area": true
}
}
device.status_changedUse is_online and was_online to determine whether the device came online or went offline.
{
"event": "device.status_changed",
"timestamp": "2026-04-03T07:55:00.000000Z",
"organization_id": 42,
"data": {
"device_id": 18,
"imei": "IMEI123456789",
"is_online": false,
"was_online": true
}
}
alert.speedingFired when a vehicle exceeds its effective speed limit — the geofence override where present, otherwise the organization's configured limit.
{
"event": "alert.speeding",
"timestamp": "2026-04-03T08:20:00.000000Z",
"organization_id": 42,
"data": {
"vehicle_id": 3,
"vehicle_name": "Toyota Hilux",
"speed_kmh": 85.0,
"speed_limit": 60,
"detected_at": "2026-04-03T08:20:00Z"
}
}
alert.idlingFired when a vehicle is detected idling.
{
"event": "alert.idling",
"timestamp": "2026-04-03T08:25:00.000000Z",
"organization_id": 42,
"data": {
"vehicle_id": 3,
"imei": "IMEI123456789",
"detected_at": "2026-04-03T08:25:00Z"
}
}
alert.speeding and alert.idling currently require a linked vehicle. Unlike location.updated, these two still depend on vehicle-scoped detection and won't fire for a device with no linked Vehicle record. If you're integrating as a device-only client, plan around this until broader device-only coverage ships.
test.pingThe test endpoint queues this event so client developers can validate connectivity and signature verification without waiting for a fleet event.
{
"event": "test.ping",
"timestamp": "2026-04-03T08:00:00.000000Z",
"organization_id": 42,
"data": {
"message": "This is a test delivery from EonsFleet."
}
}
fuel_alert_rule.triggered is delivered to endpoints selected on a specific fuel alert rule, rather than through an endpoint's regular events list.
GET /api/v1/webhooks/events returns event names and high-level examples. This page documents the concrete delivery format emitted to webhook URLs.
Deliveries run on the webhooks queue. What happens after a failed attempt depends on why it failed — some responses are retried, some never are.
Any 2xx response marks the delivery delivered and resets the endpoint's failure_count to 0.
5xx responses, connection errors/timeouts, and other non-permanent statuses (e.g. 408) are retried up to 3 attempts total, with backoff of 1 min, then 5 min, then 15 min. After the 3rd failure the delivery is finalized as failed and dead-lettered.
Not counted against the 3-try budget. EonsFleet reads the receiving endpoint's Retry-After header (falls back to 60s if absent) and reschedules for exactly that delay.
HTTP 400, 401, 403, 404, 405, 410, 413, 415, 422 mean the endpoint itself is rejecting the request — retrying won't help. These fail and dead-letter immediately, on the first attempt.
If the endpoint URL resolves to a private/internal/loopback/metadata address at send time, EonsFleet refuses to deliver at all. Fails and dead-letters immediately — no attempt is made.
Webhook POSTs time out after 10 seconds. Keep your handler fast and offload expensive work to a background job on your side.
An endpoint's status is set to failed after 10 consecutive failures (across all its deliveries, not per-delivery). Organization admins get an in-app notification. Reactivate by updating status to active.
A delivery is dead-lettered when EonsFleet has given up on it — whether that took one attempt (permanent failure, SSRF block) or three (exhausted retries). This is recorded as a dead_lettered_at timestamp, separate from status, so you can distinguish "still might succeed" from "definitely won't without intervention." Query it directly with ?status=dead_lettered on either deliveries endpoint below — that filters on dead_lettered_at IS NOT NULL, which is a superset of deliveries whose status column happens to read failed.
A delivery log row (returned by the two deliveries endpoints) is overwritten on every retry — it always reflects only the latest attempt. To see every individual try — including the exact request headers sent, the raw response, and per-attempt timing — use the delivery attempts endpoint. Attempt rows are write-once and never change after being recorded.
# GET /api/v1/webhooks/12/deliveries/80/attempts — 200 OK { "message": "success", "data": { "attempts": [ { "id": 1, "public_id": "att_01JABCXYZQRS7T8U9V0WXYZ12", "attempt_number": 1, "response_status": 503, "response_body": "Service Unavailable", "error_type": "http_5xx", "error_message": null, "latency_ms": 320, "started_at": "2026-08-06T08:00:59.000000Z", "completed_at": "2026-08-06T08:00:59.320000Z" } ], "pagination": { "current_page": 1, "per_page": 15, "total": 1, "last_page": 1 } } }
error_type is one of ssrf_blocked, http_4xx, http_5xx, rate_limited, connection_error, or null on a successful attempt.
| Method | Endpoint | Use |
|---|---|---|
| GET | /api/v1/webhooks | List webhook endpoints. |
| POST | /api/v1/webhooks | Register an endpoint. |
| GET | /api/v1/webhooks/events | Fetch event catalog and examples. |
| GET | /api/v1/webhooks/stats | Fetch 30-day aggregate delivery stats. |
| GET | /api/v1/webhooks/deliveries | List recent deliveries across all endpoints. Supports ?status=dead_lettered. |
| GET | /api/v1/webhooks/{id} | Fetch endpoint details. |
| PUT | /api/v1/webhooks/{id} | Update URL, events, subscriptions, payload_version, location_throttle_seconds, description, name, or status. |
| DELETE | /api/v1/webhooks/{id} | Delete an endpoint. |
| POST | /api/v1/webhooks/{id}/test | Queue a test.ping delivery. |
| POST | /api/v1/webhooks/{id}/regenerate-secret | Rotate the signing secret. The new secret is returned once. |
| GET | /api/v1/webhooks/{id}/deliveries | List deliveries for one endpoint. Supports ?status=dead_lettered. |
| GET | /api/v1/webhooks/{id}/deliveries/{deliveryId}/attempts | List every individual attempt for one delivery. |
| POST | /api/v1/webhooks/{id}/deliveries/{deliveryId}/retry | Retry a single delivery immediately. Allowed unless it's already delivered — including dead-lettered ones. |
# Retry a single delivery POST /api/v1/webhooks/12/deliveries/80/retry Authorization: Bearer <access_token> X-Request-Type: client # 422 if it was already delivered { "message": "Delivery was already successful — retry is not needed" }
To resend more than one delivery — e.g. after fixing a receiving endpoint that was down for an hour — queue a bulk replay instead of retrying one at a time. A replay never modifies the original delivery records; it creates a brand-new delivery per match, linked back to the original, and queues each one for a fresh attempt.
Filter by webhook_endpoint_id, event_type, status (failed or dead_lettered — default failed), and/or a date_from/date_to range on created_at. All optional; omitting everything matches every failed delivery org-wide.
0 matches → 422. Over 500 matches → 422, narrow your filters. Between 101–500 matches → 422 unless you resend with "confirm": true.
5 replay requests per hour per organization. Exceeding it returns 429.
# Queue a replay of everything dead-lettered for one endpoint this week POST /api/v1/webhook-replays Authorization: Bearer <access_token> X-Request-Type: client Content-Type: application/json { "webhook_endpoint_id": 12, "status": "dead_lettered", "date_from": "2026-08-01" }
# 202 Accepted { "message": "Replay queued", "data": { "id": 1, "public_id": "rpl_01JABCXYZQRS7T8U9V0WXYZ12", "webhook_endpoint_id": 12, "filters": { "webhook_endpoint_id": 12, "status": "dead_lettered", "date_from": "2026-08-01" }, "total_count": 3, "replayed_count": 0, "status": "pending", "started_at": null, "completed_at": null } }
| Method | Endpoint | Use |
|---|---|---|
| POST | /api/v1/webhook-replays | Queue a bulk replay. Rate-limited to 5/hour/org. |
| GET | /api/v1/webhook-replays | List past replay runs, most recent first. |
| GET | /api/v1/webhook-replays/{id} | Poll a replay's progress — status moves pending → processing → completed, replayed_count climbs to total_count. |
WebhookDeliveryLog rows like any other, just created by the replay job instead of a live event.