feat(webhooks): verifyAndParse* API for compressed payloads (CHA-3071)#169
feat(webhooks): verifyAndParse* API for compressed payloads (CHA-3071)#169nijeesh-stream wants to merge 9 commits into
Conversation
Adds Client::decompressWebhookBody and Client::verifyAndDecodeWebhook so handlers can accept the new outbound webhook compression (GetStream/chat#13222) without changing how X-Signature is verified. decompressWebhookBody runs gzdecode when the Content-Encoding header is gzip, returns the body unchanged when the header is null or empty, and throws StreamException for any other value with a message that points the operator at the app's webhook_compression_algorithm setting. verifyAndDecodeWebhook chains decompression with the existing HMAC check and returns the raw JSON when the signature matches. The signature is always computed over the uncompressed bytes, matching the server. verifyWebhook switches to hash_equals so the comparison is constant-time. Tests cover gzip round-trip, null/empty/whitespace passthrough, case- insensitive Content-Encoding, invalid gzip bytes, every non-gzip encoding being rejected with a clear message, signature mismatch, and the regression case where the signature was computed over the compressed bytes. Co-authored-by: Cursor <cursoragent@cursor.com>
Extends `decompressWebhookBody` and `verifyAndDecodeWebhook` with an optional `$payloadEncoding` argument. When set to "base64" (the wrapper Stream applies for SQS / SNS firehose so the message stays valid UTF-8 over the queue), the body is base64-decoded before gzip decompression. The HMAC signature continues to be computed over the innermost (uncompressed, base64-decoded) JSON, so the verification rule is invariant across HTTP webhooks and SQS / SNS. `null` / `""` for payloadEncoding is a no-op, so the HTTP webhook path is byte-identical to before this change. Default value of `null` preserves backward compatibility with the previous 3-argument call. Co-authored-by: Cursor <cursoragent@cursor.com>
Replaces verifyAndDecodeWebhook / decompressWebhookBody with the cross-SDK contract documented at https://getstream.io/chat/docs/node/webhooks_overview/. Helpers on Client: Static primitives: Client::ungzipPayload - gzip magic-byte detection + inflate Client::decodeSqsPayload - base64 then ungzip-if-magic Client::decodeSnsPayload - alias for decodeSqsPayload Client::verifySignature - constant-time HMAC-SHA256 comparison (parameter order matches the cross-SDK spec: body, signature, secret) Client::parseEvent - JSON -> array (typed event lands later) Instance composite (return parsed event array): \$client->verifyAndParseWebhook(\$body, \$signature) \$client->verifyAndParseSqs(\$messageBody, \$signature) \$client->verifyAndParseSns(\$message, \$signature) 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. The legacy \$client->verifyWebhook(\$body, \$signature) bool helper is kept for backward compatibility (now delegates to verifySignature). 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>
Mirrors the Ruby StreamChat::Webhook module / Java App.* / .NET WebhookHelpers shape: `Webhook::verifyAndParseWebhook($body, $signature, $secret)` (and the SQS / SNS variants) are now available as static methods with an explicit `secret` argument, alongside the primitives ungzipPayload, decodeSqsPayload, decodeSnsPayload, verifySignature, parseEvent. `Client::verifyAndParseWebhook` (and the SQS / SNS variants) still work as 2-arg instance methods that pull the secret from the configured client; they now delegate to the new static helpers so the two surfaces stay in lockstep. Tests cover the new static class, the parity between the two surfaces, and the existing regression cases. Co-authored-by: Cursor <cursoragent@cursor.com>
Replace the references to phantom helpers (verifyAndDecodeWebhook, decompressWebhookBody, $contentEncoding / $payloadEncoding arguments) with the API actually exposed on the Client and Webhook classes: verifyAndParseWebhook, verifyAndParseSqs, verifyAndParseSns and the underlying ungzipPayload / verifySignature / parseEvent primitives. Co-authored-by: Cursor <cursoragent@cursor.com>
…CHA-3071) The cross-SDK contract puts the static helpers on the Webhook class (`Webhook::verifyAndParseWebhook(body, signature, secret)`); other SDKs in the org follow the same shape. Move the actual implementations of ungzipPayload, decodeSqsPayload, decodeSnsPayload, verifySignature, parseEvent, and the verifyAndParse* composites onto Webhook, and reduce the Client static counterparts to one-line delegators kept for backward compatibility. Behaviour is unchanged; the existing test suite (covering both Client::* and Webhook::*) still passes. 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. Two 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.
Test adds a realistic SNS HTTP notification body fixture and exercises
both the new envelope path and the existing pre-extracted Message path.
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>
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 (
GetStream\\StreamChat\\Client)Static primitives:
gunzipPayload(string): string— gzip-magic-byte detection, no-op when not compresseddecodeSqsPayload(string): string— base64 decode then gunzip-if-magicdecodeSnsPayload(notificationBody): string— 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(string, string, string): bool— 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(string): array— JSON → associative arrayInstance composites (return
array):verifyAndParseWebhook(string \$body, string \$signature): arrayverifyAndParseSqs(string \$body, string \$signature): arrayverifyAndParseSns(string \$body, string \$signature): arrayBackwards compatibility
\$client->verifyWebhook(\$body, \$signature)is preserved and now delegates toClient::verifySignature. The experimentaldecompressWebhookBodyandverifyAndDecodeWebhooksurfaces are removed (they were never released).Tests
tests/unit/WebhookCompressionTest.phpcovers plain / gzip / base64 / base64+gzip payloads, signature mismatches, malformed bytes, and JSON parsing into an associative array. 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
php-cs-fixer fix— clean