From 08e1a17724c2ad51db4bec8171f779d52d2afe7d Mon Sep 17 00:00:00 2001 From: Brian Love Date: Thu, 3 Sep 2026 14:12:40 -0700 Subject: [PATCH] fix(growth): accept Resend webhook payloads that carry a headers array; log every outcome Resend's webhook event log showed why recipient delivery state never left "submitted": every recipient message carries List-Unsubscribe, the job id, Bcc, and Reply-To, and Resend echoes them back as a data.headers array. The closed parser rejected the key, the route answered 400, and Resend marked the events failed with no further attempts. Founder notifications have no custom headers, which is why only those succeeded. The parser now validates the known keys and a bounded headers array, and ignores unknown keys within a bounded key count instead of failing the event. Two drifts in one day showed the closed set was hurting more than it protected. The route now logs one structured line per call with the status and reason so future attempts are visible in Vercel logs. Co-Authored-By: Claude Fable 5.1 --- .../src/app/api/webhooks/resend/route.ts | 41 +++++++---- libs/growth/src/lib/webhooks.spec.ts | 68 +++++++++++++++++-- libs/growth/src/lib/webhooks.ts | 39 ++++++----- 3 files changed, 110 insertions(+), 38 deletions(-) diff --git a/apps/website/src/app/api/webhooks/resend/route.ts b/apps/website/src/app/api/webhooks/resend/route.ts index 435d07489..e25f6287c 100644 --- a/apps/website/src/app/api/webhooks/resend/route.ts +++ b/apps/website/src/app/api/webhooks/resend/route.ts @@ -99,18 +99,35 @@ export function createResendWebhookRoute( return response(503); } try { - const result = await dependencies.processVerifiedResendWebhook(database, { - providerEventId: id, - payload: verifiedPayload, - }); - if ( - !result.applied && - result.reason === 'retryable_unmatched_job' - ) { - return response(503); - } - return response(200); - } catch { + const result = await dependencies.processVerifiedResendWebhook( + database, + { + providerEventId: id, + payload: verifiedPayload, + } + ); + const status = + !result.applied && result.reason === 'retryable_unmatched_job' + ? 503 + : 200; + console.info( + JSON.stringify({ + route: 'webhooks/resend', + status, + applied: result.applied, + reason: result.applied ? undefined : result.reason, + }) + ); + return response(status); + } catch (error) { + console.info( + JSON.stringify({ + route: 'webhooks/resend', + status: 400, + reason: 'payload_rejected', + message: error instanceof Error ? error.message : 'unknown', + }) + ); return response(400); } finally { await database.close?.(); diff --git a/libs/growth/src/lib/webhooks.spec.ts b/libs/growth/src/lib/webhooks.spec.ts index c748c4db3..b7295dec0 100644 --- a/libs/growth/src/lib/webhooks.spec.ts +++ b/libs/growth/src/lib/webhooks.spec.ts @@ -335,16 +335,72 @@ describe('processVerifiedResendWebhook', () => { ).resolves.toMatchObject({ applied: true }); }); - it('still rejects unknown data keys so the closed schema stays enforced', async () => { - const harness = executorWith({}); + it('accepts the real Resend payload for a message with custom headers (headers array present)', async () => { + // Captured verbatim from the production webhook event log on + // 2026-09-03: recipient messages carry List-Unsubscribe, the job id, + // Bcc, and Reply-To as a `headers` array. Rejecting it left every + // recipient delivery stuck at "submitted". + const harness = webhookHarness(); await expect( - processVerifiedResendWebhook(harness.executor, { - providerEventId: 'msg_unknown_key', - payload: event('email.delivered', { headers: [] }), + processVerifiedResendWebhook( + harness.executor, + { + providerEventId: 'msg_headers_shape', + payload: event('email.delivered', { + message_id: '<111-222-333@email.example.com>', + headers: [ + { + name: 'List-Unsubscribe', + value: '', + }, + { + name: 'List-Unsubscribe-Post', + value: 'List-Unsubscribe=One-Click', + }, + { + name: 'X-Threadplane-Job-ID', + value: '68add347-ef99-414c-b96f-89475cf4ee26', + }, + { + name: 'Bcc', + value: 'Brian at Threadplane ', + }, + { + name: 'Reply-To', + value: 'Brian at Threadplane ', + }, + ], + }), + }, + harness.dependencies + ) + ).resolves.toMatchObject({ applied: true }); + }); + + it('ignores unknown data keys instead of failing the whole event, within a bounded key count', async () => { + const harness = webhookHarness(); + + await expect( + processVerifiedResendWebhook( + harness.executor, + { + providerEventId: 'msg_unknown_key', + payload: event('email.delivered', { some_future_field: 'x' }), + }, + harness.dependencies + ) + ).resolves.toMatchObject({ applied: true }); + + const tooMany = Object.fromEntries( + Array.from({ length: 40 }, (_, index) => [`k${index}`, 'v']) + ); + await expect( + processVerifiedResendWebhook(executorWith({}).executor, { + providerEventId: 'msg_too_many_keys', + payload: event('email.delivered', tooMany), }) ).rejects.toThrow(/Invalid Resend webhook payload/u); - expect(harness.runTransaction).not.toHaveBeenCalled(); }); it('marks only a permanent bounce as a hard-bounce stop', async () => { diff --git a/libs/growth/src/lib/webhooks.ts b/libs/growth/src/lib/webhooks.ts index 3347ef6f7..d98ca3228 100644 --- a/libs/growth/src/lib/webhooks.ts +++ b/libs/growth/src/lib/webhooks.ts @@ -32,22 +32,6 @@ 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', - 'to', - 'bounce', - 'failed', - 'suppressed', -]); type SupportedResendEventType = | 'email.sent' @@ -169,11 +153,14 @@ function boundedRecord( ); } +const MAX_DATA_KEYS = 32; +const MAX_HEADER_ENTRIES = 32; + +// Known keys are validated; unknown keys are ignored rather than fatal, because +// Resend has added keys (message_id, headers) without notice and each addition +// used to turn every delivery event into a 400. The total key count stays bounded. function validateProviderBaseData(data: Record): void { - if ( - Object.keys(data).length > BASE_DATA_KEYS.size || - Object.keys(data).some((key) => !BASE_DATA_KEYS.has(key)) - ) { + if (Object.keys(data).length > MAX_DATA_KEYS) { throw new Error('Invalid Resend webhook payload'); } boundedDate(data['created_at']); @@ -195,6 +182,18 @@ function validateProviderBaseData(data: Record): void { if (data['message_id'] !== undefined && data['message_id'] !== null) { boundedText(data['message_id'], 998); } + if (data['headers'] !== undefined && data['headers'] !== null) { + const headers = data['headers']; + if (!Array.isArray(headers) || headers.length > MAX_HEADER_ENTRIES) { + throw new Error('Invalid Resend webhook payload'); + } + for (const header of headers) { + const entry = plainObject(header); + if (!entry) throw new Error('Invalid Resend webhook payload'); + boundedText(entry['name'], 128); + boundedText(entry['value'], 2_000); + } + } } function validateClosedDetails(