Retour au blog

How to verify crypto payment webhooks (HMAC-SHA256) without shooting yourself in the foot

NextCryptoPay
How to verify crypto payment webhooks (HMAC-SHA256) without shooting yourself in the foot

An unverified webhook endpoint is a free-order endpoint

A payment webhook is a POST request from the internet that says "order ORDER-1001 has been paid". If your handler believes it without checking a signature, then anyone who learns the URL — from a leaked log, a browser extension, an old commit, or simple guessing — can ship themselves goods for free. There is no rate limit that fixes this and no obscurity that makes it acceptable.

Signature verification is the entire defence, and it is about twenty lines of code. This article covers how the signature is built, the five mistakes that make verification look correct while doing nothing, and working handlers you can paste.

How the signature is built

Every NextCryptoPay delivery is a POST with these headers:

HeaderMeaning
x-nextcryptopay-eventEvent name: invoice.confirming, invoice.paid, invoice.expired, invoice.updated
x-nextcryptopay-deliveryStable delivery id. A retried delivery keeps the same id — use it to deduplicate
x-nextcryptopay-timestampUnix seconds at signing time
x-nextcryptopay-signatureHex HMAC-SHA256 of the string {timestamp}.{rawBody}, keyed with your shop's webhook secret

The signed string is the timestamp, a literal dot, then the raw request body exactly as it arrived on the wire. Including the timestamp inside the signed material is what makes replay protection possible: an attacker who captures a valid delivery cannot change its age without invalidating the signature.

Legacy aliases. The same four headers are also sent with the older x-cryptopay-* prefix and identical values, so integrations written before the rename keep working. They will be removed on 2027-03-31. New code should read x-nextcryptopay-* and treat the old names purely as a fallback during a rollout.

The five mistakes

1. Signing the parsed body instead of the raw body

This is the most common failure by a wide margin, and it is nasty because it usually works in testing and breaks in production. JSON.parse followed by JSON.stringify does not reproduce the original bytes: key order can change, whitespace disappears, numbers get renormalised, non-ASCII characters may be escaped differently. The HMAC is over bytes, so a single different byte means a different digest.

In Express this means express.json() must not touch the webhook route. In frameworks that hand you a Request, call await req.text() before anything parses it.

2. Comparing strings with ===

A naive string comparison returns as soon as it finds a mismatched character. The time it takes therefore leaks how many leading characters were correct, and an attacker who can send many requests can walk the correct signature out one character at a time. Use a constant-time comparison. In Node that is crypto.timingSafeEqual, which throws if the two buffers differ in length — so check lengths first, as a plain if, before calling it.

3. Not checking the timestamp

Without a freshness check, a valid delivery captured once is valid forever. Anyone who can replay it can re-trigger whatever your handler does. Reject anything older than a few minutes; five is a reasonable default, and it is generous enough to survive modest clock drift between your server and ours.

4. Not deduplicating

Retries exist. Deliveries can also be re-sent manually from the dashboard. If your fulfilment is not idempotent, the same order gets shipped twice. The x-nextcryptopay-delivery id is stable across retries of the same event: store processed ids and return 200 immediately for anything you have seen before. A unique index on the delivery id in your database is a stronger guarantee than an in-memory set, because it survives a restart and works across multiple app instances.

5. Returning 2xx before the work is safe

A 2xx tells us the delivery succeeded and we stop retrying. If your handler answers 200 and then crashes while writing to the database, that event is gone. The correct shape is: verify, record the event durably (a row in a queue table is enough), answer 2xx, then do the slow work asynchronously. You have fifteen seconds before the request times out; a failed or slow delivery is retried up to six times with a backoff of 1, 5, 30, 120, 360 and 1440 minutes.

A correct Node handler

import crypto from "node:crypto";

const MAX_AGE_SECONDS = 300;

