You can listen to events using webhooks, SQS or SNS.
When setting up a webhook you can specify the exact events you want to receive, or select to receive all events.
To ensure that a webhook is triggered by Stream you can verify it's signature.
Webhook retries are in place. If you want to ensure an outage in your API never loses an event, it's better to use SQS or SNS for reliability.
Use an empty event_types array to receive all existing and future events:
// Subscribe to all events (empty array = all events)await client.updateAppSettings({ event_hooks: [ { enabled: true, hook_type: "webhook", webhook_url: "https://example.com/webhooks/stream/all", event_types: [], // empty array = all events }, ],});
from getstream.models import EventHook# Subscribe to all events (empty list = all events)client.update_app( event_hooks=[ EventHook( enabled=True, hook_type="webhook", webhook_url="https://example.com/webhooks/stream/all", event_types=[], # empty list = all events ) ])
require 'getstream_ruby'Models = GetStream::Generated::Models# Subscribe to all events (empty array = all events)client.common.update_app(Models::UpdateAppRequest.new( event_hooks: [ { 'enabled' => true, 'hook_type' => 'webhook', 'webhook_url' => 'https://example.com/webhooks/stream/all', 'event_types' => [] # empty array = all events } ]))
// Subscribe to all events (empty array = all events)$client->updateApp(new Models\UpdateAppRequest( eventHooks: [ new Models\EventHook( enabled: true, hookType: "webhook", webhookUrl: "https://example.com/webhooks/stream/all", eventTypes: [], // empty array = all events ), ],));
// Subscribe to all events (empty slice = all events)client.UpdateApp(ctx, &getstream.UpdateAppRequest{ EventHooks: []getstream.EventHook{ { HookType: getstream.PtrTo("webhook"), Enabled: getstream.PtrTo(true), EventTypes: []string{}, // empty slice = all events WebhookUrl: getstream.PtrTo("https://example.com/webhooks/stream/all"), }, },})
// Subscribe to all events (empty list = all events)var webhookHook = EventHook.builder() .hookType("webhook") .enabled(true) .eventTypes(Collections.emptyList()) // empty list = all events .webhookUrl("https://example.com/webhooks/stream/all") .build();client.updateApp(UpdateAppRequest.builder() .eventHooks(List.of(webhookHook)) .build()).execute();
// Subscribe to all events (empty list = all events)var webhookHook = new EventHook{ HookType = "webhook", Enabled = true, EventTypes = new List<string>(), // empty list = all events WebhookUrl = "https://example.com/webhooks/stream/all",};await client.UpdateAppAsync(new UpdateAppRequest{ EventHooks = new List<EventHook> { webhookHook },});
For reliable event delivery, you can also configure SQS or SNS instead of webhooks.
Your endpoint should accept POST requests with JSON, return a 2xx response on success, be idempotent (Stream retries on network or 5xx errors), and run over HTTPS with Keep-Alive enabled.
To verify and parse the event, call verifyAndParseWebhook on your chat client. It transparently decompresses the body when Payload Compression is on (detected from the body bytes, so it works behind middleware that auto-decompresses the request) and verifies the HMAC X-Signature header against the API secret the client was constructed with. Every SDK returns a typed event object — unknown event types fall back to an UnknownEvent shape so your handler keeps working when Stream introduces new ones.
Every failure mode — signature mismatch, gzip decompression failure, base64 decode failure, JSON parse failure — raises a single, language-idiomatic webhook error so you only need one catch arm. The message text identifies which mode fired, so callers that want to differentiate (security logging, retry policy) can filter on it:
same (InvalidWebhookError::SIGNATURE_MISMATCH etc.)
Go
sentinel stream.ErrInvalidWebhook
same prefixes; use errors.Is(err, stream.ErrInvalidWebhook) for the unified check
Java
InvalidWebhookError
same (InvalidWebhookError.SIGNATURE_MISMATCH etc.)
.NET (C#)
InvalidWebhookError
same (InvalidWebhookError.SignatureMismatch etc.)
Pass the raw body bytes and the X-Signature header value. The client already knows your API secret, so the call stays a two-argument one-liner.
// rawBody is the request body as Buffer or string (NOT a parsed JSON object).// signature is the value of the x-signature header.// Returns the parsed event object. Throws InvalidWebhookError on any failure.try { const event = client.verifyAndParseWebhook( req.rawBody, req.headers["x-signature"], ); // event.type, event.message, event.user, ...} catch (err) { // err instanceof InvalidWebhookError; err.message identifies the mode}
# request_body is the raw request body string/bytes.# signature is the value of the x-signature header.event = client.verify_and_parse_webhook(request_body, signature)# event.type, event.message, event.user, ...
// $requestBody is the raw request body string.// $signature is the value of the x-signature header.$event = $client->verifyAndParseWebhook($requestBody, $signature);// $event['type'], $event['message'], $event['user'], ...
// body is the raw request body bytes.// signature is the value of the x-signature header.event, err := client.VerifyAndParseWebhook(body, signature)if err != nil { if errors.Is(err, stream.ErrInvalidWebhook) { // err.Error() contains the failure mode (e.g. "signature mismatch", "invalid base64 encoding") } return}// event.Type, event.Message, event.User, ...
// requestBody is the raw request body bytes.// signature is the value of the x-signature header.var ev = client.VerifyAndParseWebhook(requestBody, signature);// ev.Type, ev.Message, ev.User, ...
// body is the raw request body bytes.// signature is the value of the x-signature header.var event = client.verifyAndParseWebhook(body, signature);// event.getType(), event.getMessage(), event.getUser(), ...
Where each argument comes from
Argument
Source
Example
body / rawBody
Raw HTTP request body bytes (not a parsed JSON object)
The API secret comes from the client you constructed at startup (new StreamChat(apiKey, apiSecret) and friends), so it's never passed into the helper.
Pass the raw body bytes, before any JSON parsing or string normalization — the helper hashes them as-is. In Express, enable express.raw({ type: 'application/json' }); in Django use request.body; in Flask use request.data; in Go drain r.Body once and reuse the bytes. If your framework hands you a parsed object, the signature check fails.
No client handy? Every SDK also ships the same logic as a stateless static / module-level helper that takes the API secret as an explicit third argument. The instance method is a thin wrapper around it, so the two forms behave identically — pick whichever fits the call site (Lambdas, edge functions, queue consumers, tests).
The same dual-API surface exists on the legacy stream-chat-* SDKs (e.g. import { verifyAndParseWebhook } from "stream-chat", \GetStream\StreamChat\Webhook::verifyAndParseWebhook, stream_chat.webhook.verify_and_parse_webhook).
Return type on legacy stream-chat-* SDKs.stream-chat-go, stream-chat-js, stream-chat-java, and stream-chat-net return typed event objects; stream-chat-python, stream-chat-ruby, and stream-chat-php return parsed JSON (dict / Hash / array) until typed events ship in those SDKs. The older verifyWebhook / verify_webhook_signature helpers still work for plain (uncompressed) bodies and are now also exposed under the name verifySignature; to support compressed payloads switch to verifyAndParseWebhook or call verifySignature(gunzipPayload(body), …) yourself.
Building this without a Stream SDK? Expand the per-language reference implementation below — language tabs cover JavaScript, Python, Ruby, PHP, Go, Java, and C#.
The SDK helper is a thin wrapper around three primitives — re-implement them in your runtime:
Helper
Purpose
gunzip_payload(body) -> bytes
Detect the RFC 1952 gzip magic (1f 8b) and decompress; pass through unchanged otherwise
verify_signature(body, signature, secret) -> bool
HMAC-SHA256 over the uncompressed body. Use constant-time comparison on HTTP webhooks; Stream does not attach this app-level signature to SQS/SNS payloads
parse_event(payload) -> Event
Parse JSON. Treat unknown event types as a generic event so handlers don't fail
verify_and_parse_webhook(body, signature, secret) is parse_event(verify_signature(gunzip_payload(body), …)). The references below show that composite per language — drop in your own JSON decoder for parse_event.
Enable gzip compression for hook payloads from the Dashboard or with enable_hook_payload_compression. Compressed payloads are typically 70–90% smaller and the decompression cost is negligible. HTTP webhooks get a Content-Encoding: gzip header; SQS and SNS messages are gzipped and base64-wrapped (because both transports are UTF-8 only). The verifyAndParseWebhook helper (HTTP) and parseSqs / parseSns (SQS/SNS, no app-level HMAC) detect the encoding from the body bytes, so the same handler code works whether or not compression is on — and stays correct even when middleware (Rails, Django, Laravel, Phoenix) auto-decompresses the request.
Apps created after May 7, 2026 have enable_hook_payload_compression set to true by default — your handlers will receive compressed payloads out of the box, so make sure they call verifyAndParseWebhook on HTTP traffic and parse_sqs / parse_sns on queue/topic deliveries (or equivalent decode + parse_event primitives) before going live. Apps created before that date stay opt-in; run the snippet below to turn compression on.
Use a current SDK — verifyAndParseWebhook for HTTP (decompress + verify_signature + typed event) and parse_sqs / parse_sns for SQS/SNS (decode + typed event, no app-level HMAC). All three handle uncompressed, gzipped, and base64+gzipped payloads without caller-side branching.
Without an SDK, your handler must accept Content-Encoding: gzip and gzip-decompress the body; for SQS/SNS, base64-decode then gzip-decompress (or detect via the gzip magic bytes 1f 8b, per RFC 1952).
Verify HMAC on the uncompressed bytes — never on the gzipped or base64-wrapped envelope.
Small payloads stay uncompressed to avoid envelope overhead, even with the flag on. The composite helpers handle both cases transparently.
The example below shows how to use the push webhooks
// Note: Any previously existing hooks not included in event_hooks array will be deleted.// Get current settings first to preserve your existing configuration.// STEP 1: Get current app settings to preserve existing hooksconst response = await client.getAppSettings();console.log("Current event hooks:", response.event_hooks);// STEP 2: Add webhook hook while preserving existing hooksconst existingHooks = response.event_hooks || [];const newWebhookHook = { enabled: true, hook_type: "webhook", webhook_url: "https://example.com/webhooks/stream/push", event_types: [], // empty array = all events};// STEP 3: Update with complete array including existing hooksawait client.updateAppSettings({ event_hooks: [...existingHooks, newWebhookHook],});// Test the webhook connectionawait client.testWebhookSettings({ webhook_url: "https://example.com/webhooks/stream/push",});
from getstream.models import EventHook# Note: Any previously existing hooks not included in event_hooks array will be deleted.# Get current settings first to preserve your existing configuration.# STEP 1: Get current app settings to preserve existing hooksresponse = client.get_app()existing_hooks = response.data.app.event_hooks or []print("Current event hooks:", existing_hooks)# STEP 2: Add webhook hook while preserving existing hooksnew_webhook_hook = EventHook( enabled=True, hook_type="webhook", webhook_url="https://example.com/webhooks/stream/push", event_types=[], # empty array = all events)# STEP 3: Update with complete array including existing hooksclient.update_app( event_hooks=existing_hooks + [new_webhook_hook])# Test webhook delivery using the Stream Dashboard
require 'getstream_ruby'Models = GetStream::Generated::Models# Note: Any previously existing hooks not included in event_hooks array will be deleted.# Get current settings first to preserve your existing configuration.# STEP 1: Get current app settings to preserve existing hooksresponse = client.common.get_appexisting_hooks = response.app.event_hooks || []puts "Current event hooks:", existing_hooks# STEP 2: Add webhook hook while preserving existing hooksnew_webhook_hook = { 'enabled' => true, 'hook_type' => 'webhook', 'webhook_url' => 'https://example.com/webhooks/stream/push', 'event_types' => [] # empty array = all events}# STEP 3: Update with complete array including existing hooksclient.common.update_app(Models::UpdateAppRequest.new( event_hooks: existing_hooks + [new_webhook_hook]))# Test the webhook connectionclient.common.check_push(Models::CheckPushRequest.new)
// Note: Any previously existing hooks not included in event_hooks array will be deleted.// Get current settings first to preserve your existing configuration.// STEP 1: Get current app settings to preserve existing hooks$response = $client->getApp();$existingHooks = $response->getData()->app->eventHooks ?? [];// STEP 2: Add webhook hook while preserving existing hooks$newWebhookHook = new Models\EventHook( enabled: true, hookType: "webhook", webhookUrl: "https://example.com/webhooks/stream/push", eventTypes: [], // empty array = all events);// STEP 3: Update with complete array including existing hooks$client->updateApp(new Models\UpdateAppRequest( eventHooks: array_merge($existingHooks, [$newWebhookHook]),));
// Note: Any previously existing hooks not included in event_hooks array will be deleted.// Get current settings first to preserve your existing configuration.// STEP 1: Get current app settings to preserve existing hookssettings, err := client.GetApp(ctx, &getstream.GetAppRequest{})if err != nil { log.Fatal(err)}existingHooks := settings.Data.App.EventHooks// STEP 2: Add webhook hook while preserving existing hooksnewWebhookHook := getstream.EventHook{ HookType: getstream.PtrTo("webhook"), Enabled: getstream.PtrTo(true), EventTypes: []string{}, // empty slice = all events WebhookUrl: getstream.PtrTo("https://example.com/webhooks/stream/push"),}// STEP 3: Update with complete array including existing hooksallHooks := append(existingHooks, newWebhookHook)_, err = client.UpdateApp(ctx, &getstream.UpdateAppRequest{ EventHooks: allHooks,})if err != nil { log.Fatal(err)}// Test the webhook connectionclient.CheckPush(ctx, &getstream.CheckPushRequest{})
// Note: Any previously existing hooks not included in event_hooks array will be deleted.// Get current settings first to preserve your existing configuration.// STEP 1: Get current app settings to preserve existing hooksvar response = client.getApp(GetAppRequest.builder().build()).execute().getData();var existingHooks = response.getApp().getEventHooks();System.out.println("Current event hooks: " + existingHooks);// STEP 2: Add webhook hook while preserving existing hooksvar newWebhookHook = EventHook.builder() .hookType("webhook") .enabled(true) .eventTypes(Collections.emptyList()) // empty list = all events .webhookUrl("https://example.com/webhooks/stream/push") .build();// STEP 3: Update with complete array including existing hooksvar allHooks = new ArrayList<>(existingHooks);allHooks.add(newWebhookHook);client.updateApp(UpdateAppRequest.builder() .eventHooks(allHooks) .build()).execute();// Test the webhook connectionclient.checkPush(CheckPushRequest.builder() .build()).execute();
// Note: Any previously existing hooks not included in event_hooks array will be deleted.// Get current settings first to preserve your existing configuration.// STEP 1: Get current app settings to preserve existing hooksvar settings = await client.GetAppAsync();var existingHooks = settings.App.EventHooks ?? new List<EventHook>();Console.WriteLine($"Current event hooks: {existingHooks}");// STEP 2: Add webhook hook while preserving existing hooksvar newWebhookHook = new EventHook{ HookType = "webhook", Enabled = true, EventTypes = new List<string>(), // empty list = all events WebhookUrl = "https://example.com/webhooks/stream/push",};// STEP 3: Update with complete array including existing hooksvar allHooks = new List<EventHook>(existingHooks) { newWebhookHook };await client.UpdateAppAsync(new UpdateAppRequest{ EventHooks = allHooks,});// Test the webhook connectionawait client.CheckPushAsync(new CheckPushRequest{ WebhookUrl = "https://example.com/webhooks/stream/push",});
You can also configure specific event types by providing an array of event names instead of an empty array:
// Configure webhook for specific events onlyconst newWebhookHook = { enabled: true, hook_type: "webhook", webhook_url: "https://example.com/webhooks/stream/messages", event_types: ["message.new", "message.updated", "message.deleted"], // specific events};await client.updateAppSettings({ event_hooks: [newWebhookHook],});
from getstream.models import EventHook# Configure webhook for specific events onlynew_webhook_hook = EventHook( enabled=True, hook_type="webhook", webhook_url="https://example.com/webhooks/stream/messages", event_types=["message.new", "message.updated", "message.deleted"], # specific events)client.update_app(event_hooks=[new_webhook_hook])
Some webhooks contain a field request_info , which holds information about the client that issued the request. This info is intended as an additional signal that you can use for moderation, fraud detection, or other similar purposes.
When configuring the SDK, you may also set an additional x-stream-ext header to be sent with each request. The value of this header is passed along as an ext field in the request_info . You can use this to pass along information that may be useful, such as device information. Refer to the SDK-specific docs on how to set this header.
"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", "ext": "device-id=123"}
For example, in Javascript, you can set the value like this:
The format of the ext header is up to you and you may leave it blank if you don't need it. The value is passed as-is, so you can use a simple value, comma-separated key-values, or more structured data, such as JSON. Binary data must be encoded as a string, for example using base64 or hex encoding.
You may set up to two pending message hooks per application. Only the first commit to a pending message will succeed; any subsequent commit attempts will return an error, as the message is no longer pending. If multiple hooks specify a timeout_ms, the system will use the longest timeout value.
For more information on configuring pending messages, please refer to the Pending Messages documentation.
If necessary, you can only expose your webhook service to Stream. This is possible by configuring your network (eg. iptables rules) to drop all incoming traffic that is not coming from our API infrastructure.
Below you can find the complete list of egress IP addresses that our webhook infrastructure uses. Such list is static and is not changing over time.