Commit 2872cb4
feat(webhooks): verify_and_parse_* API for compressed payloads (CHA-3071) (#230)
* feat(webhooks): add verify_and_decode_webhook for compressed payloads (CHA-3071)
Stream Chat backend can now compress outbound webhook payloads with gzip
and, for SQS / SNS firehose delivery, base64-wrap the compressed bytes so
they remain valid UTF-8 over the queue. Add two new client methods that
let customers decompress + verify in a single call:
- decompress_webhook_body(body, content_encoding=None, payload_encoding=None)
primitive decode that handles gzip and/or base64
- verify_and_decode_webhook(body, x_signature, content_encoding=None,
payload_encoding=None) decode + HMAC-SHA256 verify
Both are exposed on the sync StreamChat and async StreamChatAsync clients
through the shared StreamChatInterface base, mirroring the existing
verify_webhook helper. The existing verify_webhook signature and behavior
are unchanged for backward compatibility.
A new WebhookSignatureError (extends StreamAPIException) is raised on
signature mismatch, malformed gzip, or malformed base64. Unsupported
encoding values raise ValueError with a message that points at the
supported algorithm (gzip).
The decoding logic lives in stream_chat/webhook.py so it can be tested
without instantiating an HTTP client. The new tests cover the cross-SDK
contract: passthrough, gzip round-trip, base64 round-trip, base64 + gzip
(SQS / SNS shape), case-insensitive aliases, every unsupported
content_encoding (br / brotli / zstd / deflate / compress / lz4),
unsupported payload_encoding (hex / url / binary), invalid gzip / base64
input, and three signature-mismatch variants (wrong signature, signature
over compressed bytes, signature over wrapped bytes).
Docs: webhooks_overview.md gets a "Compressed webhook bodies" section
with Django, Flask, and SQS / SNS usage examples.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(tests): satisfy isort import-block rule
Co-authored-by: Cursor <cursoragent@cursor.com>
* refactor(webhooks): switch to verify_and_parse_* API (CHA-3071)
Replaces the earlier verify_and_decode_webhook surface with the
cross-SDK contract documented at
https://getstream.io/chat/docs/node/webhooks_overview/.
Module-level helpers in stream_chat.webhook:
Primitives:
ungzip_payload - gzip magic-byte detection + inflate
decode_sqs_payload - base64 then ungzip-if-magic
decode_sns_payload - alias for decode_sqs_payload
verify_signature - constant-time HMAC-SHA256 comparison
parse_event - JSON -> dict (typed event lands later)
Composite (return parsed event dict):
verify_and_parse_webhook
verify_and_parse_sqs
verify_and_parse_sns
The composite functions auto-detect compression from body bytes, so
the same handler stays correct whether or not Stream is currently
compressing payloads, and behind middleware that auto-decompresses.
Client instance methods (StreamChat / StreamChatAsync) mirror the
three composite helpers with api_secret pulled from the client.
The legacy verify_webhook(body, x_signature) -> bool boolean helper
is unchanged for backward compatibility.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(webhooks): drop redundant binascii.Error in except (B014)
binascii.Error is a subclass of ValueError, so listing both in the
except clause triggers flake8-bugbear B014. Catching ValueError alone
covers both cases.
Co-authored-by: Cursor <cursoragent@cursor.com>
* refactor(webhooks): use 2-byte gzip magic per RFC 1952 (CHA-3071)
RFC 1952 defines the gzip magic number as the two-byte sequence
1F 8B; the third byte (CM) is informational and not part of the
identifier. Trim the magic check from three bytes to two to match
the spec and stay consistent with the reference implementations
in the public docs.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(webhooks): make verify_signature robust against malformed signatures
Previously, passing a signature with non-ASCII bytes (e.g. b"\xff..."),
a non-ASCII unicode string, or a non-string type would raise
UnicodeDecodeError / TypeError from inside verify_signature, leaking
through verify_and_parse_webhook / _sqs / _sns and breaking the
documented contract that says malformed inputs must surface as
WebhookSignatureError.
The boolean primitive now returns False for those inputs (an
invalid-format signature can by definition never match), so the
composite helpers raise WebhookSignatureError("invalid webhook
signature") as expected. The constant-time HMAC comparison path is
unchanged for well-formed inputs.
Adds regression tests for non-ASCII bytes, non-ASCII str, and
non-string signature inputs at both the primitive and composite
layers.
Co-authored-by: Cursor <cursoragent@cursor.com>
* docs(webhooks): align compression section with the shipped API
The previous draft referenced helpers that were renamed during the
refactor to the verify_and_parse_* contract (CHA-3071):
- client.verify_and_decode_webhook(...) -> verify_and_parse_webhook
- decompress_webhook_body(...) -> removed (no public form)
- content_encoding / payload_encoding -> removed (magic-byte detect)
Following the old snippets would hit AttributeError immediately. The
section is rewritten to document the real surface area:
- client.verify_and_parse_webhook(body, signature)
- client.verify_and_parse_sqs(message_body, signature)
- client.verify_and_parse_sns(message, signature)
- module-level webhook.verify_and_parse_* helpers for stateless use
- WebhookSignatureError as the single error class
It also clarifies the return type (parsed dict, not raw bytes) and
notes that the legacy verify_webhook bool helper stays unchanged.
Co-authored-by: Cursor <cursoragent@cursor.com>
* 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>
* style(webhooks): apply black formatting to SNS envelope test additions
Co-authored-by: Cursor <cursoragent@cursor.com>
* refactor(webhooks): rename ungzip_payload to gunzip_payload + add golden fixtures (CHA-3071)
Per Tommaso's suggestion, align the gzip helper with the GNU `gunzip`
command name. The function was added in this PR and not yet released,
so this is a straight rename with no back-compat alias.
Adds Tommaso's reference fixtures to the test suite as named cases so
future SDKs can sanity-check against the same payloads:
aGVsbG93b3JsZA== -> helloworld (base64)
H4sIAGrYAWoAA8tIzcnJL88vykkBAK0g6/kKAAAA -> helloworld (base64+gzip)
Co-authored-by: Cursor <cursoragent@cursor.com>
* refactor(webhooks): unify webhook errors under InvalidWebhookError (CHA-3071)
Per cross-SDK coordination (mogita's review on the 6 sibling SDK PRs),
every webhook failure path now terminates at a single exception class.
Customers only need one except arm and can filter by message text for
mode-specific behaviour (signature mismatch vs invalid base64 etc.).
Renames the previously-unreleased WebhookSignatureError to
InvalidWebhookError and threads it through every primitive:
verify_signature -> 'signature mismatch'
gunzip_payload -> 'gzip decompression failed'
decode_sqs_payload -> 'invalid base64 encoding'
parse_event -> 'invalid JSON payload'
StreamChat#verify_webhook (the legacy bool helper) is untouched. The
message constants are exported so callers can exact-match if they
prefer that over substring matching.
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(webhooks): make signature optional on verify_and_parse_sqs/sns (CHA-3071)
Stream does not ship an X-Signature on SQS or SNS deliveries — those
transports ride AWS-internal infrastructure (IAM-authenticated queues
and AWS-signed SNS notifications), so HMAC verification on top is
theatre. signature + secret are now optional on both module helpers
and on the StreamChat / StreamChatAsync instance methods.
- verify_and_parse_sqs(body) -> decode + parse
- verify_and_parse_sqs(body, sig, secret) -> decode + verify + parse
- verify_and_parse_sns(envelope_body) -> unwrap + decode + parse
- verify_and_parse_sns(envelope_body, sig, secret) -> + verify
Passing only one of (signature, secret) raises InvalidWebhookError.
The HTTP-webhook path (verify_and_parse_webhook) is unchanged.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(webhooks): parseSqs/ParseSns decode-only; HTTP verify via verifyAndParseWebhook; docs + tests
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(webhooks): Go ErrInvalidWebhook + VerifySignature(error); Ruby WebhookSignatureError + parse_*; Python WebhookSignatureError; guard test init without STREAM_*
* feat(webhooks): align cross-SDK contract — InvalidWebhookError + gunzip_payload
- Rename WebhookSignatureError → InvalidWebhookError
- Rename ungzip_payload → gunzip_payload
- Align error messages to documented strings: signature mismatch /
invalid base64 encoding / gzip decompression failed / invalid JSON payload
- parse_event now wraps json.JSONDecodeError as InvalidWebhookError
- Export INVALID_WEBHOOK_* constants for exact-match filtering
---------
Co-authored-by: Cursor <cursoragent@cursor.com>1 parent 7640db7 commit 2872cb4
5 files changed
Lines changed: 656 additions & 0 deletions
File tree
- docs/webhooks/webhooks_overview
- stream_chat
- base
- tests
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
90 | 90 | | |
91 | 91 | | |
92 | 92 | | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
| 182 | + | |
93 | 183 | | |
94 | 184 | | |
95 | 185 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
133 | 133 | | |
134 | 134 | | |
135 | 135 | | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
136 | 171 | | |
137 | 172 | | |
138 | 173 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
6 | 6 | | |
7 | 7 | | |
8 | 8 | | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
9 | 26 | | |
10 | 27 | | |
11 | 28 | | |
| |||
0 commit comments