function verify(rawBody, headers, secret) {
  const ts = headers["x-nextcryptopay-timestamp"] ?? headers["x-cryptopay-timestamp"];
  const sig = headers["x-nextcryptopay-signature"] ?? headers["x-cryptopay-signature"];
  if (!ts || !sig) return false;

  // 1) Replay protection.
  if (Math.abs(Date.now() / 1000 - Number(ts)) > MAX_AGE_SECONDS) return false;

  // 2) HMAC over "timestamp.rawBody", compared in constant time.
  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${ts}.${rawBody}`)
    .digest("hex");
  if (sig.length !== expected.length) return false;
  return crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
}

// express.raw, NOT express.json — the signature is over the raw bytes.
app.post("/webhooks/nextcryptopay", express.raw({ type: "application/json" }), async (req, res) => {
  const raw = req.body.toString("utf8");
  if (!verify(raw, req.headers, process.env.NEXTCRYPTOPAY_WEBHOOK_SECRET)) {
    return res.status(401).end();
  }

  const deliveryId = req.headers["x-nextcryptopay-delivery"];
  if (await alreadyProcessed(deliveryId)) return res.status(200).end();

  const { event, invoice } = JSON.parse(raw);
  await recordDelivery(deliveryId, event, invoice); // durable, then answer
  res.status(200).end();

  if (event === "invoice.paid") await fulfillOrder(invoice.orderId); // idempotent
});

The same thing in PHP

<?php
$raw = file_get_contents('php://input');
$h   = array_change_key_case(getallheaders(), CASE_LOWER);

$ts  = $h['x-nextcryptopay-timestamp'] ?? $h['x-cryptopay-timestamp'] ?? '';
$sig = $h['x-nextcryptopay-signature'] ?? $h['x-cryptopay-signature'] ?? '';

if ($ts === '' || $sig === '' || abs(time() - (int)$ts) > 300) {
    http_response_code(401);
    exit;
}

$expected = hash_hmac('sha256', $ts . '.' . $raw, getenv('NEXTCRYPTOPAY_WEBHOOK_SECRET'));
if (!hash_equals($expected, $sig)) {   // hash_equals is constant time
    http_response_code(401);
    exit;
}

$payload = json_decode($raw, true);
if (($payload['event'] ?? '') === 'invoice.paid') {
    fulfill_order($payload['invoice']['orderId']);  // must be idempotent
}
http_response_code(200);

And in Python

import hmac, hashlib, time, json

def verify(raw: bytes, headers, secret: str) -> bool:
    ts = headers.get("x-nextcryptopay-timestamp") or headers.get("x-cryptopay-timestamp")
    sig = headers.get("x-nextcryptopay-signature") or headers.get("x-cryptopay-signature")
    if not ts or not sig:
        return False
    if abs(time.time() - int(ts)) > 300:
        return False
    expected = hmac.new(
        secret.encode(),
        f"{ts}.".encode() + raw,          # raw bytes, never a re-serialised dict
        hashlib.sha256,
    ).hexdigest()
    return hmac.compare_digest(expected, sig)

What the payload looks like

{
  "event": "invoice.paid",
  "invoice": {
    "id": "inv_abc123",
    "orderId": "ORDER-1001",
    "status": "PAID",
    "chain": "tron",
    "asset": "USDT",
    "amount": "99.50",
    "paidAmount": "99.50",
    "payAddress": "TJ9x…"
  }
}

Fulfil only on status: "PAID". CONFIRMING means the transaction has been seen on chain but has not yet reached the confirmation threshold for that network, and a transaction that has been seen is not a transaction that is final.

Three more things worth knowing

Redirects are not followed. If your webhook URL responds with a 301 or 302, the delivery is treated as failed. Point us at the final URL, and watch out for frameworks that redirect HTTP to HTTPS or add or remove trailing slashes.

Private network URLs are rejected. A webhook URL that resolves to a loopback or private range is refused at creation time with UNSAFE_URL, and the outbound request pins the resolved address to prevent DNS rebinding. If you are testing locally, use a tunnel with a public hostname.

Rotate the secret deliberately. During rotation, accept either the old or the new secret for a short window, then drop the old one. Verifying against two candidate secrets is still constant time if you compare both with timingSafeEqual rather than short-circuiting on the first match.

Related reading

The full API surface, including invoice creation and the live status stream, is in the documentation, with a machine-readable version at /docs-agent.md for AI coding agents. For the end-to-end setup of a first integration see the guide to accepting USDT on TRC-20, and for why none of this involves handing over a private key, see non-custodial versus custodial gateways.