> ## Documentation Index
> Fetch the complete documentation index at: https://www.niftipay.com/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks (Crypto & Fiat)

> How to create and receive Niftipay webhooks for crypto orders and fiat (card) orders, including signing, retries, and best practices.

# Webhooks events

Niftipay can send **webhook events** to your server whenever an order changes status (crypto invoices, fiat card orders, refunds, payouts, etc.).

You can register **one or multiple webhook URLs**. Niftipay will POST JSON to each URL.

## Scoping webhooks to integrations

Webhook URLs live in two places:

1. **Settings → Webhooks** (global list, `GET/POST /api/user/webhooks`). Each row can be bound to a specific **fiat integration**, or left on *All integrations* (wildcard).
2. **Integrations → Webhook URL** (`fiatIntegration.merchantWebhookUrl`). Edited per-integration; always scoped to that integration's orders.

When an event fires, Niftipay delivers it to:

* the integration's own `merchantWebhookUrl` (if any), **plus**
* every global webhook whose `integrationId` is either the originating integration or `null` (wildcard).

**If you run more than one site, bind each global webhook to its integration** — otherwise the same event lands on both sites and each will try to update its own copy of the order. Crypto, invoice and ramping events have no integration concept and are delivered only to wildcard rows.

***

## What you need to build on your side

You must expose an HTTPS endpoint that accepts:

* **Method:** `POST`
* **Content-Type:** `application/json`
* **Path:** **`/niftipay/webhook`** *(required)*

> Important: when you register a webhook URL in Niftipay, we **force the path** to `/niftipay/webhook`.
> So if you type `https://example.com/hooks`, the stored URL becomes:
> `https://example.com/hooks/niftipay/webhook`

Your endpoint should:

1. **Verify the signature** (recommended, see below)
2. **Parse the JSON body**
3. **Return HTTP 2xx quickly** (recommended: within 2–3 seconds)
4. Process the event **asynchronously** (queue/job) to avoid timeouts and retries

***

## Managing your webhook URLs (merchant API)

All webhook management is done via the authenticated merchant API.

### List webhooks

`GET /api/user/webhooks`

Returns your configured webhooks. We do **not** return secrets here.

**Response**

```json theme={null}
{
  "webhooks": [
    {
      "id": "0ec3c2a2-209c-46b4-a847-c1bd35b4bdf9",
      "url": "https://example.com/niftipay/webhook",
      "integrationId": null,
      "createdAt": "2026-02-11T12:00:00.000Z",
      "hasSecret": true
    }
  ]
}
```

`integrationId` is `null` for wildcard webhooks (receive events from every integration and every integration-less event) or the id of a specific `fiatIntegration`.

### Create a webhook

`POST /api/user/webhooks`

**Body**

```json theme={null}
{
  "url": "https://example.com",
  "integrationId": "int_abc123"
}
```

`integrationId` is optional. Omit it (or send `null`) for a wildcard webhook. When provided, the id must belong to the calling user's fiat integrations.

**Response (secret returned only once)**

```json theme={null}
{
  "id": "0ec3c2a2-209c-46b4-a847-c1bd35b4bdf9",
  "url": "https://example.com/niftipay/webhook",
  "integrationId": "int_abc123",
  "createdAt": "2026-02-11T12:00:00.000Z",
  "webhookSecret": "64_hex_chars..."
}
```

✅ Store `webhookSecret` securely. You will not see it again unless you rotate.

### Update a webhook URL

`PUT /api/user/webhooks/:id`

**Body**

```json theme={null}
{
  "url": "https://example.com/hooks",
  "integrationId": null
}
```

`integrationId` is optional; omit it to leave the current binding unchanged. Send `null` (or an empty string) to clear the binding (wildcard), or a specific fiat integration id to bind the webhook.

**Response**

```json theme={null}
{
  "id": "0ec3c2a2-209c-46b4-a847-c1bd35b4bdf9",
  "url": "https://example.com/hooks/niftipay/webhook",
  "integrationId": null
}
```

`integrationId` appears in the response only when the field was included in the request body. Omit it from the body to leave the current binding untouched (and keep the response field absent).

### Delete a webhook

`DELETE /api/user/webhooks/:id`

**Response**

```json theme={null}
{ "ok": true }
```

### Rotate a webhook secret

`POST /api/user/webhooks/:id/secret`

Rotates the secret and returns the new one **once**.

**Response**

