Adyen's webhook HMAC is unlike almost every other provider's: it doesn't sign the raw request body at all. It signs a colon-joined string of 8 extracted fields, keyed with a hex-decoded key, output in base64. Each of those three properties breaks a habit you learned verifying GitHub- or Stripe-style webhooks, and each is a distinct way to fail. Here's the exact algorithm, then the causes in the order to check them.
For each NotificationRequestItem in the body, the signature in
additionalData.hmacSignature is:
signed_string = pspReference + ":" + originalReference + ":" + merchantAccountCode
+ ":" + merchantReference + ":" + amount.value + ":" + amount.currency
+ ":" + eventCode + ":" + success
signature = base64( HMAC_SHA256( key = hex_decode(hmac_key), message = signed_string ) )
Empty fields stay empty — originalReference is usually absent on an
AUTHORISATION, so the signed string contains a double colon. Adyen's own docs provide a
full test vector (reproduced at the bottom of this page) — use it before you touch a
real delivery.
This is the Adyen HMAC bug. The Customer Area gives you the key as a hex string like
44782DEF547AAA06C910C43932B1EB0C71FC68D9D0C057550C48EC2ACF6BA056
and every official library decodes it to 32 raw bytes before keying the HMAC —
Buffer.from(key, "hex") in Node, binascii.a2b_hex in Python.
Feed the 64 hex characters in directly and you get a stable, plausible-looking,
wrong signature. With Adyen's documented test vector, the hex-chars-as-key mistake
computes
v1SgtPdCljLGt5Ln1m/87X4DF+iNzvtUfStAjQlfiWw= ← wrong (hex chars as key) coqCmt/IZ4E3CzPvMY8zTjQVL5hYJUiBRg8UU+iCWo0= ← correct (key hex-decoded)
If your computed value matches the first form, this is your bug.
originalReference contributes an empty string, so
7914073381342284::TestMerchant:… (double colon) is correct. Skipping the
position entirely shifts every later field.success and live are JSON
strings ("true"), while amount.value is an integer in
minor units (1130 = €11.30). Build the string from the parsed values as
they serialize naturally — don't stringify booleans that were never booleans.pspReference:originalReference:merchantAccountCode:merchantReference:amount.value:amount.currency:eventCode:success.
Not alphabetical, not the JSON key order.Blog posts (and some AI-generated answers) tell you to escape : and
\ in the field values. In Adyen's official Node and Java libraries that
escaping only applies to the legacy sorted-map variant (classic HPP payments) —
not to NotificationRequestItem field signing. If you added
an escaping pass, remove it; for standard payment webhooks the values are joined
verbatim.
Generic HMAC testers — and most webhook middleware — compute HMAC over the raw request body. Adyen's payment webhooks structurally can't be verified that way: there is no raw-body signature. (The upside of Adyen's design: a proxy that re-serializes JSON or reorders keys can't break the signature.) You need a verifier that knows the field-extraction scheme — this in-browser debugger implements it (100% client-side; the key never leaves your machine), or set the Adyen scheme on a capture bin (below) for live ✓/✗ badges on real deliveries.
hmacSignature is base64. If your HMAC library returns
hex digests by default (Python's hexdigest(), Node's
digest('hex')), the comparison never matches even when the computation is
right. Compare base64 to base64 — constant-time.
Adyen's "other" webhook families (Banking / platform / Management API) do not
embed a signature in the body. They send a base64 HMAC-SHA256 over the raw
body in an hmacsignature header, alongside
protocol: HmacSHA256 — same hex-encoded key format, still hex-decode it.
If your endpoint receives both families, branch on whether the body has
notificationItems. (A capture bin with the Adyen scheme applies the same
fallback automatically.)
notificationItems is an array. Each item carries its
own hmacSignature — verify every item, not just the first.From Adyen's docs (key, payload and expected signature all documented):
const crypto = require('crypto');
const key = '44782DEF547AAA06C910C43932B1EB0C71FC68D9D0C057550C48EC2ACF6BA056';
const signed = '7914073381342284::TestMerchant:TestPayment-1407325143704:1130:EUR:AUTHORISATION:true';
const sig = crypto.createHmac('sha256', Buffer.from(key, 'hex'))
.update(signed, 'utf8').digest('base64');
// → coqCmt/IZ4E3CzPvMY8zTjQVL5hYJUiBRg8UU+iCWo0=
If this vector passes but real deliveries fail, the bug is in your field extraction (cause 2) — capture a real delivery in a bin and rebuild the signed string from the actual bytes.
Adyen expects any 2xx within 10 seconds (status code alone — the
legacy [accepted] body is no longer required). On failure it retries at
9s/18s/27s, then from a queue at 2m/5m/10m/15m/30m/1h/2h/4h/8h intervals for up to
30 days. Two consequences people miss:
eventCode + pspReference (duplicates can arrive
with a different eventDate; trust the latest).Failure alerts fire after 5 attempts and again at 7 days; the Customer Area has manual Retry/Ignore per event.
Create a free bin, set the signature scheme to Adyen in bin settings, paste your hex HMAC key, and point a test webhook at the capture URL (Customer Area → webhook → Test configuration). Every delivery gets a live ✓/✗ badge per item — if Adyen's own delivery verifies ✓ against your key but your handler says invalid, the bug is in your code, not your key. The bin answers 200 within Adyen's 10-second deadline, so nothing queues up while you debug.
Related: the Adyen AUTHORISATION payload example (its signature actually verifies against the documented sample key), the 23-provider retry-schedule comparison, and Square / HubSpot — the other providers generic raw-body testers structurally can't verify.
No signup needed. Or from your terminal: curl https://hookden.pages.dev/new
← All guides · Signature debugger · Payload examples · Docs · Hookden vs webhook.site