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.
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 asNERVIXPAY_WEBHOOK_SECRETon your server. Saving a new URL rotates the secret. You can also register it with a live key that haswebhooks: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" }'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 charactersBody { "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" }amountReceivedis the exact amount that arrived, in coin units.customerReference,customerEmailandoriginSitearenullwhen the checkout carried nothing.originSiteis best-effort (it comes from the browser) and must never decide access; usecustomerReferenceandorderId.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 anyv1=value in the header.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:
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)
})
}
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:
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
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
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).