Skip to content
NervixPayDocs

Webhooks: payment.paid

Receive a signed payment.paid event on your server, verify X-NervixPay-Signature in Node, Python or PHP, and credit the right customer once.

When an order settles, NervixPay sends one signed payment.paid POST to your HTTPS endpoint. It carries the exact amount received and the customerReference and customerEmail your page passed, so your backend can credit the right user.

  1. Step 1: Register your endpoint

    In Integrations, open Payment webhook and enter a public https:// URL (no custom port, no IP address, no localhost). The signing secret is shown once: store it as NERVIXPAY_WEBHOOK_SECRET on your server. Saving a new URL rotates the secret. You can also register it with a live key that has webhooks:manage:

    Terminal
    curl -X POST https://nervixpay.live/api/v1/webhook \
      -H "Authorization: Bearer $NERVIXPAY_LIVE_TOKEN" \
      -H "Content-Type: application/json" \
      -d '{ "url": "https://shop.example.com/webhooks/nervixpay" }'
  2. Step 2: Understand the request

    Headers
    POST /webhooks/nervixpay HTTP/1.1
    Content-Type: application/json
    X-NervixPay-Event: payment.paid
    X-NervixPay-Delivery: 6f1c2a9e-7b3d-4e5f-8a1b-2c3d4e5f6a7b
    X-NervixPay-Signature: t=1790416800,v1=5d2c...64 hex characters
    Body
    {
      "event": "payment.paid",
      "orderId": "22222222-2222-4222-8222-222222222222",
      "paymentLinkId": "0f6d2c4e-8a1b-4e3f-9c7d-5b2a1e0f9d8c",
      "itemName": "Pro plan",
      "network": "polygon",
      "currencyToken": "USDC",
      "amountReceived": "25.000123",
      "amountReceivedBaseUnits": "25000123",
      "transactionId": "0xabc0000000000000000000000000000000000000000000000000000000000001",
      "paidAt": "2026-09-26T10:02:10.000Z",
      "customerReference": "user_42",
      "customerEmail": "buyer@example.com",
      "originSite": "shop.example.com"
    }

    amountReceived is the exact amount that arrived, in coin units. customerReference, customerEmail and originSite are null when the checkout carried nothing. originSite is best-effort (it comes from the browser) and must never decide access; use customerReference and orderId.

  3. Step 3: Verify the signature

    The header is t=<unix seconds>,v1=<hex>, where the hex is HMAC-SHA256 with your secret over <t>.<raw body>. Compute it over the raw body bytes before parsing JSON, compare in constant time, and reject timestamps more than 5 minutes from now. Accept any v1= value in the header.

  4. Step 4: Answer 2xx quickly and process once

    Reply with any 2xx within 5 seconds. Store processed orderIds: a repeat delivery for the same order must do nothing and still return 2xx.

Verify in Node

A dependency-free verifier. Save it as verify-nervixpay.mjs:

verify-nervixpay.mjs
import { createHmac, timingSafeEqual } from 'node:crypto'

const TOLERANCE_SECONDS = 5 * 60

/** header: the X-NervixPay-Signature value. rawBody: the exact bytes you received (string or Buffer). */
export function verifyNervixPaySignature(secret, header, rawBody, now = Math.floor(Date.now() / 1000)) {
  if (!secret || typeof header !== 'string') return false
  const parts = header.split(',').map((part) => part.trim())
  const timestamp = Number((parts.find((part) => part.startsWith('t=')) || '').slice(2))
  const signatures = parts.filter((part) => part.startsWith('v1=')).map((part) => part.slice(3))
  if (!Number.isSafeInteger(timestamp) || signatures.length === 0) return false
  if (Math.abs(now - timestamp) > TOLERANCE_SECONDS) return false
  const expected = createHmac('sha256', secret).update(timestamp + '.').update(rawBody).digest()
  return signatures.some((signature) => {
    const candidate = Buffer.from(signature, 'hex')
    return candidate.length === expected.length && timingSafeEqual(candidate, expected)
  })
}
Express route
import express from 'express'
import { verifyNervixPaySignature } from './verify-nervixpay.mjs'

const app = express()

