Skip to main content

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
{
  "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
{
  "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)
{
  "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
{
  "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
{
  "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
{ "ok": true }

Rotate a webhook secret

POST /api/user/webhooks/:id/secret Rotates the secret and returns the new one once. Response
{
  "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

curl -X POST "https://www.niftipay.com/api/user/webhooks/YOUR_WEBHOOK_ID/test" \
  -H "x-api-key: YOUR_API_KEY"

Example response

{
  "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:
{
  "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.
  • 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).

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

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

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:
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");
}
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. 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)

{
  "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

{
  "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)

{
  "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 / processingpending
  • completedpaid
  • cancelledcancelled
  • expiredexpired
  • refunded (refund detected) → refunded
Your merchant webhook receives a normalized event.

Example payloads (fiat)

pending example

{
  "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
  }
}
{
  "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)

{
  "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.
{
  "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.
{
  "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 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. pendingPaymentwaitingPaymentpaymentReceivedcomplete). Fires every time the upstream provider’s status changes. The status field on the payload mirrors Banxa’s canonical status (see 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 statusTerminal?Niftipay treatment
pendingPaymentnonon-terminal — invoice stays pending, eligible for 72h extension
waitingPaymentnonon-terminal — invoice stays pending, eligible for 72h extension
paymentReceivednonon-terminal — invoice stays pending, eligible for 72h extension
extraVerificationnonon-terminal — explicit KYC-needs-more-docs state, eligible for 72h extension
inProgressnonon-terminal — Banxa has marked this status for deprecation
coinTransferred / cryptoTransferrednonon-terminal — crypto on-chain, awaiting confirmations
complete (sent in webhook payloads)yessuccess — invoice marked appropriately when on-chain delivery is confirmed by Tatum
completed (returned by Banxa Get-Order endpoint)yessame as complete
cancelledyesfailure — invoice cancelled
declinedyesfailure — invoice cancelled
expiredyesfailure — invoice cancelled
refundedyesfailure — 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.

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):
{
  "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

{
  "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

{
  "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.
  • 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.

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)

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);