Skip to content

Explore Zenq Pay

Sections of the home page, pricing and developer docs.

Sign in

Merchant dashboard

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_types limits 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

TypeSent when
invoice.confirmingA payment was seen on chain but is not yet confirmed. Do not fulfil.
invoice.paidThe payment is confirmed. The only event that should fulfil an order.
invoice.partialLess than the quoted amount was confirmed.
invoice.expiredThe invoice expired without payment.
invoice.late_reviewFunds arrived late or to a replaced quote and are held for review.
invoice.cancelledThe invoice was cancelled or closed after review.
invoice.reorg_detectedA chain reorganisation affected a credited payment; funds on that invoice are held.
invoice.reorg_recoveredThe payment was re-verified after a reorganisation.
refund.requested, refund.approved, refund.rejected, refund.cancelledA refund request changed state. refund.cancelled means an approved refund was stopped by the platform before anything was sent; the amount is available again.
pingYou 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:

  1. Read the raw body before any JSON parsing. Re-serialising parsed JSON will not reproduce the signed bytes.
  2. Parse t and every v1 from the header.
  3. Reject the request if t is more than 300 seconds away from your clock. This stops an old, validly signed body from being replayed.
  4. Compute the expected value and compare it with each v1 using a constant-time comparison. Accept if any matches.
  5. Answer 400 on 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:

  1. Start a database transaction.
  2. Insert the envelope id (also sent as X-NexusPay-Event-Id) into a processed_events table whose primary key is that id.
  3. If the insert fails because the id exists, roll back and answer 200: you have already handled it.
  4. 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 2xx response within 10 seconds. Only the first 64 KiB of your response is read.
  • Redirects are not followed. A 3xx counts 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.