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
89 changes: 89 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,95 @@ 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.base.exceptions.WebhookSignatureError` 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, verifies the HMAC, and returns the parsed event.

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

```python
event = client.verify_and_parse_sqs(
sqs_message["Body"],
sqs_message["MessageAttributes"]["X-Signature"]["StringValue"],
)
```

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
import json

# Django SNS HTTP delivery
attrs = json.loads(request.body)["MessageAttributes"]
event = client.verify_and_parse_sns(
request.body, # raw envelope (bytes/str)
attrs["X-Signature"]["Value"],
)
```

#### 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. They take the API secret as a third argument and are otherwise identical:

```python
from stream_chat import webhook

event = webhook.verify_and_parse_webhook(body, signature, secret)
event = webhook.verify_and_parse_sqs(message_body, signature, secret)
event = webhook.verify_and_parse_sns(notification_body, signature, secret)
```

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
61 changes: 61 additions & 0 deletions stream_chat/base/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,67 @@ 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.WebhookSignatureError: 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 verify_and_parse_sqs(
self,
message_body: Union[bytes, str],
signature: Union[str, bytes],
) -> Dict[str, Any]:
"""Verify and parse an SQS firehose webhook event.

Reverses the base64 (+ optional gzip) wrapping on the SQS
``Body``, verifies the ``X-Signature`` message attribute against
the app's API secret, and returns the parsed event.

:param message_body: SQS message ``Body`` (string)
:param signature: ``X-Signature`` message attribute value
:raises stream_chat.base.exceptions.WebhookSignatureError: on
signature mismatch or any decode error
"""
from stream_chat.webhook import verify_and_parse_sqs

return verify_and_parse_sqs(message_body, signature, self.api_secret)

def verify_and_parse_sns(
self,
message: Union[bytes, str],
signature: Union[str, bytes],
) -> Dict[str, Any]:
"""Verify and parse an SNS firehose webhook event.

Reverses the base64 (+ optional gzip) wrapping on the SNS
``Message``, verifies the ``X-Signature`` message attribute
against the app's API secret, and returns the parsed event.

:param message: SNS notification ``Message`` field (string)
:param signature: ``X-Signature`` message attribute value
:raises stream_chat.base.exceptions.WebhookSignatureError: on
signature mismatch or any decode error
"""
from stream_chat.webhook import verify_and_parse_sns

return verify_and_parse_sns(message, signature, self.api_secret)

@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