Fleiko

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

  1. Register your endpoint URL. You'll see the signing secret once — copy it now.
  2. Your server receives POST requests with a JSON body and the headers below.
  3. Verify X-Fleiko-Signature against the secret. Reject if it doesn't match.
  4. Return 2xx within 10 seconds (or your configured per-endpoint timeout, up to 30s). Anything else triggers a retry.

Request headers

HeaderDescription
X-Fleiko-SignatureHMAC-SHA256 signature. Format: t=<unix-seconds>,v1=<hex>
X-Fleiko-TimestampUnix seconds when we built the signature. Same value as t= above.
X-Fleiko-EventEvent type, e.g. job.created.
X-Fleiko-DeliveryUnique delivery id. Same value repeats on retry — use it to dedupe.
X-Fleiko-Delivery-Attempt1 on the first delivery, 2+ on retries.
User-AgentFleiko-Webhooks/1.0

Signing algorithm

To verify a webhook, reconstruct the signed payload and compare it to the v1 value(s) in the header.

  1. Extract t and every v1 from the X-Fleiko-Signature header.
  2. Build the signed payload: `${t}.${rawBody}`.
  3. Compute hex(hmac-sha256(secret, signedPayload)).
  4. Walk every v1 value and compare with constant-time equality. Accept on the first match.
  5. Reject the request if |now - t| > 300 seconds (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 "", 200

Ruby / 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
end

PHP

<?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

EventFires when
job.createdA dispatch job is created.
job.assignedA driver is assigned to a job.
job.status_changedAny status transition. Includes from_status + to_status.
job.completedA job reaches terminal completed status. Also fires job.status_changed.
job.cancelledA job is cancelled. Includes reason + reason_category.
customer_portal.booking_createdA customer creates a job via the self-serve portal.
test.pingFired 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:

AttemptDelay after the prior attempt
1immediate
21 minute
35 minutes
430 minutes
52 hours
66 hours
724 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.ping event. If that doesn't arrive, check your firewall and DNS.
Webhook reference | Fleiko Dispatch