From 3686cadc13dfd8fe3ff4575f49e5fccbb9446bba Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 21:32:59 +0000 Subject: [PATCH 1/4] feat: Raise a distinct error for an unreadable webhook payload Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Y1RzepycXEYA3LStfjt8cY --- README.md | 25 ++++ src/lib/index.ts | 1 + src/lib/seam-invalid-webhook-payload-error.ts | 19 +++ src/lib/seam-webhook.test.ts | 138 ++++++++++++++++++ src/lib/seam-webhook.ts | 45 +++++- 5 files changed, 226 insertions(+), 2 deletions(-) create mode 100644 src/lib/seam-invalid-webhook-payload-error.ts diff --git a/README.md b/README.md index b9fc59b..d34382c 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,31 @@ Use it to parse and validate [Seam webhook events](https://docs.seam.co/latest/d Refer to the [Svix docs on Consuming Webhooks](https://docs.svix.com/receiving/introduction) for an in-depth guide on best-practices for handling webhooks in your application. +Verification failures throw Svix's `WebhookVerificationError`, re-exported as +`SeamWebhookVerificationError`: treat the payload as forged and respond with an +error status so Svix retries. + +A payload that is correctly signed but cannot be read as a Seam event throws a +`SeamInvalidWebhookPayloadError` instead. Verification has already succeeded by +then, so the payload is genuinely from Seam and will never become readable: +log it as a bug rather than reporting a verification failure and letting Svix +retry it through its full backoff schedule. Use `isSeamInvalidWebhookPayloadError` +to discriminate. + +```js +import { isSeamInvalidWebhookPayloadError, SeamWebhook } from '@seamapi/webhook' + +try { + data = webhook.verify(req.body, req.headers) +} catch (err) { + if (isSeamInvalidWebhookPayloadError(err)) { + console.error('Unreadable Seam webhook payload', err) + return res.status(204).send() + } + return res.status(400).send() +} +``` + > [!TIP] > This example is for [Express](https://expressjs.com/), > see the [Svix docs for more examples in specific frameworks](https://docs.svix.com/receiving/verifying-payloads/how). diff --git a/src/lib/index.ts b/src/lib/index.ts index bc7b291..0d3082a 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -1,2 +1,3 @@ +export * from './seam-invalid-webhook-payload-error.js' export * from './seam-webhook.js' export { WebhookVerificationError as SeamWebhookVerificationError } from 'svix' diff --git a/src/lib/seam-invalid-webhook-payload-error.ts b/src/lib/seam-invalid-webhook-payload-error.ts new file mode 100644 index 0000000..742b6b4 --- /dev/null +++ b/src/lib/seam-invalid-webhook-payload-error.ts @@ -0,0 +1,19 @@ +/** + * Thrown when a webhook payload passes signature verification + * but cannot be read as a Seam event. + * + * Verification has already succeeded by this point, so the payload is + * genuinely from Seam and will never become readable. Report it as a bug + * instead of letting the sender retry it, and do not treat it as forgery. + */ +export class SeamInvalidWebhookPayloadError extends Error { + constructor(message: string, options?: ErrorOptions) { + super(message, options) + this.name = 'SeamInvalidWebhookPayloadError' + } +} + +export const isSeamInvalidWebhookPayloadError = ( + error: unknown, +): error is SeamInvalidWebhookPayloadError => + error instanceof SeamInvalidWebhookPayloadError diff --git a/src/lib/seam-webhook.test.ts b/src/lib/seam-webhook.test.ts index 6092b3f..5d9de2b 100644 --- a/src/lib/seam-webhook.test.ts +++ b/src/lib/seam-webhook.test.ts @@ -1,7 +1,145 @@ import test from 'ava' +import { Webhook } from 'svix' +import { + isSeamInvalidWebhookPayloadError, + SeamInvalidWebhookPayloadError, +} from './seam-invalid-webhook-payload-error.js' import { SeamWebhook } from './seam-webhook.js' +const secret = 'MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw' + +const eventPayload = JSON.stringify({ + event_id: '11111111-1111-1111-1111-111111111111', + event_type: 'device.connected', + workspace_id: '22222222-2222-2222-2222-222222222222', + device_id: '33333333-3333-3333-3333-333333333333', + created_at: '2026-08-27T00:00:00.000Z', + occurred_at: '2026-08-27T00:00:00.000Z', +}) + +const signHeaders = ( + payload: string, + { msgId = 'msg_1', timestamp = new Date(), signingSecret = secret } = {}, +): Record => ({ + 'svix-id': msgId, + 'svix-timestamp': Math.floor(timestamp.getTime() / 1000).toString(), + 'svix-signature': new Webhook(signingSecret).sign(msgId, timestamp, payload), +}) + test('SeamWebhook: constructor', (t) => { t.truthy(new SeamWebhook('1234')) }) + +test('SeamWebhook: verifies and parses a signed event', (t) => { + const webhook = new SeamWebhook(secret) + + const event = webhook.verify(eventPayload, signHeaders(eventPayload)) + + t.is(event.event_type, 'device.connected') + t.is(event.event_id, '11111111-1111-1111-1111-111111111111') +}) + +test('SeamWebhook: accepts mixed case headers', (t) => { + const webhook = new SeamWebhook(secret) + const headers = Object.fromEntries( + Object.entries(signHeaders(eventPayload)).map(([key, value]) => [ + key.toUpperCase(), + value, + ]), + ) + + t.is(webhook.verify(eventPayload, headers).event_type, 'device.connected') +}) + +test('SeamWebhook: a tampered payload fails verification', (t) => { + const webhook = new SeamWebhook(secret) + const headers = signHeaders(eventPayload) + const tampered = eventPayload.replace( + 'device.connected', + 'device.disconnected', + ) + + const error = t.throws(() => webhook.verify(tampered, headers)) + + t.false(isSeamInvalidWebhookPayloadError(error)) + t.regex(error?.message ?? '', /No matching signature/) +}) + +test('SeamWebhook: a wrong secret fails verification', (t) => { + const webhook = new SeamWebhook(secret) + const headers = signHeaders(eventPayload, { + signingSecret: 'WrongQ9r8GKYqrTwjUPD8ILPZIo2LaLa', + }) + + const error = t.throws(() => webhook.verify(eventPayload, headers)) + + t.regex(error?.message ?? '', /No matching signature/) +}) + +test('SeamWebhook: an expired timestamp fails verification', (t) => { + const webhook = new SeamWebhook(secret) + const headers = signHeaders(eventPayload, { + timestamp: new Date(Date.now() - 60 * 60 * 1000), + }) + + const error = t.throws(() => webhook.verify(eventPayload, headers)) + + t.regex(error?.message ?? '', /too old/) +}) + +for (const missing of ['svix-id', 'svix-timestamp', 'svix-signature']) { + test(`SeamWebhook: a missing ${missing} header fails verification`, (t) => { + const webhook = new SeamWebhook(secret) + const { [missing]: _, ...headers } = signHeaders(eventPayload) + + const error = t.throws(() => webhook.verify(eventPayload, headers)) + + t.false(isSeamInvalidWebhookPayloadError(error)) + t.regex(error?.message ?? '', /Missing required headers/) + }) +} + +test('SeamWebhook: a signed but unparseable payload is not forgery', (t) => { + const webhook = new SeamWebhook(secret) + const payload = '{"event_id": "trailing-comma",}' + + const error = t.throws(() => webhook.verify(payload, signHeaders(payload)), { + instanceOf: SeamInvalidWebhookPayloadError, + }) + + t.regex(error?.message ?? '', /not valid JSON/) + t.truthy(error?.cause) +}) + +for (const payload of ['null', '[1]', '42', '"event"', '{}']) { + test(`SeamWebhook: a signed non-event payload ${payload} is not forgery`, (t) => { + const webhook = new SeamWebhook(secret) + + const error = t.throws( + () => webhook.verify(payload, signHeaders(payload)), + { + instanceOf: SeamInvalidWebhookPayloadError, + }, + ) + + t.regex(error?.message ?? '', /did not contain a Seam event/) + }) +} + +test('SeamWebhook: an unknown event type still parses', (t) => { + const webhook = new SeamWebhook(secret) + const payload = JSON.stringify({ + event_id: '11111111-1111-1111-1111-111111111111', + event_type: 'future.event_type', + }) + + // event_type is a closed union of known types, so widen it to assert + // that an unrecognized type still round-trips. + const event: { event_type?: string | undefined } = webhook.verify( + payload, + signHeaders(payload), + ) + + t.is(event.event_type, 'future.event_type') +}) diff --git a/src/lib/seam-webhook.ts b/src/lib/seam-webhook.ts index c793463..a44a1bf 100644 --- a/src/lib/seam-webhook.ts +++ b/src/lib/seam-webhook.ts @@ -1,5 +1,7 @@ import type { SeamEvent } from '@seamapi/http/connect' -import { Webhook } from 'svix' +import { Webhook, WebhookVerificationError } from 'svix' + +import { SeamInvalidWebhookPayloadError } from './seam-invalid-webhook-payload-error.js' export class SeamWebhook { readonly #webhook: Webhook @@ -8,11 +10,50 @@ export class SeamWebhook { this.#webhook = new Webhook(secret) } + /** + * Verifies an incoming webhook request and returns the event it carries. + * + * @throws {WebhookVerificationError} When the signature does not match. + * @throws {SeamInvalidWebhookPayloadError} When the signature matches + * but the body is not a Seam event. + */ verify(payload: string, headers: Record): SeamEvent { const normalizedHeaders = Object.fromEntries( Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value]), ) - return this.#webhook.verify(payload, normalizedHeaders) as SeamEvent + let data: unknown + + try { + data = this.#webhook.verify(payload, normalizedHeaders) + } catch (error) { + if (error instanceof WebhookVerificationError) throw error + + // svix parses the body only once the signature matches, + // so anything thrown past that point is an unreadable payload. + throw new SeamInvalidWebhookPayloadError( + `The verified webhook payload is not valid JSON: ${getErrorMessage(error)}`, + { cause: error }, + ) + } + + if (!isSeamEvent(data)) { + throw new SeamInvalidWebhookPayloadError( + 'The verified webhook payload did not contain a Seam event', + ) + } + + return data } } + +const isSeamEvent = (data: unknown): data is SeamEvent => + typeof data === 'object' && + data !== null && + 'event_id' in data && + typeof data.event_id === 'string' && + 'event_type' in data && + typeof data.event_type === 'string' + +const getErrorMessage = (error: unknown): string => + error instanceof Error ? error.message : String(error) From 194cf493ba810c2b15a55fb724a57a98b50b76da Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 21:42:43 +0000 Subject: [PATCH 2/4] docs: Align the webhook error guidance with the other SDKs Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Y1RzepycXEYA3LStfjt8cY --- README.md | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index d34382c..423bb51 100644 --- a/README.md +++ b/README.md @@ -82,15 +82,12 @@ Refer to the [Svix docs on Consuming Webhooks](https://docs.svix.com/receiving/i for an in-depth guide on best-practices for handling webhooks in your application. Verification failures throw Svix's `WebhookVerificationError`, re-exported as -`SeamWebhookVerificationError`: treat the payload as forged and respond with an -error status so Svix retries. - -A payload that is correctly signed but cannot be read as a Seam event throws a -`SeamInvalidWebhookPayloadError` instead. Verification has already succeeded by -then, so the payload is genuinely from Seam and will never become readable: -log it as a bug rather than reporting a verification failure and letting Svix -retry it through its full backoff schedule. Use `isSeamInvalidWebhookPayloadError` -to discriminate. +`SeamWebhookVerificationError`: treat the payload as forged and respond with +an error status so Svix retries. A payload that is correctly signed but +unreadable throws a `SeamInvalidWebhookPayloadError` instead: it is genuinely +from Seam and will never become readable, so log it as a bug rather than +reporting a verification failure and letting Svix retry it through its full +backoff schedule. Use `isSeamInvalidWebhookPayloadError` to discriminate. ```js import { isSeamInvalidWebhookPayloadError, SeamWebhook } from '@seamapi/webhook' From 9a37805c3bc76fca23641a8b43aa01d883362eb9 Mon Sep 17 00:00:00 2001 From: Evan Sosenko Date: Fri, 28 Aug 2026 15:10:31 -0700 Subject: [PATCH 3/4] Apply suggestion from @razor-x --- src/lib/seam-webhook.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/lib/seam-webhook.ts b/src/lib/seam-webhook.ts index a44a1bf..12ac10c 100644 --- a/src/lib/seam-webhook.ts +++ b/src/lib/seam-webhook.ts @@ -29,8 +29,6 @@ export class SeamWebhook { } catch (error) { if (error instanceof WebhookVerificationError) throw error - // svix parses the body only once the signature matches, - // so anything thrown past that point is an unreadable payload. throw new SeamInvalidWebhookPayloadError( `The verified webhook payload is not valid JSON: ${getErrorMessage(error)}`, { cause: error }, From 42e14601a0769c4aa3ad25ab3447f244ad426d90 Mon Sep 17 00:00:00 2001 From: Evan Sosenko Date: Fri, 28 Aug 2026 20:00:39 -0700 Subject: [PATCH 4/4] Apply suggestion from @razor-x --- README.md | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 423bb51..523316a 100644 --- a/README.md +++ b/README.md @@ -82,12 +82,8 @@ Refer to the [Svix docs on Consuming Webhooks](https://docs.svix.com/receiving/i for an in-depth guide on best-practices for handling webhooks in your application. Verification failures throw Svix's `WebhookVerificationError`, re-exported as -`SeamWebhookVerificationError`: treat the payload as forged and respond with -an error status so Svix retries. A payload that is correctly signed but -unreadable throws a `SeamInvalidWebhookPayloadError` instead: it is genuinely -from Seam and will never become readable, so log it as a bug rather than -reporting a verification failure and letting Svix retry it through its full -backoff schedule. Use `isSeamInvalidWebhookPayloadError` to discriminate. +`SeamWebhookVerificationError`. +A payload that is correctly signed but unreadable throws a `SeamInvalidWebhookPayloadError`. ```js import { isSeamInvalidWebhookPayloadError, SeamWebhook } from '@seamapi/webhook'