Skip to main content
ShadhinPay Docs

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

EventFired whenDefault
payment.completedA payment succeeds✅ on
payment.failedA payment fails✅ on
payment.refundedA refund settles✅ on
payment.refund_failedA refund fails✅ on
invoice.paidAn invoice is paid✅ on
invoice.expiredAn invoice expires unpaid✅ on
payout.settledA payout reaches your bank✅ on
payout.failedA payout fails✅ on
payment.cancelledA payment is cancelledopt-in
payment.expiredA payment's window lapsesopt-in
invoice.cancelledAn invoice is cancelledopt-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

HeaderPurpose
X-ShadhinPay-SignatureThe signature to verify (see below)
X-ShadhinPay-Event-IdSame as eventId; dedupe on this
X-ShadhinPay-Delivery-AttemptAttempt counter, 16
X-ShadhinPay-Trace-IdCorrelation 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.
  • v1HMAC-SHA256 of the string t=<t>.<raw request body>, keyed with your business's webhook signing secret, hex-encoded.
Read the raw request body as bytes — verify before any JSON parsing or re-serialisation, which would change the bytes.
Parse t and v1 from the header.
Reject if t is more than 5 minutes from now (replay protection).
Recompute 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 2xx quickly to acknowledge receipt. Do slow work asynchronously.
  • Any non-2xx response, 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  →  +12h

After 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.

Next steps

On this page