Webhooks
Set up an endpoint
- Create a
POSTroute on your server that is reachable over HTTPS. - Open Dashboard → Developers → Webhooks and add its URL.
- Copy the signing secret. It starts with
whsec_and is shown only once, so store it straight away, for example in an environment variable.
Test mode and live mode have separate webhook URLs and separate secrets. Test events only go to the test URL, and live events only go to the live URL.
What DottsPay sends
Each event is a POST with a JSON body and these headers:
Content-Type: application/json
X-DottsPay-Signature: 3f9c2a…e81bThe body always has the same shape. The contents of data depend on the event.
{
"id": "evt_Rk4xP9mQ2sLt7WvB3nYh6c",
"event": "charge.success",
"mode": "test",
"created_at": "2026-10-12T09:31:05.000Z",
"data": {
"id": "cm2x9k1f00004l708qv3t2abc",
"reference": "dp_Hk2pQx9LmT4sWb7NcY",
"status": "success",
"mode": "test",
"amount": 500000,
"charged_amount": 500000,
"fees": 7500,
"net": 492500,
"currency": "NGN",
"channel": "card",
"fee_bearer": "merchant",
"settlement": "split",
"customer": {
"email": "ada@example.com",
"name": "Ada Obi",
"phone": "+2348012345678"
},
"custom_fields": null,
"metadata": {
"order_id": "ord_1042"
},
"payment_link_id": null,
"checkout_session_id": "cs_8Kq2LmT4xPz9WbN3vR6hYd",
"failure_reason": null,
"paid_at": "2026-10-12T09:31:04.000Z",
"created_at": "2026-10-12T09:30:12.000Z"
}
}Event types
| Event | When | data |
|---|---|---|
charge.success | A payment succeeded. | Transaction |
charge.failed | A payment attempt failed. | Transaction |
refund.created | A refund was created. | Refund |
payout.paid | A payout reached your bank. | { reference, amount, currency } |
payout.failed | A payout could not be paid. | { reference, amount, currency, reason } |
payout.paid- WhenA payout reached your bank.
- data
{ reference, amount, currency } payout.failed- WhenA payout could not be paid.
- data
{ reference, amount, currency, reason }
refund.created
{
"id": "evt_Tn2bV8kD4rQx9LmC6pHw1z",
"event": "refund.created",
"mode": "test",
"created_at": "2026-10-12T14:02:46.000Z",
"data": {
"id": "cm2xa3b7n0009l708h4kd9xyz",
"transaction_reference": "dp_Hk2pQx9LmT4sWb7NcY",
"amount": 200000,
"currency": "NGN",
"status": "pending",
"reason": "Customer returned one item",
"created_at": "2026-10-12T14:02:45.000Z"
}
}payout.paid and payout.failed
Payout events only apply to pooled settlement. With split settlement, money goes straight to your bank with each payment.
{
"id": "evt_Mq7sW3cJ9yLp2XbK5dFt8a",
"event": "payout.failed",
"mode": "live",
"created_at": "2026-10-13T08:00:12.000Z",
"data": {
"reference": "po_Zr8nK2wQ5tLm9XcV4b",
"amount": 18450000,
"currency": "NGN",
"reason": "Account name does not match"
}
}Verify the signature
X-DottsPay-Signature is a hex-encoded HMAC-SHA512 of the raw request body, keyed with your webhook secret. Check it before you parse or act on the body. Anyone can send a POST to your URL; only DottsPay knows your secret.
- Use the raw body bytes exactly as received. Parsing the JSON and serialising it again changes the bytes and the check fails.
- Compare with a constant-time function, not
===, so the comparison does not leak timing information. - Reject anything that does not match with a 400, and do nothing else.
import express from 'express'
import { DottsPay } from '@dottspay/node'
const app = express()
// Register this route before app.use(express.json()), so the body stays raw.
app.post('/webhooks/dottspay', express.raw({ type: 'application/json' }), async (req, res) => {
let event
try {
event = DottsPay.webhooks.constructEvent(
req.body, // a Buffer
req.get('X-DottsPay-Signature'),
process.env.DOTTSPAY_WEBHOOK_SECRET,
)
} catch {
return res.sendStatus(400)
}
// Acknowledge first. Do slow work after replying, or on a queue.
res.sendStatus(200)
if (await seenEvents.has(event.id)) return // duplicate delivery
await seenEvents.add(event.id)
switch (event.event) {
case 'charge.success':
await orders.markPaid(event.data.metadata?.order_id, event.data.reference)
break
case 'charge.failed':
await orders.noteFailure(event.data.reference, event.data.failure_reason)
break
case 'refund.created':
await orders.noteRefund(event.data.transaction_reference, event.data.amount)
break
}
})
app.use(express.json()) // the rest of your appimport crypto from 'node:crypto'
import express from 'express'
const app = express()
app.post('/webhooks/dottspay', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.get('X-DottsPay-Signature') ?? ''
const expected = crypto
.createHmac('sha512', process.env.DOTTSPAY_WEBHOOK_SECRET)
.update(req.body) // the raw Buffer, not a parsed object
.digest()
const given = Buffer.from(signature, 'hex')
if (given.length !== expected.length || !crypto.timingSafeEqual(given, expected)) {
return res.sendStatus(400)
}
// Only now is it safe to parse.
const event = JSON.parse(req.body.toString('utf8'))
res.sendStatus(200)
handleEvent(event).catch(console.error)
})<?php
// webhook.php
$payload = file_get_contents('php://input'); // raw body
$signature = strtolower($_SERVER['HTTP_X_DOTTSPAY_SIGNATURE'] ?? '');
$secret = getenv('DOTTSPAY_WEBHOOK_SECRET');
$expected = hash_hmac('sha512', $payload, $secret);
// hash_equals is a constant-time comparison.
if (!hash_equals($expected, $signature)) {
http_response_code(400);
exit;
}
http_response_code(200);
$event = json_decode($payload, true);
if ($event['event'] === 'charge.success') {
$tx = $event['data'];
// Dedupe on $event['id'], then fulfil the order for $tx['reference'].
}app.use(express.json()) parses the body before your route sees it. Register the webhook route with express.raw() before the JSON parser, as above.Next.js route handler
In the App Router, read the body with req.text() to get it raw.
import { DottsPay } from '@dottspay/node'
export async function POST(req: Request) {
const raw = await req.text() // raw body, before any JSON parsing
let event
try {
event = DottsPay.webhooks.constructEvent(
raw,
req.headers.get('x-dottspay-signature'),
process.env.DOTTSPAY_WEBHOOK_SECRET!,
)
} catch {
return new Response('Invalid signature', { status: 400 })
}
if (event.event === 'charge.success') {
await markPaid(event.data.reference)
}
return new Response(null, { status: 200 })
}Responding and retries
Reply with any 2xx status as soon as the signature checks out. Do slow work, such as sending emails or calling other services, after you reply or on a background queue.
If your endpoint returns a non-2xx status or takes longer than 15 seconds, DottsPay tries again. It makes up to 8 attempts in total, with an exponential backoff that starts at 30 seconds and grows with each attempt.
Duplicates and ordering
Because of retries, the same event can arrive more than once, and events can arrive out of order. Build your handler for that:
- Store each event
idyou have processed and skip ones you have seen. - Make fulfilment idempotent. Marking an order paid twice should be harmless.
- Never move an order backwards. A late
charge.failedfor an earlier attempt should not undo a paid order. - When in doubt, fetch the latest state with Verify a transaction.
Testing locally
DottsPay needs a public HTTPS URL to reach you. While you develop, expose your local server with a tunnelling tool such as ngrok or Cloudflare Tunnel, and set the tunnel URL as your test webhook URL. Then make a payment with a test card and watch the event arrive.