Guide to Onfleet Webhooks: Features and Best Practices
Onfleet webhooks tell your systems what is happening out on the road: a driver starts a task, gets close to the drop-off, completes or fails a delivery, goes on or off duty, or a route plan changes. If you run deliveries on Onfleet, the last-mile delivery platform, webhooks are how that activity reaches your own services, such as order systems, customer notifications and analytics, as it happens and without polling the API.
This guide covers how Onfleet webhooks are registered, validated and signed, the triggers you can subscribe to and the shape of their payloads, the quirks that catch out new integrations, and the best practices for running an Onfleet receiver in production.
What are Onfleet webhooks?
An Onfleet webhook is a subscription to a single trigger. Each one has a name, an HTTPS URL and a trigger ID, and when that trigger fires, Onfleet sends a JSON POST to the URL. To receive task started, task completed and task failed events, you create three webhooks. They can all point at the same endpoint, because every body carries triggerId and triggerName, and Onfleet says it includes them "so you may overload the same url on your application as required".
Two kinds of request reach your endpoint. When a webhook is created, Onfleet sends an unsigned GET with a check query parameter to prove you control the URL. After that, every event is a POST with an X-Onfleet-Signature header: a hex HMAC-SHA512 of the raw request body.
The detail that matters most is the signing key. Onfleet's webhook secret is a hex string, and the HMAC key is the bytes it encodes, not the string itself. Code that passes the secret straight to an HMAC function produces a well-formed signature that never matches.
Onfleet webhook features
| Feature | Details |
|---|---|
| Configuration | The dashboard (Configuration > API & Webhook) or POST https://onfleet.com/api/v2/webhooks |
| Subscription model | One trigger per webhook. Several webhooks can share a URL |
| URL validation | An unsigned GET <url>?check=<random_ on creation. Answer 200 with the string as plain text |
| Signature header | X-Onfleet-Signature |
| Scheme | HMAC-SHA512, hex digest (128 characters) |
| Signed content | The raw request body, nothing else |
| Secret | One per organisation, shared by every webhook, and separate from your API key |
| Replay window | None. No timestamp is sent or signed |
| Response window | 10 seconds |
| Retries | Timed-out requests are retried every 30 seconds |
| Auto-disable | After 300 consecutive failed requests |
| Thresholds | taskEta and taskDelayed (seconds), taskArrival (meters) and predictedTaskDelay (seconds) fire relative to a threshold you set |
| Limits | taskEta, taskArrival and taskDelayed are limited to 10 webhooks each |
The webhook URL must use HTTPS with a valid certificate. Onfleet publishes no source IP allowlist for webhooks, so the signature is the credential.
Common events
Onfleet documents 29 triggers and they have added triggers over time, so treat the list as something that grows.
| Area | Triggers | Fires when |
|---|---|---|
| Task progress | taskStarted (0), taskEta (1), taskArrival (2), taskCompleted (3), taskFailed (4) | A driver starts a task, their ETA drops to the threshold, they come within the threshold distance, or they complete the task as succeeded or failed |
| Task changes | taskCreated (6), taskUpdated (7), taskDeleted (8), taskAssigned (9), taskUnassigned (10), taskCloned (13) | A task is created, edited, deleted, assigned to or removed from a driver or team, or cloned |
| Delays | taskDelayed (12), predictedTaskDelay (30) | A task's delay, or its predicted delay, reaches the threshold |
| Workers | workerDuty (5), workerCreated (15), workerDeleted (16), workerUpdated (25) | A driver goes on or off duty, or is created, deleted or updated |
| Route plans | routePlanCreated (22), routePlanStarted (23), routePlanCompleted (24), routePlanUpdated (26), routePlanUnassigned (27), routePlanAssigned (28), routePlanDelayed (29) | A route plan is created, started, completed, changed, assigned, unassigned or marked as delayed |
| SMS | smsRecipientResponseMissed (14), SMSRecipientOptOut (17) | A recipient's SMS reply was missed, or a recipient replied STOP |
| Async jobs | autoDispatchJobCompleted (18), taskBatchCreateJobCompleted (19), routeOptimizationJobCompleted (20) | An auto-dispatch, batch task creation or route optimization job finishes |
Some triggers have firing rules of their own. taskEta and taskArrival fire at most once every 30 seconds. taskDelayed fires once per active task. taskCreated does not fire for tasks imported in the dashboard or for clones, which fire taskCloned instead. Trigger names are case-sensitive and irregular: taskEta, not taskETA, and SMSRecipientOptOut beside smsRecipientResponseMissed.
Every payload is a flat JSON object. The trigger metadata and the event fields sit side by side at the top level, and full objects go under data. Here is Onfleet's taskCreated example, trimmed:
{
"actionContext": { "id": "vjw*RDMKDljKVDve1Vtcplgu", "type": "ADMIN" },
"adminId": "vjw*RDMKDljKVDve1Vtcplgu",
"data": {
"task": {
"id": "txiK2xHBIaUwAKB~BJrjscKu",
"shortId": "fab829cf",
"state": 1,
"container": { "type": "WORKER", "worker": "COwfwH~Zogm1LXIZYbPlLAyw" },
"destination": { "location": [-117.8764687, 33.8078476] },
"metadata": []
}
},
"taskId": "txiK2xHBIaUwAKB~BJrjscKu",
"time": 1613002584051,
"triggerId": 6,
"triggerName": "taskCreated",
"workerId": null
}
The same eight keys appear in every published example: triggerId, triggerName, time, taskId, workerId, adminId, actionContext and data. Onfleet says that properties "not relevant to the trigger may be provided as null", so workerId is null above rather than missing. Some triggers add fields at the top level: etaSeconds on taskEta, distance on taskArrival, delay on taskDelayed, status (0 off duty, 1 on duty) on workerDuty, and routePlanId on the route plan triggers. The SMS triggers put their fields at the top level and leave data as {}.
time and the timestamps inside objects are milliseconds since the epoch. etaSeconds and delay are seconds, and etaSeconds is fractional, for example 298.2603148875159. Locations are [longitude, latitude], and IDs are opaque strings that can contain ~ and *.
Setting up Onfleet webhooks
Start your receiver before you create the webhook. Onfleet validates the URL during creation, and if nothing answers, the webhook is not saved.
- In the Onfleet dashboard, go to Configuration > API & Webhook and click Show Secret. Store the value server-side, for example as
ONFLEET_WEBHOOK_SECRET. There is one secret for the whole organisation. - On the same page, add a webhook with a name, your HTTPS endpoint URL and a trigger. For
taskEta,taskArrival,taskDelayedandpredictedTaskDelay, enter the threshold too. - Save. Onfleet sends the validation request, and saves the webhook once your endpoint answers it.
- Repeat for each trigger you need, pointing at the same URL.
To create webhooks from code, call the API with your API key as the Basic Auth username and an empty password:
curl -X POST "https://onfleet.com/api/v2/webhooks" \
-u "$ONFLEET_API_KEY:" \
-d '{"url":"https://example.com/webhooks/onfleet","name":"Task completed","trigger":3}'
The body takes url, name and the numeric trigger, plus an optional threshold. If validation fails, the call returns an InvalidContent error and no webhook is created. Organisations that are connected to others can also set canReceiveConnectionEvents, which defaults to false.
Validation works like this:
GET https://example.com/webhooks/onfleet?check=<random_string>
Your endpoint must respond with status 200 and the check value, unchanged, as a plain-text body. Onfleet rejects a 204 even though it is a success code. The request is not signed, and the string changes on every attempt, so echo it rather than hard-coding it.
Securing Onfleet webhooks
Read X-Onfleet-Signature, hex-decode your secret into bytes, compute an HMAC-SHA512 of the raw request body with those bytes as the key, encode the result as hex, and compare it with the header in constant time. Answer the GET ?check= request on its own route and never run it through signature verification.
const crypto = require('crypto');
const express = require('express');
function isValidHexSecret(secret) {
return typeof secret === 'string' && secret.length % 2 === 0 && /^[0-9a-fA-F]+$/.test(secret);
}
function verifyOnfleetSignature(rawBody, signatureHeader, secretHex) {
if (!signatureHeader) return false;
// The key is the secret HEX-DECODED to bytes, not the secret string.
const expected = crypto
.createHmac('sha512', Buffer.from(secretHex, 'hex'))
.update(rawBody)
.digest('hex');
const received = Buffer.from(signatureHeader.trim().toLowerCase(), 'utf8');
const computed = Buffer.from(expected, 'utf8');
return received.length === computed.length && crypto.timingSafeEqual(received, computed);
}
const app = express();
// URL validation: unsigned, and must return the check value as plain text with a 200.
app.get('/webhooks/onfleet', (req, res) => {
const check = req.query.check;
if (typeof check !== 'string') return res.sendStatus(400);
res.status(200).type('text/plain').send(check);
});
app.post('/webhooks/onfleet', express.raw({ type: '*/*' }), (req, res) => {
const secret = process.env.ONFLEET_WEBHOOK_SECRET;
// Buffer.from(secret, 'hex') silently stops at the first non-hex character,
// so a mis-pasted secret would give a short key instead of an error.
if (!isValidHexSecret(secret)) return res.sendStatus(500); // fail closed
if (!verifyOnfleetSignature(req.body, req.get('x-onfleet-signature'), secret)) {
return res.sendStatus(401);
}
const event = JSON.parse(req.body.toString('utf8'));
// Route on event.triggerName (or event.triggerId). Persist the event, then process async.
res.sendStatus(200);
});
The same check in Python:
import hashlib
import hmac
import os
import re
HEX_PATTERN = re.compile(r"[0-9a-fA-F]+")
def is_valid_hex_secret(secret) -> bool:
return isinstance(secret, str) and len(secret) % 2 == 0 and bool(HEX_PATTERN.fullmatch(secret))
def verify_onfleet_signature(raw_body: bytes, signature_header, secret_hex: str) -> bool:
if not signature_header:
return False
expected = hmac.new(
bytes.fromhex(secret_hex), # hex-decoded, not secret_hex.encode()
raw_body,
hashlib.sha512,
).hexdigest()
# Compare as bytes: compare_digest raises TypeError on a str with non-ASCII characters.
received = signature_header.strip().lower().encode("utf-8")
return hmac.compare_digest(received, expected.encode("utf-8"))
# In FastAPI: raw_body = await request.body()
# verify_onfleet_signature(raw_body, request.headers.get("x-onfleet-signature"),
# os.environ["ONFLEET_WEBHOOK_SECRET"])
Onfleet's own samples key the HMAC with binascii.a2b_hex(secret) in Python, Buffer.from(secret, 'hex') in Node and hex2bin($secret) in PHP. bytes.fromhex is equivalent to the first. The docs say to compare the signatures as strings. Lowercasing the header first, as above, costs nothing and also accepts an uppercase hex digest.
To check your verifier, use the secret 6f6e666c6565742d746573742d7365637265742d6b6579 and this exact body:
{"triggerId":6,"triggerName":"taskCreated","taskId":"txiK2xHBIaUwAKB~BJrjscKu","time":1613002584051}
The signature should be:
d7b1dead2bca4ecb74d35eba8541ec308d372367c9068bb207467cd35f230572875a05e2b159373f2d9d8ca1ebf812522c861758b288d4ce041d827ab47c4f69
Make Onfleet webhooks production-ready. Hookdeck Event Gateway answers Onfleet's
GET ?check=validation for you, verifies theX-Onfleet-Signatureheader with the hex-decoded secret, and durably queues every task, worker and route plan event before your service sees it.
Onfleet webhook limitations and pain points
Using the secret as a string
The Problem: Every delivery fails verification, even though the secret is correct and the code looks right.
Why It Happens: Most HMAC examples use the secret string as the key. Onfleet's secret is a hex encoding of the key, so the string and the bytes it encodes are different keys. Using SHA-256 instead of SHA-512, or your API key instead of the webhook secret, fails the same way. Every one of these mistakes produces a valid-looking hex digest, so nothing tells you which part is wrong.
Workarounds:
- Decode the secret with
Buffer.from(secret, 'hex')orbytes.fromhex(secret)before passing it to the HMAC. - Check that the secret is an even-length hex string at startup, and fail closed if it is not. Node's hex decoder silently truncates at the first invalid character.
- Test your verifier against the known-good signature in the previous section.
How Hookdeck Can Help: Hookdeck's Onfleet source type decodes the secret and verifies X-Onfleet-Signature for you, so the key handling lives in one place rather than in every service that receives Onfleet events.
Validation fails, and nothing tells you why
The Problem: Onfleet refuses to save the webhook, and there is no delivery to inspect because none was ever sent.
Why It Happens: Creation depends on your endpoint answering GET ?check= with a 200 and the exact string as plain text. A route that only accepts POST, signature middleware that also runs on GET, a handler that returns JSON or a 204, or a server that is not running yet all fail validation.
Workarounds:
- Handle
GETandPOSTon the same path, and keep signature verification on thePOSTroute only. - Return the
checkvalue astext/plainwith a200, and nothing else. - Start your server and any tunnel before you create the webhook.
How Hookdeck Can Help: Hookdeck answers Onfleet's ?check= request itself, before the request reaches your code, so a webhook pointed at a Hookdeck source validates even while your own service is offline.
No timestamp means no replay protection
The Problem: A captured request can be sent again at any time, and it still verifies.
Why It Happens: The signature covers only the body. Onfleet sends no timestamp header and signs nothing else, so there is no freshness check to make.
Workarounds:
- Make handlers idempotent, which you need anyway because of retries.
- For high-value actions, treat the webhook as a notification and re-read the task or worker from the Onfleet API before acting.
- Keep the endpoint on HTTPS so deliveries cannot be captured in transit.
How Hookdeck Can Help: Hookdeck's deduplication can drop repeated deliveries of the same event before they reach your service, based on the fields you choose.
A sustained outage switches the integration off
The Problem: After a long enough outage, Onfleet stops sending events entirely, and nothing arrives until someone notices and re-enables the webhook.
Why It Happens: Onfleet waits 10 seconds for a response, retries timed-out requests every 30 seconds, and disables the webhook after 300 consecutive failed requests. Onfleet does not document whether a fast error response is retried on the same schedule.
Workarounds:
- Respond with
200straight after verification and do the work afterwards. This is the pattern Onfleet recommends. - Answer
200to triggers you do not recognise, so new triggers never count as failures. - Poll
GET /api/v2/webhooksand alert on any webhook whoseisEnabledhas turnedfalse, which is how Onfleet marks one it has disabled.
How Hookdeck Can Help: Hookdeck accepts each delivery as soon as it arrives and stores it, then retries against your service on a schedule you control and lets you replay any event, so your own downtime does not count against Onfleet's failure limit.
One trigger, two names
The Problem: A handler that matches trigger 30 by name may never match it.
Why It Happens: Onfleet's trigger table calls trigger 30 predictedTaskDelay, but the published sample payload for it carries "triggerName": "taskPredictedDelay". Both are Onfleet's own documentation, and nothing public settles which one is sent.
Workarounds:
- Route trigger 30 on
triggerId === 30, which is the same under both spellings. - If you route on names, accept both.
How Hookdeck Can Help: Hookdeck filters can match on triggerId as well as triggerName, so you can route trigger 30 to the right destination whichever spelling arrives.
Best practices
Verify against the raw body
The signature covers the exact bytes Onfleet sent. In Express, mount express.raw() on the webhook route and parse only after verification. In a Next.js route handler, use Buffer.from(await request.arrayBuffer()). In FastAPI, read await request.body(). Parsing and re-serialising the JSON changes the bytes, and the signature will not match.
Route on triggerId or triggerName
Because each webhook carries one trigger and many webhooks can share a URL, one handler usually receives every trigger. Switch on triggerName, or on triggerId where you want a key that is stable across the trigger 30 naming conflict. Log and acknowledge anything you do not recognise.
Treat every field as nullable
Fields that do not apply to a trigger arrive as null, and data can be {}. Use optional access everywhere, for example event.data?.task?.id ?? event.taskId. Do not validate IDs as alphanumeric, because they contain ~ and *.
Keep the units straight
Every time field is in milliseconds except etaSeconds and delay, which are in seconds. An etaSeconds of 284.5 is just under five minutes. Thresholds use seconds for the ETA and delay triggers and meters for taskArrival.
Deduplicate deliveries
Onfleet payloads have no event ID. Build an idempotency key from triggerId, the entity ID (taskId, workerId or routePlanId) and time, and store it before you process the event. See our guide to webhook idempotency.
Acknowledge fast and process asynchronously
Onfleet times out after 10 seconds and counts that as a failure, which is how slow handlers produce retries and, eventually, a disabled webhook. Verify, persist the event, return 200, and do the real work afterwards. See why to process webhooks asynchronously.
Set up several ETA thresholds when you need them
To send a customer a message at 10 minutes and again at 2 minutes out, create two taskEta webhooks with thresholds of 600 and 120. Remember that taskEta, taskArrival and taskDelayed are each limited to 10 webhooks.
Conclusion
Onfleet signs every delivery with a hex HMAC-SHA512 of the raw body in the X-Onfleet-Signature header, keyed with the organisation secret decoded from hex. Answer the unsigned GET ?check= request with a plain-text 200, verify against the raw bytes, route on triggerName or triggerId, treat every field as nullable, and acknowledge quickly so the 300-failure limit never switches your integration off.
Hookdeck Event Gateway sits in front of your receiver, answers Onfleet's validation request, checks every X-Onfleet-Signature, and holds each event until your service has processed it, so an outage on your side costs latency instead of a disabled webhook.
Get started with Hookdeck for free and handle Onfleet webhooks reliably in minutes.