Skip to main content

Confirm

Are you sure?

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 · 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.

SenderSigned stringHeader(s)Timestamp signed?
Stripetimestamp.bodyStripe-Signature: t=…,v1=<hex>Yes
Standard Webhooks (Svix and others)id.timestamp.bodywebhook-id, webhook-timestamp, webhook-signature: v1,<base64>Yes
GitHubbodyX-Hub-Signature-256: sha256=<hex>No
URLpipetimestamp.bodyX-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

  1. 1
    Hashing 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_post in Rails, express.raw() on the route in Express, request.get_data() in Flask, php://input in PHP.
  2. 2
    Comparing 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.
  3. 3
    No 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.
  4. 4
    Comparing 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 v2 is 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.

Verify a signed delivery
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(","))

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?
Almost always because you are hashing a parsed and re-serialized body instead of the raw bytes. JSON parsers reorder keys and change whitespace and escapes, and any change alters the digest. Read the raw body before any middleware parses it.
Why compare signatures in constant time?
An ordinary string compare returns at the first differing byte, and that timing leaks how much of a forged signature was right. Constant-time compares (hmac.compare_digest, crypto.timingSafeEqual, hash_equals) take the same time either way.
What timestamp tolerance should I use?
Five minutes is the common default — Stripe's libraries use it. Short enough to make a captured request useless soon, long enough for clock drift and a slow retry.
Is HTTPS alone not enough to secure a webhook?
HTTPS protects the request in transit, but anyone who learns your endpoint's URL can send it a well-formed request. A signature proves the request was made by someone holding the secret.
Should my endpoint be idempotent too?
Yes. Providers retry deliveries that time out or fail, so the same event can arrive more than once. Key your handler on the event's id or token and treat a repeat as already done.

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.