Webhooks
Receive signed event notifications, verify their signatures, and handle retries and idempotency.
Webhooks push events to your server the moment something happens — a payment completes, an invoice is paid, a payout settles — so you don't have to poll. Configure your endpoint URL and events per business in the dashboard (see Businesses & providers).
Event types
| Event | Fired when | Default |
|---|---|---|
payment.completed | A payment succeeds | ✅ on |
payment.failed | A payment fails | ✅ on |
payment.refunded | A refund settles | ✅ on |
payment.refund_failed | A refund fails | ✅ on |
invoice.paid | An invoice is paid | ✅ on |
invoice.expired | An invoice expires unpaid | ✅ on |
payout.settled | A payout reaches your bank | ✅ on |
payout.failed | A payout fails | ✅ on |
payment.cancelled | A payment is cancelled | opt-in |
payment.expired | A payment's window lapses | opt-in |
invoice.cancelled | An invoice is cancelled | opt-in |
Payload
Every delivery is a JSON POST shaped like this:
{
"eventId": "550e8400-e29b-41d4-a716-446655440000",
"eventType": "payment.completed",
"businessId": 12345,
"occurredAt": "2026-05-17T10:05:00Z",
"payload": {
"paymentId": "PAY_20260517_K8X3M2",
"merchantTxnId": "order-7831",
"amount": "1500.50",
"currency": "BDT",
"status": "COMPLETED",
"vendor": "BKASH"
}
}eventId is stable across retries — use it to deduplicate (see below).
Headers
| Header | Purpose |
|---|---|
X-ShadhinPay-Signature | The signature to verify (see below) |
X-ShadhinPay-Event-Id | Same as eventId; dedupe on this |
X-ShadhinPay-Delivery-Attempt | Attempt counter, 1–6 |
X-ShadhinPay-Trace-Id | Correlation ID for support |
Verifying the signature
Always verify the signature before trusting a webhook — it proves the request came from ShadhinPay and wasn't tampered with.
The X-ShadhinPay-Signature header looks like:
X-ShadhinPay-Signature: t=1747476600,v1=5257a869e7...t— the Unix timestamp when the event was signed.v1—HMAC-SHA256of the stringt=<t>.<raw request body>, keyed with your business's webhook signing secret, hex-encoded.
t and v1 from the header.t is more than 5 minutes from now (replay protection).HMAC-SHA256(secret, "t=" + t + "." + rawBody) and compare to v1 using a constant-time comparison.Example (Node.js)
import crypto from 'node:crypto'
// rawBody: the exact bytes of the request body (Buffer/string), NOT re-serialised JSON
export function verifyWebhook(rawBody, signatureHeader, secret) {
const parts = Object.fromEntries(
signatureHeader.split(',').map((kv) => kv.split('=')),
)
const t = Number(parts.t)
const v1 = parts.v1
// 1. Reject stale timestamps (replay protection)
const ageSeconds = Math.floor(Date.now() / 1000) - t
if (!Number.isFinite(t) || Math.abs(ageSeconds) > 300) return false
// 2. Recompute and compare in constant time
const expected = crypto
.createHmac('sha256', secret)
.update(`t=${t}.${rawBody}`)
.digest('hex')
const a = Buffer.from(expected)
const b = Buffer.from(v1 ?? '')
return a.length === b.length && crypto.timingSafeEqual(a, b)
}Verify the raw body, not the parsed object
Frameworks that auto-parse JSON give you a re-serialised object whose bytes differ
from what was signed. Capture the raw body (e.g. Express express.raw(), or a body
buffer) and run the JSON parse only after the signature checks out.
Responding
- Return
2xxquickly to acknowledge receipt. Do slow work asynchronously. - Any non-
2xxresponse, or a timeout (each attempt waits up to 10 seconds), is treated as a failure and retried.
Retries
Failed deliveries are retried up to 6 times with exponential backoff:
attempt 1 → +30s → +2m → +10m → +1h → +4h → +12hAfter the final attempt the event is dead-lettered: it stops retrying and shows up in your dashboard notifications, where you (or support) can trigger a manual re-delivery.
Idempotency & ordering
Delivery is at-least-once, so you may receive the same event more than once
(for example, if your 2xx was lost on the way back):
- Deduplicate on
eventId(X-ShadhinPay-Event-Id), not on payload contents. - On a duplicate you've already handled, just return
2xx— don't re-process. - Don't assume strict ordering between different events; reconcile using the payment/invoice status in the payload.
Testing webhooks
When you set or change your webhook URL, ShadhinPay sends a test ping and
requires a 2xx before saving — so a misconfigured endpoint can't be saved. You
can also re-send a ping from the dashboard. Drive real events end to end using the
sandbox; see Testing.