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.

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",
      "createdAt": "2026-02-11T12:00:00.000Z",
      "hasSecret": true
    }
  ]
}

Create a webhook

POST /api/user/webhooks Body
{ "url": "https://example.com" }
Response (secret returned only once)
{
  "id": "0ec3c2a2-209c-46b4-a847-c1bd35b4bdf9",
  "url": "https://example.com/niftipay/webhook",
  "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" }
Response
{
  "id": "0ec3c2a2-209c-46b4-a847-c1bd35b4bdf9",
  "url": "https://example.com/hooks/niftipay/webhook"
}

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..."
}

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
  • refunded
  • payout_upcoming
  • payout_sent
Most merchants only need to handle: paid, cancelled, refunded (+ optionally underpaid).

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)
Use x-timestamp to reject old requests. A typical window is 5 minutes:
  • abs(now - ts) <= 300
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.

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

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