Skip to content

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

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

[CHA-3071] feat: decode gzip-compressed webhook bodies#255
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

Three additions on App (all public static, no breaking changes):

  1. verifyWebhookSignature(apiSecret, byte[], signature)byte[] overload of the existing string version, with constant-time HMAC compare via MessageDigest.isEqual.
  2. decompressWebhookBody(byte[] body, String contentEncoding [, String payloadEncoding]) — primitive that undoes the encoding wrappers Stream applies. gzip for compression, base64 for the SQS / SNS transport wrapper. Anything else throws IllegalStateException with a clear message.
  3. verifyAndDecodeWebhook(byte[] body, String signature, String contentEncoding [, String payloadEncoding]) — convenience: decode + verify in one call. Throws SecurityException 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.

The existing App.verifyWebhook(body, signature) and App.verifyWebhookSignature(apiSecret, body, signature) (string forms) are byte-for-byte unchanged for backward compatibility.

Cross-SDK contract

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

  • Java: App.verifyAndDecodeWebhook(rawBody, signature, contentEncoding, payloadEncoding)
  • PHP: $client->verifyAndDecodeWebhook($body, $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

WebhookCompressionTest 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) is rejected with a message pointing operators back to gzip
  • Every non-base64 payload_encoding (hex, url, ascii85, binary) is rejected
  • Invalid gzip / invalid base64 input → clear IllegalStateException
  • byte[] overload of verifyWebhookSignature matches the string version
  • Happy paths for verifyAndDecodeWebhook (plain, gzip, base64+gzip)
  • Signature mismatch → SecurityException
  • Signature computed over compressed bytes → rejected
  • Signature computed over base64-wrapped bytes → rejected

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 Java 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. Mirrored across every SDK.
  • 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 dependencies — java.util.zip.GZIPInputStream and java.util.Base64 are JDK-builtin.

Verification

./gradlew :spotlessJavaApply :test --tests 'io.getstream.chat.java.WebhookCompressionTest'

All tests pass under amazoncorretto:11.

Adds App.decompressWebhookBody and App.verifyAndDecodeWebhook so
handlers can accept the new outbound webhook compression
(GetStream/chat#13222) without changing how X-Signature is verified.

decompressWebhookBody returns the body unchanged when Content-Encoding
is null or empty, gunzips with java.util.zip.GZIPInputStream when the
header is gzip (case-insensitive, trimmed), and throws
IllegalStateException for any other value with a message that points
the operator at the app's webhook_compression_algorithm setting.

verifyWebhookSignature gains a byte[] overload so the existing String
overload no longer round-trips through UTF-8 unnecessarily, and the
equality check moves to MessageDigest.isEqual so comparison is
constant-time.

verifyAndDecodeWebhook chains decompression with the HMAC check and
returns the raw JSON when the signature matches; SecurityException is
thrown otherwise. The signature is always computed over the
uncompressed bytes, matching the server.

The webhook docs are updated with the new Content-Encoding header row
and a worked example using verifyAndDecodeWebhook.

Tests cover gzip round-trip, null/empty/whitespace passthrough, case-
insensitive Content-Encoding, invalid gzip bytes, every non-gzip
encoding rejected with a clear hint, byte[] / String HMAC overload
parity, 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. The existing 3-argument
overloads of `decompressWebhookBody` and `verifyAndDecodeWebhook` are
preserved for backward compatibility.

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