# 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_string>` 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:

```json
{
  "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.

1. 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.
2. On the same page, add a webhook with a name, your HTTPS endpoint URL and a trigger. For `taskEta`, `taskArrival`, `taskDelayed` and `predictedTaskDelay`, enter the threshold too.
3. Save. Onfleet sends the validation request, and saves the webhook once your endpoint answers it.
4. 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:

```bash
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.

```javascript
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:

```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](/event-gateway) answers Onfleet's `GET ?check=` validation for you, verifies the `X-Onfleet-Signature` header 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')` or `bytes.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 `GET` and `POST` on the same path, and keep signature verification on the `POST` route only.
* Return the `check` value as `text/plain` with a `200`, 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 `200` straight after verification and do the work afterwards. This is the pattern Onfleet recommends.
* Answer `200` to triggers you do not recognise, so new triggers never count as failures.
* Poll `GET /api/v2/webhooks` and alert on any webhook whose `isEnabled` has turned `false`, 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](/webhooks/guides/implement-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](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

### 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](https://hookdeck.com) 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](https://dashboard.hookdeck.com/signup) for free and handle Onfleet webhooks reliably in minutes.