Payment Confirmation Webhook for a Custom Website: Verify the HMAC Signature
To confirm wallet payments on a custom website with QRPayBD, your server creates an order over REST, redirects the customer to the returned paymentUrl, and waits for a signed webhook. Verify the HMAC-SHA256 signature on the raw request body, compare it in constant time, answer with a 2xx quickly and make your handler idempotent. Webhooks are not retried, so also reconcile pending orders with GET /orders/:id.
The flow in four steps
- Your server creates an order with the amount and your own order number.
- You redirect the customer to the payment page. It shows the shop's wallet number, the exact amount and an order reference.
- The customer sends money with their wallet app. QRPayBD matches the payment SMS read by the shop's Collector app to the order.
- QRPayBD sends a signed webhook to your server. You verify it, update the order and respond.
QRPayBD never holds or moves money. It confirms payments that already reached the shop's own wallet number, by reading SMS. It is not an official wallet API, so see the limits at the end.
Step 1: create an order
From your server, send POST /orders with the amount in taka and your own unique storeOrderId. You may add a returnUrl so the payment page can show a Back to shop button. The call is authenticated with your store API key, which belongs on the server and never in a browser or mobile app. Exact request details and examples in curl, Node.js and PHP are in the integration guide.
The response contains the paymentUrl, along with the order's status and reference code. Sending the same storeOrderId again returns the same order, so retrying a timed-out request is safe.
Step 2: redirect the customer to paymentUrl
Redirect the customer's browser to paymentUrl or show it as a link. The customer sends money from their wallet app, typing the order reference in the Reference field if the app has one, or entering the TrxID on the page if not.
Do not mark an order paid because the customer came back to your site. Only a verified webhook, or a status check from your server, confirms a payment.
Step 3: receive the signed webhook
When an order is paid, QRPayBD sends a POST to your webhook address. The address must be a public https URL. The request carries three headers and a JSON body.
- X-QRPayBD-Signature: sha256= followed by the hex HMAC-SHA256 of the raw body, made with your webhook secret.
- X-QRPayBD-Event: the event name.
- X-QRPayBD-Delivery: an identifier for this delivery.
The body contains these fields: event, orderId, storeOrderId, referenceCode, amountPaisa, receivedAmountPaisa, amountMismatch, matchMethod, trxId and paidAt. Amounts are in paisa, where 100 paisa is 1 taka.
Step 4: verify the signature on the raw body
The signature covers the exact bytes that were sent. If a framework parses the JSON and you serialise it again, whitespace or key order may change and the check will fail. Capture the raw body, compute the HMAC, and compare with a constant-time function so the comparison does not leak timing information.
import crypto from "node:crypto";
import express from "express";
const app = express();
// express.raw keeps the exact bytes; do not use express.json() on this route.
app.post("/webhooks/qrpaybd", express.raw({ type: "application/json" }), (req, res) => {
const expected =
"sha256=" +
crypto.createHmac("sha256", process.env.QRPAYBD_WEBHOOK_SECRET).update(req.body).digest("hex");
const received = String(req.headers["x-qrpaybd-signature"] || "");
const ok =
received.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected));
if (!ok) return res.status(401).end();
const event = JSON.parse(req.body.toString("utf8"));
// Find your order by event.storeOrderId, skip it if already paid,
// compare event.amountPaisa with your own total, then mark it paid.
res.status(200).json({ ok: true });
});Handle the event safely
- Be idempotent. Key your handling on orderId (or storeOrderId) and ignore an event for an order that is already paid. The same delivery may arrive more than once.
- Compare amountPaisa with your own order total. If amountMismatch is true, the payment message showed a different amount from the order. Hold the order for a manual check instead of fulfilling it.
- Store trxId and paidAt with the order for your records.
- Answer with any 2xx status fast. Do slow work, such as sending email or calling other services, after you respond.
- Keep the webhook secret and the API key in environment variables, never in front-end code or version control.
Common mistakes when verifying webhooks
- Parsing the body with a JSON middleware first and hashing the re-serialised object. The bytes differ, so valid signatures fail.
- Comparing the signature with == or a normal string compare. Use a constant-time function, and check the lengths first because timingSafeEqual throws on unequal lengths.
- Forgetting the sha256= prefix when you build the expected value, or comparing only the hex part against the whole header.
- Using the wrong secret. The webhook secret is separate from the store API key.
- Returning 200 before verifying, or doing slow work before responding.
Webhooks are not retried: reconcile
QRPayBD sends each webhook once. If your server is down or returns an error, there is no automatic retry yet. Protect yourself with a reconciliation job: every few minutes, fetch your pending orders with GET /orders/:id (using the orderId you stored) and treat a status of paid exactly like the webhook. The WordPress plugin does the same re-check for pending orders.
Limits to design around
Confirmation is based on the payment SMS read by the shop's Android phone, not on an official wallet API. If the phone is off or offline, or a message format is new, confirmation can be delayed or need a manual match in the dashboard. A forged SMS is possible. The message readers for bKash, Nagad and Upay have been verified on real payments. For large payments, check the wallet balance before fulfilment. Providers' terms may restrict business use of personal accounts, so the shop owner should check them.
Open the full integration guide
To get a store API key and webhook secret, create an account. It is free during early access.