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. To set one up:
- Log in to merchant.dodo.co.tz.
- Go to Settings → Webhooks → Add endpoint.
- Enter your URL (must be HTTPS in production).
- Select the events you want to receive.
- Click Save — copy the signing secret shown on the next screen.
The signing secret is shown once. Store it in your environment as DODO_WEBHOOK_SECRET. You can rotate it from the dashboard at any time.
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.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 six gives you full visibility. For minimal integrations, delivery.delivered + delivery.failed + shipment.cancelled cover all terminal states.
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}" |
Payload
{
"shipment_id": 12345,
"shipment_number": "SHP-20260515-A1B2C3",
"delivery_id": 8842,
"delivered_at": "2026-05-15T11:28:14Z"
}
Payload fields vary by 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 |
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 |
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 data = JSON.parse(req.body);
switch (event) {
case 'delivery.delivered':
console.log(`Shipment ${data.shipment_number} delivered`);
break;
case 'delivery.failed':
console.warn(`Shipment ${data.shipment_number} failed: ${data.failure_reason}`);
break;
case 'shipment.cancelled':
console.log(`Shipment ${data.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
data = request.json
if event == 'delivery.delivered':
print(f"Shipment {data['shipment_number']} delivered")
elif event == 'delivery.failed':
print(f"Shipment {data['shipment_number']} failed: {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 — counts as a failure.
Failed deliveries are retried up to 5 times with exponential backoff:
| Attempt | Delay after previous |
|---|---|
| 1 | — (immediate) |
| 2 | 60s |
| 3 | 120s |
| 4 | 240s |
| 5 | 480s |
| 6 | 960s |
After 5 consecutive failures, the webhook is automatically disabled. You'll get an email, and you can re-enable it in the dashboard once your endpoint is fixed.
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.