Skip to main content

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:

  1. Log in to merchant.dodo.co.tz.
  2. Go to Settings → Webhooks → Add endpoint.
  3. Enter your URL (must be HTTPS in production).
  4. Select the events you want to receive.
  5. 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

EventTriggered whenStatus transition
shipment.createdShipment created and paid (wallet)confirmed
shipment.paidGateway payment confirmedconfirmed
shipment.cancelledShipment cancelled by you or admincancelled
delivery.picked_upRider has collected the packagepicked_up
delivery.deliveredPackage delivered to receiverdelivered
delivery.failedDelivery attempts exhaustedfailed

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:

HeaderValue
Content-Typeapplication/json
User-AgentDodo-Webhook/1.0
X-Dodo-EventThe event name (e.g. delivery.delivered)
X-Dodo-TimestampUnix timestamp when the event was signed
X-Dodo-Signaturesha256=<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:

FieldPresent onTypeDescription
shipment_idall eventsintegerDodo's internal shipment ID
shipment_numberall eventsstringHuman reference (SHP-YYYYMMDD-XXXXXX)
merchant_referenceall events (if set on create)stringYour internal order ID
tracking_codeshipment.createdstringPublic tracking code
total_amountshipment.created, shipment.paidintegerAmount in TZS
payment_sourceshipment.createdstring"wallet" or "gateway"
payment_referenceshipment.paidstringInternal payment transaction ref
delivery_iddelivery.* eventsintegerThe delivery (rider assignment) record
picked_up_atdelivery.picked_upstringISO timestamp
delivered_atdelivery.deliveredstringISO timestamp
failure_reasondelivery.failedstringWhy the delivery failed
reasonshipment.cancelledstringCancellation reason
shipment_id is an integer

The 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.

Read the raw body before parsing JSON

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:

AttemptDelay after previous
1— (immediate)
260s
3120s
4240s
5480s
6960s

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.