Skip to content

feat(webhooks): verifyAndParse* API for compressed payloads (CHA-3071)#169

Open
nijeesh-stream wants to merge 9 commits into
mainfrom
nijeeshjoshy/cha-3071-compress-webhook-payloads
Open

feat(webhooks): verifyAndParse* API for compressed payloads (CHA-3071)#169
nijeesh-stream wants to merge 9 commits into
mainfrom
nijeeshjoshy/cha-3071-compress-webhook-payloads

Conversation

@nijeesh-stream
Copy link
Copy Markdown
Contributor

@nijeesh-stream nijeesh-stream commented May 6, 2026

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 compressed
  • decodeSqsPayload(string): string — base64 decode then gunzip-if-magic
  • decodeSnsPayload(notificationBody): string — JSON-parse the SNS HTTP notification envelope, extract the inner Message, then run the SQS pipeline. Falls through to a pre-extracted Message string when the input is not a JSON envelope
  • verifySignature(string, string, string): bool — HMAC-SHA256 over the uncompressed body, with a constant-time comparison (matters for the HTTP webhook path where the X-Signature header is exposed publicly; SQS / SNS deliveries arrive over AWS-internal transports where timing-attack resistance is not strictly required)
  • parseEvent(string): array — JSON → associative array

Instance composites (return array):

  • verifyAndParseWebhook(string \$body, string \$signature): array
  • verifyAndParseSqs(string \$body, string \$signature): array
  • verifyAndParseSns(string \$body, string \$signature): array

Typed Event objects will land in PHP in a follow-up release. Until then the helpers return the parsed JSON as an associative array.

Backwards compatibility

\$client->verifyWebhook(\$body, \$signature) is preserved and now delegates to Client::verifySignature. The experimental decompressWebhookBody and verifyAndDecodeWebhook surfaces are removed (they were never released).

Tests

tests/unit/WebhookCompressionTest.php covers 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:

aGVsbG93b3JsZA==                          -> helloworld   (base64)
H4sIAGrYAWoAA8tIzcnJL88vykkBAK0g6/kKAAAA -> helloworld   (base64 + gzip)

Test plan

  • PHPUnit (php 8.2 in Docker): 25 passed / 35 assertions
  • php-cs-fixer fix — clean

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>
nijeesh-stream and others added 2 commits May 7, 2026 12:33
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>
@nijeesh-stream nijeesh-stream changed the title [CHA-3071] feat: decode gzip-compressed webhook bodies feat(webhooks): verifyAndParse* API for compressed payloads (CHA-3071) May 8, 2026
nijeesh-stream and others added 4 commits May 8, 2026 16:53
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>
Copy link
Copy Markdown
Contributor

@mogita mogita left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cross-SDK review pass for CHA-3071. Two inline comments — see below.

Comment thread lib/GetStream/StreamChat/Webhook.php Outdated
Comment thread tests/unit/WebhookCompressionTest.php Outdated
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>
@mogita
Copy link
Copy Markdown
Contributor

mogita commented May 11, 2026

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 catch block in customer code. A signature/parse split adds structural complexity without changing customer behavior. Customers who want to filter security logs for signature mismatches specifically can do so via exception message text or Throwable::getPrevious() cause-chain.

Class name family: InvalidWebhookException — "Invalid" covers all failure modes accurately, consistent with stdlib naming patterns (InvalidArgumentException, etc.).

Per-SDK naming across the rollout:

SDK Class name
JS InvalidWebhookError (extends Error)
Python InvalidWebhookError
Go sentinel ErrInvalidWebhook + struct InvalidWebhookError
Java InvalidWebhookException (extends existing StreamException)
PHP InvalidWebhookException (extends existing StreamException)
Ruby StreamChat::InvalidWebhookError (extends StandardError)
.NET StreamInvalidWebhookException (extends StreamBaseException)

Asks for this PR:

  1. Introduce GetStream\StreamChat\InvalidWebhookException extending existing StreamException, replacing any per-mode exception classes introduced in this PR
  2. Wrap all failure paths into this single type — signature mismatch, gzip failure, base64 failure, SNS envelope failure, JSON parse failure, missing type/schema failure
  3. Attach a human-readable message identifying which failure mode fired (e.g. "signature mismatch", "invalid base64", "missing type field") so customers can filter on message content
  4. Legacy Client::verifyWebhook (returning bool) stays unchanged — back-compat preserved. The vestigial @throws StreamException PHPDoc on the legacy method should be removed since its body cannot throw.
  5. Update PHPUnit tests to assert against the new exception name; for mode-specific tests, also assert on message-content substrings

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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants