Skip to content

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

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

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

Conversation

@nijeesh-stream
Copy link
Copy Markdown
Contributor

@nijeesh-stream nijeesh-stream commented May 7, 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 (src/signing.ts)

Primitives:

  • gunzipPayload(body) — gzip-magic-byte detection, no-op when not compressed
  • decodeSqsPayload(body) — strict base64 decode then gunzip-if-magic
  • decodeSnsPayload(notificationBody) — 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(body, signature, secret) — 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(payload) — JSON → typed Event<…>

Composites (return a typed Event<…>):

  • verifyAndParseWebhook(body, signature, secret)
  • verifyAndParseSqs(body, signature, secret)
  • verifyAndParseSns(body, signature, secret)

StreamChat#verifyAndParseWebhook / verifyAndParseSqs / verifyAndParseSns use the configured client secret automatically.

Backwards compatibility

  • StreamChat#verifyWebhook is preserved and now delegates to verifySignature (same constant-time HMAC-SHA256).
  • CheckSignature is deprecated and aliases verifySignature.
  • A new WebhookSignatureError is thrown by composites on signature mismatch.

Tests

test/unit/webhook-compression.test.ts covers plain / gzip / base64 / base64+gzip payloads, signature mismatches, malformed bytes, and JSON parsing into Event<…>. 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

  • yarn vitest run test/unit/webhook-compression.test.ts — 35 passed
  • yarn lint — clean on touched files
  • yarn prettier:check — clean

…HA-3071)

Adds two new helpers on `StreamChat` so customers can decode + verify
gzip-compressed and base64-wrapped webhook payloads in one call:

- `decompressWebhookBody(rawBody, contentEncoding?, payloadEncoding?)`
- `verifyAndDecodeWebhook(rawBody, xSignature, contentEncoding?, payloadEncoding?)`

Both helpers are no-ops when the encoding arguments are null / undefined /
empty, so existing HTTP webhook integrations behave identically. The HMAC
signature is always checked against the innermost (uncompressed,
base64-decoded) JSON, so the same call works for HTTP webhooks and for
SQS / SNS firehose envelopes (pass `payloadEncoding: 'base64'`).

A new `WebhookSignatureError` class is exported from the package barrel for
typed error handling. The legacy `verifyWebhook` and the underlying
`CheckSignature` helper are intentionally untouched for backward
compatibility.

Co-authored-by: Cursor <cursoragent@cursor.com>
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 7, 2026

Size Change: +3.99 kB (+1.05%)

Total Size: 385 kB

📦 View Changed
Filename Size Change
dist/cjs/index.browser.js 128 kB +1.32 kB (+1.04%)
dist/cjs/index.node.js 129 kB +1.37 kB (+1.07%)
dist/esm/index.mjs 127 kB +1.3 kB (+1.03%)

compressed-size-action

Replaces verifyAndDecodeWebhook / decompressWebhookBody with the
cross-SDK contract documented at
https://getstream.io/chat/docs/node/webhooks_overview/.

Module-level helpers in src/signing:

  Primitives:
    ungzipPayload    - gzip magic-byte detection + inflate
    decodeSqsPayload - base64 then ungzip-if-magic
    decodeSnsPayload - alias for decodeSqsPayload
    verifySignature  - constant-time HMAC-SHA256 comparison
                       (parameter order matches the cross-SDK spec)
    parseEvent       - JSON -> typed Event

  Composite (return Event):
    verifyAndParseWebhook
    verifyAndParseSqs
    verifyAndParseSns

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.

Client instance methods (StreamChat.verifyAndParse*) mirror the three
composite helpers and pull the API secret from the client.

Backward compatibility:
  * StreamChat.verifyWebhook(body, signature) -> bool kept unchanged
    (now delegates to verifySignature internally).
  * CheckSignature(body, secret, signature) preserved as a deprecated
    alias for verifySignature(body, signature, secret).

WebhookSignatureError stays the sentinel error for HMAC mismatch and
malformed gzip / base64 envelopes.

Co-authored-by: Cursor <cursoragent@cursor.com>
@nijeesh-stream nijeesh-stream changed the title feat(webhooks): add verifyAndDecodeWebhook for compressed payloads 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>
…-3071)

The previous webhooks doc described a verifyAndDecodeWebhook /
decompressWebhookBody surface with contentEncoding / payloadEncoding
arguments that was never implemented. The shipped API exposes
verifyAndParseWebhook / verifyAndParseSqs / verifyAndParseSns (and the
matching module-level functions) with magic-byte gzip detection and no
encoding arguments. Rewrite the guide and reference table to match what
actually exists, including SQS / SNS examples, the lower-level
ungzipPayload / decodeSqsPayload / verifySignature helpers, and a note
that the helpers detect gzip from the body bytes rather than the
Content-Encoding header.

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 src/signing.ts Outdated
Comment thread test/unit/webhook-compression.test.ts
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 fixture adds a realistic SNS HTTP notification body 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) 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 cause-chain.

Class name family: InvalidWebhookError — "Invalid" covers all failure modes accurately (it's the standard stdlib term across all target languages: InvalidArgumentException, InvalidOperationException, ValueError, etc., as opposed to "Parse" which would undersell the signature-failure case).

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. Rename WebhookSignatureErrorInvalidWebhookError
  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 verifyWebhook (returning bool) stays unchanged — back-compat preserved
  5. Update unit 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