app.post('/webhooks/nervixpay', express.raw({ type: 'application/json' }), async (req, res) => {
  const signature = req.get('X-NervixPay-Signature')
  if (!verifyNervixPaySignature(process.env.NERVIXPAY_WEBHOOK_SECRET, signature, req.body)) {
    return res.status(401).end()
  }
  const event = JSON.parse(req.body.toString('utf8'))
  if (event.event === 'payment.paid') {
    // Idempotent: skip if you already processed event.orderId, then credit event.customerReference.
    await markOrderPaid(event)
  }
  res.status(200).end()
})

Coming soon: the NervixPay integration kit (@nervixpay/integration) is not on npm yet. Once published, its verifyWebhookSignature does the same check as the code above:

Integration kit
import { verifyWebhookSignature, WEBHOOK_SIGNATURE_HEADER } from '@nervixpay/integration'

// rawBody must be the unparsed request body (string or Buffer).
const valid = verifyWebhookSignature(process.env.NERVIXPAY_WEBHOOK_SECRET, request.headers[WEBHOOK_SIGNATURE_HEADER], rawBody)

Verify in Python

Python
import hashlib
import hmac
import time

TOLERANCE_SECONDS = 5 * 60


def verify_nervixpay_signature(secret: str, header: str, raw_body: bytes, now: int | None = None) -> bool:
    """header: the X-NervixPay-Signature value. raw_body: the exact request bytes."""
    if not secret or not header:
        return False
    parts = [part.strip() for part in header.split(",")]
    timestamps = [part[2:] for part in parts if part.startswith("t=")]
    signatures = [part[3:] for part in parts if part.startswith("v1=")]
    if not timestamps or not timestamps[0].isdigit() or not signatures:
        return False
    timestamp = int(timestamps[0])
    current = int(time.time()) if now is None else now
    if abs(current - timestamp) > TOLERANCE_SECONDS:
        return False
    signed = str(timestamp).encode() + b"." + raw_body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    return any(hmac.compare_digest(expected, signature) for signature in signatures)


# Flask example:
# @app.post("/webhooks/nervixpay")
# def nervixpay_webhook():
#     if not verify_nervixpay_signature(os.environ["NERVIXPAY_WEBHOOK_SECRET"],
#                                       request.headers.get("X-NervixPay-Signature", ""),
#                                       request.get_data()):
#         abort(401)
#     event = request.get_json()
#     ...  # idempotent by event["orderId"]
#     return "", 200

Verify in PHP

PHP
<?php
const NERVIXPAY_TOLERANCE_SECONDS = 300;

/** $header: the X-NervixPay-Signature value. $rawBody: file_get_contents('php://input'). */
function verify_nervixpay_signature(string $secret, string $header, string $rawBody, ?int $now = null): bool
{
    if ($secret === '' || $header === '') {
        return false;
    }
    $timestamp = null;
    $signatures = [];
    foreach (explode(',', $header) as $part) {
        $part = trim($part);
        if (str_starts_with($part, 't=') && ctype_digit(substr($part, 2))) {
            $timestamp = (int) substr($part, 2);
        } elseif (str_starts_with($part, 'v1=')) {
            $signatures[] = substr($part, 3);
        }
    }
    if ($timestamp === null || $signatures === []) {
        return false;
    }
    if (abs(($now ?? time()) - $timestamp) > NERVIXPAY_TOLERANCE_SECONDS) {
        return false;
    }
    $expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
    foreach ($signatures as $signature) {
        if (hash_equals($expected, $signature)) {
            return true;
        }
    }
    return false;
}

// Usage:
// $valid = verify_nervixpay_signature(getenv('NERVIXPAY_WEBHOOK_SECRET'),
//     $_SERVER['HTTP_X_NERVIXPAY_SIGNATURE'] ?? '', file_get_contents('php://input'));
// if (!$valid) { http_response_code(401); exit; }

Retries

A failed delivery (non-2xx, timeout or redirect) is retried up to 8 attempts in total, waiting at least 2, 4, 8, 16, 32, 64, 128 minutes between them (retries run with the background reconciliation, about every ten minutes). If a delivery never succeeds, read the payment from GET /api/v1/payments/:id (server API).

Webhooks: payment.paid | NervixPay Docs