# Webhooks & signatures

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 `2xx` status 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:

```text
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.

```json
{
  "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:

```json
{
  "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:

```json
{
  "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`.

```js
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.
```