```json theme={null}
{
  "id": "0ec3c2a2-209c-46b4-a847-c1bd35b4bdf9",
  "url": "https://example.com/niftipay/webhook",
  "webhookSecret": "64_hex_chars_new..."
}
```

***

## Test your webhook receiver

Before going live, send a test payload to verify your endpoint works.

### Endpoint

`POST /api/user/webhooks/:id/test`

Sends a signed test webhook with event type `"test"` and sample order data to your URL.

### Example request

```bash theme={null}
curl -X POST "https://www.niftipay.com/api/user/webhooks/YOUR_WEBHOOK_ID/test" \
  -H "x-api-key: YOUR_API_KEY"
```

### Example response

```json theme={null}
{
  "success": true,
  "url": "https://example.com/niftipay/webhook",
  "httpStatus": 200,
  "signed": true,
  "responseBody": "{\"ok\":true}",
  "payload": {
    "event": "test",
    "order": {
      "id": "test_00000000-0000-0000-0000-000000000000",
      "reference": "TEST-WEBHOOK-PING",
      "chain": "ETH",
      "asset": "USDT",
      "amount": "100.00",
      "status": "paid"
    }
  }
}
```

* `success: true` means your endpoint returned HTTP 2xx
* `signed: true` means the payload included HMAC signature headers
* Your endpoint should handle the `"test"` event gracefully (return 200 and ignore)

***

## Webhook payload format (common)

Niftipay sends a JSON object shaped like:

```json theme={null}
{
  "event": "paid",
  "order": { /* event-specific */ },
  "...": "optional extra fields"
}
```

### Event names

You may receive these `event` values:

* `pending`
* `paid`
* `underpaid`
* `cancelled`
* `expired` — sent when an order is force-cancelled past a hard timeout. Currently emitted for Banxa onramp orders that exceed the 72h KYC hard cap (payload includes `reason: "ramping_kyc_timeout"`). See [Ramping webhooks](#ramping-webhooks-onramp-lifecycle).
* `refunded`
* `payout_upcoming`
* `payout_sent`
* `chargeback` — a chargeback has been filed against one of your orders
* `risk_alert` — an IP address has been flagged for repeated chargebacks

> Most merchants only need to handle: `paid`, `cancelled`, `refunded` (+ optionally `underpaid`, `chargeback`).

***

## Signing & verification (recommended)

When a webhook has a secret, Niftipay signs the payload and includes:

* `x-webhook-id`: webhook id (or `legacy:<userId>` for older single-webhook setups)
* `x-timestamp`: unix seconds (string)
* `x-signature`: `v1=<hex_hmac_sha256>`

Signature algorithm:

* `payload = raw JSON string` (exact bytes as sent)
* `ts = x-timestamp`
* `signed = HMAC_SHA256(secret, ts + "." + payload)`
* `x-signature = "v1=" + hex(signed)`

### Node.js verification example

```js theme={null}
import crypto from "crypto";

export function verifyNiftipayWebhook(req, rawBody, secret) {
  const ts = req.headers["x-timestamp"];
  const sig = req.headers["x-signature"];

  if (!ts || !sig || typeof sig !== "string") return false;
  if (!sig.startsWith("v1=")) return false;

  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${ts}.${rawBody}`, "utf8")
    .digest("hex");

  return crypto.timingSafeEqual(
    Buffer.from(sig.slice(3), "hex"),
    Buffer.from(expected, "hex"),
  );
}
```

### Python verification example

```py theme={null}
import hmac, hashlib

