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.

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.1 are rejected — use a tunneling tool like ngrok for local dev).
  • Accept POST with Content-Type: application/json.
  • Return any 2xx status 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.

This is not a Dodo URL

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

  1. Log in to merchant.dodo.co.tz.
  2. Go to Settings → Webhooks → Add webhook.
  3. Paste your endpoint URL from step 1.
  4. Tick the events you want to receive.
  5. Click Add webhook.

3. Retrieve your signing secret

Open the new webhook (EditSigning 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

EventTriggered whenStatus transition
shipment.createdShipment created and paid (wallet)confirmed
shipment.paidGateway payment confirmedconfirmed
shipment.cancelledShipment cancelled by you or admincancelled
delivery.rider_assignedA rider has accepted the jobpickup_dispatched
delivery.en_route_to_pickupRider is moving toward the pickup(no status change)
delivery.picked_upRider has collected the packagepicked_up
delivery.deliveredPackage delivered to receiverdelivered
delivery.failedDelivery attempts exhaustedfailed

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:

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}"
X-Dodo-Delivery-AttemptDelivery 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.

Legacy flat shape removed

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)

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
riderdelivery.rider_assigned, delivery.en_route_to_pickup, delivery.picked_up, delivery.delivered, delivery.failedobject{ id, name, phone, vehicle_plate } — surface to your end-customer for "your rider is on the way" UX and support escalation
assigned_atdelivery.rider_assignedstringISO timestamp when rider accepted
picked_up_atdelivery.picked_upstringISO timestamp
delivered_atdelivery.deliveredstringISO timestamp
failure_reasondelivery.failedstringWhy the delivery failed
reasonshipment.cancelledstringCancellation reason
windowshipment.cancelledstringgrace | assigned | en_route — which cancel window applied
feeshipment.cancelledintegerTZS retained as a kill fee
refundshipment.cancelledintegerTZS credited back to wallet (if wallet-paid)
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 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.