NiriPay sends HTTP POST requests with a JSON body to the webhook_url supplied during transaction creation.
Your webhook endpoint should:
- Be publicly accessible over HTTPS.
- Verify the signature and timestamp before parsing or processing the body.
- Respond with a
2xxstatus within 10 seconds, only after durable acceptance or successful idempotent processing. - Store the verified raw payload for audit.
- Locate the local transaction by
transaction_id. - Process duplicate events idempotently.
- Apply each balance change only once.
- Use status polling as a fallback.
Verify webhook authenticity#
Use your existing merchant API key as the signing secret. There is no separate webhook key, and the secret is never sent in the callback. Keep it on your server.
| Header | Format |
|---|---|
X-NiriPay-Timestamp | Unix time in seconds, expressed as an integer string |
X-NiriPay-Signature | HMAC-SHA256 digest encoded as 64 lowercase hexadecimal characters |
Calculate the expected signature as:
HMAC-SHA256(key = merchant API key, message = timestamp + "." + raw request body)Use the original timestamp header string and the exact raw body bytes, including whitespace and UTF-8 encoding. Do not parse and reserialize JSON before verification. Decode the hexadecimal signatures and compare equal-length buffers using a constant-time comparison.
Reject missing or malformed headers and invalid signatures. We recommend rejecting timestamps more than 300 seconds (five minutes) in the past or future relative to your server clock. Keep the clock synchronized. This is recommended receiver-side validation, not a gateway-enforced policy. A valid timestamp does not prevent duplicate processing within that window: apply each balance change only once.
Signing uses the merchant's current API key at delivery time, rather than a key snapshotted when the transaction was created. Update your receiver configuration when changing that key. An in-flight callback signed before the change may fail verification with the new key; reconcile missed events using the status API.
Fiat deposit and withdrawal callbacks use the same signing contract. Crypto callbacks also use it, but their payload contains event_id, event, and transaction; deduplicate crypto events by event_id. Crypto delivery attempts are retried and signed again with a fresh timestamp and the current key. The current fiat delivery queue also retries unsuccessful deliveries with backoff and supplies an X-NiriPay-Event-Id header. The HMAC signs the timestamp and raw body, not this event-ID header; do not use that header alone to authorize a financial effect. Deduplicate verified transaction state changes and retain status reconciliation for both APIs.
Deposit postback#
Deposit postbacks are sent for successful payments.
{
"event": "deposit.status_updated",
"data": {
"transaction_id": "TXN123456789",
"amount": 1000,
"status": "success",
"remarks": null,
"fees": {
"percentage": 3,
"fixed_amount": 2,
"total_amount": 32.0,
"net_credit_amount": 968.0
}
}
}| Field | Type | Description |
|---|---|---|
event | String | Always deposit.status_updated |
data.transaction_id | String | Your deposit transaction ID |
data.amount | Number | Deposit amount in INR |
data.status | String | success for completed deposits |
data.remarks | String or null | Additional transaction remarks |
data.fees | Object or null | Saved order pricing; see Order fee details |
Use Get Deposit Status to recover expired, failed, or missing notifications.
Withdrawal postback#
Success:
{
"event": "withdrawal.status_updated",
"data": {
"transaction_id": "WD-2026-001",
"amount": 5000,
"status": "success",
"proof": "UTR1234567890123456",
"fees": {
"percentage": 3,
"fixed_amount": 2,
"total_amount": 152.0,
"total_debit_amount": 5152.0
}
}
}Failure:
{
"event": "withdrawal.status_updated",
"data": {
"transaction_id": "WD-2026-001",
"amount": 5000,
"status": "failed",
"proof": null,
"fees": {
"percentage": 3,
"fixed_amount": 2,
"total_amount": 152.0,
"total_debit_amount": 5152.0
}
}
}| Field | Type | Description |
|---|---|---|
event | String | Always withdrawal.status_updated |
data.transaction_id | String | Your withdrawal transaction ID |
data.amount | Number | Withdrawal amount in INR |
data.status | String | success or failed |
data.proof | String or null | Bank UTR for successful withdrawals |
data.fees | Object or null | Saved order pricing, including after reversal; see Order fee details |
Node.js webhook example#
Install Express and use Node.js ES modules. Set NIRIPAY_MERCHANT_API_KEY on your server to your existing merchant API key; this is a merchant-side configuration name used by this example.
Register the webhook route with express.raw() before any global express.json() middleware. The example returns HTTP 401 for invalid authentication, 400 for a verified but invalid payload, and 500 if durable acceptance fails. It sends 200 only after the persistence hook succeeds.
Implement persistEventOnce before use. It deliberately throws until you replace it with your database transaction or durable queue. Ensure duplicate events do not repeat balance changes; a signature is not an event identifier. Do not acknowledge an event and then rely on an unpersisted background task to process it.
The handler validates fiat deposit and withdrawal payloads. For crypto, reuse verifyWebhook but replace validFiatEvent with validation of the crypto payload and deduplicate by event_id.
import express from "express";
import { createHmac, timingSafeEqual } from "node:crypto";
const app = express();
// Set this on YOUR server to your existing NiriPay merchant API key.
const apiKey = process.env.NIRIPAY_MERCHANT_API_KEY;
if (!apiKey) throw new Error("Set NIRIPAY_MERCHANT_API_KEY before starting");
function verifyWebhook(rawBody, timestamp, signature, now = Date.now()) {
if (!Buffer.isBuffer(rawBody) || typeof timestamp !== "string" ||
!/^[0-9]+$/.test(timestamp) || typeof signature !== "string" ||
!/^[a-f0-9]{64}$/.test(signature)) return false;
const seconds = Number(timestamp);
if (!Number.isSafeInteger(seconds) ||
Math.abs(Math.floor(now / 1000) - seconds) > 300) return false;
const expected = createHmac("sha256", apiKey)
.update(timestamp + ".", "utf8")
.update(rawBody)
.digest();
const received = Buffer.from(signature, "hex");
return received.length === expected.length && timingSafeEqual(received, expected);
}
function validFiatEvent(payload) {
const data = payload?.data;
if (!data || typeof data.transaction_id !== "string" || !data.transaction_id ||
typeof data.amount !== "number" || !Number.isFinite(data.amount) ||
data.amount <= 0) return false;
if (payload.event === "deposit.status_updated") {
return data.status === "success" &&
(data.remarks === null || typeof data.remarks === "string");
}
if (payload.event === "withdrawal.status_updated") {
return ["success", "failed"].includes(data.status) &&
(data.proof === null || typeof data.proof === "string");
}
return false;
}
async function persistEventOnce(payload) {
// MERCHANT IMPLEMENTATION REQUIRED: durably store/queue the verified event
// or finish processing it in a database transaction before resolving.
// Reconcile with the status API and your stored order amount/currency.
// Make duplicate deliveries no-ops and apply each balance change only once.
// Do not deduplicate solely by signature: a repeated event can be re-signed.
// Throw on persistence failure so the handler does not acknowledge success.
void payload;
throw new Error("Implement persistEventOnce using your database or durable queue");
}
// Register this route BEFORE app.use(express.json()) so req.body stays a Buffer.
app.post("/webhook", express.raw({ type: "application/json" }), async (req, res) => {
if (!verifyWebhook(req.body, req.get("X-NiriPay-Timestamp"),
req.get("X-NiriPay-Signature"))) return res.sendStatus(401);
let payload;
try {
payload = JSON.parse(req.body.toString("utf8"));
} catch {
return res.sendStatus(400);
}
if (!validFiatEvent(payload)) return res.sendStatus(400);
try {
await persistEventOnce(payload);
return res.sendStatus(200);
} catch {
return res.sendStatus(500);
}
});
app.use(express.json()); // JSON parsing for your other routes goes AFTER /webhook.