Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/pay-webhook-verify-raw-body-hints.md
Original file line number Diff line number Diff line change
@@ -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.
33 changes: 33 additions & 0 deletions packages/pay/src/resources/webhooks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
});
});
18 changes: 16 additions & 2 deletions packages/pay/src/resources/webhooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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=<timestamp>,v1=<hmac>
Expand Down Expand Up @@ -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.',
);
}

Expand Down
Loading