feat(webhooks): VerifyAndParse* API for compressed payloads (CHA-3071)#210
Conversation
…HA-3071)
Stream now supports gzip compression on outbound webhooks (and the same
encoding wrappers apply to SQS / SNS firehose payloads, plus a base64
layer to keep them transport-safe). The existing string-based
VerifyWebhook helper is kept untouched for backward compatibility, but
it cannot operate on the raw byte stream and does not understand the
layered encodings.
Two new helpers on IAppClient cover the new contract:
- DecompressWebhookBody(body, contentEncoding, payloadEncoding) reverses
the encoding wrappers (base64 first, then gzip) and returns the raw
JSON bytes.
- VerifyAndDecodeWebhook(body, signature, contentEncoding,
payloadEncoding) does the same and additionally verifies the
HMAC-SHA256 signature against the decoded JSON, throwing
StreamWebhookSignatureException on mismatch.
The signature is always computed by the server over the innermost
(uncompressed, base64-decoded) JSON, so the same verification rule
applies to plain HTTP webhooks and SQS / SNS firehose. Comparison uses
CryptographicOperations.FixedTimeEquals on supported targets and a
constant-time fallback on .NET Framework / netstandard <= 2.0.
Unsupported encodings (br/zstd/deflate/... for Content-Encoding,
hex/url/binary for payload_encoding) throw InvalidOperationException
with a message pointing at the supported value, so misconfigured app
settings fail loud rather than silently.
Co-authored-by: Cursor <cursoragent@cursor.com>
Replace the experimental VerifyAndDecodeWebhook surface with the new
VerifyAndParse{Webhook,Sqs,Sns} family that mirrors the cross-SDK
contract:
- WebhookHelpers becomes public and exposes the primitive helpers
UngzipPayload, DecodeSqsPayload, DecodeSnsPayload, VerifySignature,
ParseEvent.
- VerifyAndParse* compose those primitives, transparently handle gzip
via magic-byte detection (and base64+gzip for SQS/SNS), and return a
typed EventResponse.
- VerifyWebhook is preserved for backwards compatibility and now uses
the same constant-time HMAC-SHA256 path.
- Tests cover plain / gzip / base64 / base64+gzip payloads, signature
mismatches, malformed bodies, and parsing into EventResponse.
Co-authored-by: Cursor <cursoragent@cursor.com>
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>
Surface the dual-API pattern at the level callers actually hold a reference to. IStreamClientFactory / StreamClientFactory now ship VerifyAndParseWebhook, VerifyAndParseSqs, and VerifyAndParseSns that delegate to the existing IAppClient methods using the factory's configured API secret. Mirrors the dual-API surface in stream-chat-go and matches the call shape documented at getstream.io/chat/docs/dotnet-csharp/webhooks_overview/. The static WebhookHelpers and the IAppClient instance methods remain unchanged for callers that still want to drill through GetAppClient() or use the stateless helpers in workers. Co-authored-by: Cursor <cursoragent@cursor.com>
The compressed-webhook section referenced a `VerifyAndDecodeWebhook` / `DecompressWebhookBody` surface with `contentEncoding` and `payloadEncoding` parameters that does not exist in the SDK. The shipped API is `VerifyAndParseWebhook` / `VerifyAndParseSqs` / `VerifyAndParseSns` on `IAppClient`, `IStreamClientFactory`, and the static `WebhookHelpers`, returning a parsed `EventResponse`. Update the prose and both code samples so copy-pasted snippets compile against the actual public API. Co-authored-by: Cursor <cursoragent@cursor.com>
The cross-SDK contract makes `WebhookHelpers` and `StreamWebhookSignatureException` part of the public surface, but every method on the helper class shipped without XML docs and the exception summary only mentioned signature mismatches even though it is also raised for malformed gzip / base64 / JSON envelopes. Add IntelliSense docs for each public helper (inputs, error cases, the constant-time guarantee on signature verification) and broaden the exception summary to match its actual throw sites. Co-authored-by: Cursor <cursoragent@cursor.com>
mogita
left a comment
There was a problem hiding this comment.
Cross-SDK review pass for CHA-3071. Three inline comments — see below.
DecodeSnsPayload 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.
Legacy AppClient.VerifyWebhook now delegates to WebhookHelpers.VerifySignature,
which uses CryptographicOperations.FixedTimeEquals for constant-time
comparison instead of naive string equality.
Tests add a realistic SNS HTTP notification body fixture and exercise
both the new envelope path and the existing pre-extracted Message path.
Co-authored-by: Cursor <cursoragent@cursor.com>
Drop the "in constant time" claim from VerifyAndParseSqs / VerifyAndParseSns XML docs and the .NET overview prose. The primitive WebhookHelpers.VerifySignature remains constant-time (used by all three composites and the legacy VerifyWebhook), but timing-attack resistance is only meaningful for the HTTP webhook X-Signature header that is exposed publicly. SQS / SNS deliveries arrive over AWS-internal transports where the attack vector does not apply. Co-authored-by: Cursor <cursoragent@cursor.com>
|
Cross-SDK coordination: unifying webhook exception types After the review pass across all 6 SDKs in this rollout and team discussion, we're consolidating the new webhook exception strategy to a single unified exception class rather than the split (signature vs parse exceptions) being introduced in this PR. The Webhook Handling Spec on Notion (CHA-2961) has been revised to reflect this — §5.2 / §5.3 / §7 now specify a single class. Why unified: From a customer's perspective, all failure modes — signature mismatch, gzip decompression failure, base64 decode failure, SNS envelope failure, JSON parse failure, missing schema field — terminate at the same Class name family: Per-SDK naming across the rollout:
Asks for this PR:
This same comment is being posted on all 6 SDK PRs (JS / Go / Ruby / PHP / Java / .NET) for coordination. Happy to discuss naming or scope tradeoffs. |
…n 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>
…ception (CHA-3071) Per cross-SDK coordination (mogita's review on all 6 sibling SDK PRs), every webhook failure path now terminates at a single exception class. Customers only need one catch arm and can filter on Message text for mode-specific behaviour. Renames the previously-unreleased StreamWebhookSignatureException to StreamInvalidWebhookException (still extends StreamBaseException) and threads it through every primitive: VerifyAndParseWebhook -> 'signature mismatch' GunzipPayload -> 'gzip decompression failed' DecodeSqsPayload -> 'invalid base64 encoding' ParseEvent -> 'invalid JSON payload' VerifySignature keeps its boolean return at the primitive layer; the composite VerifyAndParse* helpers throw on mismatch. The legacy AppClient.VerifyWebhook (bool return) is untouched. Co-authored-by: Cursor <cursoragent@cursor.com>
|
Shipped in
Docs side, docs-content#1276 now carries the per-SDK error-class table on the Webhooks overview page. |
…-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 nullable on both WebhookHelpers static methods and on the AppClient instance methods. WebhookHelpers.VerifyAndParseSqs(body) -> decode + parse WebhookHelpers.VerifyAndParseSqs(body, sig, secret) -> + verify appClient.VerifyAndParseSns(envelopeBody) -> unwrap + decode + parse Passing only one of (signature, secret) throws StreamInvalidWebhookException. The HTTP-webhook path is unchanged. Co-authored-by: Cursor <cursoragent@cursor.com>
…ndParseWebhook; docs + tests Co-authored-by: Cursor <cursoragent@cursor.com>
…ipPayload - Rename StreamInvalidWebhookException to InvalidWebhookError (still extends StreamBaseException) - Rename UngzipPayload to GunzipPayload - Constants and messages unchanged: SignatureMismatch / InvalidBase64 / GzipFailed / InvalidJson
Summary
Adds first-class support for gzip-compressed webhook payloads (HTTP webhooks, SQS, SNS) and exposes a stable
VerifyAndParse*API that mirrors the cross-SDK contract published in Webhooks Overview.New public API (
StreamChat.Clients.WebhookHelpers)WebhookHelpersis nowpublicso apps can compose the primitives directly:GunzipPayload(byte[])— gzip-magic-byte detection, no-op when not compressedDecodeSqsPayload(string)— base64 decode then gunzip-if-magicDecodeSnsPayload(notificationBody)— JSON-parse the SNS HTTP notification envelope, extract the innerMessage, then run the SQS pipeline. Falls through to a pre-extractedMessagestring when the input is not a JSON envelopeVerifySignature(byte[], string, string)— HMAC-SHA256 over the uncompressed body, with a constant-time comparison (matters for the HTTP webhook path where theX-Signatureheader is exposed publicly; SQS / SNS deliveries arrive over AWS-internal transports where timing-attack resistance is not strictly required)ParseEvent(byte[])— JSON → typedEventResponsevia Newtonsoft.JsonComposites on
IAppClient(return a typedEventResponse):VerifyAndParseWebhook(byte[], string)VerifyAndParseSqs(string, string)VerifyAndParseSns(string, string)Backwards compatibility
AppClient.VerifyWebhookis preserved and now uses the same constant-time HMAC-SHA256 path. The legacyVerifyAndDecodeWebhooksurface is removed (it was experimental and not yet released).Unified error handling
Per cross-SDK coordination (mogita's review across the 6 sibling SDK PRs), every webhook failure path now terminates at a single exception class —
StreamInvalidWebhookException(extendsStreamBaseException) so customers only need onecatcharm. The message text identifies which failure mode fired so callers that want to differentiate (security logging, retry policy) can filter on substring:signature mismatchinvalid base64 encodinggzip decompression failedinvalid JSON payloadpublic const stringfailure-mode constants on the class (StreamInvalidWebhookException.SignatureMismatchetc.) are available for exact-match filtering. The legacyAppClient.VerifyWebhook(bool return) is untouched.Tests
tests/WebhookCompressionTests.cscovers plain / gzip / base64 / base64+gzip payloads, signature mismatches, malformed bytes, and JSON parsing intoEventResponse. Linked Linear ticket: CHA-3071.Golden test fixtures (Tommaso)
Added shared reference fixtures to the test suite so future SDKs can sanity-check decoders against the same payloads:
Test plan
dotnet build— cleandotnet test --filter ~WebhookCompressionTests— 22 passed