Skip to content

Commit 60775a5

Browse files
fix(webhooks): unwrap SNS notification envelope in decode_sns_payload
decode_sns_payload now JSON-parses the SNS HTTP notification envelope ({"Type":"Notification","Message":"..."}) and extracts the inner Message field before running the SQS pipeline. Falls through to the pre-extracted Message string when the input is not a JSON envelope so existing call sites keep working. Test adds a realistic SNS HTTP notification body fixture and exercises both the new envelope path and the existing pre-extracted Message path. Docs updated to show the typical "pass the raw HTTP body" call site. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 03849df commit 60775a5

3 files changed

Lines changed: 94 additions & 17 deletions

File tree

docs/webhooks/webhooks_overview/webhooks_overview.md

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -141,17 +141,27 @@ The original `client.verify_webhook(request.body, request.headers["X-Signature"]
141141

142142
#### SQS / SNS firehose
143143

144-
For events delivered through SQS or SNS, call the matching helper on the message body. It base64-decodes the envelope, gzip-decompresses when the magic bytes are present, verifies the HMAC, and returns the parsed event.
144+
For events delivered through SQS or SNS, call the matching helper. It base64-decodes the envelope, gzip-decompresses when the magic bytes are present, verifies the HMAC, and returns the parsed event.
145+
146+
For SQS, pass the message `Body` (already the payload):
145147

146148
```python
147149
event = client.verify_and_parse_sqs(
148150
sqs_message["Body"],
149151
sqs_message["MessageAttributes"]["X-Signature"]["StringValue"],
150152
)
153+
```
154+
155+
For SNS, pass the **raw notification body** (the full `{"Type":"Notification", ...}` JSON envelope Amazon delivers). The SDK extracts the inner `Message` field for you, so the call site mirrors what HTTP frameworks already hand you in `request.body`:
156+
157+
```python
158+
import json
151159

160+
# Django SNS HTTP delivery
161+
attrs = json.loads(request.body)["MessageAttributes"]
152162
event = client.verify_and_parse_sns(
153-
sns_notification["Message"],
154-
sns_notification["MessageAttributes"]["X-Signature"]["Value"],
163+
request.body, # raw envelope (bytes/str)
164+
attrs["X-Signature"]["Value"],
155165
)
156166
```
157167

@@ -164,7 +174,7 @@ from stream_chat import webhook
164174

165175
event = webhook.verify_and_parse_webhook(body, signature, secret)
166176
event = webhook.verify_and_parse_sqs(message_body, signature, secret)
167-
event = webhook.verify_and_parse_sns(message, signature, secret)
177+
event = webhook.verify_and_parse_sns(notification_body, signature, secret)
168178
```
169179

170180
The module also exposes the primitives the composites are built from — `ungzip_payload`, `decode_sqs_payload`, `decode_sns_payload`, `verify_signature` (constant-time HMAC-SHA256), and `parse_event` — for callers that need to run the steps individually.

stream_chat/tests/test_webhook_compression.py

Lines changed: 52 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -115,14 +115,40 @@ def test_invalid_base64_raises(self):
115115
assert "base64" in str(exc_info.value).lower()
116116

117117

118+
def _sns_envelope(inner_message: str) -> str:
119+
return json.dumps(
120+
{
121+
"Type": "Notification",
122+
"MessageId": "22b80b92-fdea-4c2c-8f9d-bdfb0c7bf324",
123+
"TopicArn": "arn:aws:sns:us-east-1:123456789012:stream-webhooks",
124+
"Message": inner_message,
125+
"Timestamp": "2026-05-11T10:00:00.000Z",
126+
"SignatureVersion": "1",
127+
"MessageAttributes": {
128+
"X-Signature": {"Type": "String", "Value": "<signature placeholder>"},
129+
},
130+
}
131+
)
132+
133+
118134
class TestDecodeSnsPayload:
119-
def test_aliases_decode_sqs_payload(self):
135+
def test_pre_extracted_message_matches_decode_sqs_payload(self):
120136
wrapped = _b64(_gzip(JSON_BODY))
121137
assert decode_sns_payload(wrapped) == decode_sqs_payload(wrapped)
122138

123-
def test_round_trip(self):
139+
def test_pre_extracted_message_round_trip(self):
124140
assert decode_sns_payload(_b64(_gzip(JSON_BODY))) == JSON_BODY
125141

142+
def test_unwraps_full_sns_envelope(self):
143+
wrapped = _b64(_gzip(JSON_BODY))
144+
envelope = _sns_envelope(wrapped)
145+
assert decode_sns_payload(envelope) == JSON_BODY
146+
147+
def test_handles_envelope_with_leading_whitespace(self):
148+
wrapped = _b64(_gzip(JSON_BODY))
149+
envelope = "\n " + _sns_envelope(wrapped)
150+
assert decode_sns_payload(envelope) == JSON_BODY
151+
126152

127153
class TestVerifySignature:
128154
def test_matching(self):
@@ -181,7 +207,9 @@ def test_plain_body(self):
181207

