# Guide to Stream (getstream.io) Webhooks: Features and Best Practices

Stream webhooks tell your backend what is happening inside your in-app chat, activity feeds, video calls and moderation queues: a message is sent or flagged, a reaction is added, a member joins a channel, a user is banned, or an export finishes. If you build on Stream (getstream.io), webhooks are how those events reach your own services for notifications, search indexing, analytics or moderation workflows.

This guide covers how Stream hooks are configured and signed, the payload compression setting that changes what you must verify, the chat events and their payloads, Stream's very short retry budget, and the practices that keep a Stream receiver reliable. It is about Stream from getstream.io, not Bunny Stream or other products with a similar name.

## What are Stream webhooks?

Every Stream product can deliver events to your server through an HTTP webhook, an Amazon SQS queue or an Amazon SNS topic. Hooks are configured per app, either in the Dashboard or through the `event_hooks` app setting, and you can have several of them, for example an SNS topic and an HTTP webhook. The event selection applies to all of an app's hooks: either a list of specific types, or every type, including types Stream adds later.

HTTP webhooks are JSON `POST` requests. Each one carries an `X-Signature` header: a lowercase hex HMAC-SHA256 of the request body, keyed with your app's API secret. There is no separate webhook secret, and no timestamp is signed.

The detail that catches people out is payload compression. With `enable_hook_payload_compression` on, Stream gzips bodies larger than 256 bytes and sends them with `Content-Encoding: gzip`, but it computes the signature over the uncompressed bytes. Apps created after May 7, 2026 have compression on by default.

## Stream webhook features

