Guide
How to verify webhook signatures with HMAC-SHA256
A webhook endpoint accepts POSTs from the whole internet. A signature is how it tells the real sender from everyone else. This guide covers the scheme most providers use and the four mistakes that break it.
By Roger Campos · Last updated: September 2026
TL;DR
To verify a webhook signature, recompute HMAC-SHA256 over the exact bytes the sender signed — usually a timestamp, a dot and the raw request body — with your shared secret, and compare it to the header in constant time. Reject timestamps outside a few minutes to stop replays, never verify a re-serialized body, and accept any of several signatures so the secret can rotate.
Free plan, no credit card. 1,000 credits a month.
Why sign
What a webhook signature proves
A webhook endpoint is a URL that accepts POST requests from the internet, unauthenticated. Anyone who learns it can send it something that looks like an event. A signature is how your endpoint tells the real sender from everyone else.
The sender and you share a secret. For each delivery, the sender computes an HMAC — a keyed hash — of the request and puts it in a header. You compute the same HMAC over what you received, with the same secret, and compare. Only someone holding the secret can produce a matching value, and any change to the signed bytes changes it completely.
HTTPS doesn't do this job. It keeps the request private in transit, but says nothing about who sent it. You want both.
The scheme
The shape most providers use
Details differ — header names, hex or Base64, where the timestamp goes — but the widely copied scheme has the same four parts: a timestamp, the raw body, HMAC-SHA256, and a version prefix on the signature.
| Sender | Signed string | Header(s) | Timestamp signed? |
|---|---|---|---|
| Stripe | timestamp.body | Stripe-Signature: t=…,v1=<hex> | Yes |
| Standard Webhooks (Svix and others) | id.timestamp.body | webhook-id, webhook-timestamp, webhook-signature: v1,<base64> | Yes |
| GitHub | body | X-Hub-Signature-256: sha256=<hex> | No |
| URLpipe | timestamp.body | X-URLpipe-Timestamp, X-URLpipe-Signature: v1=<hex> | Yes |
Signing the timestamp is what makes it useful. If the timestamp sat beside the signature rather than inside it, an attacker who captured one delivery could replay it forever with a fresh timestamp. With the timestamp in the signed string, changing it breaks the signature — so rejecting old timestamps actually bounds how long a captured request stays usable. A body-only scheme like GitHub's has no such bound; there, deduplicating by delivery id is your replay defence.
The four mistakes
Why signature checks fail — or pass when they shouldn't
- 1Hashing a re-serialized body. The most common bug by far. Your framework parsed the JSON, you serialized it back, and the bytes changed: key order, whitespace, Unicode escapes. The digest changes with them and nothing ever verifies. Read the raw request body before any JSON middleware touches it —
request.raw_postin Rails,express.raw()on the route in Express,request.get_data()in Flask,php://inputin PHP. - 2Comparing with
==. An ordinary string compare stops at the first differing byte, and how long it took leaks how much of a guess was right. Use the constant-time compare your language ships:hmac.compare_digest,crypto.timingSafeEqual,OpenSSL.secure_compare,hash_equals,hmac.Equal. - 3No timestamp check. A valid signature on a week-old request is still a replay. Reject timestamps more than a few minutes from your clock — five is the common default, and what Stripe's libraries use — in both directions.
- 4Comparing the whole header. Senders put more than one signature in the header while a secret is rotating, one per secret. A handler that compares the entire header to one expected value breaks the day you rotate. Split it and accept the delivery if any signature matches, matching on the version prefix so a future
v2is ignored rather than fatal.
Also check the secret's format
Some providers' secrets are Base64 after a prefix and must be decoded before use (Standard Webhooks' whsec_…); others are used exactly as shown. Get this wrong and every signature fails. URLpipe's secret also starts with whsec_, but is the HMAC key as-is, prefix included.
The code
Verifying a signature in five languages
Each example verifies URLpipe's scheme — v1= hex HMAC-SHA256 over <timestamp>.<raw body>, timestamp in X-URLpipe-Timestamp — and adapts to Stripe's by changing how the header is parsed. Keep the secret in an environment variable.
import hashlib, hmac, os, time
SECRET = os.environ["URLPIPE_WEBHOOK_SECRET"].encode()
TOLERANCE = 300 # seconds
def verify(raw_body: bytes, timestamp: str, header: str) -> bool:
if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > TOLERANCE:
return False
signed = timestamp.encode() + b"." + raw_body
expected = "v1=" + hmac.new(SECRET, signed, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(sig.strip(), expected) for sig in header.split(","))const crypto = require("crypto");
const SECRET = process.env.URLPIPE_WEBHOOK_SECRET;
const TOLERANCE = 300; // seconds
// rawBody: a Buffer — e.g. from express.raw({ type: "application/json" })
function verify(rawBody, timestamp, header) {
const ts = Number(timestamp);
if (!Number.isInteger(ts) || Math.abs(Date.now() / 1000 - ts) > TOLERANCE) return false;
const expected = Buffer.from("v1=" + crypto.createHmac("sha256", SECRET)
.update(`${ts}.`).update(rawBody).digest("hex"));
return header.split(",").some((sig) => {
const given = Buffer.from(sig.trim());
return given.length === expected.length && crypto.timingSafeEqual(given, expected);
});
}require "openssl"
SECRET = ENV.fetch("URLPIPE_WEBHOOK_SECRET")
TOLERANCE = 300 # seconds
# raw_body: request.raw_post in Rails, request.body.read in Rack
def verify(raw_body, timestamp, header)
return false unless timestamp.to_s.match?(/\A\d+\z/)
return false if (Time.now.to_i - timestamp.to_i).abs > TOLERANCE
expected = "v1=" + OpenSSL::HMAC.hexdigest("SHA256", SECRET, "#{timestamp}.#{raw_body}")
header.to_s.split(",").any? { |sig| OpenSSL.secure_compare(sig.strip, expected) }
end<?php
const TOLERANCE = 300; // seconds
function verify(string $rawBody, string $timestamp, string $header): bool {
if (!ctype_digit($timestamp) || abs(time() - (int) $timestamp) > TOLERANCE) {
return false;
}
$secret = getenv("URLPIPE_WEBHOOK_SECRET");
$expected = "v1=" . hash_hmac("sha256", "{$timestamp}.{$rawBody}", $secret);
foreach (explode(",", $header) as $sig) {
if (hash_equals($expected, trim($sig))) {
return true;
}
}
return false;
}
// $rawBody = file_get_contents("php://input");package webhooks
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"math"
"os"
"strconv"
"strings"
"time"
)
const tolerance = 300 // seconds
func Verify(rawBody []byte, timestamp, header string) bool {
ts, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil || math.Abs(float64(time.Now().Unix()-ts)) > tolerance {
return false
}
mac := hmac.New(sha256.New, []byte(os.Getenv("URLPIPE_WEBHOOK_SECRET")))
mac.Write([]byte(timestamp + "."))
mac.Write(rawBody)
expected := []byte("v1=" + hex.EncodeToString(mac.Sum(nil)))
for _, sig := range strings.Split(header, ",") {
if hmac.Equal([]byte(strings.TrimSpace(sig)), expected) {
return true
}
}
return false
}Return 401 when verification fails. Most senders retry non-2xx responses, which is what you want while you're fixing a bug in the verifier and harmless for a forged request.
Around the check
The rest of a safe webhook handler
- Be idempotent. Retries mean the same event can arrive twice. Key your processing on the event's id — for URLpipe, the
token— and treat a repeat as done. - Answer fast, work later. Verify, enqueue, return 2xx. Senders time out slow endpoints and retry them, which multiplies the load you were trying to avoid.
- Roll out in the safe order. Deploy a verifier that accepts unsigned deliveries, turn signing on, then make the signature required. Turning signing off is the breaking direction.
- Rotate on a schedule, and immediately on a leak. A grace window with both signatures lets you deploy the new secret without dropping deliveries — but for a leaked secret, end the window at once, since the old key is exactly what a forger holds.
With URLpipe
URLpipe's webhooks, specifically
URLpipe delivers async results as signed webhooks when you turn signing on for a project (Settings → Webhook Signing, where the whsec_ secret appears). Every attempt — retries included — is signed afresh with a new timestamp, so a retry twenty minutes later still passes a five-minute tolerance. Rotating the secret opens a 24-hour window in which each delivery carries two signatures, newest first, and a button ends that window early.
A rejected delivery is retried with backoff, up to six attempts over roughly twenty minutes, and can be re-sent by hand from the dashboard at any time; the result also stays available from GET /result/:token for 30 days. The async and webhooks docs have the payload, the headers and the full rotation procedure.
FAQ
Frequently asked questions
Why does my webhook signature never match?
Why compare signatures in constant time?
What timestamp tolerance should I use?
Is HTTPS alone not enough to secure a webhook?
Should my endpoint be idempotent too?
Put this into practice.
Each of the eight kinds of data URLpipe returns has a free, no-signup tool — try the ideas from this guide on a real page, then grab an API key to run them from your code. 1,000 credits a month, no card.