Skip to content

[CHA-3071] feat: decode gzip-compressed webhook bodies#169

Open
nijeesh-stream wants to merge 2 commits intomainfrom
nijeeshjoshy/cha-3071-compress-webhook-payloads
Open

[CHA-3071] feat: decode gzip-compressed webhook bodies#169
nijeesh-stream wants to merge 2 commits intomainfrom
nijeeshjoshy/cha-3071-compress-webhook-payloads

Conversation

@nijeesh-stream
Copy link
Copy Markdown
Contributor

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

Summary

Adds SDK-side support for the gzip webhook compression that the Stream chat backend can now apply to outbound webhook payloads. Linear: CHA-3071.

The same helper covers HTTP webhooks and SQS / SNS firehose payloads, so customers get one entry point regardless of transport.

What's new

Two new public methods on Client:

  1. decompressWebhookBody($body, ?$contentEncoding, ?$payloadEncoding = null): string — primitive that undoes the encoding wrappers Stream applies. gzip for compression, base64 for the SQS / SNS transport wrapper. Anything else throws StreamException with a clear message.
  2. verifyAndDecodeWebhook($body, $signature, ?$contentEncoding, ?$payloadEncoding = null): string — convenience: decode + verify in one call. Throws StreamException("invalid webhook signature") on signature mismatch. The HMAC is always validated over the innermost (uncompressed, base64-decoded) JSON, so the verification rule is invariant across HTTP and SQS / SNS.

verifyWebhook($requestBody, $XSignature) is unchanged on its public signature; internally it now uses hash_equals for constant-time comparison.

Cross-SDK contract

Same surface is shipping in every SDK so customers see a uniform API:

  • PHP: $client->verifyAndDecodeWebhook($body, $signature, $contentEncoding, $payloadEncoding)
  • Java: App.verifyAndDecodeWebhook(rawBody, signature, contentEncoding, payloadEncoding)
  • Go: client.VerifyAndDecodeWebhook(body, signature, contentEncoding, payloadEncoding)
  • Python / Ruby / .NET / JS: equivalent.

$payloadEncoding is null for HTTP webhooks today; it's the slot the SQS / SNS firehose path uses to flag a base64 wrapper.

Tests

tests/unit/WebhookCompressionTest.php covers:

  • gzip round-trip + case-insensitive Content-Encoding
  • base64 + gzip round-trip (SQS / SNS shape)
  • base64-only round-trip
  • Every non-gzip Content-Encoding (br, brotli, zstd, deflate, compress, lz4) rejected
  • Every non-base64 payload_encoding (hex, url, binary) rejected
  • Invalid gzip / invalid base64 input → StreamException
  • verifyWebhook constant-time compare
  • Happy paths for verifyAndDecodeWebhook (plain, gzip, base64+gzip)
  • Signature mismatch → StreamException
  • Signature computed over compressed bytes → rejected
  • Signature computed over base64-wrapped bytes → rejected

24 tests / 44 assertions, all green.

Docs

Updates docs/webhooks/webhooks_overview/webhooks_overview.md with the public-facing copy from the Linear ticket, a Content-Encoding row in the headers table, and PHP usage examples for both HTTP webhooks and SQS / SNS.

Notes for review

  • gzip is the only compression algorithm we support today, despite the server's validation tag.
  • HMAC is always computed over the innermost JSON. The server PR (GetStream/chat#13222) signs before compressing and (when SQS/SNS lands) before base64-wrapping, so this rule is consistent across transports.
  • No new Composer dependencies — gzdecode, base64_decode, hash_hmac, hash_equals are all built-in.

Verification

vendor/bin/phpunit --testsuite "Unit Test Suite" --filter WebhookCompressionTest
vendor/bin/php-cs-fixer fix --dry-run --diff

All green.

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>
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.

1 participant