Skip to content

Commit 3686cad

Browse files
committed
feat: Raise a distinct error for an unreadable webhook payload
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y1RzepycXEYA3LStfjt8cY
1 parent 9c0166f commit 3686cad

5 files changed

Lines changed: 226 additions & 2 deletions

File tree

README.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,31 @@ Use it to parse and validate [Seam webhook events](https://docs.seam.co/latest/d
8181
Refer to the [Svix docs on Consuming Webhooks](https://docs.svix.com/receiving/introduction)
8282
for an in-depth guide on best-practices for handling webhooks in your application.
8383

84+
Verification failures throw Svix's `WebhookVerificationError`, re-exported as
85+
`SeamWebhookVerificationError`: treat the payload as forged and respond with an
86+
error status so Svix retries.
87+
88+
A payload that is correctly signed but cannot be read as a Seam event throws a
89+
`SeamInvalidWebhookPayloadError` instead. Verification has already succeeded by
90+
then, so the payload is genuinely from Seam and will never become readable:
91+
log it as a bug rather than reporting a verification failure and letting Svix
92+
retry it through its full backoff schedule. Use `isSeamInvalidWebhookPayloadError`
93+
to discriminate.
94+
95+
```js
96+
import { isSeamInvalidWebhookPayloadError, SeamWebhook } from '@seamapi/webhook'
97+
98+
try {
99+
data = webhook.verify(req.body, req.headers)
100+
} catch (err) {
101+
if (isSeamInvalidWebhookPayloadError(err)) {
102+
console.error('Unreadable Seam webhook payload', err)
103+
return res.status(204).send()
104+
}
105+
return res.status(400).send()
106+
}
107+
```
108+
84109
> [!TIP]
85110
> This example is for [Express](https://expressjs.com/),
86111
> see the [Svix docs for more examples in specific frameworks](https://docs.svix.com/receiving/verifying-payloads/how).

src/lib/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
1+
export * from './seam-invalid-webhook-payload-error.js'
12
export * from './seam-webhook.js'
23
export { WebhookVerificationError as SeamWebhookVerificationError } from 'svix'
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
/**
2+
* Thrown when a webhook payload passes signature verification
3+
* but cannot be read as a Seam event.
4+
*
5+
* Verification has already succeeded by this point, so the payload is
6+
* genuinely from Seam and will never become readable. Report it as a bug
7+
* instead of letting the sender retry it, and do not treat it as forgery.
8+
*/
9+
export class SeamInvalidWebhookPayloadError extends Error {
10+
constructor(message: string, options?: ErrorOptions) {
11+
super(message, options)
12+
this.name = 'SeamInvalidWebhookPayloadError'
13+
}
14+
}
15+
16+
export const isSeamInvalidWebhookPayloadError = (
17+
error: unknown,
18+
): error is SeamInvalidWebhookPayloadError =>
19+
error instanceof SeamInvalidWebhookPayloadError

src/lib/seam-webhook.test.ts

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,145 @@
11
import test from 'ava'
2+
import { Webhook } from 'svix'
23

4+
import {
5+
isSeamInvalidWebhookPayloadError,
6+
SeamInvalidWebhookPayloadError,
7+
} from './seam-invalid-webhook-payload-error.js'
38
import { SeamWebhook } from './seam-webhook.js'
49

10+
const secret = 'MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw'
11+
12+
const eventPayload = JSON.stringify({
13+
event_id: '11111111-1111-1111-1111-111111111111',
14+
event_type: 'device.connected',
15+
workspace_id: '22222222-2222-2222-2222-222222222222',
16+
device_id: '33333333-3333-3333-3333-333333333333',
17+
created_at: '2026-08-27T00:00:00.000Z',
18+
occurred_at: '2026-08-27T00:00:00.000Z',
19+
})
20+
21+
const signHeaders = (
22+
payload: string,
23+
{ msgId = 'msg_1', timestamp = new Date(), signingSecret = secret } = {},
24+
): Record<string, string> => ({
25+
'svix-id': msgId,
26+
'svix-timestamp': Math.floor(timestamp.getTime() / 1000).toString(),
27+
'svix-signature': new Webhook(signingSecret).sign(msgId, timestamp, payload),
28+
})
29+
530
test('SeamWebhook: constructor', (t) => {
631
t.truthy(new SeamWebhook('1234'))
732
})
33+
34+
test('SeamWebhook: verifies and parses a signed event', (t) => {
35+
const webhook = new SeamWebhook(secret)
36+
37+
const event = webhook.verify(eventPayload, signHeaders(eventPayload))
38+
39+
t.is(event.event_type, 'device.connected')
40+
t.is(event.event_id, '11111111-1111-1111-1111-111111111111')
41+
})
42+
43+
test('SeamWebhook: accepts mixed case headers', (t) => {
44+
const webhook = new SeamWebhook(secret)
45+
const headers = Object.fromEntries(
46+
Object.entries(signHeaders(eventPayload)).map(([key, value]) => [
47+
key.toUpperCase(),
48+
value,
49+
]),
50+
)
51+
52+
t.is(webhook.verify(eventPayload, headers).event_type, 'device.connected')
53+
})
54+
55+
test('SeamWebhook: a tampered payload fails verification', (t) => {
56+
const webhook = new SeamWebhook(secret)
57+
const headers = signHeaders(eventPayload)
58+
const tampered = eventPayload.replace(
59+
'device.connected',
60+
'device.disconnected',
61+
)
62+
63+
const error = t.throws(() => webhook.verify(tampered, headers))
64+
65+
t.false(isSeamInvalidWebhookPayloadError(error))
66+
t.regex(error?.message ?? '', /No matching signature/)
67+
})
68+
69+
test('SeamWebhook: a wrong secret fails verification', (t) => {
70+
const webhook = new SeamWebhook(secret)
71+
const headers = signHeaders(eventPayload, {
72+
signingSecret: 'WrongQ9r8GKYqrTwjUPD8ILPZIo2LaLa',
73+
})
74+
75+
const error = t.throws(() => webhook.verify(eventPayload, headers))
76+
77+
t.regex(error?.message ?? '', /No matching signature/)
78+
})
79+
80+
test('SeamWebhook: an expired timestamp fails verification', (t) => {
81+
const webhook = new SeamWebhook(secret)
82+
const headers = signHeaders(eventPayload, {
83+
timestamp: new Date(Date.now() - 60 * 60 * 1000),
84+
})
85+
86+
const error = t.throws(() => webhook.verify(eventPayload, headers))
87+
88+
t.regex(error?.message ?? '', /too old/)
89+
})
90+
91+
for (const missing of ['svix-id', 'svix-timestamp', 'svix-signature']) {
92+
test(`SeamWebhook: a missing ${missing} header fails verification`, (t) => {
93+
const webhook = new SeamWebhook(secret)
94+
const { [missing]: _, ...headers } = signHeaders(eventPayload)
95+
96+
const error = t.throws(() => webhook.verify(eventPayload, headers))
97+
98+
t.false(isSeamInvalidWebhookPayloadError(error))
99+
t.regex(error?.message ?? '', /Missing required headers/)
100+
})
101+
}
102+
103+
test('SeamWebhook: a signed but unparseable payload is not forgery', (t) => {
104+
const webhook = new SeamWebhook(secret)
105+
const payload = '{"event_id": "trailing-comma",}'
106+
107+
const error = t.throws(() => webhook.verify(payload, signHeaders(payload)), {
108+
instanceOf: SeamInvalidWebhookPayloadError,
109+
})
110+
111+
t.regex(error?.message ?? '', /not valid JSON/)
112+
t.truthy(error?.cause)
113+
})
114+
115+
for (const payload of ['null', '[1]', '42', '"event"', '{}']) {
116+
test(`SeamWebhook: a signed non-event payload ${payload} is not forgery`, (t) => {
117+
const webhook = new SeamWebhook(secret)
118+
119+
const error = t.throws(
120+
() => webhook.verify(payload, signHeaders(payload)),
121+
{
122+
instanceOf: SeamInvalidWebhookPayloadError,
123+
},
124+
)
125+
126+
t.regex(error?.message ?? '', /did not contain a Seam event/)
127+
})
128+
}
129+
130+
test('SeamWebhook: an unknown event type still parses', (t) => {
131+
const webhook = new SeamWebhook(secret)
132+
const payload = JSON.stringify({
133+
event_id: '11111111-1111-1111-1111-111111111111',
134+
event_type: 'future.event_type',
135+
})
136+
137+
// event_type is a closed union of known types, so widen it to assert
138+
// that an unrecognized type still round-trips.
139+
const event: { event_type?: string | undefined } = webhook.verify(
140+
payload,
141+
signHeaders(payload),
142+
)
143+
144+
t.is(event.event_type, 'future.event_type')
145+
})

src/lib/seam-webhook.ts

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import type { SeamEvent } from '@seamapi/http/connect'
2-
import { Webhook } from 'svix'
2+
import { Webhook, WebhookVerificationError } from 'svix'
3+
4+
import { SeamInvalidWebhookPayloadError } from './seam-invalid-webhook-payload-error.js'
35

46
export class SeamWebhook {
57
readonly #webhook: Webhook
@@ -8,11 +10,50 @@ export class SeamWebhook {
810
this.#webhook = new Webhook(secret)
911
}
1012

13+
/**
14+
* Verifies an incoming webhook request and returns the event it carries.
15+
*
16+
* @throws {WebhookVerificationError} When the signature does not match.
17+
* @throws {SeamInvalidWebhookPayloadError} When the signature matches
18+
* but the body is not a Seam event.
19+
*/
1120
verify(payload: string, headers: Record<string, string>): SeamEvent {
1221
const normalizedHeaders = Object.fromEntries(
1322
Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value]),
1423
)
1524

16-
return this.#webhook.verify(payload, normalizedHeaders) as SeamEvent
25+
let data: unknown
26+
27+
try {
28+
data = this.#webhook.verify(payload, normalizedHeaders)
29+
} catch (error) {
30+
if (error instanceof WebhookVerificationError) throw error
31+
32+
// svix parses the body only once the signature matches,
33+
// so anything thrown past that point is an unreadable payload.
34+
throw new SeamInvalidWebhookPayloadError(
35+
`The verified webhook payload is not valid JSON: ${getErrorMessage(error)}`,
36+
{ cause: error },
37+
)
38+
}
39+
40+
if (!isSeamEvent(data)) {
41+
throw new SeamInvalidWebhookPayloadError(
42+
'The verified webhook payload did not contain a Seam event',
43+
)
44+
}
45+
46+
return data
1747
}
1848
}
49+
50+
const isSeamEvent = (data: unknown): data is SeamEvent =>
51+
typeof data === 'object' &&
52+
data !== null &&
53+
'event_id' in data &&
54+
typeof data.event_id === 'string' &&
55+
'event_type' in data &&
56+
typeof data.event_type === 'string'
57+
58+
const getErrorMessage = (error: unknown): string =>
59+
error instanceof Error ? error.message : String(error)

0 commit comments

Comments
 (0)