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 usereq.body(aBuffer) for verification andJSON.parse(req.body)afterwards. - Flask: use
request.get_data()for verification, thenjson.loadsit. - Laravel: use
$request->getContent(), not$request->all(). - Go:
io.ReadAll(r.Body)once, verify, thenjson.Unmarshalthe same bytes.
After verification
A verified webhook is genuine, but it can still be a repeat or arrive out of order. Then:
- Store it and respond
2xxquickly. Do the slow work afterwards. Noria Pay treats any2xxas delivered and retries anything else with backoff. - Deduplicate on
Pay-Event-Id. The same event can be delivered more than once. - Keep the newest
sequenceper transaction. Ignore older events that arrive late. - Give value only for
succeeded. Events such ascreated,dispatchedandcallback_receiveddescribe 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.