Guide · Developers

How to verify crypto payment webhook signatures

A webhook endpoint is a public URL that can mark orders as paid. Verifying the signature is what stops anyone but the gateway from doing that.

Updated · 2 min read

Build it with an AI agent

Give this to your AI agent

Paste it into Claude Code, Cursor or any agent that can read a URL. It reads the OpenAPI contract and the signing recipe, and follows the rules for handling real funds.

Read https://www.payaries.com/ai-agents.md and integrate Payaries crypto payments into this project. Follow its rules for handling real funds.
OpenAPI specDocs as markdown

Never paste your API secret into an agent. Keep it in an environment variable.

01What arrives

Headers
X-Gateway-Signature: <hex HMAC-SHA256>
X-Gateway-Timestamp: <unix ms>

Signature = HMAC-SHA256(webhookSecret, timestamp + rawBody)

The signature covers the timestamp and the raw request body. Verify against the raw string, before any JSON parsing — re-serialising a parsed body can change whitespace or key order and break the match.

02Verify it

Node.js
function verifyWebhook(rawBody, signature, timestamp, webhookSecret, toleranceMs = 5 * 60 * 1000) {
  if (Math.abs(Date.now() - timestamp) > toleranceMs) return false;
  const expected = crypto
    .createHmac('sha256', webhookSecret)
    .update(timestamp + rawBody)
    .digest('hex');
  const a = Buffer.from(expected, 'hex');
  const b = Buffer.from(signature, 'hex');
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}
  • The timestamp check rejects old deliveries, which limits replay of a captured request.
  • timingSafeEqual compares in constant time; check the buffer lengths first, because it throws on a length mismatch.

03A complete handler

Express
app.post('/webhooks/payaries', express.raw({ type: 'application/json' }), (req, res) => {
  const signature = req.header('X-Gateway-Signature');
  const timestamp = Number(req.header('X-Gateway-Timestamp'));
  const rawBody = req.body.toString('utf8');   // the RAW body — before any JSON parsing

  if (!verifyWebhook(rawBody, signature, timestamp, process.env.WEBHOOK_SECRET)) return res.sendStatus(401);

  const event = JSON.parse(rawBody);
  res.sendStatus(200);                          // acknowledge first, do the slow work after
  queue.add(event);                             // dedupe on event.idempotencyKey
});

Acknowledge with a 2xx quickly — the endpoint has 10 seconds to respond — and do the slow work afterwards. A slow handler risks a needless retry even though you already received the event.

04Retries and duplicates

AttemptWhen
1Immediately
2After 1 minute
3After 5 minutes
4After 15 minutes
5After 1 hour
FinalAfter 6 hours

Because a delivery can repeat, deduplicate on the event's idempotencyKey and make your handler idempotent on the invoice and status. After the last failed attempt an invoice's webhookStatus becomes failed; re-fire it with POST /v1/invoices/:id/resend-webhook.

05Endpoint requirements

  • A public https:// URL. http://, localhost, private or link-local addresses and internal hostnames are refused, and re-checked on every delivery.
  • Redirects aren't followed — register the final URL.
  • Don't rely on the source address for authenticity. Always verify the signature.

Questions

Why must I verify the webhook against the raw body?
The signature is computed over the exact bytes sent. Parsing and re-serialising the JSON can change whitespace or key order, so the recomputed HMAC no longer matches.
How do I handle duplicate webhook deliveries?
Deliveries are retried on failure, so the same event can arrive more than once. Deduplicate on the idempotencyKey and make your handler idempotent on the invoice and status.
How fast must my webhook endpoint respond?
Within 10 seconds with a 2xx status. Acknowledge first and process afterwards.

Next

Send yourself a signed test event

Test keys work immediately, with no approval step. Payaries runs on real mainnet networks, so use a small real payment to test.