Webhooks
Webhooks push real-time status updates to your server as shipments move through the lifecycle. They're the standard way to keep your system in sync without polling.
Configuration
Webhook subscriptions are managed in the merchant dashboard, not via the API.
1. Stand up an endpoint on your server
You provide the URL — it's on your own infrastructure, not Dodo's. Dodo will POST to this URL every time one of your shipments hits a subscribed event.
The endpoint must:
- Be publicly reachable over HTTPS in production (
http://is rejected;localhost/127.0.0.1are rejected — use a tunneling tool like ngrok for local dev). - Accept
POSTwithContent-Type: application/json. - Return any
2xxstatus within 30 seconds. - Be idempotent — the same event can arrive more than once on retries (see Idempotency).
A typical URL looks like https://yourcompany.co.tz/integrations/dodo/webhook. The path is yours to choose; Dodo only cares that it exists and responds correctly.
The field on the dashboard asks for your URL, not ours. If you paste a Dodo URL there, no events will reach your system.
2. Register the endpoint in the dashboard
- Log in to merchant.dodo.co.tz.
- Go to Settings → Webhooks → Add webhook.
- Paste your endpoint URL from step 1.
- Tick the events you want to receive.
- Click Add webhook.
3. Retrieve your signing secret
Open the new webhook (Edit → Signing secret), reveal it, and copy it. Store it on your server as DODO_WEBHOOK_SECRET. The same screen has a Rotate button — use it any time you suspect the secret is compromised; the old value stops working immediately, so deploy the new value before rotating.
Event types
| Event | Triggered when | Status transition |
|---|---|---|
shipment.created | Shipment created and paid (wallet) | → confirmed |
shipment.paid | Gateway payment confirmed | → confirmed |
shipment.cancelled | Shipment cancelled by you or admin | → cancelled |
delivery.rider_assigned | A rider has accepted the job | → pickup_dispatched |
delivery.en_route_to_pickup | Rider is moving toward the pickup | (no status change) |
delivery.picked_up | Rider has collected the package | → picked_up |
delivery.delivered | Package delivered to receiver | → delivered |
delivery.failed | Delivery attempts exhausted | → failed |
Subscribing to all eight gives you full visibility. For minimal integrations, delivery.delivered + delivery.failed + shipment.cancelled cover all terminal states. The two intermediate events (rider_assigned, en_route_to_pickup) are most useful for customer-facing UX during the 10–30 minute window between confirmation and pickup — partners that don't surface that detail can ignore them.
Request format
Every webhook is an HTTPS POST with a JSON body and these headers:
| Header | Value |
|---|---|
Content-Type | application/json |
User-Agent | Dodo-Webhook/1.0 |
X-Dodo-Event | The event name (e.g. delivery.delivered) |
X-Dodo-Timestamp | Unix timestamp when the event was signed |
X-Dodo-Signature | sha256=<hex> — HMAC-SHA256 of "{timestamp}.{raw_body}" |
X-Dodo-Delivery-Attempt | Delivery attempt number (1 for first, 2 and up for retries) |
Payload shape
Every webhook is sent as an envelope — common identifiers at the top level, event-specific fields nested under data. The shape is the same for every event, so you can route on event without branching on which field lives at which level.
{
"event": "delivery.delivered",
"timestamp": "2026-05-15T11:28:14Z",
"shipment_id": 12345,
"shipment_number": "SHP-20260515-A1B2C3",
"merchant_reference": "your-internal-order-id-123",
"data": {
"delivery_id": 8842,
"rider": {
"id": 5,
"name": "Juma Mwangi",
"phone": "+255712345678",
"vehicle_plate": "T123 ABC"
},
"delivered_at": "2026-05-15T11:28:14Z"
}
}
event / timestamp / shipment_id / shipment_number / merchant_reference are at the top level on every event. All other fields live inside data.
The v1 (flat) payload shape that previously lived alongside this envelope was retired on 2026-05-19. The payload_version toggle on the dashboard is gone. If you were on v1, your handler needs to read top-level identifiers as before but reach into data for event-specific fields (data.delivery_id, data.failure_reason, data.reason, etc.).
Field reference (per event)
| Field | Present on | Type | Description |
|---|---|---|---|
shipment_id | all events | integer | Dodo's internal shipment ID |
shipment_number | all events | string | Human reference (SHP-YYYYMMDD-XXXXXX) |
merchant_reference | all events (if set on create) | string | Your internal order ID |
tracking_code | shipment.created | string | Public tracking code |
total_amount | shipment.created, shipment.paid | integer | Amount in TZS |
payment_source | shipment.created | string | "wallet" or "gateway" |
payment_reference | shipment.paid | string | Internal payment transaction ref |
delivery_id | delivery.* events | integer | The delivery (rider assignment) record |
rider | delivery.rider_assigned, delivery.en_route_to_pickup, delivery.picked_up, delivery.delivered, delivery.failed | object | { id, name, phone, vehicle_plate } — surface to your end-customer for "your rider is on the way" UX and support escalation |
assigned_at | delivery.rider_assigned | string | ISO timestamp when rider accepted |
picked_up_at | delivery.picked_up | string | ISO timestamp |
delivered_at | delivery.delivered | string | ISO timestamp |
failure_reason | delivery.failed | string | Why the delivery failed |
reason | shipment.cancelled | string | Cancellation reason |
window | shipment.cancelled | string | grace | assigned | en_route — which cancel window applied |
fee | shipment.cancelled | integer | TZS retained as a kill fee |
refund | shipment.cancelled | integer | TZS credited back to wallet (if wallet-paid) |
shipment_id is an integerThe shipment_id field is an integer, not a string. If you correlate by merchant_reference instead, that field is a string.
Verifying the signature
Always verify before processing. The signature is HMAC-SHA256 over "{X-Dodo-Timestamp}.{raw_body}" — the timestamp from the header, a literal ., then the raw request body bytes.
The body must be hashed exactly as sent. If your framework parses JSON and re-serializes it, the signature will not match. Use the raw bytes.
Node.js (Express)
const crypto = require('crypto');
const express = require('express');
const app = express();
const SECRET = process.env.DODO_WEBHOOK_SECRET;
function verify(rawBody, timestamp, signature) {
const message = Buffer.concat([
Buffer.from(`${timestamp}.`),
Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(rawBody),
]);
const expected = 'sha256=' + crypto
.createHmac('sha256', SECRET)
.update(message)
.digest('hex');
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
}
// IMPORTANT: use express.raw — NOT express.json — to get raw bytes
app.post('/webhooks/dodo', express.raw({ type: 'application/json' }), (req, res) => {
const sig = req.headers['x-dodo-signature'];
const ts = req.headers['x-dodo-timestamp'];
const event = req.headers['x-dodo-event'];
if (!sig || !ts || !verify(req.body, ts, sig)) {
return res.status(401).send('Invalid signature');
}
const body = JSON.parse(req.body);
// top-level: event, timestamp, shipment_id, shipment_number, merchant_reference
// event-specific fields live under body.data
const { shipment_number, data } = body;
switch (event) {
case 'delivery.delivered':
console.log(`Shipment ${shipment_number} delivered`);
break;
case 'delivery.failed':
console.warn(`Shipment ${shipment_number} failed: ${data.failure_reason}`);
break;
case 'shipment.cancelled':
console.log(`Shipment ${shipment_number} cancelled: ${data.reason}`);
break;
}
res.sendStatus(200);
});
Python (Flask)
import hmac, hashlib, os
from flask import Flask, request
app = Flask(__name__)
SECRET = os.environ['DODO_WEBHOOK_SECRET'].encode()
def verify(raw_body, timestamp, signature):
message = f"{timestamp}.".encode() + raw_body
expected = 'sha256=' + hmac.new(SECRET, message, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)
@app.route('/webhooks/dodo', methods=['POST'])
def webhook():
sig = request.headers.get('X-Dodo-Signature', '')
ts = request.headers.get('X-Dodo-Timestamp', '')
event = request.headers.get('X-Dodo-Event', '')
raw = request.get_data() # raw bytes — must read before request.json
if not sig or not ts or not verify(raw, ts, sig):
return 'Unauthorized', 401
body = request.json
# top-level: event, timestamp, shipment_id, shipment_number, merchant_reference
# event-specific fields live under body['data']
if event == 'delivery.delivered':
print(f"Shipment {body['shipment_number']} delivered")
elif event == 'delivery.failed':
print(f"Shipment {body['shipment_number']} failed: {body['data']['failure_reason']}")
return '', 200
PHP
<?php
$secret = getenv('DODO_WEBHOOK_SECRET');
$raw_body = file_get_contents('php://input');
$sig = $_SERVER['HTTP_X_DODO_SIGNATURE'] ?? '';
$timestamp = $_SERVER['HTTP_X_DODO_TIMESTAMP'] ?? '';
$event = $_SERVER['HTTP_X_DODO_EVENT'] ?? '';
$message = $timestamp . '.' . $raw_body;
$expected = 'sha256=' . hash_hmac('sha256', $message, $secret);
if (!hash_equals($expected, $sig)) {
http_response_code(401);
exit('Invalid signature');
}
$data = json_decode($raw_body, true);
// ... handle $event
http_response_code(200);
Retry policy
Your endpoint must return a 2xx status within 30 seconds. Anything else — including timeouts and non-2xx responses — counts as a failure.
Failed deliveries are retried a small number of times (currently 3) with a delay (currently 60 s) between attempts. The exact retry cadence is operational and may change — design your handler to tolerate at-least-once delivery and rely on idempotency, not the exact retry count.
Every request carries X-Dodo-Delivery-Attempt: N so you can distinguish the first attempt from a retry without deduping by event ID. Log it alongside your acks so you can tell at a glance which incidents were retry storms.
After all retries fail, the attempt is logged and the webhook stays subscribed. If a single endpoint fails repeatedly, you'll receive an email alert with delivery-log links so you can fix or rotate the URL from the dashboard.
Idempotency
Webhooks can arrive more than once for the same event (network retries, transient failures, your endpoint timing out after partial processing). Make your handler idempotent.
Recommended pattern: store a (shipment_id, event) tuple as a unique key, and skip work if you've seen it before.
const seen = await db.query(
'INSERT INTO webhook_events (shipment_id, event) VALUES ($1, $2) ON CONFLICT DO NOTHING RETURNING id',
[data.shipment_id, event],
);
if (seen.rowCount === 0) {
return res.sendStatus(200); // already processed
}
// ... do the real work
Testing webhooks
In the dashboard, Settings → Webhooks → {endpoint} → Send test event dispatches a sample payload for any subscribed event. The test events carry real signatures — your verification code will work against them.
For local development, use a tunneling tool like ngrok to expose your localhost to a public URL, then point your sandbox webhook at it.
Debugging delivery failures
The dashboard's Webhooks → Delivery logs view shows every attempt — request headers, body, response status, response body, error message. Use this to debug why your endpoint is rejecting events.