Skip to content

How to verify payment webhook signatures with HMAC-SHA256

Why every payment webhook must be verified before you give value, how HMAC-SHA256 signatures with timestamps work, the raw-body mistake that breaks most implementations, and working code in Node.js, Python, PHP and Go.

· 8 min read · Noria Technologies

A payment webhook tells your server that money arrived, and your server releases goods because of it. Anyone who knows your webhook URL can send that same request. Signature verification is the only thing standing between a forged “payment succeeded” and a free order.

This guide explains how HMAC webhook signatures work, the mistakes that break them, and gives working verification code for Noria Pay webhooks in four languages.

How an HMAC signature works

The sender and your server share a secret. For every delivery, the sender computes an HMAC-SHA256 of the request body with that secret and sends the result in a header. Your server computes the same HMAC over the body it received. If the two match, the request came from someone who knows the secret, and the body was not changed on the way.

Adding a timestamp to what is signed protects against replay: an attacker who captures a genuine webhook cannot resend it next week, because the old timestamp falls outside your tolerance window.

The Noria Pay signature

Every Noria Pay delivery carries three headers:

Header Example Meaning
Pay-Signature t=1794131667,v1=5f2b... Unix timestamp and hex HMAC-SHA256
Pay-Event-Id 0193a1d9-fcc3-... Unique event ID, for deduplication
Pay-Event-Type succeeded The event type

The signed message is the timestamp, a dot, and the exact raw request body:

signed_payload = t + "." + raw_body
v1 = hex(HMAC_SHA256(endpoint_secret, signed_payload))

Each webhook endpoint has its own signing secret, shown once when you create the endpoint. Store it like a password.

Verification code

Each function returns true only when the signature matches and the timestamp is within five minutes. All four compare in constant time, which stops an attacker learning the signature one character at a time from response timings.

Node.js

import crypto from 'node:crypto';

export function verifyNoriaSignature(rawBody, header, secret, toleranceSeconds = 300) {
  const parts = Object.fromEntries(header.split(',').map((part) => part.split('=')));
  const timestamp = Number(parts.t);
  if (!timestamp || Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return false;
  const expected = crypto.createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest();
  const received = Buffer.from(parts.v1 || '', 'hex');
  return received.length === expected.length && crypto.timingSafeEqual(received, expected);
}

Python

import hashlib
import hmac
import time


def verify_noria_signature(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
    parts = dict(part.split("=", 1) for part in header.split(","))
    timestamp = int(parts.get("t", "0"))
    if not timestamp or abs(time.time() - timestamp) > tolerance:
        return False
    expected = hmac.new(secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, parts.get("v1", ""))

PHP

<?php
function verifyNoriaSignature(string $rawBody, string $header, string $secret, int $tolerance = 300): bool
{
    $parts = [];
    foreach (explode(',', $header) as $part) {
        [$key, $value] = array_pad(explode('=', $part, 2), 2, '');
        $parts[$key] = $value;
    }
    $timestamp = (int) ($parts['t'] ?? 0);
    if ($timestamp === 0 || abs(time() - $timestamp) > $tolerance) {
        return false;
    }
    $expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
    return hash_equals($expected, $parts['v1'] ?? '');
}

Go

func verifyNoriaSignature(rawBody []byte, header, secret string, tolerance time.Duration) bool {
	var timestamp int64
	var signature string
	for part := range strings.SplitSeq(header, ",") {
		key, value, _ := strings.Cut(part, "=")
		switch key {
		case "t":
			timestamp, _ = strconv.ParseInt(value, 10, 64)
		case "v1":
			signature = value
		}
	}
	age := time.Since(time.Unix(timestamp, 0)).Abs()
	if timestamp == 0 || age > tolerance {
		return false
	}
	mac := hmac.New(sha256.New, []byte(secret))
	fmt.Fprintf(mac, "%d.%s", timestamp, rawBody)
	return hmac.Equal([]byte(hex.EncodeToString(mac.Sum(nil))), []byte(signature))
}

The raw-body mistake

Almost every broken implementation makes the same mistake: it verifies the parsed JSON instead of the bytes that arrived. Parsing and re-serialising changes whitespace and can change key order, so the HMAC never matches, and the developer “fixes” it by turning verification off.

Capture the raw body before any JSON middleware runs:

  • Express: mount express.raw({ type: "application/json" }) on the webhook route, then use req.body (a Buffer) for verification and JSON.parse(req.body) afterwards.
  • Flask: use request.get_data() for verification, then json.loads it.
  • Laravel: use $request->getContent(), not $request->all().
  • Go: io.ReadAll(r.Body) once, verify, then json.Unmarshal the same bytes.

After verification

A verified webhook is genuine, but it can still be a repeat or arrive out of order. Then:

  1. Store it and respond 2xx quickly. Do the slow work afterwards. Noria Pay treats any 2xx as delivered and retries anything else with backoff.
  2. Deduplicate on Pay-Event-Id. The same event can be delivered more than once.
  3. Keep the newest sequence per transaction. Ignore older events that arrive late.
  4. Give value only for succeeded. Events such as created, dispatched and callback_received describe progress, not payment.

The guide on idempotency keys shows a handler that does all four.

Rotating and recovering

If a secret leaks, create a new webhook endpoint, which gets a new secret, point your receiver at it, and delete the old endpoint. If your server was down, nothing is lost: deliveries keep retrying, every delivery is listed in the dashboard with its response, and you can re-send any of them.

More on how Pay protects payments is on the security page, and the full event contract is in the API reference.

Ready to take payments properly?

Tell us what you are building and which providers you use. We set up your workspace and help you go live.