DottsPay
Guides

Webhooks

DottsPay tells your server when something happens, such as a successful charge or a refund. Webhooks are the reliable way to learn about payments, even when a customer closes the tab before they return to your site.

Set up an endpoint

  1. Create a POST route on your server that is reachable over HTTPS.
  2. Open Dashboard → Developers → Webhooks and add its URL.
  3. 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:

HTTP
Content-Type: application/json
X-DottsPay-Signature: 3f9c2a…e81b

The body always has the same shape. The contents of data depend on the event.

charge.success
{
  "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

charge.success
WhenA payment succeeded.
dataTransaction
charge.failed
WhenA payment attempt failed.
dataTransaction
refund.created
WhenA refund was created.
dataRefund
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

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.

payout.failed
{
  "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 app
Express users
A global 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.

app/api/webhooks/dottspay/route.ts
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 id you 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.failed for 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.