Skip to content

feat: add raw_json to the verified event and reject unreadable payloads - #20

Closed
razor-x wants to merge 4 commits into
mainfrom
claude/sdk-webhook-union-resilience-mu1ldf
Closed

feat: add raw_json to the verified event and reject unreadable payloads#20
razor-x wants to merge 4 commits into
mainfrom
claude/sdk-webhook-union-resilience-mu1ldf

Conversation

@razor-x

@razor-x razor-x commented Sep 1, 2026

Copy link
Copy Markdown
Member

The two rules

1. Runtime does not explode on values the SDK does not recognize. This already held, because the package performs no runtime conversion — verify returns svix's parsed object. An event type this SDK predates comes back intact, with the caveat that it is typed as one of the 108 members it does know. That type-level gap is not addressed here; widening the union is inherently breaking and needs its own decision.

2. The webhook event carries the payload it was parsed from.

const event = webhook.verify(payload, headers)
JSON.parse(event.raw_json())['a_field_this_version_predates']

The generated SeamEvent types cover only the fields they were generated for, so a field Seam adds to an existing event is otherwise unreachable, and an event type this version predates is mistyped as one it knows. raw_json() is the escape hatch for both.

This is the true original string, not a re-serialization, since verify receives it.

It is attached with Object.defineProperty and enumerable: false. A spread would make it enumerable, and JSON.stringify(event) would then embed a second, escaped copy of the payload inside the payload — doubling its size and looking broken to anyone logging or persisting events that way. A test covers this.

Signed but unreadable payloads

verify blind-cast whatever svix returned:

return this.#webhook.verify(payload, normalizedHeaders) as SeamEvent

So a correctly signed body that is not an event reached the caller mistyped — an object with no event_type, a JSON array, and a bare null all came back as a SeamEvent, and a body that is not JSON leaked svix's internal SyntaxError.

These now raise SeamInvalidWebhookPayloadError, kept distinct from SeamWebhookVerificationError because the two call for opposite responses. A verification failure means the sender may not be Seam, so the right answer is an error status that makes it retry. An invalid payload means the sender is Seam and the body will never become readable, so retrying only repeats the failure. This matches the Python and PHP SDKs.

An unrecognized event_type is explicitly not in this category — it passes through, as rule 1 requires.

Tests

The package had one test, for the constructor; verify was never exercised at all. It now has 15 covering the real HMAC signing path — known and unrecognized events, bad signature, stale timestamp, the unreadable-payload matrix, and raw_json round-tripping.

100% coverage. tsc and lint clean.

🤖 Generated with Claude Code

https://claude.ai/code/session_01M2kJ4nGaM8imZCVMEKjmXA

verify() blind-cast whatever svix returned to SeamEvent, so a correctly signed
body that is not an event reached the caller mistyped: an object with no
event_type, a JSON array, or a bare null all came back as a SeamEvent, and a
body that is not JSON leaked svix's internal SyntaxError.

These now raise SeamInvalidWebhookPayloadError, kept distinct from
SeamWebhookVerificationError because the two call for opposite responses. A
verification failure means the sender may not be Seam, so the right answer is an
error status that makes it retry. An invalid payload means the sender is Seam and
the body will never become readable, so retrying only repeats the failure.

This matches what the Python and PHP SDKs already do.

The package had one test, for the constructor, and verify() was never exercised
at all. It now has 12 covering the real signing path, at 100% coverage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M2kJ4nGaM8imZCVMEKjmXA
The added comments ran to roughly double the density of the code around them and
mostly restated what the line below already said. Kept the ones carrying
information the code cannot: why svix/util has to be required, why both key
shapes are accepted after symbolize_names, and why array_map needed replacing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M2kJ4nGaM8imZCVMEKjmXA
The generated SeamEvent types cover only the fields they were generated for, so
a field Seam adds to an existing event between SDK releases is unreachable, and
an event type this version predates is mistyped as one it knows. verify() now
returns the event plus the payload it was parsed from:

    JSON.parse(event.raw_json())['a_field_this_version_predates']

This is the true original string, not a re-serialization, since verify()
receives it. It is attached non-enumerably so JSON.stringify(event) does not
embed a second, escaped copy of the payload inside the payload, which is also
covered by a test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M2kJ4nGaM8imZCVMEKjmXA
@razor-x razor-x changed the title Add webhook payload validation to SeamWebhook feat: add raw_json to the verified event and reject unreadable payloads Sep 2, 2026
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M2kJ4nGaM8imZCVMEKjmXA
@razor-x razor-x closed this Sep 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants