An Adyen webhook signature does not cover the whole request. It covers eight fields joined by colons, and the rest of the payload (the refusal reason, the payment method, most of additionalData) sits outside it. A Checkout.com or GoCardless signature covers the whole body but no timestamp, so a captured request replays cleanly a month later.
Most guides to webhook signature verification show one HMAC snippet and move on. In payments that is not enough, because every provider signs something different, encodes it differently and retries on a different clock. Below is what Stripe, Adyen, Checkout.com, PayPal, GoCardless, Square, Wise, Circle and Mollie actually sign, checked against their docs and SDK source in September 2026, and the verification bugs each design invites.
How Does Webhook Signature Verification Work?
The provider and your server share a secret, or the provider holds a private key and publishes the public half. For every delivery the provider computes a signature over some bytes and sends it in a header. Your server recomputes it over the bytes it received and compares.
Three questions decide whether that protects you:
- What exactly is signed? The raw body, a timestamp plus the body, a subset of fields, or the URL plus the body.
- Is a timestamp inside the signature? If not, there is no replay protection, whatever your middleware claims.
- How do you compare?
==on strings leaks timing. Constant-time comparison does not.
Stripe vs Adyen vs Checkout.com vs PayPal: Webhook Signatures Compared
| Provider | Header | Algorithm | What is signed | Encoding | Timestamp signed? | |||
|---|---|---|---|---|---|---|---|---|
| Stripe | Stripe-Signature (t=…,v1=…) | HMAC-SHA256 | {t}.{raw body} | hex | Yes | |||
| Adyen (standard) | additionalData.hmacSignature in the body | HMAC-SHA256, hex key decoded to bytes | 8 colon-joined fields | base64 | No | |||
| Adyen (other types) | hmacsignature HTTP header | HMAC-SHA256 | raw body | base64 | No | |||
| Checkout.com | Cko-Signature | HMAC-SHA256 | raw body | hex | No | |||
| PayPal | paypal-transmission-sig + 4 others | RSA SHA-256 with a PayPal cert | id\ | time\ | webhookId\ | crc32(body) | base64 | Yes |
| GoCardless | Webhook-Signature | HMAC-SHA256 | raw body | hex | No | |||
| Square | x-square-hmacsha256-signature | HMAC-SHA256 | notification URL + raw body | base64 | No | |||
| Wise | X-Signature-SHA256 | RSA SHA-256 | raw body | base64 | No | |||
| Circle | X-Circle-Signature + X-Circle-Key-Id | ECDSA SHA-256 | raw body | base64 | No | |||
| Mollie (next-gen) | X-Mollie-Signature: sha256=… | HMAC-SHA256 | raw body | hex | No | |||
| Standard Webhooks | webhook-signature | HMAC-SHA256 or Ed25519 | {id}.{timestamp}.{body} | base64 | Yes |
Of nine payment providers here, two put a timestamp inside the signature. That is the finding I did not expect when I started building this table.
How to Verify a Stripe Webhook Signature
Stripe's header looks like t=1492774577,v1=5257a8…. You build the string t + . + raw body, HMAC it with SHA-256, hex-encode the result and compare against every v1 value in the header. Only v1 is a live scheme. Test events carry a fake v0, and Stripe tells you to ignore anything that is not v1, which blocks downgrade attacks.
There are several v1 values during secret rotation. Stripe can keep the old secret active for up to 24 hours and "generates one signature per secret" in that window, so a verifier that reads only the first v1 breaks on rotation day.
The secret starts with whsec_, and stripe-node uses the whole string, prefix included, as the UTF-8 key. Nothing is decoded. Hold on to that detail for the Standard Webhooks section.
Now the SDK behaviour, from stripe-node's Webhooks.ts:
constructEventdefaults to 300 seconds of tolerance. It passestolerance || DEFAULT_TOLERANCE, so passing0gives you 300. The docs say 0 disables the check. In Node, it does not.- The lower-level
signature.verifyHeaderdoes the opposite:tolerance || 0, which skips the timestamp check entirely. ATODO(MAJOR)comment in the source says this will change in the next major version. - The check is
tolerance > 0 && timestampAge > tolerance. A timestamp in the future produces a negative age and passes.
Live-mode retries run for up to three days with exponential backoff, and every retry carries a fresh timestamp and signature. Stripe does not guarantee order and says to dedupe on the event ID. It also says some changes produce two separate Event objects, so dedupe on data.object.id plus type as well.
Adyen HMAC Signature Validation: Eight Fields and a Hex Key
For standard payment webhooks, Adyen signs this string:
pspReference:originalReference:merchantAccountCode:merchantReference:value:currency:eventCode:success
Empty fields become empty strings, so a typical authorisation looks like 7914073381342284::TestMerchant:TestPayment-1407325143704:1130:EUR:AUTHORISATION:true. Note the double colon where originalReference is blank.
The key in your Customer Area is a hex string. You decode it to bytes before using it as the HMAC key, and the result is base64. The Node library does Buffer.from(key, "hex") and .digest("base64"). Use the hex string as UTF-8 text, which is the default in most HMAC helpers, and every signature fails.
Other Adyen webhook families send the signature in an hmacsignature HTTP header computed over the raw body, which is closer to what everyone else does. The Node library exposes both: validateHMAC(item, key) for payments and validateHMACSignature(key, sig, body) for the rest.
Two consequences follow from the design. First, only those eight fields are integrity-protected, so do not make decisions on unsigned additionalData without fetching the payment. Second, nothing time-based is signed, so there is no replay protection at all. Adyen's deduplication advice (key on eventCode plus pspReference) is doing the job a timestamp would do elsewhere.
The acknowledgement is its own trap. Webhooks created before Adyen's 2xx option still expect the literal body [accepted]. Newer ones take any 2xx, with 202 recommended, within 10 seconds. Switching an existing webhook is permanent, so "tidying up" the response on a legacy webhook without changing the setting turns every delivery into a retry. And the retries are long: three at 9, 18 and 27 seconds, then a queue from 2 minutes out to 8 hours, for up to 30 days.
How to Verify PayPal Webhooks Without the API Call
PayPal offers two routes. The POST /v1/notifications/verify-webhook-signature endpoint takes the five transmission headers plus your webhook ID and event, and returns verification_status: SUCCESS or FAILURE. It does not work for simulator or mock events.
PayPal itself calls self-verification "the preferred method". You rebuild this message:
{paypal-transmission-id}|{paypal-transmission-time}|{your webhook id}|{crc32 of raw body, decimal}
Then you verify paypal-transmission-sig (base64) with SHA256withRSA against the certificate downloaded from paypal-cert-url. The CRC32 has to be computed on the original bytes and written in decimal, not hex.
There is a catch in PayPal's own Node sample: it calls downloadAndCache(headers['paypal-cert-url']) without checking the host. The certificate URL comes from the request you are trying to authenticate. An attacker who controls that header can point it at their own certificate, sign with their own key and pass. Check the URL is HTTPS on a PayPal domain, and check the certificate chain, before you trust it. PayPal retries up to 25 times over three days.
Legacy IPN is worse. CVE-2026-77999 (September 2026) covers a J2Store listener that treated PayPal's UNVERIFIED reply as valid. CVE-2025-11271 covers Easy Digital Downloads ≤3.5.2, which skipped verification entirely when the POST body contained verification_override=1.
Why Does Webhook Signature Verification Fail? The Raw Body
Almost every "signature mismatch" ticket I have seen comes down to the framework parsing JSON before your verifier sees it. Re-serialising changes whitespace, key order and number formatting. Checkout.com warns that re-serialising "could change the precision of some values", which is the same float problem I covered in the ISO 4217 minor units guide.
The fixes by framework:
- Express: mount
express.raw({ type: 'application/json' })on the webhook route, and registerexpress.json()after it. - Next.js App Router: read
await req.text()and verify that string. Never callreq.json()first. - Next.js Pages Router: set
export const config = { api: { bodyParser: false } }and read the stream. - AWS API Gateway + Lambda: make sure the mapping template passes the body through untouched, and base64-decode it when
isBase64Encodedis true.
Which Payment Webhooks Have Replay Protection?
Only Stripe and PayPal sign a timestamp, plus anything built on Standard Webhooks. For Adyen, Checkout.com, GoCardless, Square, Wise, Circle and Mollie, a valid captured request stays valid for good.
In practice the defence is idempotent processing, not the signature. Dedupe on the provider's event ID in the same database transaction as the side effect, as described in the idempotency keys guide. A replayed AUTHORISATION then becomes a no-op rather than a second shipment.
Timing-safe comparison is the other gap. Square's docs require constant-time comparison, yet its official Node SDK's WebhooksHelper compares with ===. In the pay Rails gem, the Paddle Billing verifier used Ruby's String#==, and GitHub rated the result high (GHSA-mjgf-xj26-9qf9, CVSS 7.4, versions ≤11.6.1). Use crypto.timingSafeEqual, hmac.compare_digest or ActiveSupport::SecurityUtils.secure_compare, and compare equal-length buffers.
import { createHmac, timingSafeEqual } from "node:crypto";
// Stripe-style: all v1 values, tolerance in both directions
export function verifyStripe(raw: string, header: string, secret: string, tol = 300) {
const parts = header.split(",").map((p) => p.split("=", 2) as [string, string]);
const t = Number(parts.find(([k]) => k === "t")?.[1]);
const sigs = parts.filter(([k]) => k === "v1").map(([, v]) => v);
if (!Number.isFinite(t) || sigs.length === 0) return false;
if (Math.abs(Date.now() / 1000 - t) > tol) return false; // rejects future timestamps too
const expected = createHmac("sha256", secret).update(${t}.${raw}).digest();
return sigs.some((s) => {
const got = Buffer.from(s, "hex");
return got.length === expected.length && timingSafeEqual(got, expected);
});
}
Payment Webhook Retries and Timeouts by Provider
This decides how long an outage can last before you lose events, and how fast your handler must acknowledge.
| Provider | Ack deadline | Retry window | Notes |
|---|---|---|---|
| Stripe | not documented | up to 3 days (live) | resend manually for 15 days (Dashboard) or 30 days (CLI) |
| Adyen | 10 s | up to 30 days | [accepted] on legacy webhooks |
| Checkout.com | 10 s | 8 retries, ~30 h | cancelled after the last attempt |
| PayPal | not documented | 25 retries over 3 days | 2xx only |
| GoCardless | 10 s | 9 attempts total | up to 250 events per request; answer 498 on bad signature |
| Square | 10 s | 11 retries over ~24 h (per its overview page) | doubling from 1 min to 8 h |
| Wise | 5 s | 25 retries over 2 weeks | out-of-order possible |
| Mollie | 15 s | 10 retries over ~26 h | only 200 OK counts |
Wise's 5 seconds is the tightest here. Verify, persist the raw event, return 2xx, and do the real work on a queue. That pattern fits inside every deadline in the table.
Standard Webhooks vs Stripe: Same whsec_ Prefix, Different Key
Standard Webhooks is an open spec with headers webhook-id, webhook-timestamp and webhook-signature, signing {id}.{timestamp}.{body} and sending v1, for HMAC or v1a, for Ed25519. Svix's CEO sits on its steering committee. The adopters listed on its site include OpenAI, Anthropic, Twilio, Brex and Lithic. It is the only design here that signs a message ID, a timestamp and the full body together.
Its secrets also start with whsec_. Stripe uses that string as-is as the key. Standard Webhooks strips the prefix and base64-decodes the rest. Feed a Stripe secret to a Standard Webhooks library, or the reverse, and nothing verifies, with no hint why. I would put the provider name in the secret's variable name for exactly this reason.
Should You Allowlist Webhook IP Addresses?
The providers disagree. Stripe publishes 15 IPs and says to use allowlisting alongside signatures. GoCardless and Square publish static addresses. Adyen gives only the domain out.adyen.com. Checkout.com and Mollie advise against IP restrictions. I treat IP allowlists as noise reduction at the edge, never as authentication, and only where the provider commits to notice periods (Stripe gives 7 days, GoCardless 2 weeks).
My Take: Verify the Signature, Then Fetch the Object
This is opinion. A webhook signature proves who sent a message. It does not prove the message is current, complete or in order. So for anything that moves money, treat the verified webhook as a prompt and read the object back from the API before acting. Mollie's classic webhooks work like this by design: they carry only an id, because in Mollie's words fake calls "will never result in orders being processed without being actually paid".
That pattern closes three gaps at once: Adyen's unsigned fields, replayable requests from providers that sign no timestamp, and out-of-order delivery everywhere. It costs one API call per event, which is cheap next to a double fulfilment.
My prediction: none of the big card PSPs will move their main webhooks to Standard Webhooks in the next few years. Changing what is signed breaks every live integration, the same reason Adyen still honours [accepted]. At most they will offer it as an opt-in endpoint version. Newer card-issuing and banking APIs will keep adopting it, as Lithic and Brex already have.
What This Means for Payments Engineers
1. Keep the raw bytes. Verify on the unparsed body, and store it alongside the event ID so you can re-verify during a dispute or incident.
2. Write one verifier per provider, with a test fixture taken from each provider's docs example, and never share an HMAC helper across them. Key decoding and encoding differ between every row of the first table.
3. Accept multiple signatures for Stripe and Mollie rotation, and keep the previous Adyen key live until its new key has propagated.
4. Enforce your own time window where a timestamp is signed (Stripe, PayPal, Standard Webhooks), in both directions.
5. Dedupe on event ID in the same transaction as the side effect. For providers without signed timestamps, this is your only replay defence.
6. Pin PayPal's certificate host before downloading anything from paypal-cert-url.
7. Ack within 5 seconds, queue the work, and fetch the object from the API before any state change that moves money.
If you run webhooks for PCI-scoped flows, this sits next to the script-integrity work in the PCI DSS 4.0 developer guide. If you want a second pair of eyes on a multi-PSP webhook layer, you can find me via Tom Wang.
Key Takeaways
- Of nine payment providers compared, only Stripe and PayPal sign a timestamp. Adyen, Checkout.com, GoCardless, Square, Wise, Circle and Mollie signatures never expire.
- Adyen's standard HMAC covers eight colon-joined fields, not the body, and needs the hex key decoded to bytes.
- stripe-node's
constructEventturns a tolerance of 0 into 300 seconds, its low-levelverifyHeaderdefaults to no check at all, and future timestamps pass. - PayPal's sample downloads the verification certificate from a URL in the request without checking the host.
- Stripe and Standard Webhooks both use
whsec_secrets, but only Standard Webhooks base64-decodes them. - Ack deadlines range from 5 s (Wise) to 15 s (Mollie). Retry windows range from about 24 hours (Square) to 30 days (Adyen).