Webhooks
Zenq Pay tells your server about invoice and refund changes with signed HTTP POST requests. Webhooks, not the customer's browser, are how you learn that an order is paid.
Register an endpoint
Add an endpoint under Webhooks & API in the dashboard, or with the API:
curl -X POST https://<api-host>/v1/webhook-endpoints \
-H "Authorization: Bearer nxp_test_…" \
-H "Content-Type: application/json" \
-d '{"url":"https://shop.example/zenqpay/webhook","event_types":["invoice.paid"]}'- The URL must be an absolute
https://URL without credentials or a fragment. It must resolve only to public addresses: private, loopback, link-local and cloud metadata addresses are refused. event_typeslimits which events are sent; leave it out to receive all of them.- The response contains the signing secret,
whsec_…. It is shown once. Store it with your other server secrets.
Endpoints belong to the environment of the key or store that created them, so test and live have separate endpoints and secrets.
What a delivery looks like
POST /zenqpay/webhook HTTP/1.1
Content-Type: application/json
User-Agent: NexusPay-Webhooks/1.0
X-NexusPay-Event-Id: 3f0e…
X-NexusPay-Event-Type: invoice.paid
X-NexusPay-Environment: test
X-NexusPay-Signature: t=1767225600,v1=5d1c…The body is the event envelope, serialised as compact JSON with sorted keys:
{
"id": "3f0e…",
"type": "invoice.paid",
"version": 1,
"created_at": "2026-10-04T12:00:00+00:00",
"environment": "test",
"organization_id": "…",
"store_id": "…",
"data": {
"invoice_id": "…",
"public_id": "chk_…",
"merchant_order_id": "1001",
"status": "paid",
"mode": "standard",
"amount_mode": "fixed",
"currency": "USD",
"amount": "75.00",
"metadata": {},
"paid_at": "…",
"payment": {
"asset_symbol": "USDT",
"network_id": "bsc-testnet",
"deposit_address": "0x…",
"crypto_amount": "75",
"tx_hash": "0x…",
"block_number": 123
}
}
}Invoice events also carry fee_cents and fee_usd (the same fee as a decimal string, e.g. "1.90"; it depends on the network the customer paid on and is final once the invoice is paid), customer_reference, description and expires_at in data, and payment also includes network_name, crypto_amount_base_units and confirmed_base_units. Amounts are decimal strings. The JSON Schema for the envelope is packages/contracts/events/webhook.v1.schema.json in the repository.
Event types
| Type | Sent when |
|---|---|
invoice.confirming | A payment was seen on chain but is not yet confirmed. Do not fulfil. |
invoice.paid | The payment is confirmed. The only event that should fulfil an order. |
invoice.partial | Less than the quoted amount was confirmed. |
invoice.expired | The invoice expired without payment. |
invoice.late_review | Funds arrived late or to a replaced quote and are held for review. |
invoice.cancelled | The invoice was cancelled or closed after review. |
invoice.reorg_detected | A chain reorganisation affected a credited payment; funds on that invoice are held. |
invoice.reorg_recovered | The payment was re-verified after a reorganisation. |
refund.requested, refund.approved, refund.rejected, refund.cancelled | A refund request changed state. refund.cancelled means an approved refund was stopped by the platform before anything was sent; the amount is available again. |
ping | You pressed Send test in the dashboard, or called POST /v1/webhook-endpoints/{id}/ping. Always sent, whatever event_types says. |
Verify the signature
The X-NexusPay-Signature header is t=<unix seconds>,v1=<hex>. The v1 value is the lowercase hex HMAC-SHA256, keyed with your endpoint secret, of the timestamp, a full stop, and the exact raw body bytes:
v1 = hex( HMAC_SHA256( secret, "<t>." + raw_body ) )To verify:
- Read the raw body before any JSON parsing. Re-serialising parsed JSON will not reproduce the signed bytes.
- Parse
tand everyv1from the header. - Reject the request if
tis more than 300 seconds away from your clock. This stops an old, validly signed body from being replayed. - Compute the expected value and compare it with each
v1using a constant-time comparison. Accept if any matches. - Answer
400on failure, without saying why.
The timestamp is fresh on every attempt, so a retried delivery carries a new t and a new signature over the same body.
Secret rotation: two v1 values
Rotate a secret from the dashboard or with POST /v1/webhook-endpoints/{id}/rotate-secret. The new secret is returned once and signs immediately. For the next 24 hours the previous secret keeps signing too, so the header carries two signatures, the new one first:
X-NexusPay-Signature: t=1767225600,v1=<signed with new secret>,v1=<signed with previous secret>Accepting any matching v1 means you can deploy the new secret at any point in that window without dropping a delivery.
Python
From examples/merchant/python/app.py (TOLERANCE_SECONDS = 300):
def verify_signature(secret: str, header: str, body: bytes, now: int | None = None) -> bool:
timestamp, candidates = None, []
for part in header.split(","):
key, _, value = part.strip().partition("=")
if key == "t" and value.isdigit():
timestamp = int(value)
elif key == "v1" and value:
candidates.append(value)
if timestamp is None or not candidates:
return False
if abs((now or int(time.time())) - timestamp) > TOLERANCE_SECONDS:
return False
expected = hmac.new(
secret.encode(), f"{timestamp}.".encode() + body, hashlib.sha256
).hexdigest()
return any(hmac.compare_digest(expected, c) for c in candidates)Node.js
From examples/merchant/node/server.js (TOLERANCE_SECONDS = 300; mount the route with a raw body parser so rawBody is the exact bytes):
function verifySignature(header, rawBody, secret, now = Math.floor(Date.now() / 1000)) {
if (!secret || !header) return false;
let timestamp = null;
const candidates = [];
for (const part of String(header).split(",")) {
const index = part.indexOf("=");
if (index === -1) continue;
const key = part.slice(0, index).trim();
const value = part.slice(index + 1).trim();
if (key === "t" && /^\d+$/.test(value)) timestamp = Number(value);
else if (key === "v1" && /^[0-9a-f]+$/i.test(value)) candidates.push(value);
}
if (timestamp === null || candidates.length === 0) return false;
// Replay window: an old body with a valid signature is still refused.
if (Math.abs(now - timestamp) > TOLERANCE_SECONDS) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${timestamp}.`)
.update(rawBody)
.digest("hex");
const expectedBuffer = Buffer.from(expected, "utf8");
return candidates.some(candidate => {
const candidateBuffer = Buffer.from(candidate, "utf8");
// timingSafeEqual throws on a length mismatch, so the length is checked first. A length
// difference is not secret: it is fixed by the hash algorithm.
return (
candidateBuffer.length === expectedBuffer.length &&
crypto.timingSafeEqual(candidateBuffer, expectedBuffer)
);
});
}PHP
From examples/merchant/php/lib.php. Read the header from $_SERVER['HTTP_X_NEXUSPAY_SIGNATURE'] and the body from php://input:
function verify_signature(string $secret, string $header, string $body, int $tolerance = 300): bool
{
if ($secret === '') {
return false;
}
$timestamp = null;
$candidates = [];
foreach (explode(',', $header) as $part) {
[$key, $value] = array_pad(explode('=', trim($part), 2), 2, '');
if ($key === 't' && ctype_digit($value)) $timestamp = (int) $value;
elseif ($key === 'v1' && $value !== '') $candidates[] = $value;
}
if ($timestamp === null || !$candidates) return false;
if (abs(time() - $timestamp) > $tolerance) return false;
$expected = hash_hmac('sha256', $timestamp . '.' . $body, $secret);
foreach ($candidates as $c) {
if (hash_equals($expected, $c)) return true;
}
return false;
}Deduplicate on the event id
Delivery is at-least-once. Network failures, timeouts and replays mean you will receive some events more than once, and a successful delivery does not prove you processed it. Every receiver in examples/merchant/ does the same thing:
- Start a database transaction.
- Insert the envelope
id(also sent asX-NexusPay-Event-Id) into aprocessed_eventstable whose primary key is that id. - If the insert fails because the id exists, roll back and answer
200: you have already handled it. - Otherwise apply the change (mark the order paid) and commit both together.
Because the id and the order update commit in one transaction, a crash between the two cannot credit an order twice or lose an event.
Delivery, retries and replay
- Success is any
2xxresponse within 10 seconds. Only the first 64 KiB of your response is read. - Redirects are not followed. A
3xxcounts as a failure; register the final URL. - The endpoint's address is resolved and checked again on every attempt, and the connection goes only to the checked address.
- Retries: a failed delivery is retried after about 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, 12 hours and 24 hours (each delay plus up to 20% random jitter): 8 attempts over roughly two days.
- After the last attempt fails the delivery is marked exhausted and the dashboard tells you to fix the endpoint.
- Replay a delivery from Webhooks & API or with
POST /v1/webhook-deliveries/{id}/replay. A replay sends the byte-identical body again, with a new signature. It does not restart the retry schedule: a replay of an exhausted delivery is a single attempt.
GET /v1/webhook-deliveries and GET /v1/events list recent deliveries and events with their attempt history.