Skip to content

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

Merged
nijeesh-stream merged 13 commits into
mainfrom
nijeeshjoshy/cha-3071-compress-webhook-payloads
May 13, 2026
Merged

feat(webhooks): verifyAndParse* API for compressed payloads (CHA-3071)#169
nijeesh-stream merged 13 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).

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 — InvalidWebhookException (extends StreamException) so customers only need one catch arm. The message text identifies which failure mode fired so callers that want to differentiate (security logging, retry policy) can filter on substring:

Failure mode Message
Signature mismatch signature mismatch
Base64 decode invalid base64 encoding
Gzip decompression gzip decompression failed
JSON parse invalid JSON payload

public const failure-mode messages on the class (InvalidWebhookException::SIGNATURE_MISMATCH etc.) are available for exact-match filtering. The legacy Client#verifyWebhook (bool return) is untouched.

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.

nijeesh-stream and others added 2 commits May 11, 2026 15:32
…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>
…n (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 by getMessage() text
for mode-specific behaviour.

Renames the previously-unreleased WebhookSignatureException to
InvalidWebhookException (still extends StreamException) and threads it
through every primitive:

  verifyAndParseWebhook -> 'signature mismatch'
  gunzipPayload         -> 'gzip decompression failed'
  decodeSqsPayload      -> 'invalid base64 encoding'
  parseEvent            -> 'invalid JSON payload'

The legacy Client#verifyWebhook helper (bool return) is untouched.
verifySignature stays bool-returning at the primitive layer; the
composite verifyAndParse* helpers throw on mismatch.

Co-authored-by: Cursor <cursoragent@cursor.com>
@nijeesh-stream
Copy link
Copy Markdown
Contributor Author

Shipped in 619e75c along with the SNS envelope fix.

  • New class \GetStream\StreamChat\InvalidWebhookException extending StreamException (the failure paths previously threw plain StreamException with ad-hoc messages — no WebhookSignatureException ever existed on this branch, so this is a clean introduction, not a rename)
  • public const SIGNATURE_MISMATCH / INVALID_BASE64 / GZIP_FAILED / INVALID_JSON on the class for exact-match filtering; JsonException chained via $previous
  • verifySignature keeps its primitive bool contract; composite verifyAndParse* helpers throw on mismatch
  • Legacy Client#verifyWebhook (bool return) is untouched
  • tests/unit/WebhookCompressionTest.php: 43 tests / 65 assertions (was 39) — every failure assertion migrated from StreamException + regex to InvalidWebhookException + exact expectExceptionMessage(InvalidWebhookException::*); added the three spec-named tests plus an inheritance invariant. Full unit suite green in Docker (php:8.3-cli + PHPUnit 9.6.22)

Docs side, docs-content#1276 now carries the per-SDK error-class table on the Webhooks overview page.

nijeesh-stream and others added 3 commits May 12, 2026 14:40
…-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 Webhook::* helpers
and on the Client instance methods.

  Webhook::verifyAndParseSqs(body)                -> decode + parse
  Webhook::verifyAndParseSqs(body, sig, secret)   -> + verify
  \$client->verifyAndParseSns(envelopeBody)       -> unwrap + decode + parse

Passing only one of (signature, secret) throws InvalidWebhookException.
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 InvalidWebhookException → InvalidWebhookError (extends StreamException)
- Actually throw InvalidWebhookError on every failure path (was raw StreamException)
- Align messages to canonical strings via class constants: SIGNATURE_MISMATCH /
  INVALID_BASE64 / GZIP_FAILED / INVALID_JSON
- parseEvent JsonException + non-array now wrapped as InvalidWebhookError(INVALID_JSON)
- Rename ungzipPayload → gunzipPayload everywhere
- declare(strict_types=1) in new files
@nijeesh-stream nijeesh-stream merged commit a342991 into main May 13, 2026
4 checks passed
@nijeesh-stream nijeesh-stream deleted the nijeeshjoshy/cha-3071-compress-webhook-payloads branch May 13, 2026 10:20
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.

3 participants