| Feature | Details |
| --- | --- |
| Configuration | The Dashboard, or `event_hooks` in the app settings |
| Transports | HTTP webhook, Amazon SQS, Amazon SNS |
| Event selection | Specific types, or all types including future ones. One selection per app |
| Signature header | `X-Signature` |
| Scheme | HMAC-SHA256, lowercase hex |
| Signed content | The request body, after decompression |
| Key | The app's API secret |
| Replay window | None. No timestamp is sent or signed |
| Other headers | `X-Webhook-Id` (same on retries), `X-Webhook-Attempt` (from 1), `X-Api-Key` (your app's public key) |
| Compression | `enable_hook_payload_compression`: gzip for bodies over 256 bytes. On by default for apps created after May 7, 2026 |
| Response window | 6 seconds per attempt |
| Retries | Immediate, no backoff. Stops at 5 attempts, 15 seconds in total, 3 retryable responses or 2 network errors |
| Failover | Undeliverable events can be written to a GCS or S3 bucket |

Stream asks for HTTPS with a certificate from a trusted authority and Keep-Alive support. It does not publish source IP addresses. SQS and SNS deliveries carry no `X-Signature`; they are authenticated by AWS instead.

## Common events

Each Stream product documents its own events. The Chat list has about 50, and the event name is in the body's `type` field.

| Area | Events |
| --- | --- |
| Messages | `message.new`, `message.updated`, `message.deleted`, `message.read`, `message.flagged` |
| Reactions | `reaction.new`, `reaction.updated`, `reaction.deleted` |
| Channels and members | `channel.created`, `channel.updated`, `channel.deleted`, `channel.hidden`, `channel.muted`, `channel.truncated`, `member.added`, `member.updated`, `member.removed` |
| Users | `user.updated`, `user.deleted`, `user.deactivated`, `user.reactivated`, `user.banned`, `user.unbanned`, `user.flagged`, `user.muted`, `user.messages.deleted`, `user.unread_message_reminder` |
| Polls | `poll.updated`, `poll.closed`, `poll.deleted`, `poll.vote_casted`, `poll.vote_changed`, `poll.vote_removed` |
| Reminders, groups and jobs | `reminder.created`, `notification.reminder_due`, `user_group.created`, `channel_batch_update.completed`, `export.users.success`, `export.channels.error` and more |

Feeds, Video and Moderation events share the same delivery, signing and retry rules but are listed on their own pages. If you subscribe to all events, new event types start arriving without warning.

Payloads share `type` and `created_at`, and the other fields depend on the event: `cid`, `channel_type` and `channel_id` for channel events, plus `message`, `user`, `members` or `request_info` where relevant. Here is Stream's `message.read` example:

```json
{
  "cid": "messaging:fun",
  "type": "message.read",
  "user": {
    "id": "a6e21b36-798b-408a-9cd1-0cf6c372fc7f",
    "role": "user",
    "created_at": "2019-04-24T08:49:58.170034Z",
    "updated_at": "2019-04-24T08:49:59.345304Z",
    "last_active": "2019-04-24T08:49:59.344201Z",
    "online": true,
    "total_unread_count": 0,
    "unread_channels": 0,
    "unread_count": 0,
    "channel_unread_count": 0,
    "channel_last_read_at": "2019-04-24T08:49:59.365498Z"
  },
  "created_at": "2019-04-24T08:49:59.365489Z",
  "request_info": {
    "type": "client",
    "ip": "86.84.2.2",
    "user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:109.0) Gecko/20100101 Firefox/117.0",
    "sdk": "stream-chat-react-10.11.0-stream-chat-javascript-client-browser-8.12.1"
  }
}

```

Stream asks receivers to tolerate new fields and new event types, logging them rather than failing.

## Setting up Stream webhooks

1. In the Stream Dashboard, open your app's webhook settings and add your HTTPS URL, or add a hook with `hook_type: "webhook"` to the app's `event_hooks` through the API.
2. Choose the event types to receive, or all of them.
3. Copy the app's **API secret** and store it server-side, for example as `STREAM_API_SECRET`.
4. Decide on compression. If you verify with your own code, you can keep it on and decompress before verifying. If you receive through Hookdeck, turn it off.

Updating `event_hooks` through the API replaces the whole array, and any hook you leave out is deleted. Fetch the current settings first and send back the full list with your change.

If you receive through Hookdeck, create a Stream source, use its URL as the webhook URL, and paste your app's API secret into the source's **Webhook Signing Secret** field. Then turn **Payload Compression** off in Stream, since Hookdeck can't verify a compressed payload against a signature taken over the uncompressed bytes:

```javascript
await client.updateAppSettings({ enable_hook_payload_compression: false });

```

## Securing Stream webhooks

Take the raw request body. If it starts with the gzip magic bytes `1f 8b`, decompress it. Compute an HMAC-SHA256 of the uncompressed bytes with the API secret as a UTF-8 key, encode it as hex, and compare it with `X-Signature` in constant time. Optionally check that `X-Api-Key` matches your app's key, which helps if one endpoint serves several apps.

```javascript
const crypto = require('crypto');
const express = require('express');
const zlib = require('zlib');

function decompressIfGzipped(rawBody) {
  if (rawBody.length >= 2 && rawBody[0] === 0x1f && rawBody[1] === 0x8b) {
    return zlib.gunzipSync(rawBody);
  }
  return rawBody;
}

function verifyStreamSignature(body, signatureHeader, apiSecret) {
  if (!signatureHeader || !apiSecret) return false;
  const expected = crypto.createHmac('sha256', apiSecret).update(body).digest('hex');
  const received = Buffer.from(signatureHeader.trim().toLowerCase(), 'utf8');
  const computed = Buffer.from(expected, 'utf8');
  return received.length === computed.length && crypto.timingSafeEqual(received, computed);
}

const app = express();

// express.raw inflates Content-Encoding: gzip bodies by default. The magic-byte check
// also covers a body that reaches the handler still compressed.
app.post('/webhooks/stream', express.raw({ type: '*/*' }), (req, res) => {
  const apiSecret = process.env.STREAM_API_SECRET;
  if (!apiSecret) return res.sendStatus(500); // fail closed

  let body;
  try {
    body = decompressIfGzipped(req.body);
  } catch {
    return res.sendStatus(400);
  }

  if (!verifyStreamSignature(body, req.get('x-signature'), apiSecret)) {
    return res.sendStatus(401);
  }

  const event = JSON.parse(body.toString('utf8'));
  // Route on event.type. Deduplicate on the X-Webhook-Id header.
  res.sendStatus(200);
});

```

The same check in Python:

```python
import gzip
import hashlib
import hmac

def verify_stream_signature(raw_body: bytes, signature_header, api_secret: str) -> bool:
    if not signature_header or not api_secret:
        return False
    body = gzip.decompress(raw_body) if raw_body[:2] == b"\x1f\x8b" else raw_body
    expected = hmac.new(api_secret.encode("utf-8"), body, hashlib.sha256).hexdigest()
    received = signature_header.strip().lower().encode("utf-8")
    return hmac.compare_digest(received, expected.encode("utf-8"))

# In FastAPI: raw_body = await request.body()
# verify_stream_signature(raw_body, request.headers.get("x-signature"),
#                         os.environ["STREAM_API_SECRET"])

```

Every Stream server SDK also offers `verifyAndParseWebhook`, which decompresses, verifies and parses in one call and works whether or not compression is on.

Because the key is your app's API secret, a full credential for the Stream API, keep it out of anything that doesn't need it. That is a good reason to verify in one place rather than in every service that consumes Stream events.

> **Make Stream webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) verifies the `X-Signature` header, answers Stream within its 6-second window, and durably queues every chat, feed and moderation event before your service sees it.

