Webhooks
Register an HTTPS URL and we POST to it whenever a transaction changes status. The body contains the event name and the same transaction object the API returns.
When webhooks are sent
- For every transaction made through the API, whenever its status changes (e.g. pending → successful or failed).
- For purchases made on the website or app too, once you have an active webhook.
- Deliveries go to the URL set with PUT /webhook. If none is active, API transactions fall back to the webhook URL on your approved API access request.
- Events:
transaction.successful,transaction.pending,transaction.failed. Saving a URL also sends awebhook.verifyprobe (unlessverify: false). - Sandbox (
ds_test_) purchases are delivered to the same URL withmode: "test"anddata.sandbox: true. Ignore them in production, or use them to test your handler end to end.
Payload
data is the transaction object described in Responses & statuses. mode is live or test (sandbox).
JSON
{
"event": "transaction.successful",
"mode": "live",
"delivery_id": "9b2f6c1e-4d7a-4f0b-8e3c-2a1d5f6e7b80",
"sent_at": "2026-10-05T10:15:24+01:00",
"data": {
"status": "successful",
"reference": "ORDER-10021",
"channel": "api",
"transaction_id": "DS261005101522K7Q2ZP4M",
"category": "data",
"message": "Transaction successful.",
"network": "MTN",
"biller": "MTN Data",
"biller_slug": "mtn-data",
"product_id": 212,
"product": "MTN SME 1GB - 30 Days",
"plan": "MTN SME 1GB - 30 Days",
"phone": "08031234567",
"customer_ref": "08031234567",
"quantity": 1,
"unit_amount": "480.00",
"amount": "480.00",
"face_amount": "500.00",
"fee": "0.00",
"discount": "20.00",
"total_deducted": "480.00",
"balance_before": "25000.00",
"balance_after": "24520.00",
"provider_reference": "PRV-88213409",
"purchased_at": "2026-10-05T10:15:22+01:00"
}
}Request headers
| Header | Value |
|---|---|
| Content-Type | application/json |
| X-Datason-Event | Event name, e.g. transaction.successful |
| X-Datason-Delivery | UUID of this delivery attempt (same as delivery_id in the body) |
| X-Datason-Attempt | Attempt number, 1–5 (1–3 for sandbox) |
| X-Datason-Mode | live, or test for sandbox purchases and the webhook simulator |
| User-Agent | Datason-Webhooks/1.0 |
| X-Datason-Signature | sha256=<hex HMAC-SHA256 of the raw body using your webhook secret> |
Delivery and retries
- Respond with any 2xx status within 15 seconds. Redirects are not followed and count as a failure.
- Failed deliveries are retried: 5 attempts in total, waiting about 30 s, 2 min, 10 min and 30 min between them.
- Each attempt gets a new
delivery_id, so de-duplicate ondata.reference+eventand make your handler idempotent. - See every attempt with GET /webhook/deliveries and resend one with POST /webhook/deliveries/{id}/resend.
Verify the signature
Recompute the HMAC on the raw request bytes (before JSON parsing), compare in constant time and reject mismatches.
import crypto from "node:crypto";
import express from "express";
const app = express();
const WEBHOOK_SECRET = process.env.DATASON_WEBHOOK_SECRET;
app.post("/webhooks/vtu", express.raw({ type: "application/json" }), (req, res) => {
const received = req.get("X-Datason-Signature") || "";
const expected =
"sha256=" + crypto.createHmac("sha256", WEBHOOK_SECRET).update(req.body).digest("hex");
const valid =
received.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected));
if (!valid) {
return res.status(401).send("Invalid signature");
}
const { event, data } = JSON.parse(req.body.toString("utf8"));
// Find your order by data.reference and update it with data.status (idempotently).
res.sendStatus(200);
});