Agent skill
Onfleet Webhooks Skill
Receive and verify Onfleet last-mile delivery webhooks. Use when setting up Onfleet webhook handlers, answering the GET ?check= validation handshake, debugging X-Onfleet-Signature verification (HMAC-SHA512 keyed with the HEX-DECODED org secret), or handling triggers like taskStarted, taskEta, taskCompleted, taskFailed, workerDuty and routePlanCompleted.
Install this skill
npx skills add hookdeck/webhook-skills --skill onfleet-webhooks
Onfleet is a last-mile delivery platform. Each Onfleet webhook targets one trigger (task started, task completed, worker on duty, and so on). When you create a webhook, Onfleet first sends an unsigned GET ?check=<random_ request to validate the URL. After that, every event arrives as a signed POST with an X-Onfleet-Signature header.
When to Use This Skill
- How do I receive Onfleet webhooks?
- Why won't Onfleet save my webhook URL? (the
GET ?check=handshake) - How do I verify the
X-Onfleet-Signatureheader? - Why does my Onfleet signature never match? (the secret is hex)
- How do I handle
taskCompleted,taskFailed,taskEtaorworkerDuty?
Validation Handshake
When a webhook is created, Onfleet sends GET <your url>?check=<random_. Reply 200 with the exact string as a plain-text body. Onfleet doesn't accept a 204, and the request is not signed, so don't run signature verification on it. The string changes on every attempt.
app.get('/webhooks/onfleet', (req, res) => {
const check = req.query.check;
if (typeof check !== 'string') {
return res.status(400).type('text/plain').send('Missing check parameter');
}
res.status(200).type('text/plain').send(check);
});
Signature Verification (core)
| Header | X-Onfleet-Signature |
| Algorithm | HMAC-SHA512, hex digest |
| Key | The organisation's webhook secret, hex-decoded to bytes |
| Signed content | The raw request body bytes. No timestamp, no prefix |
The secret is a hex string. Use it as an HMAC key only after hex-decoding it. If you pass the string as-is, you get a well-formed digest that never matches.
const crypto = require('crypto');
function verifyOnfleetSignature(rawBody, signatureHeader, secretHex) {
if (!signatureHeader) {
return { valid: false, error: 'Missing X-Onfleet-Signature header' };
}
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');
// timingSafeEqual throws on unequal lengths, so check first.
if (received.length !== computed.length) {
return { valid: false, error: 'Invalid signature' };
}
if (!crypto.timingSafeEqual(received, computed)) {
return { valid: false, error: 'Invalid signature' };
}
return { valid: true };
}
Python:
expected = hmac.new(bytes.fromhex(secret_hex), raw_body, hashlib.sha512).hexdigest()
received = signature_header.strip().lower().encode("utf-8")
valid = hmac.compare_digest(received, expected.encode("utf-8"))
For complete handlers with tests, see examples/express/, examples/nextjs/, examples/fastapi/.
The examples also check that the secret is valid hex before using it, because Buffer.from(hex) silently truncates at the first non-hex character. If it isn't, they fail closed with a 500. They acknowledge with 200 first and then process the event, and cover every documented trigger in their tests.
Webhook Triggers (triggerName / triggerId)
| ID | triggerName | ID | triggerName |
|---|---|---|---|
| 0 | taskStarted | 16 | workerDeleted |
| 1 | taskEta (threshold, seconds) | 17 | SMSRecipientOptOut |
| 2 | taskArrival (threshold, meters) | 18 | autoDispatchJobCompleted |
| 3 | taskCompleted | 19 | taskBatchCreateJobCompleted |
| 4 | taskFailed | 20 | routeOptimizationJobCompleted |
| 5 | workerDuty | 22 | routePlanCreated |
| 6 | taskCreated | 23 | routePlanStarted |
| 7 | taskUpdated | 24 | routePlanCompleted |
| 8 | taskDeleted | 25 | workerUpdated |
| 9 | taskAssigned | 26 | routePlanUpdated |
| 10 | taskUnassigned | 27 | routePlanUnassigned |
| 12 | taskDelayed (threshold, seconds) | 28 | routePlanAssigned |
| 13 | taskCloned | 29 | routePlanDelayed |
| 14 | smsRecipientResponseMissed | 30 | predictedTaskDelay (threshold, seconds) |
| 15 | workerCreated |
IDs 11 and 21 aren't documented. For trigger 30, the trigger table says predictedTaskDelay but the sample payload says taskPredictedDelay, so accept both names or route on triggerId. See references/overview.md for payload shapes.
Handler Rules
- Answer
GET ?check=with200plain text. Otherwise Onfleet won't save the webhook. - Verify the raw bytes, then parse. Re-serialised JSON won't match.
- Respond within 10 seconds. Onfleet retries timed-out requests every 30 seconds and disables the webhook after 300 consecutive failures.
- Treat every field as optional. Fields that don't apply to a trigger arrive as
null, anddatacan be{}. - Mind the units.
timeis milliseconds;etaSecondsanddelayare seconds. - Acknowledge unknown triggers. Log them and return
200.
Environment Variables
# Organisation-wide webhook secret (hex). Not your API key.
# Dashboard: Configuration -> API & Webhook -> Show Secret
ONFLEET_WEBHOOK_SECRET=your_hex_webhook_secret
Local Development
# Start tunnel (no account needed)
npx hookdeck-cli listen 3000 onfleet --path /webhooks/onfleet
Run your handler first, so the GET ?check= handshake succeeds when you create the webhook in Onfleet.
Reference Materials
- references/overview.md: Triggers, payload shape, units, naming quirks
- references/setup.md: Find the secret, create webhooks (dashboard or API), thresholds
- references/verification.md: HMAC-SHA512 details, common mistakes, debugging