Skip to content

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

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

feat(webhooks): verifyAndParse* API for compressed payloads (CHA-3071)#169
nijeesh-stream wants to merge 5 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 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:

  • ungzipPayload(string): string — gzip-magic-byte detection, no-op when not compressed
  • decodeSqsPayload(string): string / decodeSnsPayload(string): string — base64 decode then ungzip-if-magic
  • verifySignature(string, string, string): bool — constant-time HMAC-SHA256 over the uncompressed body
  • 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.

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