Webhook reference
Fleiko Dispatch fires a signed HTTP POST to your URL whenever something happens — a job is created, assigned, completed, or cancelled. This page documents the signing algorithm, retry schedule, and full payload shape.
Quick start
- Register your endpoint URL. You'll see the signing secret once — copy it now.
- Your server receives
POSTrequests with a JSON body and the headers below. - Verify
X-Fleiko-Signatureagainst the secret. Reject if it doesn't match. - Return
2xxwithin 10 seconds (or your configured per-endpoint timeout, up to 30s). Anything else triggers a retry.
Request headers
| Header | Description |
|---|---|
X-Fleiko-Signature | HMAC-SHA256 signature. Format: t=<unix-seconds>,v1=<hex> |
X-Fleiko-Timestamp | Unix seconds when we built the signature. Same value as t= above. |
X-Fleiko-Event | Event type, e.g. job.created. |
X-Fleiko-Delivery | Unique delivery id. Same value repeats on retry — use it to dedupe. |
X-Fleiko-Delivery-Attempt | 1 on the first delivery, 2+ on retries. |
User-Agent | Fleiko-Webhooks/1.0 |
Signing algorithm
To verify a webhook, reconstruct the signed payload and compare it to the v1 value(s) in the header.
- Extract
tand everyv1from theX-Fleiko-Signatureheader. - Build the signed payload:
`${t}.${rawBody}`. - Compute
hex(hmac-sha256(secret, signedPayload)). - Walk every
v1value and compare with constant-time equality. Accept on the first match. - Reject the request if
|now - t| > 300seconds (replay protection).
Always use the raw request body, not a re-serialised JSON object. Most frameworks parse JSON before your handler runs — capture the raw body upstream of that parse step or you'll get false negatives.
Multiple v1 values during rotation. When you rotate the signing secret we sign every delivery with BOTH the new and previous secrets for 24h. The header carries two v1= entries — your verification loop must iterate them rather than reading the first one only. All the code samples below already do this.
Node.js / Express
import crypto from "crypto";
import express from "express";
const app = express();
// IMPORTANT: get the RAW body for signature verification.
app.use("/webhooks/fleiko", express.raw({ type: "*/*" }));
function parseSig(header) {
let t = 0;
const v1s = [];
for (const pair of header.split(",")) {
const idx = pair.indexOf("=");
if (idx < 0) continue;
const k = pair.slice(0, idx).trim();
const v = pair.slice(idx + 1).trim();
if (k === "t") t = parseInt(v, 10);
else if (k === "v1") v1s.push(v);
}
return { t, v1s };
}
app.post("/webhooks/fleiko", (req, res) => {
const { t, v1s } = parseSig(req.header("X-Fleiko-Signature") || "");
// Replay window.
if (Math.abs(Date.now() / 1000 - t) > 300) {
return res.status(401).send("stale");
}
const expected = crypto
.createHmac("sha256", process.env.FLEIKO_SECRET)
.update(`${t}.${req.body.toString("utf8")}`)
.digest("hex");
// Walk every v1 value — there are two during a rotation overlap.
const ok = v1s.some(
(v1) =>
expected.length === v1.length &&
crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1)),
);
if (!ok) return res.status(401).send("bad signature");
const event = JSON.parse(req.body.toString("utf8"));
// Dedupe on event.id with your own store.
console.log("got", event.type, event.id);
res.sendStatus(200);
});Python / Flask
import hashlib
import hmac
import time
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = b"...your secret..."
def parse_sig(header):
t, v1s = 0, []
for pair in header.split(","):
k, _, v = pair.partition("=")
if k.strip() == "t":
t = int(v or 0)
elif k.strip() == "v1":
v1s.append(v.strip())
return t, v1s
@app.post("/webhooks/fleiko")
def receive():
t, v1s = parse_sig(request.headers.get("X-Fleiko-Signature", ""))
if abs(time.time() - t) > 300:
abort(401, "stale")
expected = hmac.new(
SECRET,
f"{t}.".encode() + request.get_data(),
hashlib.sha256,
).hexdigest()
# Walk every v1 value — two during a rotation overlap window.
if not any(hmac.compare_digest(expected, v1) for v1 in v1s):
abort(401, "bad signature")
event = request.get_json(force=True)
print("got", event["type"], event["id"])
return "", 200Ruby / Sinatra
require "sinatra"
require "openssl"
require "json"
SECRET = ENV.fetch("FLEIKO_SECRET")
def parse_sig(header)
t, v1s = 0, []
header.to_s.split(",").each do |pair|
k, v = pair.split("=", 2)
case k.to_s.strip
when "t" then t = v.to_i
when "v1" then v1s << v.to_s.strip
end
end
[t, v1s]
end
post "/webhooks/fleiko" do
body = request.body.read
t, v1s = parse_sig(request.env["HTTP_X_FLEIKO_SIGNATURE"])
halt 401, "stale" if (Time.now.to_i - t).abs > 300
expected = OpenSSL::HMAC.hexdigest("SHA256", SECRET, "#{t}.#{body}")
# Walk every v1 — two during a rotation overlap window.
ok = v1s.any? { |v1| Rack::Utils.secure_compare(expected, v1) }
halt 401, "bad signature" unless ok
event = JSON.parse(body)
puts "got #{event["type"]} #{event["id"]}"
status 200
endPHP
<?php
$secret = getenv("FLEIKO_SECRET");
$body = file_get_contents("php://input");
$sig = $_SERVER["HTTP_X_FLEIKO_SIGNATURE"] ?? "";
$t = 0;
$v1s = [];
foreach (explode(",", $sig) as $p) {
$kv = explode("=", $p, 2);
if (count($kv) !== 2) continue;
if (trim($kv[0]) === "t") $t = (int)$kv[1];
elseif (trim($kv[0]) === "v1") $v1s[] = trim($kv[1]);
}
if (abs(time() - $t) > 300) {
http_response_code(401);
exit("stale");
}
$expected = hash_hmac("sha256", "$t.$body", $secret);
$ok = false;
foreach ($v1s as $v1) { // walk every v1 — two during a rotation overlap.
if (hash_equals($expected, $v1)) { $ok = true; break; }
}
if (!$ok) {
http_response_code(401);
exit("bad signature");
}
$event = json_decode($body, true);
error_log("got " . $event["type"] . " " . $event["id"]);
http_response_code(200);Event types and payloads
| Event | Fires when |
|---|---|
job.created | A dispatch job is created. |
job.assigned | A driver is assigned to a job. |
job.status_changed | Any status transition. Includes from_status + to_status. |
job.completed | A job reaches terminal completed status. Also fires job.status_changed. |
job.cancelled | A job is cancelled. Includes reason + reason_category. |
customer_portal.booking_created | A customer creates a job via the self-serve portal. |
test.ping | Fired by the "Test" button in settings. Use it to verify your receiver. |
Every payload wraps the event data with this envelope:
{
"id": "f7c3a2e6-5e7e-4f3a-9e1c-2d4b8a9c1234",
"type": "job.created",
"created": 1717420800,
"company_id": "co_…",
"api_version": "1",
"data": {
// event-specific fields
}
}Retries and dead-letter
If your server returns a 5xx, times out, or fails network-level, we retry on this schedule:
| Attempt | Delay after the prior attempt |
|---|---|
| 1 | immediate |
| 2 | 1 minute |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 2 hours |
| 6 | 6 hours |
| 7 | 24 hours |
After attempt 7 the delivery is moved to dead_letter and you can replay it manually from the deliveries log in settings.
Permanent 4xx codes (400, 401, 403, 404, 405, 410, 415, 422, 451) dead-letter immediately — we trust you when you tell us we'll never succeed. 408 and 429 are treated as transient and retried.
If an endpoint accumulates 25 consecutive failures we auto-disable it and write an audit log entry. Re-enable from the settings page when your server is back.
Deduplication
Every event payload includes a unique id (UUID). The same id repeats on retry of the same delivery. We recommend deduping on this id with a 7-day window — long enough to cover the maximum retry timeline plus your own re-processing buffer.
IP allowlisting
Deliveries currently originate from the same egress pool as the rest of our application. We do not publish a stable IP allowlist; verify the signature instead. If your security policy mandates IP allowlisting, contact support@fleiko.com.
Secret storage
Signing secrets are stored encrypted at rest using AES-256-GCM. The decryption key lives outside the database in our infrastructure secret store and is required to read any secret back. Database exports and replica snapshots never contain plaintext secrets.
Rotating the signing secret
From the endpoint row, click Rotate. We mint a new secret and show it once. The old secret stops working immediately— there is no overlap window in v1. Update your server config first if your receiver has zero tolerance for a verification gap.
Troubleshooting
- Signature mismatch — most likely you're hashing a re-serialised JSON object instead of the raw body. Capture the raw body upstream of your framework's JSON parser.
- Stale timestamp — check your server's clock is in sync. We reject anything outside ±300s.
- Endpoint auto-disabled — open the settings page and look at the recent deliveries log. The last response code and body are captured. Fix the underlying issue, then click Enable to resume.
- Receiving duplicates — dedupe on the payload
idfield. Retries always carry the same id. - Not receiving anything — click Test in settings to fire a
test.pingevent. If that doesn't arrive, check your firewall and DNS.