From 7fee38abdd3cf4ea393bf2831d5bcffee225cb3d Mon Sep 17 00:00:00 2001 From: Brian Love Date: Thu, 3 Sep 2026 11:55:30 -0700 Subject: [PATCH] fix(growth): accept Resend's current webhook payload shape Every real Resend webhook was answered 400, so delivery state never left "submitted" even though Resend reported the mail delivered. The parser enforces a closed key set pinned to the SDK 6.10 types, but Resend's wire payload now carries data.message_id, and transactional mail sends null broadcast_id and template_id. Any of those failed the closed validation. Allow message_id as bounded text and null for the optional ids. Unknown keys are still rejected so the schema stays closed. Verified against production with signed replays before the change (400) and the parser tests after it. Co-Authored-By: Claude Fable 5.1 --- libs/growth/src/lib/webhooks.spec.ts | 35 ++++++++++++++++++++++++++++ libs/growth/src/lib/webhooks.ts | 14 ++++++++--- 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/libs/growth/src/lib/webhooks.spec.ts b/libs/growth/src/lib/webhooks.spec.ts index 0ffb7b461..c748c4db3 100644 --- a/libs/growth/src/lib/webhooks.spec.ts +++ b/libs/growth/src/lib/webhooks.spec.ts @@ -312,6 +312,41 @@ describe('processVerifiedResendWebhook', () => { } }); + it('accepts the documented Resend payload shape: message_id present and null optional ids', async () => { + // Resend's wire payload gained `message_id` after SDK 6.10 pinned its + // types, and transactional mail carries null broadcast/template ids. + // Both must parse, or every real event answers 400 and delivery state + // never leaves "submitted". + const harness = webhookHarness(); + + await expect( + processVerifiedResendWebhook( + harness.executor, + { + providerEventId: 'msg_documented_shape', + payload: event('email.delivered', { + broadcast_id: null, + template_id: null, + message_id: '<111-222-333@email.example.com>', + }), + }, + harness.dependencies + ) + ).resolves.toMatchObject({ applied: true }); + }); + + it('still rejects unknown data keys so the closed schema stays enforced', async () => { + const harness = executorWith({}); + + await expect( + processVerifiedResendWebhook(harness.executor, { + providerEventId: 'msg_unknown_key', + payload: event('email.delivered', { headers: [] }), + }) + ).rejects.toThrow(/Invalid Resend webhook payload/u); + expect(harness.runTransaction).not.toHaveBeenCalled(); + }); + it('marks only a permanent bounce as a hard-bounce stop', async () => { const hard = webhookHarness(); await processVerifiedResendWebhook( diff --git a/libs/growth/src/lib/webhooks.ts b/libs/growth/src/lib/webhooks.ts index 6fd3eae27..3347ef6f7 100644 --- a/libs/growth/src/lib/webhooks.ts +++ b/libs/growth/src/lib/webhooks.ts @@ -32,11 +32,14 @@ const DELIVERY_STATUS_PRECEDENCE: Readonly< suppressed: 3, complained: 4, }; +// Resend's wire payload gained `message_id` after the pinned SDK types were +// written, and transactional mail carries null broadcast/template ids. const BASE_DATA_KEYS = new Set([ 'broadcast_id', 'created_at', 'email_id', 'from', + 'message_id', 'subject', 'tags', 'template_id', @@ -184,9 +187,14 @@ function validateProviderBaseData(data: Record): void { throw new Error('Invalid Resend webhook payload'); } for (const recipient of data['to']) boundedText(recipient, 254); - if (data['broadcast_id'] !== undefined) - boundedText(data['broadcast_id'], 256); - if (data['template_id'] !== undefined) boundedText(data['template_id'], 256); + for (const key of ['broadcast_id', 'template_id'] as const) { + if (data[key] !== undefined && data[key] !== null) { + boundedText(data[key], 256); + } + } + if (data['message_id'] !== undefined && data['message_id'] !== null) { + boundedText(data['message_id'], 998); + } } function validateClosedDetails(