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
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,24 @@ 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`.
A payload that is correctly signed but unreadable throws a `SeamInvalidWebhookPayloadError`.

```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).
Expand Down
1 change: 1 addition & 0 deletions src/lib/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from './seam-invalid-webhook-payload-error.js'
export * from './seam-webhook.js'
export { WebhookVerificationError as SeamWebhookVerificationError } from 'svix'
19 changes: 19 additions & 0 deletions src/lib/seam-invalid-webhook-payload-error.ts
Original file line number Diff line number Diff line change
@@ -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
138 changes: 138 additions & 0 deletions src/lib/seam-webhook.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> => ({
'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')
})
43 changes: 41 additions & 2 deletions src/lib/seam-webhook.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -8,11 +10,48 @@ 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<string, string>): 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

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)
Loading