def verify(secret: str, timestamp: str, signature: str, raw_body: str) -> bool:
    if not signature.startswith("v1="):
        return False
    expected = hmac.new(
        secret.encode("utf-8"),
        f"{timestamp}.{raw_body}".encode("utf-8"),
        hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(signature[3:], expected)
```

### Replay protection (required for production)

Reject webhooks with timestamps older than **5 minutes** to prevent replay attacks:

```js theme={null}
const MAX_AGE_SECONDS = 300; // 5 minutes
const ts = Number(req.headers["x-timestamp"]);
const age = Math.abs(Math.floor(Date.now() / 1000) - ts);
if (age > MAX_AGE_SECONDS) {
  return res.status(401).send("timestamp too old");
}
```

```python theme={null}
import time
MAX_AGE_SECONDS = 300
ts = int(request.headers.get("x-timestamp", "0"))
if abs(int(time.time()) - ts) > MAX_AGE_SECONDS:
    return Response("timestamp too old", status=401)
```

Also treat `(event, order.id, txId)` as idempotency keys (see below).

***

## Delivery behavior, timeouts, and retries

### Timeouts

Webhook delivery uses a short timeout (default \~6 seconds). If your endpoint is slow, the request may time out and be retried.

### Retries

Failed webhook deliveries may be retried (best-effort):

* **Retry interval:** \~15 minutes
* **Max attempts:** 3

HTTP codes considered **retryable** include:

* `408`, `409`, `425`, `429`
* any `5xx`

HTTP codes considered **non-retryable** include:

* `400`, `401`, `403`, `404`, `405`, `410`, `415`, `422`

**Recommendation:** If you accept the event and will process it later, return **`200`** immediately.

***

## Idempotency & processing recommendations

Webhook requests can be delivered more than once (network retries, timeouts, upstream duplicates).

Best practice:

* Deduplicate by a stable key:
  * Crypto: `(event, order.id, txId)` or just `txId` for payments
  * Fiat: `(event, order.id, nopayn.order_id)` and/or NoPayn refund id when present
* Store “processed” markers in your DB
* Keep the webhook handler **stateless** and **fast**
* Always verify signatures in production

***

# Crypto webhooks (orders paid on-chain)

Crypto payments are detected via the Tatum inbound webhook (`/api/tatum/webhook`), then Niftipay emits a merchant webhook event.

## Crypto order events you’ll commonly see

### `pending`

A crypto order was created and is waiting for payment.

### `paid`

Payment was received and accepted (including under-payment within tolerance, if enabled on the platform).

### `underpaid`

Payment was received but not enough to consider the order paid. You may receive multiple `underpaid` events as more funds arrive.

### `cancelled` / `expired`

Order was cancelled or expired before completion.

### `refunded`

Refund was executed from the deposit address to a refund address (for cancelled orders).

## Example payloads (crypto)

### `paid` example

```json theme={null}
{
  "event": "paid",
  "order": {
    "id": "ord_123",
    "reference": "INV-1001",
    "merchantId": "m_abc",
    "txId": "0xabc123...",
    "blockNumber": 12345678,
    "chain": "ETH",
    "asset": "USDT",
    "amount": "70.04",
    "address": "0xDepositAddress...",
    "depositAddress": "0xDepositAddress...",
    "paymentUri": "ethereum:0xDepositAddress...?contract=0xdAC17F...&amount=70.040000",
    "qrUrl": "https://api.qrserver.com/v1/create-qr-code?size=300x300&data=..."
  }
}
```

### `underpaid` example

```json theme={null}
{
  "event": "underpaid",
  "order": {
    "id": "ord_123",
    "reference": "INV-1001",
    "merchantId": "m_abc",
    "chain": "BTC",
    "asset": "BTC",
    "amount": "0.00100000",
    "received": "0.00070000",
    "expected": "0.00100000",
    "txId": "f00dbeef...",
    "address": "bc1DepositAddress...",
    "depositAddress": "bc1DepositAddress...",
    "fees": {
      "kind": "crypto",
      "currency": "BTC",
      "invoiceAmount": "0.00100000",
      "networkFee": null,
      "totalToSend": "0.00100000",
      "platformFeePercent": null,
      "platformFeeAmount": "0"
    }
  }
}
```

### `refunded` example (crypto refund endpoint)

```json theme={null}
{
  "event": "refunded",
  "order": {
    "id": "ord_123",
    "reference": "INV-1001",
    "merchantId": "m_abc",
    "refundedAt": "2026-02-11T12:30:00.000Z",
    "amount": "0.00095000",
    "chain": "BTC",
    "asset": "BTC"
  }
}
```

***

# Fiat webhooks

Fiat card payments are different than crypto payments so the shape will be different

Niftipay emits a **merchant webhook** to your configured URL(s) with the same `event` model as crypto (`pending`, `paid`, `cancelled`, `expired`, `refunded`).

## Fiat status mapping (simplified)

NoPayn order status → merchant webhook event:

* `new` / `processing` → `pending`
* `completed` → `paid`
* `cancelled` → `cancelled`
* `expired` → `expired`
* refunded (refund detected) → `refunded`

> Your merchant webhook receives a normalized `event`.

## Example payloads (fiat)

### `pending` example

```json theme={null}
{
  "event": "pending",
  "order": {
    "id": "fo_123",
    "integrationId": "fi_abc",
    "kind": "order",
    "amountCents": 7004,
    "subtotalCents": 6800,
    "serviceFeePayer": "customer",
    "serviceFeePercent": 3.0,
    "serviceFeeCents": 204,
    "currency": "GBP",
    "status": "processing",
    "psp": "nopayn",
    "pspOrderId": "NP_987",
    "pspStatus": "processing",
    "orderUrl": "https://checkout.nopayn.com/...",
    "returnUrl": "https://merchant.example.com/return",
    "failureUrl": "https://merchant.example.com/fail",
    "webhookUrl": "https://merchant.example.com/niftipay/webhook",
    "merchantReference": "POS-1234",
    "createdAt": "2026-02-11T12:00:00.000Z",
    "updatedAt": "2026-02-11T12:01:00.000Z"
  },
  "nopayn": {
    "event": "status_changed",
    "project_id": "proj_1",
    "order_id": "NP_987",
    "status": "processing",
    "refunded_amount": null,
    "refund_of_order_id": null,
    "related_payment_link_id": null
  }
}
```

### `paid` example

```json theme={null}
{
  "event": "paid",
  "order": {
    "id": "fo_123",
    "currency": "EUR",
    "amountCents": 1999,
    "subtotalCents": 1900,
    "serviceFeePayer": "customer",
    "serviceFeeCents": 99,
    "status": "completed",
    "psp": "nopayn",
    "pspOrderId": "NP_987",
    "pspStatus": "completed",
    "merchantReference": "POS-1234",
    "completedAt": "2026-02-11T12:05:00.000Z"
  },
  "nopayn": {
    "order_id": "NP_987",
    "status": "completed"
  }
}
```

### `refunded` example (fiat)

```json theme={null}
{
  "event": "refunded",
  "order": {
    "id": "fo_123",
    "currency": "EUR",
    "amountCents": 1999,
    "subtotalCents": 1900,
    "serviceFeePayer": "merchant",
    "serviceFeeCents": 99,
    "status": "refunded",
    "psp": "nopayn",
    "pspOrderId": "NP_987",
    "pspStatus": "refunded",
    "merchantReference": "POS-1234"
  },
  "nopayn": {
    "order_id": "NP_987",
    "status": "completed",
    "refunded_amount": 1999,
    "refund_of_order_id": null
  }
}
```

> Note: fiat webhook payloads intentionally avoid exposing internal `orderKey` to merchants.

### `chargeback` example

Sent when a chargeback is filed against one of your orders.

```json theme={null}
{
  "event": "chargeback",
  "order": {
    "id": "fo_123",
    "merchantReference": "POS-1234",
    "status": "chargeback",
    "currency": "EUR",
    "amountCents": 5000
  },
  "nopayn": {
    "event": "chargeback_detected",
    "original_order_id": "NP_987",
    "chargeback_order_id": "NP_cb_001",
    "chargeback_of_order_id": "NP_987",
    "flags_original": null,
    "flags_chargeback": null
  },
  "risk": {
    "ip": "203.0.113.42",
    "ipMasked": "203.0.113.xxx"
  }
}
```

### `risk_alert` example

Sent when an IP address is flagged for repeated chargebacks across your orders. This indicates a potential fraud pattern.

```json theme={null}
{
  "event": "risk_alert",
  "order": {
    "id": "fo_123",
    "merchantReference": "POS-1234",
    "status": "chargeback",
    "currency": "EUR",
    "amountCents": 5000
  },
  "risk": {
    "kind": "chargeback_ip_spike",
    "ip": "203.0.113.42",
    "ipMasked": "203.0.113.xxx",
    "windowDays": 60,
    "thresholds": {
      "merchantIpThreshold": 2,
      "platformIpThreshold": 5,
      "distinctMerchantsThreshold": 2
    },
    "stats": {
      "merchantCount": 3,
      "platformCount": 3,
      "distinctMerchants": 1
    },
    "severity": "medium",
    "message": "This IP has repeated chargebacks for your store."
  },
  "nopayn": {
    "original_order_id": "NP_987",
    "chargeback_order_id": "NP_cb_001"
  }
}
```

> **Tip:** When you receive a `chargeback` or `risk_alert` event, check the [Chargeback Cases API](/api/chargebacks#list-chargeback-cases) to see if the IP or email has been automatically blocked.

***

# Ramping webhooks (onramp lifecycle)

Onramp orders (e.g. Banxa fiat → crypto) emit a separate stream of events on the **same merchant webhook URL** as crypto/fiat events. Same HMAC signing scheme (`x-timestamp` / `x-signature: v1=...`), same retry behavior, same replay-protection guidance — only the `event` value differs.

These events let you track the onramp provider's own lifecycle (KYC, fiat capture, status transitions) in addition to the underlying crypto invoice (`pending` / `paid` / `cancelled` / `expired`).

> **Envelope difference:** ramping events wrap the payload in a top-level `data` object, e.g. `{ "event": "ramping.status_changed", "data": { ... } }`. Crypto and fiat events use `order` instead.

## Ramping events you may receive

### `ramping.status_changed`

The provider reported a status change for the ramping order (e.g. `pendingPayment` → `waitingPayment` → `paymentReceived` → `complete`). Fires every time the upstream provider's status changes. The `status` field on the payload mirrors Banxa's canonical status (see [Banxa upstream statuses](#banxa-upstream-statuses) below).

### `ramping.kyc_pending`

The ramping order has been in a non-terminal upstream state long enough that the underlying crypto invoice was about to expire (default 12h), and Niftipay has automatically extended the invoice's `expiresAt` so the customer journey can complete.

* Trigger is **age-based**, not status-specific: the event fires whenever the linked Banxa order is still in any non-terminal status at the 10h mark. The most common upstream statuses at that point are `extraVerification` (Banxa requires additional ID/address documents), `pendingPayment` (KYC accepted, awaiting fiat), `waitingPayment` (payment in transit), and `paymentReceived` (fiat captured, crypto delivery pending).
* Fires **once per ramping order**, approximately **10 hours after the ramping order was created**.
* The crypto invoice's `expiresAt` is rolled forward to **`rampingOrder.createdAt + 72 hours`** (the hard cap).
* If the order does not reach a successful terminal status (`complete` / `completed`) by the 72h cap, the invoice is force-cancelled and a core `expired` event fires (see below).

> `ramping.created` and `ramping.deposit_instructions` are reserved event names that may be enabled in a future release. Today only `ramping.status_changed` and `ramping.kyc_pending` are emitted.

## Banxa upstream statuses

The `status` field passed through on `ramping.status_changed` payloads (and the `banxaStatus` field on `ramping.kyc_pending`) is Banxa's canonical order status. The full list and how Niftipay treats each:

| Banxa status                                       | Terminal? | Niftipay treatment                                                                  |
| -------------------------------------------------- | --------- | ----------------------------------------------------------------------------------- |
| `pendingPayment`                                   | no        | non-terminal — invoice stays `pending`, eligible for 72h extension                  |
| `waitingPayment`                                   | no        | non-terminal — invoice stays `pending`, eligible for 72h extension                  |
| `paymentReceived`                                  | no        | non-terminal — invoice stays `pending`, eligible for 72h extension                  |
| `extraVerification`                                | no        | non-terminal — explicit KYC-needs-more-docs state, eligible for 72h extension       |
| `inProgress`                                       | no        | non-terminal — Banxa has marked this status for deprecation                         |
| `coinTransferred` / `cryptoTransferred`            | no        | non-terminal — crypto on-chain, awaiting confirmations                              |
| `complete` (sent in webhook payloads)              | **yes** ✓ | success — invoice marked appropriately when on-chain delivery is confirmed by Tatum |
| `completed` (returned by Banxa Get-Order endpoint) | **yes** ✓ | same as `complete`                                                                  |
| `cancelled`                                        | **yes** ✗ | failure — invoice cancelled                                                         |
| `declined`                                         | **yes** ✗ | failure — invoice cancelled                                                         |
| `expired`                                          | **yes** ✗ | failure — invoice cancelled                                                         |
| `refunded`                                         | **yes** ✗ | failure — invoice cancelled                                                         |

> Banxa uses `complete` in webhook payloads but `completed` in the Get-Order REST response. Niftipay treats both as the same successful terminal state.

For the upstream definitions and the full state machine, see Banxa's [Order Status reference](https://docs.banxa.com/docs/order-status).

## Banxa KYC timeout behavior

If Banxa fails to clear KYC within **72 hours of the ramping order creation**, the underlying crypto invoice is force-cancelled and you will receive a core `expired` event (not a ramping event):

```json theme={null}
{
  "event": "expired",
  "order": {
    "id": "ord_123",
    "reference": "RAMP-ORDER-001",
    "merchantId": "m_abc",
    "chain": "ETH",
    "asset": "USDT"
  },
  "reason": "ramping_kyc_timeout",
  "rampingOrderId": "rmp_abc"
}
```

You will also receive a `ramping.status_changed` event around the same time with `reason: "kyc_timeout_72h"` for symmetry on the ramping channel.

## Example payloads (ramping)

### `ramping.status_changed` example

```json theme={null}
{
  "event": "ramping.status_changed",
  "data": {
    "id": "rmp_abc",
    "banxaOrderId": "019e02c15d...",
    "status": "waitingPayment",
    "orderType": "BUY",
    "fiat": "USD",
    "fiatAmount": "61.78",
    "crypto": "USDT",
    "cryptoAmount": "54.91",
    "blockchain": "ETH",
    "transactionHash": null,
    "orderStatusUrl": "https://...",
    "banxaWebhook": { "status": "waitingPayment", "order_id": "019e02c15d..." }
  }
}
```

### `ramping.kyc_pending` example

```json theme={null}
{
  "event": "ramping.kyc_pending",
  "data": {
    "id": "rmp_abc",
    "orderId": "ord_123",
    "banxaStatus": "extraVerification",
    "originalExpiresAt": "2026-05-08T02:04:46.000Z",
    "newExpiresAt": "2026-05-10T14:04:46.000Z",
    "hardCapAt": "2026-05-10T14:04:46.000Z",
    "ageHours": 10
  }
}
```

> `banxaStatus` here may be any non-terminal Banxa status — most commonly `extraVerification`, `pendingPayment`, `waitingPayment`, or `paymentReceived`. See the [status reference table](#banxa-upstream-statuses).

## Recommended handling

* Treat `ramping.kyc_pending` as informational — no action required, but you may want to surface a "verification in progress" notice to the customer.
* Continue to rely on the core `paid` event for final fulfilment confirmation. Crypto delivery is detected on-chain by the same Tatum pipeline used for direct crypto orders, regardless of the ramping path.
* If you receive `expired` with `reason: "ramping_kyc_timeout"`, the customer's funds (if any) remain with Banxa — Banxa handles the refund directly.

***

## Recommended webhook implementation checklist

### Security

* ✅ Use HTTPS only
* ✅ Verify `x-signature` (HMAC) using your stored `webhookSecret`
* ✅ Reject old timestamps (suggested ±5 minutes)
* ✅ Keep secrets out of logs

### Reliability

* ✅ Respond `200 OK` quickly (do not do heavy work in-request)
* ✅ Process events in a queue (Redis, SQS, database jobs, etc.)
* ✅ Deduplicate events with an idempotency key
* ✅ Make your handler idempotent (safe to run twice)

### Correctness

* ✅ Treat webhooks as the source of truth for status transitions
* ✅ Use `paid` to fulfill orders, and `refunded` to reverse fulfillment
* ✅ Handle `underpaid` if you allow partial top-ups from customers
* ✅ Handle `chargeback` to flag disputed orders and pause fulfillment
* ✅ Handle `risk_alert` to review orders from flagged IPs before shipping

### Monitoring

* ✅ Log: event, order id, timestamp, webhook id, and processing outcome
* ✅ Alert on repeated failures or signature mismatches
* ✅ Store raw webhook payloads for short-term debugging (redact secrets)

***

## Common mistakes

* Returning non-2xx while you “already accepted” the event → causes retries
* Doing heavy DB work inside the HTTP request → timeouts & duplicate deliveries
* Not deduplicating → double-fulfillment
* Not verifying signatures → anyone can spoof events to your endpoint
* Registering a URL that doesn’t implement `/niftipay/webhook` → you’ll never receive events

***

## Quick starter: minimal Express handler (with raw body)

```js theme={null}
import express from "express";
import crypto from "crypto";

const app = express();

// Important: keep the raw body for signature verification
app.use(express.json({
  verify: (req, _res, buf) => { req.rawBody = buf.toString("utf8"); }
}));

app.post("/niftipay/webhook", (req, res) => {
  const secret = process.env.NIFTIPAY_WEBHOOK_SECRET;
  const ts = req.header("x-timestamp");
  const sig = req.header("x-signature");

  if (!secret || !ts || !sig || !sig.startsWith("v1=")) {
    return res.status(400).send("bad signature headers");
  }

  const expected = crypto.createHmac("sha256", secret)
    .update(`${ts}.${req.rawBody}`, "utf8")
    .digest("hex");

  const ok = crypto.timingSafeEqual(
    Buffer.from(sig.slice(3), "hex"),
    Buffer.from(expected, "hex")
  );

  if (!ok) return res.status(401).send("invalid signature");

  // Enqueue job (recommended)
  // queue.add({ event: req.body.event, order: req.body.order, raw: req.body });

  return res.status(200).json({ ok: true });
});

app.listen(3000);
```
