From eacf5034f42529a2875c96dc4062396b7eec0946 Mon Sep 17 00:00:00 2001 From: "Panche I." Date: Tue, 18 Aug 2026 21:33:53 +0200 Subject: [PATCH] fix(pay): actionable webhook.verify errors for the raw-body mistake MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The #1 reason merchants "can't verify webhooks" is passing a parsed/re-serialized body instead of the raw request bytes (e.g. `req.body` after `express.json()`), which the SDK stringified to `[object Object]` and surfaced as a generic "signature does not match payload". They had no way to know what was wrong. - Reject a non-string/Buffer payload up front with a message that names the received type and shows the fix (`express.raw({ type: 'application/json' })`). - Add a raw-body hint to the signature-mismatch error, mirroring Stripe's "Are you passing the raw request body you received?". No change to the verification algorithm. Adds 3 tests (parsed object → helpful error, null → helpful error, mismatch → hints at raw body); 15/15 pass, tsc + biome clean. Co-Authored-By: Claude Opus 4.8 --- .../pay-webhook-verify-raw-body-hints.md | 5 +++ packages/pay/src/resources/webhooks.test.ts | 33 +++++++++++++++++++ packages/pay/src/resources/webhooks.ts | 18 ++++++++-- 3 files changed, 54 insertions(+), 2 deletions(-) create mode 100644 .changeset/pay-webhook-verify-raw-body-hints.md diff --git a/.changeset/pay-webhook-verify-raw-body-hints.md b/.changeset/pay-webhook-verify-raw-body-hints.md new file mode 100644 index 0000000..3467de3 --- /dev/null +++ b/.changeset/pay-webhook-verify-raw-body-hints.md @@ -0,0 +1,5 @@ +--- +"@agentaos/pay": patch +--- + +Make `webhooks.verify()` errors self-explaining for the most common integration mistake — passing a parsed body instead of the raw request bytes. A non-string/Buffer payload (e.g. `req.body` after `express.json()`) now throws an actionable message pointing at `express.raw()`, and a genuine signature mismatch asks whether the raw body was used, mirroring Stripe's hint. No change to the verification algorithm. diff --git a/packages/pay/src/resources/webhooks.test.ts b/packages/pay/src/resources/webhooks.test.ts index f7895bd..71789b2 100644 --- a/packages/pay/src/resources/webhooks.test.ts +++ b/packages/pay/src/resources/webhooks.test.ts @@ -127,3 +127,36 @@ describe('webhooks.verify — format and freshness', () => { ); }); }); + +describe('webhooks.verify — raw-body guidance (the #1 integration mistake)', () => { + it('rejects a parsed object with an actionable "pass the raw body" message', () => { + const signature = sign(VALID_PAYLOAD, SECRET, nowSec()); + // A JSON body parser (e.g. express.json()) hands you an object, not the raw bytes. + const parsed = JSON.parse(VALID_PAYLOAD) as unknown; + let message = ''; + try { + // @ts-expect-error — deliberately passing the parsed object a body parser produces + new WebhooksResource().verify(parsed, signature, SECRET); + } catch (err) { + message = (err as Error).message; + } + expect(message).toMatch(/raw request body/i); + expect(message).toMatch(/express\.raw/); + }); + + it('rejects null with the raw-body message', () => { + const signature = sign(VALID_PAYLOAD, SECRET, nowSec()); + expect(() => + // @ts-expect-error — null is not a valid payload + new WebhooksResource().verify(null, signature, SECRET), + ).toThrow(/raw request body/i); + }); + + it('a genuine signature mismatch also hints at the raw body', () => { + const signature = sign(VALID_PAYLOAD, SECRET, nowSec()); + const tampered = VALID_PAYLOAD.replace('s_1', 's_2'); + expect(() => new WebhooksResource().verify(tampered, signature, SECRET)).toThrow( + /RAW request body/i, + ); + }); +}); diff --git a/packages/pay/src/resources/webhooks.ts b/packages/pay/src/resources/webhooks.ts index d6a14c8..34f6867 100644 --- a/packages/pay/src/resources/webhooks.ts +++ b/packages/pay/src/resources/webhooks.ts @@ -10,6 +10,16 @@ export class WebhooksResource { secret: string, toleranceSec = 300, ): WebhookEvent { + // The signature is over the RAW request body. A parsed/re-serialized object never + // matches, so reject a non-string/Buffer payload up front with an actionable message + // instead of letting `[object Object]` fall through to a generic mismatch error. + if (typeof payload !== 'string' && !Buffer.isBuffer(payload)) { + const received = payload === null ? 'null' : typeof payload; + throw new WebhookVerificationError( + `Webhook payload must be the raw request body (a string or Buffer), but received ${received}. You are likely passing an already-parsed JSON body — verification signs the RAW bytes, so a parsed or re-serialized object never matches the signature. Read the raw body instead, e.g. in Express: app.post('/webhooks', express.raw({ type: 'application/json' }), handler).`, + ); + } + const payloadStr = typeof payload === 'string' ? payload : payload.toString('utf-8'); // Parse signature: t=,v1= @@ -44,14 +54,18 @@ export class WebhooksResource { // Timing-safe compare if (expected.length !== v1.length) { throw new WebhookVerificationError( - 'Webhook signature verification failed. Signature does not match payload.', + 'Webhook signature verification failed: no matching signature for the payload. ' + + 'Are you passing the RAW request body you received (not a parsed or re-serialized object)? ' + + 'A JSON body parser running before verification is the most common cause.', ); } const isValid = timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(v1, 'hex')); if (!isValid) { throw new WebhookVerificationError( - 'Webhook signature verification failed. Signature does not match payload.', + 'Webhook signature verification failed: no matching signature for the payload. ' + + 'Are you passing the RAW request body you received (not a parsed or re-serialized object)? ' + + 'A JSON body parser running before verification is the most common cause.', ); }