Skip to content
DocsProductSign inCreate an account

Delivery API

Webhooks

Status comes to you. Register one HTTPS endpoint, verify a signature on each request, and your system learns about every rider assignment, pickup and delivery without polling for any of it.

Setting up

Give your operator one HTTPS URL. You get back a signing secret, shown once. If you change the URL later the secret is rotated, so swap both at the same time.

You can fire a webhook.test event on demand before any real delivery exists — the fastest way to prove your endpoint is reachable, parses the body and verifies the signature.

What a delivery looks like

POST to your endpoint
POST /your/endpoint HTTP/1.1
Content-Type: application/json
X-Dispatch-Event: dispatch.delivered
X-Dispatch-Timestamp: 1760256062
X-Dispatch-Signature: sha256=4f1b…

{
  "event": "dispatch.delivered",
  "data": {
    "dispatch_id": "c0ffee11-2233-4455-6677-8899aabbccdd",
    "external_id": "order_10294",
    "status": "DELIVERED",
    "rider_id": "a1b2c3d4-…",
    "pickup":  { "address": "Shop 4, Westlands, Nairobi", "lat": -1.2672, "lng": 36.8065 },
    "dropoff": { "address": "Kileleshwa, Nairobi", "lat": -1.2833, "lng": 36.7833 },
    "payment_type": "COD",
    "amount_cents": 250000,
    "fee_cents": 32000,
    "currency": "KES",
    "target_pickup_at": null,
    "target_delivery_at": "2026-10-12T16:00:00.000Z",
    "tracking_token": "Yk3n…",
    "updated_at": "2026-10-12T09:21:02.004Z"
  }
}

external_id is your own order id, echoed back, so you can reconcile without storing ours. Webhook payloads are snake_case, while the same delivery read over REST is camelCase — map both.

Events

EventFires when
dispatch.createdA delivery is accepted — by API, CSV import or a connected store
dispatch.assignedA rider takes it
dispatch.picked_upThe rider has the parcel
dispatch.en_routeOn the way to the customer
dispatch.deliveredHanded over
dispatch.failedCould not be completed
dispatch.cancelledCalled off
dispatch.location_updatedThe assigned rider moved, at most once every 20 seconds per rider
webhook.testYou or your operator fired a test

dispatch.location_updated has a different payload from the rest: just dispatch_id, external_id, status and a location object with latitude, longitude and timestamp. It is the firehose — if you only want lifecycle changes, ignore it, and if you want to show a moving pin to a customer, use the tracking stream instead of rebroadcasting these.

Verifying the signature

We sign `${timestamp}.${rawBody}` with HMAC-SHA256 using your webhook secret, hex encoded, and send it as X-Dispatch-Signature: sha256=<hex>. The timestamp is in the signed string so a captured request cannot be replayed later with a fresh clock.

Node — Express
import crypto from 'node:crypto';
import express from 'express';

const app = express();
const SECRET = process.env.DISPATCH_WEBHOOK_SECRET;
const MAX_SKEW_SECONDS = 300;

// Keep the raw bytes — the signature is over exactly what we sent.
app.use(express.json({
  verify: (req, _res, buf) => { req.rawBody = buf; },
}));

app.post('/webhooks/dispatch', (req, res) => {
  const signature = req.get('X-Dispatch-Signature') ?? '';
  const timestamp = Number(req.get('X-Dispatch-Timestamp'));

  // 1. Reject anything too old or too far in the future.
  if (!Number.isFinite(timestamp)) return res.sendStatus(400);
  if (Math.abs(Math.floor(Date.now() / 1000) - timestamp) > MAX_SKEW_SECONDS) {
    return res.sendStatus(400);
  }

  // 2. Recompute and compare in constant time. Never use === on a digest.
  const [scheme, received] = signature.split('=');
  if (scheme !== 'sha256' || !received) return res.sendStatus(401);

  const expected = crypto
    .createHmac('sha256', SECRET)
    .update(`${timestamp}.${req.rawBody.toString()}`)
    .digest('hex');

  const a = Buffer.from(received, 'hex');
  const b = Buffer.from(expected, 'hex');
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
    return res.sendStatus(401);
  }

  // 3. Acknowledge immediately, do the work afterwards.
  res.sendStatus(200);
  void handle(req.body.event, req.body.data);
});
Python — Flask
import hmac, hashlib, time
from flask import Flask, request, abort

app = Flask(__name__)
SECRET = os.environ["DISPATCH_WEBHOOK_SECRET"].encode()
MAX_SKEW_SECONDS = 300

@app.post("/webhooks/dispatch")
def dispatch_webhook():
    signature = request.headers.get("X-Dispatch-Signature", "")
    try:
        timestamp = int(request.headers.get("X-Dispatch-Timestamp", ""))
    except ValueError:
        abort(400)

    if abs(int(time.time()) - timestamp) > MAX_SKEW_SECONDS:
        abort(400)

    scheme, _, received = signature.partition("=")
    if scheme != "sha256" or not received:
        abort(401)

    raw = request.get_data()                      # raw bytes, not request.json
    expected = hmac.new(
        SECRET, f"{timestamp}.".encode() + raw, hashlib.sha256
    ).hexdigest()

    if not hmac.compare_digest(received, expected):
        abort(401)

    enqueue(request.json["event"], request.json["data"])   # do work off-request
    return "", 200

Retries

Any 2xx is success. Anything else — including a timeout, a TLS failure or a DNS error — is a failure, and we retry with exponential backoff: up to 10 attempts, starting at 5 seconds. A delivery that is still failing after that is left recorded as failed rather than retried forever.

Every attempt is persisted with the HTTP code we got and the error we saw. Read them back with GET /api/v1/external/dispatches/:id/webhooks, which is the first place to look when your system has missed something.

Building an endpoint that will not lose events

FieldTypeDescription
Answer fastrule
required
Verify, enqueue, return 2xx. Do the real work off the request. A slow endpoint turns into a retry storm.
Be idempotentrule
required
The same event can arrive more than once. Key on (dispatch_id, status, updated_at) and make a repeat a no-op.
Expect out of orderrule
required
Retries mean a later event can land before an earlier one. Compare updated_at against what you have stored and drop anything older.
Verify before parsingrule
required
Treat the body as untrusted until the signature checks out. An unverified endpoint lets anyone mark your orders delivered.
Handle unknown eventsrule
optional
Log and ignore events you do not recognise. New ones may be added; an endpoint that 500s on a stranger will go into retry on every delivery.
Alert on silencerule
optional
A broken endpoint looks exactly like a quiet day. Alert when you have had no event for longer than normal.

Local development

Webhooks need a public HTTPS URL, so a tunnel (ngrok, Cloudflare Tunnel) is the usual way to receive them on a laptop. Point the URL at the tunnel, remember that doing so rotates your secret, and point it back when you are done.

If you only want to check your verification code, you do not need us at all: generate a signature over a body you made up, with your secret, and feed it to your own endpoint.