Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
438566f
feat(webhooks): add verify_and_decode_webhook for compressed payloads…
nijeesh-stream May 7, 2026
7b3522f
fix(tests): satisfy isort import-block rule
nijeesh-stream May 7, 2026
7402f18
refactor(webhooks): switch to verify_and_parse_* API (CHA-3071)
nijeesh-stream May 8, 2026
3f48c0e
fix(webhooks): drop redundant binascii.Error in except (B014)
nijeesh-stream May 8, 2026
97edc94
refactor(webhooks): use 2-byte gzip magic per RFC 1952 (CHA-3071)
nijeesh-stream May 8, 2026
4a3c771
fix(webhooks): make verify_signature robust against malformed signatures
nijeesh-stream May 11, 2026
03849df
docs(webhooks): align compression section with the shipped API
nijeesh-stream May 11, 2026
60775a5
fix(webhooks): unwrap SNS notification envelope in decode_sns_payload
nijeesh-stream May 11, 2026
c59702f
style(webhooks): apply black formatting to SNS envelope test additions
nijeesh-stream May 11, 2026
69048d8
refactor(webhooks): rename ungzip_payload to gunzip_payload + add gol…
nijeesh-stream May 11, 2026
7c7cbeb
refactor(webhooks): unify webhook errors under InvalidWebhookError (C…
nijeesh-stream May 12, 2026
38bd50c
feat(webhooks): make signature optional on verify_and_parse_sqs/sns (…
nijeesh-stream May 12, 2026
47b9c38
fix(webhooks): parseSqs/ParseSns decode-only; HTTP verify via verifyA…
nijeesh-stream May 12, 2026
08b893d
fix(webhooks): Go ErrInvalidWebhook + VerifySignature(error); Ruby We…
nijeesh-stream May 12, 2026
28d8015
feat(webhooks): align cross-SDK contract — InvalidWebhookError + gunz…
nijeesh-stream May 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions docs/webhooks/webhooks_overview/webhooks_overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,96 @@ valid = client.verify_webhook(request.body, request.META['HTTP_X_SIGNATURE'])
valid = client.verify_webhook(request.data, request.headers['X-SIGNATURE'])
```

### Compressed webhook bodies

GZIP compression can be enabled for hook payloads from the Dashboard. Enabling compression reduces the payload size significantly (often 70–90% smaller) reducing your bandwidth usage on Stream. The decompression cost on your side is usually negligible and offset by the much smaller payload.

When payload compression is enabled, webhook HTTP requests include the `Content-Encoding: gzip` header and the body is gzipped. SQS and SNS messages are gzipped and then base64-wrapped (both transports are UTF-8 only). Some HTTP servers and middleware (Rails, Django, Laravel, Spring Boot, ASP.NET) auto-decompress the body before your handler runs — in that case the body you see is already raw JSON.

Before enabling compression, make sure that:

* Your backend integration is using a recent version of our official SDKs with compression support
* If you don't use an official SDK, make sure that your code supports receiving compressed payloads
* The payload signature check is done on the **uncompressed** payload

The Python SDK exposes a one-liner per transport. Each helper detects the encoding from the body bytes (the gzip magic `1f 8b`, per [RFC 1952](https://datatracker.ietf.org/doc/html/rfc1952)), verifies the HMAC `X-Signature` over the uncompressed JSON, and returns the parsed event as a `dict`. Typed event classes are planned for a future release; until then handlers can key off the `type` field.

```python
from stream_chat import StreamChat

client = StreamChat(api_key="STREAM_KEY", api_secret="STREAM_SECRET")

# Django view
def stream_webhook(request):
event = client.verify_and_parse_webhook(
request.body,
request.headers["X-Signature"],
)
# ... handle event["type"], event["message"], ...
```

```python
from flask import request
from stream_chat import StreamChat

client = StreamChat(api_key="STREAM_KEY", api_secret="STREAM_SECRET")

@app.route("/webhooks/stream", methods=["POST"])
def stream_webhook():
event = client.verify_and_parse_webhook(
request.get_data(),
request.headers["X-Signature"],
)
# ... handle event["type"], event["message"], ...
```

The same call works whether or not Stream is compressing for this app, and whether or not your framework auto-decompressed the request — the helper inspects the body bytes rather than the `Content-Encoding` header.

All helpers raise `stream_chat.webhook.InvalidWebhookError` when the signature does not match, when the gzip stream is corrupt, or when the SQS/SNS base64 envelope cannot be decoded.

The original `client.verify_webhook(request.body, request.headers["X-Signature"])` — which returns a `bool` and does not decompress — stays unchanged for backward compatibility. Switch to `verify_and_parse_webhook` to support compressed payloads.

#### SQS / SNS firehose

For events delivered through SQS or SNS, call the matching helper. It base64-decodes the envelope, gzip-decompresses when the magic bytes are present, and returns the parsed event.

Stream does **not** ship an `X-Signature` on SQS or SNS deliveries: those transports run on AWS-internal infrastructure that is already authenticated end-to-end. SQS queues are reached via IAM-authenticated polling, and SNS notifications carry an AWS signature on the notification envelope itself, so verifying that the message really came from your topic happens at the AWS layer. Layering an HMAC check on top is redundant, so the SQS/SNS helpers only decode and parse — they take a single argument and never verify a signature.

For SQS, pass the message `Body` (already the payload):

```python
event = client.parse_sqs(sqs_message["Body"])
```

For SNS, pass the **raw notification body** (the full `{"Type":"Notification", ...}` JSON envelope Amazon delivers). The SDK extracts the inner `Message` field for you, so the call site mirrors what HTTP frameworks already hand you in `request.body`:

```python
# Django SNS HTTP delivery
event = client.parse_sns(request.body) # raw envelope (bytes/str)
```

#### Stateless / module-level form

If you do not want to construct a `StreamChat` client (for example in a lightweight Lambda that only handles webhooks), call the module-level helpers directly. The HTTP helper still requires the signature and secret; the SQS/SNS helpers take a single argument:

```python
from stream_chat import webhook

event = webhook.verify_and_parse_webhook(body, signature, secret)
event = webhook.parse_sqs(message_body)
event = webhook.parse_sns(notification_body)
```

##### Arguments

| Argument | `verify_and_parse_webhook` | `parse_sqs` | `parse_sns` |
| ------------------- | -------------------------- | --------------- | -------------------- |
| body / message_body / notification_body | required | required | required |
| signature | required | — | — |
| secret | required | — | — |

The module also exposes the primitives the composites are built from — `gunzip_payload`, `decode_sqs_payload`, `decode_sns_payload`, `verify_signature` (constant-time HMAC-SHA256), and `parse_event` — for callers that need to run the steps individually.

All webhook requests contain these headers:

| Name | Description | Example |
Expand Down
35 changes: 35 additions & 0 deletions stream_chat/base/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,41 @@ def verify_webhook(
).hexdigest()
return signature == x_signature

def verify_and_parse_webhook(
self,
body: Union[bytes, str],
signature: Union[str, bytes],
) -> Dict[str, Any]:
"""Verify and parse an HTTP webhook event.

Decompresses ``body`` when gzipped (detected from the body bytes),
verifies the ``X-Signature`` header against the app's API secret,
and returns the parsed event. The Python SDK currently returns a
``dict``; typed event classes are planned for a future release.

:param body: raw HTTP request body bytes Stream signed
:param signature: ``X-Signature`` header value
:raises stream_chat.base.exceptions.InvalidWebhookError: on
signature mismatch or any decode error
"""
from stream_chat.webhook import verify_and_parse_webhook

return verify_and_parse_webhook(body, signature, self.api_secret)

def parse_sqs(self, message_body: Union[bytes, str]) -> Dict[str, Any]:
"""Parse an SQS firehose body (base64 + optional gzip). No HMAC."""

from stream_chat.webhook import parse_sqs

return parse_sqs(message_body)

def parse_sns(self, message: Union[bytes, str]) -> Dict[str, Any]:
"""Parse an SNS body (unwraps SNS envelope when present). No HMAC."""

from stream_chat.webhook import parse_sns

return parse_sns(message)

@abc.abstractmethod
def update_app_settings(
self, **settings: Any
Expand Down
17 changes: 17 additions & 0 deletions stream_chat/base/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,23 @@ class StreamChannelException(Exception):
pass


class InvalidWebhookError(Exception):
"""Invalid webhook signature or malformed gzip/base64/JSON envelope.

Raised by :mod:`stream_chat.webhook` on any failure path: signature
mismatch, malformed base64, gzip decompression failure, or invalid
JSON payload. The message text identifies the failure mode so
callers that want to differentiate (security logging, retry policy)
can filter on substring or on the module-level constants.
"""


INVALID_WEBHOOK_SIGNATURE_MISMATCH = "signature mismatch"
INVALID_WEBHOOK_INVALID_BASE64 = "invalid base64 encoding"
INVALID_WEBHOOK_GZIP_FAILED = "gzip decompression failed"
INVALID_WEBHOOK_INVALID_JSON = "invalid JSON payload"


class StreamAPIException(Exception):
def __init__(self, text: str, status_code: int) -> None:
self.response_text = text
Expand Down
Loading
Loading