Skip to content

Commit daca433

Browse files
committed
fix: Separate an unreadable webhook payload from a forged one
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y1RzepycXEYA3LStfjt8cY
1 parent 8b17d94 commit daca433

5 files changed

Lines changed: 250 additions & 7 deletions

File tree

README.rst

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -597,12 +597,19 @@ Refer to the `Svix docs on Consuming Webhooks <https://docs.svix.com/receiving/i
597597
This example is for `Flask <https://flask.palletsprojects.com/>`_,
598598
see the `Svix docs for more examples in specific frameworks <https://docs.svix.com/receiving/verifying-payloads/how>`_.
599599

600+
Verification failures raise a ``SeamWebhookVerificationError``: treat the
601+
payload as forged and respond with an error status so Svix retries. A payload
602+
that is correctly signed but unreadable raises a
603+
``SeamInvalidWebhookPayloadError`` instead: it is genuinely from Seam and will
604+
never become readable, so log it as a bug rather than reporting a verification
605+
failure and letting Svix retry it through its full backoff schedule.
606+
600607
.. code-block:: python
601608
602609
import os
603610
604611
from flask import Flask, request
605-
from seam import SeamWebhook
612+
from seam import SeamInvalidWebhookPayloadError, SeamWebhook, SeamWebhookVerificationError
606613
607614
app = Flask(__name__)
608615
@@ -612,8 +619,11 @@ see the `Svix docs for more examples in specific frameworks <https://docs.svix.c
612619
def handle_webhook():
613620
try:
614621
data = webhook.verify(request.get_data(), request.headers)
615-
except Exception:
622+
except SeamWebhookVerificationError:
616623
return 'Bad Request', 400
624+
except SeamInvalidWebhookPayloadError:
625+
app.logger.exception('Unreadable Seam webhook payload')
626+
return '', 204
617627
618628
try:
619629
store_event(data)

seam/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
from .exceptions import (
99
SeamError,
1010
SeamHttpApiError,
11+
SeamInvalidWebhookPayloadError,
12+
SeamWebhookVerificationError,
1113
SeamHttpUnauthorizedError,
1214
SeamHttpInvalidInputError,
1315
SeamValidationError,
@@ -16,7 +18,6 @@
1618
SeamActionAttemptTimeoutError,
1719
)
1820
from .seam_webhook import SeamWebhook
19-
from svix.webhooks import WebhookVerificationError as SeamWebhookVerificationError
2021
from .null import NULL, Null
2122
from .url_search_params_serializer import UnserializableParamError, UrlSearchParams
2223
from .strict_url_search_params_serializer import (

seam/exceptions.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
from dataclasses import dataclass
22
from typing import Any, Dict, List, Optional
3+
4+
from svix.webhooks import WebhookVerificationError
5+
36
from .resources import ActionAttempt, ErrorActionAttempt, PendingActionAttempt
47

58

@@ -15,6 +18,27 @@ class SeamError(Exception):
1518
"""Base exception for all errors raised by the Seam SDK."""
1619

1720

21+
# Webhook
22+
class SeamWebhookVerificationError(WebhookVerificationError, SeamError):
23+
"""
24+
Exception raised when a webhook payload fails signature verification.
25+
26+
Treat the payload as forged: respond with an error status so the sender
27+
retries, and investigate if the failures persist.
28+
"""
29+
30+
31+
class SeamInvalidWebhookPayloadError(SeamError):
32+
"""
33+
Exception raised when a webhook payload passes signature verification
34+
but cannot be read as a Seam event.
35+
36+
The payload is genuinely from Seam and will never become readable, so
37+
report it as a bug instead of letting the sender retry it, and do not
38+
treat it as forgery.
39+
"""
40+
41+
1842
# HTTP
1943
class SeamHttpApiError(SeamError):
2044
"""

seam/seam_webhook.py

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
1+
from json import JSONDecodeError
12
from typing import Dict
2-
from svix.webhooks import Webhook
3+
4+
from svix.webhooks import Webhook, WebhookVerificationError
5+
6+
from .exceptions import SeamInvalidWebhookPayloadError, SeamWebhookVerificationError
37
from .resources import SeamEvent, seam_event_from_dict
48

59

@@ -25,9 +29,45 @@ def verify(self, payload: str, headers: Dict[str, str]) -> SeamEvent:
2529
:type headers: Dict[str, str]
2630
:return: The SeamEvent object created from the verified payload.
2731
:rtype: SeamEvent
28-
:raises WebhookVerificationError: If the webhook signature verification fails.
32+
:raises SeamWebhookVerificationError: If the webhook signature
33+
verification fails. Respond with an error status so the sender
34+
retries.
35+
:raises SeamInvalidWebhookPayloadError: If the payload is correctly
36+
signed but cannot be read as a Seam event. The payload will never
37+
become readable, so report it as a bug instead of letting the
38+
sender retry it.
2939
"""
30-
normalized_headers = {k.lower(): v for k, v in headers.items()}
31-
res = self._webhook.verify(payload, normalized_headers)
40+
normalized_headers: Dict[str, str] = {}
41+
for key, value in headers.items():
42+
name = str(key).lower()
43+
if name in normalized_headers and normalized_headers[name] != value:
44+
raise SeamWebhookVerificationError(
45+
f"Conflicting values for webhook header {name}"
46+
)
47+
normalized_headers[name] = value
48+
49+
try:
50+
res = self._webhook.verify(payload, normalized_headers)
51+
except JSONDecodeError as error:
52+
# The signature already checked out, so the payload is genuinely
53+
# from Seam but permanently unreadable.
54+
raise SeamInvalidWebhookPayloadError(
55+
f"The verified webhook payload is not valid JSON: {error}"
56+
) from error
57+
except WebhookVerificationError as error:
58+
raise SeamWebhookVerificationError(str(error)) from error
59+
except ValueError as error:
60+
# A malformed signature or timestamp header can leak a bare
61+
# ValueError out of the verifier; it is a verification failure.
62+
raise SeamWebhookVerificationError(str(error)) from error
63+
64+
if (
65+
not isinstance(res, dict)
66+
or not isinstance(res.get("event_id"), str)
67+
or not isinstance(res.get("event_type"), str)
68+
):
69+
raise SeamInvalidWebhookPayloadError(
70+
"The verified webhook payload did not contain a Seam event"
71+
)
3272

3373
return seam_event_from_dict(res)

test/seam_webhook_test.py

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
import json
2+
from datetime import datetime, timedelta, timezone
3+
4+
import pytest
5+
from svix.webhooks import Webhook, WebhookVerificationError
6+
7+
from seam import (
8+
SeamError,
9+
SeamInvalidWebhookPayloadError,
10+
SeamWebhook,
11+
SeamWebhookVerificationError,
12+
)
13+
14+
SECRET = "MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw"
15+
16+
EVENT_PAYLOAD = json.dumps(
17+
{
18+
"event_id": "11111111-1111-1111-1111-111111111111",
19+
"event_type": "device.connected",
20+
"workspace_id": "22222222-2222-2222-2222-222222222222",
21+
"device_id": "33333333-3333-3333-3333-333333333333",
22+
"created_at": "2026-08-27T00:00:00.000Z",
23+
"occurred_at": "2026-08-27T00:00:00.000Z",
24+
}
25+
)
26+
27+
28+
def sign_headers(payload, msg_id="msg_1", timestamp=None, secret=SECRET):
29+
timestamp = timestamp or datetime.now(timezone.utc)
30+
signature = Webhook(secret).sign(msg_id, timestamp, payload)
31+
32+
return {
33+
"svix-id": msg_id,
34+
"svix-timestamp": str(int(timestamp.timestamp())),
35+
"svix-signature": signature,
36+
}
37+
38+
39+
def test_verifies_and_parses_a_signed_event():
40+
webhook = SeamWebhook(SECRET)
41+
42+
event = webhook.verify(EVENT_PAYLOAD, sign_headers(EVENT_PAYLOAD))
43+
44+
assert event.event_type == "device.connected"
45+
assert event.event_id == "11111111-1111-1111-1111-111111111111"
46+
47+
48+
def test_accepts_mixed_case_headers():
49+
webhook = SeamWebhook(SECRET)
50+
headers = {k.upper(): v for k, v in sign_headers(EVENT_PAYLOAD).items()}
51+
52+
event = webhook.verify(EVENT_PAYLOAD, headers)
53+
54+
assert event.event_type == "device.connected"
55+
56+
57+
def test_a_tampered_payload_fails_verification():
58+
webhook = SeamWebhook(SECRET)
59+
headers = sign_headers(EVENT_PAYLOAD)
60+
tampered = EVENT_PAYLOAD.replace("device.connected", "device.disconnected")
61+
62+
with pytest.raises(SeamWebhookVerificationError, match="No matching signature"):
63+
webhook.verify(tampered, headers)
64+
65+
66+
def test_a_wrong_secret_fails_verification():
67+
webhook = SeamWebhook(SECRET)
68+
headers = sign_headers(EVENT_PAYLOAD, secret="WrongQ9r8GKYqrTwjUPD8ILPZIo2LaLa")
69+
70+
with pytest.raises(SeamWebhookVerificationError, match="No matching signature"):
71+
webhook.verify(EVENT_PAYLOAD, headers)
72+
73+
74+
def test_an_expired_timestamp_fails_verification():
75+
webhook = SeamWebhook(SECRET)
76+
headers = sign_headers(
77+
EVENT_PAYLOAD,
78+
timestamp=datetime.now(timezone.utc) - timedelta(hours=1),
79+
)
80+
81+
with pytest.raises(SeamWebhookVerificationError, match="too old"):
82+
webhook.verify(EVENT_PAYLOAD, headers)
83+
84+
85+
@pytest.mark.parametrize("missing", ["svix-id", "svix-timestamp", "svix-signature"])
86+
def test_a_missing_header_fails_verification(missing):
87+
webhook = SeamWebhook(SECRET)
88+
headers = sign_headers(EVENT_PAYLOAD)
89+
del headers[missing]
90+
91+
with pytest.raises(SeamWebhookVerificationError, match="Missing required headers"):
92+
webhook.verify(EVENT_PAYLOAD, headers)
93+
94+
95+
def test_conflicting_duplicate_headers_fail_verification():
96+
webhook = SeamWebhook(SECRET)
97+
headers = sign_headers(EVENT_PAYLOAD)
98+
headers["SVIX-ID"] = "msg_other"
99+
100+
with pytest.raises(
101+
SeamWebhookVerificationError,
102+
match="Conflicting values for webhook header svix-id",
103+
):
104+
webhook.verify(EVENT_PAYLOAD, headers)
105+
106+
107+
def test_identical_duplicate_headers_are_accepted():
108+
webhook = SeamWebhook(SECRET)
109+
headers = sign_headers(EVENT_PAYLOAD)
110+
headers["SVIX-ID"] = headers["svix-id"]
111+
112+
event = webhook.verify(EVENT_PAYLOAD, headers)
113+
114+
assert event.event_type == "device.connected"
115+
116+
117+
def test_a_malformed_signature_header_fails_verification():
118+
webhook = SeamWebhook(SECRET)
119+
headers = sign_headers(EVENT_PAYLOAD)
120+
headers["svix-signature"] = "garbage-without-a-version"
121+
122+
with pytest.raises(SeamWebhookVerificationError):
123+
webhook.verify(EVENT_PAYLOAD, headers)
124+
125+
126+
def test_a_signed_but_unparseable_payload_is_not_forgery():
127+
webhook = SeamWebhook(SECRET)
128+
payload = '{"event_id": "trailing-comma",}'
129+
130+
with pytest.raises(
131+
SeamInvalidWebhookPayloadError,
132+
match="The verified webhook payload is not valid JSON",
133+
):
134+
webhook.verify(payload, sign_headers(payload))
135+
136+
137+
@pytest.mark.parametrize("payload", ["null", "[1]", "42", '"event"', "{}"])
138+
def test_a_signed_non_event_payload_is_not_forgery(payload):
139+
webhook = SeamWebhook(SECRET)
140+
141+
with pytest.raises(
142+
SeamInvalidWebhookPayloadError,
143+
match="The verified webhook payload did not contain a Seam event",
144+
):
145+
webhook.verify(payload, sign_headers(payload))
146+
147+
148+
def test_an_unknown_event_type_still_parses():
149+
webhook = SeamWebhook(SECRET)
150+
payload = json.dumps(
151+
{
152+
"event_id": "11111111-1111-1111-1111-111111111111",
153+
"event_type": "future.event_type",
154+
"future_field": {"nested": True},
155+
}
156+
)
157+
158+
event = webhook.verify(payload, sign_headers(payload))
159+
160+
assert event.event_type == "future.event_type"
161+
assert event.future_field.nested is True
162+
163+
164+
def test_webhook_errors_are_seam_errors():
165+
assert issubclass(SeamWebhookVerificationError, SeamError)
166+
assert issubclass(SeamInvalidWebhookPayloadError, SeamError)
167+
# Existing code catching the svix error keeps working.
168+
assert issubclass(SeamWebhookVerificationError, WebhookVerificationError)

0 commit comments

Comments
 (0)