Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
73 changes: 73 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,79 @@ 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 hooks payloads from the Dashboard. Enabling compression reduces the payload size significantly (often 70–90% smaller) reducing your bandwidth usage on Stream. The computation overhead introduced by the decompression step is usually negligible and offset by the much smaller payload.

When payload compression is enabled, webhook HTTP requests will include the `Content-Encoding: gzip` header and the request body will be compressed with GZIP. Some HTTP servers and middleware (Rails, Django, Laravel, Spring Boot, ASP.NET) handle this transparently and strip the header 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 ships with `client.verify_and_decode_webhook(...)` which transparently handles plain, gzip-compressed, and base64-wrapped (SQS / SNS firehose) payloads. It returns the raw JSON body as `bytes`, ready to pass to `json.loads`.

```python
import json
from stream_chat import StreamChat

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

# Django view
def stream_webhook(request):
body = client.verify_and_decode_webhook(
request.body,
request.headers["X-Signature"],
request.headers.get("Content-Encoding"),
)
event = json.loads(body)
# ... handle event ...
```

```python
import json
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():
body = client.verify_and_decode_webhook(
request.get_data(),
request.headers["X-Signature"],
request.headers.get("Content-Encoding"),
)
event = json.loads(body)
# ... handle event ...
```

If your HTTP framework or a middleware already decompressed the body before it reached your handler, the `Content-Encoding` header will be missing (or set to `identity`) and `verify_and_decode_webhook` will be a no-op for the decompression step — the same call works in both cases.

`verify_and_decode_webhook` raises `stream_chat.base.exceptions.WebhookSignatureError` when the signature does not match or the body cannot be decoded.

The original `client.verify_webhook(request.body, request.headers["X-Signature"])` is unchanged and still available for handlers that prefer to verify and parse the body separately.

#### SQS / SNS firehose

When delivering events through SQS or SNS, Stream base64-wraps the (possibly gzip-compressed) body so the payload stays valid UTF-8 over the queue. Pass `payload_encoding="base64"` so `verify_and_decode_webhook` unwraps the envelope before verifying the HMAC signature, which is always computed over the uncompressed JSON.

```python
body = client.verify_and_decode_webhook(
sqs_message["Body"],
sqs_message["MessageAttributes"]["X-Signature"]["StringValue"],
content_encoding=sqs_message["MessageAttributes"]
.get("Content-Encoding", {})
.get("StringValue"),
payload_encoding="base64",
)
event = json.loads(body)
```

If you only need to decode the body without checking the signature (for example because you have already verified it elsewhere), use `client.decompress_webhook_body(body, content_encoding, payload_encoding)`.

All webhook requests contain these headers:

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

def decompress_webhook_body(
self,
body: Union[bytes, str],
content_encoding: Optional[str] = None,
payload_encoding: Optional[str] = None,
) -> bytes:
"""Decode a (possibly compressed and/or wrapped) webhook payload.

Stream Chat can compress outbound webhook payloads with gzip and, for
SQS / SNS firehose delivery, also wrap the compressed bytes in base64
so they remain valid UTF-8 over the queue. This helper applies the
encodings in order:

1. ``payload_encoding`` (``"base64"`` / ``"b64"``) is unwrapped first.
2. ``content_encoding`` (``"gzip"``) is decompressed next.
3. The raw JSON bytes are returned. The caller can ``.decode("utf-8")``
or pass the value straight to :func:`json.loads`, which accepts
bytes.

``None`` or an empty string for either encoding is a no-op, so the
regular HTTP webhook path stays bytewise identical to today.

This method does **not** check the ``X-Signature`` header. Use
:meth:`verify_and_decode_webhook` for the combined decode + verify
flow.

:param body: raw bytes (or str) received from Stream
:param content_encoding: value of the ``Content-Encoding`` header
(only ``"gzip"`` is supported)
:param payload_encoding: wrapper around the compressed bytes
(``"base64"`` / ``"b64"``); used by the SQS / SNS firehose
:returns: the uncompressed JSON body as bytes
"""
from stream_chat.webhook import decompress_webhook_body

return decompress_webhook_body(
body,
content_encoding=content_encoding,
payload_encoding=payload_encoding,
)

def verify_and_decode_webhook(
self,
body: Union[bytes, str],
x_signature: Union[str, bytes],
content_encoding: Optional[str] = None,
payload_encoding: Optional[str] = None,
) -> bytes:
"""Decode a webhook payload and verify its HMAC-SHA256 signature.

The signature is always computed over the **uncompressed** JSON
payload, so this method first decodes the body via
:meth:`decompress_webhook_body` and then compares the digest with
``x_signature`` using :func:`hmac.compare_digest`.

Works for plain HTTP webhooks (pass the ``Content-Encoding`` header
value) and for SQS / SNS firehose envelopes (additionally pass
``payload_encoding="base64"``).

:param body: raw bytes (or str) received from Stream
:param x_signature: the ``X-Signature`` header value sent by Stream
:param content_encoding: value of the ``Content-Encoding`` header
(only ``"gzip"`` is supported)
:param payload_encoding: wrapper around the compressed bytes
(``"base64"`` / ``"b64"``); used by the SQS / SNS firehose
:returns: the verified, uncompressed JSON body as bytes
:raises stream_chat.base.exceptions.WebhookSignatureError: on
signature mismatch or any decode error
"""
from stream_chat.webhook import verify_and_decode_webhook

return verify_and_decode_webhook(
body,
x_signature,
api_secret=self.api_secret,
content_encoding=content_encoding,
payload_encoding=payload_encoding,
)

@abc.abstractmethod
def update_app_settings(
self, **settings: Any
Expand Down
14 changes: 14 additions & 0 deletions stream_chat/base/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,17 @@ def __str__(self) -> str:
return f'StreamChat error code {self.error_code}: {self.error_message}"'
else:
return f"StreamChat error HTTP code: {self.status_code}"


class WebhookSignatureError(StreamAPIException):
"""Raised when an outbound webhook signature does not match, the
webhook payload cannot be decompressed, or the wrapping (e.g. base64)
cannot be decoded.
"""

def __init__(self, message: str) -> None:
super().__init__(message, status_code=0)
self.message = message

def __str__(self) -> str:
return f"WebhookSignatureError: {self.message}"
Loading
Loading