Search pages in the SMS Pay documentation.
Webhooks are signed server-to-server events from SMS Pay to your backend. They tell your system when a payment is paid, needs review, expires, or is rejected.
Use webhooks for fulfillment. The checkout page and browser redirects are not payment proof.
| Event | Fulfillment action |
|---|---|
payment.paid | Fulfill the order after verifying the signature and idempotency. |
payment.review_required | Do not fulfill. Ask operations to review evidence. |
payment.expired | Do not fulfill. Ask customer to retry if needed. |
payment.rejected | Do not fulfill. Evidence was rejected. |
When a merchant admin adds a webhook endpoint, select which events that endpoint should receive. For most integrations, subscribe the main order-fulfillment endpoint to all payment events and let your backend branch by event.
Only use narrower subscriptions when you intentionally split events across different backend services.
{
"event": "payment.paid",
"environment": "SANDBOX",
"timestamp": "2026-05-05T10:01:00.000Z",
"data": {
"payment_intent_id": "b5012f33-207e-4999-bf8d-5a1ebb10988e",
"amount": "500",
"currency": "BDT",
"customer_reference": "SP7A91BC2D0",
"merchant_reference": "ORDER-10045"
}
}
Use merchant_reference to find your order when you provided one. Store payment_intent_id too, because it is the stable SMS Pay identifier.
| Header | Description |
|---|---|
X-Webhook-Id | Unique delivery ID. Store it to avoid duplicate processing. |
X-Webhook-Event | Event type, for example payment.paid. |
X-Webhook-Signature | HMAC signature as sha256=<hex>. |
X-Webhook-Timestamp | Delivery timestamp. |
X-Webhook-Idempotency-Key | Stable key for this event and endpoint. |
Verify the signature against the raw request body before parsing JSON. Then process the webhook inside an idempotent database transaction.
import crypto from "node:crypto";
import express from "express";
const app = express();
const webhookSecret = process.env.SMS_PAY_WEBHOOK_SECRET!;
function safeEqual(left: string, right: string) {
const a = Buffer.from(left);
const b = Buffer.from(right);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
app.post(
"/webhooks/sms-pay",
express.raw({ type: "application/json" }),
async (req, res) => {
const rawBody = req.body as Buffer;
const signature = req.header("x-webhook-signature") ?? "";
const expected =
"sha256=" +
crypto.createHmac("sha256", webhookSecret).update(rawBody).digest("hex");
if (!safeEqual(signature, expected)) {
return res.status(401).send("invalid signature");
}
const webhookId = req.header("x-webhook-id");
const eventType = req.header("x-webhook-event");
const payload = JSON.parse(rawBody.toString("utf8"));
// Store webhookId or X-Webhook-Idempotency-Key before fulfillment.
// If the same webhook arrives again, return 200 without fulfilling twice.
if (eventType === "payment.paid") {
// Mark the order paid using payload.data.merchant_reference
// or payload.data.payment_intent_id.
}
return res.status(200).send("ok");
},
);
Webhook delivery is at least once. Return 2xx when your backend safely accepted the event. Return non-2xx only when you want SMS Pay to retry.
From Dashboard -> Webhooks, merchant admins can:
successUrl.payment.review_required.