182208
def test_gzip_body(self):
183209
sig = _sign(JSON_BODY)
184-
assert verify_and_parse_webhook(_gzip(JSON_BODY), sig, API_SECRET) == EVENT_DICT
210+
assert (
211+
verify_and_parse_webhook(_gzip(JSON_BODY), sig, API_SECRET) == EVENT_DICT
212+
)
185213

186214
def test_returns_dict(self):
187215
sig = _sign(JSON_BODY)
@@ -239,23 +267,38 @@ def test_signature_over_compressed_or_wrapped_bytes_rejected(self):
239267

240268

241269
class TestVerifyAndParseSns:
242-
def test_round_trip(self):
270+
def test_pre_extracted_message_round_trip(self):
243271
wrapped = _b64(_gzip(JSON_BODY))
244272
sig = _sign(JSON_BODY)
245273
assert verify_and_parse_sns(wrapped, sig, API_SECRET) == EVENT_DICT
246274

247-
def test_matches_sqs_behaviour(self):
275+
def test_matches_sqs_behaviour_for_pre_extracted_message(self):
248276
wrapped = _b64(_gzip(JSON_BODY))
249277
sig = _sign(JSON_BODY)
250278
assert verify_and_parse_sns(wrapped, sig, API_SECRET) == verify_and_parse_sqs(
251279
wrapped, sig, API_SECRET
252280
)
253281

282+
def test_full_sns_envelope(self):
283+
wrapped = _b64(_gzip(JSON_BODY))
284+
envelope = _sns_envelope(wrapped)
285+
sig = _sign(JSON_BODY)
286+
assert verify_and_parse_sns(envelope, sig, API_SECRET) == EVENT_DICT
287+
288+
def test_rejects_signature_over_envelope(self):
289+
wrapped = _b64(_gzip(JSON_BODY))
290+
envelope = _sns_envelope(wrapped)
291+
sig_over_envelope = _sign(envelope.encode("utf-8"))
292+
with pytest.raises(WebhookSignatureError):
293+
verify_and_parse_sns(envelope, sig_over_envelope, API_SECRET)
294+
254295

255296
class TestSyncClientMethods:
256297
def test_verify_and_parse_webhook(self, sync_client: StreamChat):
257298
sig = _sign(JSON_BODY)
258-
assert sync_client.verify_and_parse_webhook(_gzip(JSON_BODY), sig) == EVENT_DICT
299+
assert (
300+
sync_client.verify_and_parse_webhook(_gzip(JSON_BODY), sig) == EVENT_DICT
301+
)
259302

260303
def test_verify_and_parse_sqs(self, sync_client: StreamChat):
261304
wrapped = _b64(_gzip(JSON_BODY))
@@ -286,7 +329,9 @@ class TestAsyncClientMethods:
286329
async def test_verify_and_parse_webhook(self):
287330
sig = _sign(JSON_BODY)
288331
async with StreamChatAsync(api_key=API_KEY, api_secret=API_SECRET) as client:
289-
assert client.verify_and_parse_webhook(_gzip(JSON_BODY), sig) == EVENT_DICT
332+
assert (
333+
client.verify_and_parse_webhook(_gzip(JSON_BODY), sig) == EVENT_DICT
334+
)
290335

291336
async def test_verify_and_parse_sqs(self):
292337
wrapped = _b64(_gzip(JSON_BODY))

stream_chat/webhook.py

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
import hashlib
2020
import hmac
2121
import json
22-
from typing import Any, Dict, Union
22+
from typing import Any, Dict, Optional, Union
2323

2424
from stream_chat.base.exceptions import WebhookSignatureError
2525

@@ -70,11 +70,33 @@ def decode_sqs_payload(body: _BytesLike) -> bytes:
7070
return ungzip_payload(decoded)
7171

7272

73-
def decode_sns_payload(message: _BytesLike) -> bytes:
74-
"""Reverse the SNS firehose envelope. Byte-for-byte identical to
75-
:func:`decode_sqs_payload`; exposed under both names so call sites
76-
read intent."""
77-
return decode_sqs_payload(message)
73+
def decode_sns_payload(notification_body: _BytesLike) -> bytes:
74+
"""Reverse an SNS HTTP notification envelope.
75+
76+
When ``notification_body`` is a JSON envelope
77+
(``{"Type":"Notification","Message":"..."}``), the inner
78+
``Message`` field is extracted and run through
79+
:func:`decode_sqs_payload` (base64-decode, then gzip-if-magic). When
80+
the input is not a JSON envelope it is treated as the already-extracted
81+
``Message`` string, so call sites that pre-unwrap continue to work.
82+
"""
83+
raw = _to_bytes(notification_body)
84+
inner = _extract_sns_message(raw)
85+
return decode_sqs_payload(inner if inner is not None else raw)
86+
87+
88+
def _extract_sns_message(notification_body: bytes) -> Optional[str]:
89+
trimmed = notification_body.lstrip()
90+
if not trimmed or trimmed[:1] != b"{":
91+
return None
92+
try:
93+
envelope = json.loads(trimmed)
94+
except (json.JSONDecodeError, ValueError):
95+
return None
96+
if not isinstance(envelope, dict):
97+
return None
98+
message = envelope.get("Message")
99+
return message if isinstance(message, str) else None
78100

79101

80102
def verify_signature(

0 commit comments

Comments
 (0)