Gareth Wilson Gareth Wilson

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

Published


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

FeatureDetails
ConfigurationThe Dashboard, or event_hooks in the app settings
TransportsHTTP webhook, Amazon SQS, Amazon SNS
Event selectionSpecific types, or all types including future ones. One selection per app
Signature headerX-Signature
SchemeHMAC-SHA256, lowercase hex
Signed contentThe request body, after decompression
KeyThe app's API secret
Replay windowNone. No timestamp is sent or signed
Other headersX-Webhook-Id (same on retries), X-Webhook-Attempt (from 1), X-Api-Key (your app's public key)
Compressionenable_hook_payload_compression: gzip for bodies over 256 bytes. On by default for apps created after May 7, 2026
Response window6 seconds per attempt
RetriesImmediate, no backoff. Stops at 5 attempts, 15 seconds in total, 3 retryable responses or 2 network errors
FailoverUndeliverable 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.

AreaEvents
Messagesmessage.new, message.updated, message.deleted, message.read, message.flagged
Reactionsreaction.new, reaction.updated, reaction.deleted
Channels and memberschannel.created, channel.updated, channel.deleted, channel.hidden, channel.muted, channel.truncated, member.added, member.updated, member.removed
Usersuser.updated, user.deleted, user.deactivated, user.reactivated, user.banned, user.unbanned, user.flagged, user.muted, user.messages.deleted, user.unread_message_reminder
Pollspoll.updated, poll.closed, poll.deleted, poll.vote_casted, poll.vote_changed, poll.vote_removed
Reminders, groups and jobsreminder.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:

{
  "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:

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.

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:

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 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.

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.

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 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 for free and handle Stream webhooks reliably in minutes.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.