## Stream webhook limitations and pain points

### Compression breaks naive verification

**The Problem:** Verification fails for some requests, usually the larger ones, or for every request in a newly created app.

**Why It Happens:** With payload compression on, Stream gzips bodies over 256 bytes but signs the uncompressed JSON. Hashing the bytes on the wire gives the wrong answer, while small bodies, which stay uncompressed, still verify. Apps created after May 7, 2026 have compression on by default, so the failure can appear without anyone changing a setting.

**Workarounds:**

* Detect the gzip magic bytes and decompress before verifying, or use the SDK's `verifyAndParseWebhook`.
* If something in front of your receiver can't decompress, turn compression off with `enable_hook_payload_compression: false`.

**How Hookdeck Can Help:** Hookdeck's Stream source verifies uncompressed payloads. Turn compression off in Stream, and Hookdeck checks every signature before events reach your service.

### Fifteen seconds to succeed

**The Problem:** A short outage or a slow deploy loses events.

**Why It Happens:** Stream retries immediately, with no backoff, and gives up at the first of these limits: 5 attempts, 15 seconds per event, 3 retryable responses (`408`, `429` or `5xx`) or 2 network errors. Any other non-`2xx` response is final, and `Retry-After` is ignored. An endpoint that never answers sees about three attempts.

**Workarounds:**

* Keep the endpoint highly available behind a load balancer, and answer as fast as possible.
* Configure event failover to write undeliverable events to a GCS or S3 bucket, and process that bucket after an incident.
* For zero-loss requirements, Stream itself recommends SQS or SNS.

**How Hookdeck Can Help:** Hookdeck accepts each event as soon as it arrives and stores it, then retries delivery to your service on a schedule you set, for hours or days rather than seconds, and lets you replay anything that failed.

### The key is your API secret

**The Problem:** Every service that verifies Stream webhooks holds a credential that can call the whole Stream API.

**Why It Happens:** Stream has no webhook-specific secret. The HMAC key is the app's API secret.

**Workarounds:**

* Verify in a single ingress service and pass verified events on internally.
* Rotate the API secret if any receiver might have leaked it.

**How Hookdeck Can Help:** With Hookdeck verifying every request before it reaches your services, the API secret lives in one place and your downstream services never see it.

### No replay protection

**The Problem:** A captured request still verifies if someone sends it again later.

**Why It Happens:** Only the body is signed. No timestamp is included, so nothing marks a request as stale.

**Workarounds:**

* Deduplicate on `X-Webhook-Id`, which stays the same across retries of one call.
* Use HTTPS so requests can't be captured in transit.

**How Hookdeck Can Help:** Hookdeck's deduplication can drop repeated deliveries based on `X-Webhook-Id` before they reach your service.

## Best practices

### Verify against the raw, decompressed body

The signature covers the exact JSON Stream produced. In Express, use `express.raw()` and decompress if needed. In Next.js, read `Buffer.from(await request.arrayBuffer())`. In FastAPI, read `await request.body()`. Never verify a re-serialised object.

### Deduplicate on `X-Webhook-Id`

Retries reuse the same `X-Webhook-Id` and increment `X-Webhook-Attempt`. Store the ID and skip calls you have already processed. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Read, store and forget

Stream's own advice is to offload processing: read the request, store it, respond, and do the work elsewhere. With a 6-second attempt timeout, anything slow belongs in a queue. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

### Choose event types deliberately

Subscribing to all events means new types arrive automatically, which is useful for logging but noisy for business logic. If you pick specific types, remember that the selection applies to every hook in the app.

### Tolerate unknown fields and events

Log new fields and event types instead of rejecting them, as Stream recommends. A strict schema check that returns `400` turns a harmless new field into a lost event, because `4xx` responses are not retried.

## Conclusion

Stream signs each HTTP webhook with a hex HMAC-SHA256 of the uncompressed body in `X-Signature`, keyed with your app's API secret. Decompress before verifying, or turn payload compression off when something else verifies for you, deduplicate on `X-Webhook-Id`, answer within a few seconds, and plan for Stream's 15-second retry budget with failover or a durable queue.

[Hookdeck Event Gateway](https://hookdeck.com) sits in front of your receiver, checks every Stream signature, and holds each event until your service has processed it, so a brief outage no longer means lost chat events.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Stream webhooks reliably in minutes.