Every Stream product can deliver events to your server using webhooks, SQS or SNS.
When setting up a hook 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 its 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.
The configuration can be done using the API or from the Dashboard. Some important points to consider:
The selection of events you want to receive applies to all the endpoints you have configured.
You can configure multiple endpoints for the same app (eg. AWS SNS and HTTP Webhook).
If your app is configured to receive all events, you can still filter the events you want to receive in your webhook handler.
If your app is configured to receive all events, newly introduced event types will be sent to your webhook handler by default.
If you pick specific events, newly introduced event types will not be sent to your webhook handler by default (you can still manually add them later on).
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 = 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 },});
// 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();
Updating event_hooks replaces the whole array. Any previously existing hooks not included in the update will be deleted, so get the current app settings first and send back the complete list including your new hook.
For reliable event delivery, you can also configure SQS or SNS instead of webhooks.
An event_hooks update replaces the whole array: any previously existing hooks not included in it will be deleted. To add a hook, fetch the current configuration first and include the existing hooks in the update. The same pattern applies to SQS and SNS hooks.
// 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 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",});
// 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();
be reachable from the public internet. Tunneling services like Ngrok are supported
respond with response codes from 200 to 299 as fast as possible
be idempotent. Stream retries on network or 5xx errors, and retried calls carry the same X-Webhook-Id header
run over HTTPS with Keep-Alive enabled
We highly recommend following common security guidelines to make your webhook integration safe and fast:
Use HTTPS with a certificate from a trusted authority
Verify the "X-Signature" header to ensure the request is coming from Stream
Support HTTP Keep-Alive
Use a highly available infrastructure such as AWS Elastic Load Balancer, Google Cloud Load Balancer, or similar
Offload the processing of the message if possible (read, store, and forget)
When decoding JSON into objects, ensure that your webhook can handle new fields being added to the JSON payload as well as new event types (eg. log unknown fields and event types instead of failing)
All webhook requests contain these headers:
Name
Description
Example
X-Webhook-Id
Unique ID of the webhook call. This value is consistent between retries and could be used to deduplicate retry calls
123e4567-e89b-12d3-a456-426614174000
X-Webhook-Attempt
Number of webhook request attempt starting from 1
1
X-Api-Key
Your application's API key. Should be used to validate request signature
a1b23cdefgh4
X-Signature
HMAC signature of the request body. See Signature section
To verify and parse the event, call verifyAndParseWebhook on your Stream 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 using 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 (InvalidWebhookException::SIGNATURE_MISMATCH etc.)
Go
sentinel getstream.ErrInvalidWebhook
same prefixes; use errors.Is(err, getstream.ErrInvalidWebhook) for the unified check
Java
io.getstream.Webhook.InvalidWebhookError
same (Webhook.InvalidWebhookError.SIGNATURE_MISMATCH etc.)
.NET (C#)
StreamInvalidWebhookException
same (StreamInvalidWebhookException.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.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.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['user'], ...
// body is the raw request body bytes.// signature is the value of the x-signature header.event, err := client.VerifyAndParseWebhookBytes(body, signature)if err != nil { if errors.Is(err, getstream.ErrInvalidWebhook) { // err.Error() contains the failure mode (e.g. "signature mismatch", "invalid base64 encoding") } return}// event.Type, event.User, ...// Or, for net/http handlers that already have an *http.Request, use the// legacy convenience form that drains the body and reads X-Signature itself:// event, err := client.VerifyAndParseWebhook(r)
// 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.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.getUser(), ...
The API secret comes from the client you constructed at startup, 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).
// Go (getstream-go): bytes formevent, err := getstream.VerifyAndParseWebhookBytes(body, signature, secret)// Or, for net/http handlers, the convenience form that takes the request// (drains the body and reads X-Signature for you):// event, err := getstream.VerifyAndParseWebhook(r, secret)
// C# (getstream-dotnet)var ev = GetStream.Webhook.VerifyAndParseWebhook(body, signature, secret);
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 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.
Stream can send payloads of all events from your application to an Amazon SQS queue you own.
An application with a lot of users generates a lot of events. With a standard webhook configuration, events are posted to your server and can overwhelm unprepared servers during high-use periods. While the server is out, it will not be able to receive webhooks and will fail to process them. One way to avoid this issue is to use Stream's support for sending webhooks to Amazon SQS.
SQS removes the chance of losing data by providing a large, scalable bucket that holds events generated by Stream in a queue for your server. The complete list of supported events is identical to those sent through webhooks.
There are 2 ways to configure authentication on your SQS queue:
By providing a key and secret
Or by having Stream's AWS account assume a role on your SQS queue. With this option you omit the key and secret, but instead you set up a resource-based policy to grant Stream SendMessage permission on your SQS queue. The following policy needs to be attached to your queue (replace the value of Resource with the fully qualified ARN of your queue):
To configure an SQS queue, use the event_hooks array and the Update App Settings method:
// 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 SQS hook while preserving existing hooksconst existingHooks = response.event_hooks || [];const newSQSHook = { enabled: true, hook_type: "sqs", sqs_queue_url: "https://sqs.us-east-1.amazonaws.com/123456789012/MyQueue", sqs_region: "us-east-1", sqs_auth_type: "keys", // or "resource" for role-based auth sqs_key: "yourkey", sqs_secret: "yoursecret", event_types: [], // empty array = all events};// STEP 3: Update with complete array including existing hooksawait client.updateAppSettings({ event_hooks: [...existingHooks, newSQSHook],});// Test the SQS connectionawait client.testSQSSettings();
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 SQS hook while preserving existing hooksnew_sqs_hook = EventHook( enabled=True, hook_type="sqs", sqs_queue_url="https://sqs.us-east-1.amazonaws.com/123456789012/MyQueue", sqs_region="us-east-1", sqs_auth_type="keys", # or "resource" for role-based auth sqs_key="yourkey", sqs_secret="yoursecret", event_types=[], # empty array = all events)# STEP 3: Update with complete array including existing hooksclient.update_app( event_hooks=existing_hooks + [new_sqs_hook])# Test the SQS connectionclient.check_sqs(sqs_key="yourkey", sqs_secret="yoursecret", sqs_url="https://sqs.us-east-1.amazonaws.com/123456789012/MyQueue")
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 SQS hook while preserving existing hooksnew_sqs_hook = { 'enabled' => true, 'hook_type' => 'sqs', 'sqs_queue_url' => 'https://sqs.us-east-1.amazonaws.com/123456789012/MyQueue', 'sqs_region' => 'us-east-1', 'sqs_auth_type' => 'keys', # or "resource" for role-based auth 'sqs_key' => 'yourkey', 'sqs_secret' => 'yoursecret', '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_sqs_hook]))# Test the SQS connectionclient.common.check_sqs(Models::CheckSQSRequest.new( sqs_key: 'yourkey', sqs_secret: 'yoursecret', sqs_url: 'https://sqs.us-east-1.amazonaws.com/123456789012/MyQueue'))
// 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 SQS hook while preserving existing hooks$newSQSHook = new Models\EventHook( enabled: true, hookType: "sqs", sqsQueueUrl: "https://sqs.us-east-1.amazonaws.com/123456789012/MyQueue", sqsRegion: "us-east-1", sqsAuthType: "keys", // or "resource" for role-based auth sqsKey: "yourkey", sqsSecret: "yoursecret", eventTypes: [], // empty array = all events);// STEP 3: Update with complete array including existing hooks$client->updateApp(new Models\UpdateAppRequest( eventHooks: array_merge($existingHooks, [$newSQSHook]),));// Test the SQS connection$client->checkSQS(new Models\CheckSQSRequest( sqsUrl: "https://sqs.us-east-1.amazonaws.com/123456789012/MyQueue", sqsKey: "yourkey", sqsSecret: "yoursecret",));
// 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.EventHooksfmt.Printf("Current event hooks: %+v\n", existingHooks)// STEP 2: Add SQS hook while preserving existing hooksnewSQSHook := getstream.EventHook{ HookType: getstream.PtrTo("sqs"), Enabled: getstream.PtrTo(true), EventTypes: []string{}, // empty slice = all events SqsQueueUrl: getstream.PtrTo("https://sqs.us-east-1.amazonaws.com/123456789012/MyQueue"), SqsRegion: getstream.PtrTo("us-east-1"), SqsAuthType: getstream.PtrTo("keys"), // or "resource" for role-based auth SqsKey: getstream.PtrTo("yourkey"), SqsSecret: getstream.PtrTo("yoursecret"),}// STEP 3: Update with complete array including existing hooksallHooks := append(existingHooks, newSQSHook)_, err = client.UpdateApp(ctx, &getstream.UpdateAppRequest{ EventHooks: allHooks,})if err != nil { log.Fatal(err)}// Test the SQS connectionclient.CheckSQS(ctx, &getstream.CheckSQSRequest{ SqsUrl: getstream.PtrTo("https://sqs.us-east-1.amazonaws.com/123456789012/MyQueue"), SqsKey: getstream.PtrTo("yourkey"), SqsSecret: getstream.PtrTo("yoursecret"),})
// 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.Data.App.EventHooks ?? new List<EventHook>();Console.WriteLine($"Current event hooks: {existingHooks}");// STEP 2: Add SQS hook while preserving existing hooksvar newSQSHook = new EventHook{ HookType = "sqs", Enabled = true, EventTypes = new List<string>(), // empty list = all events SqsQueueUrl = "https://sqs.us-east-1.amazonaws.com/123456789012/MyQueue", SqsRegion = "us-east-1", SqsAuthType = "keys", // or "resource" for role-based auth SqsKey = "yourkey", SqsSecret = "yoursecret",};// STEP 3: Update with complete array including existing hooksvar allHooks = new List<EventHook>(existingHooks) { newSQSHook };await client.UpdateAppAsync(new UpdateAppRequest{ EventHooks = allHooks,});// Test the SQS connectionawait client.CheckSQSAsync(new CheckSQSRequest{ SqsKey = "yourkey", SqsSecret = "yoursecret", SqsUrl = "https://sqs.us-east-1.amazonaws.com/123456789012/MyQueue",});
// 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 SQS hook while preserving existing hooksvar newSQSHook = EventHook.builder() .hookType("sqs") .enabled(true) .eventTypes(Collections.emptyList()) // empty list = all events .sqsQueueUrl("https://sqs.us-east-1.amazonaws.com/123456789012/MyQueue") .sqsRegion("us-east-1") .sqsAuthType("keys") // or "resource" for role-based auth .sqsKey("yourkey") .sqsSecret("yoursecret") .build();// STEP 3: Update with complete array including existing hooksvar allHooks = new ArrayList<>(existingHooks);allHooks.add(newSQSHook);client.updateApp(UpdateAppRequest.builder() .eventHooks(allHooks) .build()).execute();// Test the SQS connectionclient.checkSQS(CheckSQSRequest.builder() .sqsKey("yourkey") .sqsSecret("yoursecret") .sqsUrl("https://sqs.us-east-1.amazonaws.com/123456789012/MyQueue") .build()).execute();
Stream needs the right permissions on your SQS queue to be able to send events to it. If updates are not showing up in your queue add the following permission policy to the queue:
SQS honours the same enable_hook_payload_compression flag (see Payload Compression). When compression is on, the message body is gzipped + base64-encoded (SQS only accepts UTF-8) and the producer sets these message attributes:
Call parseSqs on your Stream client with the SQS Body string. It reverses the base64 + gzip envelope and returns a typed event (with an UnknownEvent fallback), the same shape verifyAndParseWebhook returns for HTTP. The same call works whether or not compression is on (encoding is detected from the body bytes, so the content_encoding / payload_encoding attributes are only a hint).
No X-Signature on SQS. Stream does not ship an HMAC signature on SQS deliveries. The transport is authenticated end-to-end: the queue is gated by IAM, so only your account can read it, and only Stream's account can write to it.
// message is the SQS Message object you received from ReceiveMessageCommandconst event = client.parseSqs(message.Body);// event.type, event.user, ...
# message is an SQS message dict from boto3 receive_messageevent = client.parse_sqs(message["Body"])# event.type, event.user, ...
event = client.parse_sqs(message.body)
$event = $client->parseSqs($message['Body']);
event, err := client.ParseSqs(*message.Body)
var ev = client.ParseSqs(message.Body);
var event = client.parseSqs(message.body());
parse_sqs takes only the message body. No HMAC is involved; use your API secret only for other Stream API calls, not for parsing SQS payloads. The same decode + parse logic is exposed as a static / module-level parse_sqs (see Verifying and Parsing Events for per-language imports); use it in workers that don't keep a Stream client around.
Building this without a Stream SDK? Expand the per-language reference implementation below.
SQS adds one primitive on top of the building blocks from the webhooks overview:
Helper
Purpose
decode_sqs_payload(body) -> bytes
Base64-decode the SQS message body, then gzip-decompress when the result starts with the RFC 1952 gzip magic (1f 8b)
parse_sqs(body)
parse_event(decode_sqs_payload(body)) — reuses parse_event from the overview; no verify_signature step
parse_event is identical to the HTTP webhook implementation on the overview — only the body decoder is different. The references below show the composite per language.
const GZIP_MAGIC = Buffer.from([0x1f, 0x8b]);function decodeSqsPayload(body) { let buf = Buffer.from(body, "base64"); if (buf.length < 2 || !buf.subarray(0, 2).equals(GZIP_MAGIC)) { // not base64+gzip — fall back to the raw UTF-8 body (uncompressed case) buf = Buffer.from(body, "utf8"); } if (buf.length >= 2 && buf.subarray(0, 2).equals(GZIP_MAGIC)) { buf = require("zlib").gunzipSync(buf); } return buf;}function parseSqs(messageBody) { return parseEvent(decodeSqsPayload(messageBody));}// Usage with @aws-sdk/client-sqs:const event = parseSqs(message.Body);
Stream can send payloads of all events from your application to an Amazon SNS topic you own. SNS provides a large, scalable message exchange that delivers events generated by Stream to as many consumers as you like. The complete list of supported events is identical to those sent through webhooks.
There are 2 ways to configure authentication on your SNS topic:
By providing a key and secret
Or by having Stream's AWS account assume a role on your SNS topic. With this option you omit the key and secret, but instead you set up a resource-based policy to grant Stream Publish permission on your SNS topic. The following policy needs to be attached to your topic (replace the value of Resource with the fully qualified ARN of your topic):
To configure an SNS topic, use the event_hooks array and the Update App Settings method:
// 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 SNS hook while preserving existing hooksconst existingHooks = response.event_hooks || [];const newSNSHook = { enabled: true, hook_type: "sns", sns_topic_arn: "arn:aws:sns:us-east-1:123456789012:sns-topic", sns_region: "us-east-1", sns_auth_type: "keys", // or "resource" for role-based auth sns_key: "yourkey", sns_secret: "yoursecret", event_types: [], // empty array = all events};// STEP 3: Update with complete array including existing hooksawait client.updateAppSettings({ event_hooks: [...existingHooks, newSNSHook],});// Test the SNS connectionawait client.testSNSSettings({ sns_topic_arn: "arn:aws:sns:us-east-1:123456789012:sns-topic", sns_key: "yourkey", sns_secret: "yoursecret",});
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 SNS hook while preserving existing hooksnew_sns_hook = EventHook( enabled=True, hook_type="sns", sns_topic_arn="arn:aws:sns:us-east-1:123456789012:sns-topic", sns_region="us-east-1", sns_auth_type="keys", # or "resource" for role-based auth sns_key="yourkey", sns_secret="yoursecret", event_types=[], # empty array = all events)# STEP 3: Update with complete array including existing hooksclient.update_app( event_hooks=existing_hooks + [new_sns_hook])# Test the SNS connectionclient.check_sns(sns_key="yourkey", sns_secret="yoursecret", sns_topic_arn="arn:aws:sns:us-east-1:123456789012:sns-topic")
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 SNS hook while preserving existing hooksnew_sns_hook = { 'enabled' => true, 'hook_type' => 'sns', 'sns_topic_arn' => 'arn:aws:sns:us-east-1:123456789012:sns-topic', 'sns_region' => 'us-east-1', 'sns_auth_type' => 'keys', # or "resource" for role-based auth 'sns_key' => 'yourkey', 'sns_secret' => 'yoursecret', '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_sns_hook]))# Test the SNS connectionclient.common.check_sns(Models::CheckSNSRequest.new( sns_key: 'yourkey', sns_secret: 'yoursecret', sns_topic_arn: 'arn:aws:sns:us-east-1:123456789012:sns-topic'))
// 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 SNS hook while preserving existing hooks$newSNSHook = new Models\EventHook( enabled: true, hookType: "sns", snsTopicArn: "arn:aws:sns:us-east-1:123456789012:sns-topic", snsRegion: "us-east-1", snsAuthType: "keys", // or "resource" for role-based auth snsKey: "yourkey", snsSecret: "yoursecret", eventTypes: [], // empty array = all events);// STEP 3: Update with complete array including existing hooks$client->updateApp(new Models\UpdateAppRequest( eventHooks: array_merge($existingHooks, [$newSNSHook]),));// Test the SNS connection$client->checkSNS(new Models\CheckSNSRequest( snsTopicArn: "arn:aws:sns:us-east-1:123456789012:sns-topic", snsKey: "yourkey", snsSecret: "yoursecret",));
// 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.EventHooksfmt.Printf("Current event hooks: %+v\n", existingHooks)// STEP 2: Add SNS hook while preserving existing hooksnewSNSHook := getstream.EventHook{ HookType: getstream.PtrTo("sns"), Enabled: getstream.PtrTo(true), EventTypes: []string{}, // empty slice = all events SnsTopicArn: getstream.PtrTo("arn:aws:sns:us-east-1:123456789012:sns-topic"), SnsRegion: getstream.PtrTo("us-east-1"), SnsAuthType: getstream.PtrTo("keys"), // or "resource" for role-based auth SnsKey: getstream.PtrTo("yourkey"), SnsSecret: getstream.PtrTo("yoursecret"),}// STEP 3: Update with complete array including existing hooksallHooks := append(existingHooks, newSNSHook)_, err = client.UpdateApp(ctx, &getstream.UpdateAppRequest{ EventHooks: allHooks,})if err != nil { log.Fatal(err)}// Test the SNS connectionclient.CheckSNS(ctx, &getstream.CheckSNSRequest{ SnsTopicArn: getstream.PtrTo("arn:aws:sns:us-east-1:123456789012:sns-topic"), SnsKey: getstream.PtrTo("yourkey"), SnsSecret: getstream.PtrTo("yoursecret"),})
// 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.Data.App.EventHooks ?? new List<EventHook>();Console.WriteLine($"Current event hooks: {existingHooks}");// STEP 2: Add SNS hook while preserving existing hooksvar newSNSHook = new EventHook{ HookType = "sns", Enabled = true, EventTypes = new List<string>(), // empty list = all events SnsTopicArn = "arn:aws:sns:us-east-1:123456789012:sns-topic", SnsRegion = "us-east-1", SnsAuthType = "keys", // or "resource" for role-based auth SnsKey = "yourkey", SnsSecret = "yoursecret",};// STEP 3: Update with complete array including existing hooksvar allHooks = new List<EventHook>(existingHooks) { newSNSHook };await client.UpdateAppAsync(new UpdateAppRequest{ EventHooks = allHooks,});// Test the SNS connectionawait client.CheckSNSAsync(new CheckSNSRequest{ SnsKey = "yourkey", SnsSecret = "yoursecret", SnsTopicArn = "arn:aws:sns:us-east-1:123456789012:sns-topic",});
// 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 SNS hook while preserving existing hooksvar newSNSHook = EventHook.builder() .hookType("sns") .enabled(true) .eventTypes(Collections.emptyList()) // empty list = all events .snsTopicArn("arn:aws:sns:us-east-1:123456789012:sns-topic") .snsRegion("us-east-1") .snsAuthType("keys") // or "resource" for role-based auth .snsKey("yourkey") .snsSecret("yoursecret") .build();// STEP 3: Update with complete array including existing hooksvar allHooks = new ArrayList<>(existingHooks);allHooks.add(newSNSHook);client.updateApp(UpdateAppRequest.builder() .eventHooks(allHooks) .build()).execute();// Test the SNS connectionclient.checkSNS(CheckSNSRequest.builder() .snsKey("yourkey") .snsSecret("yoursecret") .snsTopicArn("arn:aws:sns:us-east-1:123456789012:sns-topic") .build()).execute();
SNS honours the same enable_hook_payload_compression flag (see Payload Compression). When compression is on, the notification Message is gzipped + base64-encoded (SNS only accepts UTF-8) and the producer sets the same message attributes as SQS.
Call parseSns on your Stream client with the notification body (either the raw envelope JSON string or the pre-extracted Message field). It unwraps the SNS envelope when needed, reverses the base64 + gzip payload, and returns a typed event. The return shape matches verifyAndParseWebhook sans verification. The same call works whether or not compression is on, and whether the topic is delivered over HTTP or via an SQS subscription.
No Stream X-Signature on SNS. Stream does not ship an app-level HMAC on SNS payloads. SNS itself is authenticated with AWS signatures (SigningCertURL / Signature) when you subscribe over HTTPS.
var event = client.parseSns(notification.getMessage());
parse_sns takes only the notification body string. No HMAC is involved; use your API secret only for other Stream API calls. The same unwrap + decode + parse logic is exposed as a static / module-level parse_sns (see Verifying and Parsing Events for per-language imports).
Building this without a Stream SDK? Expand the per-language reference implementation below.
SNS uses the same wire format as SQS (base64 + gzip inside Message), so the payload decoder matches decode_sqs_payload; the composite name stays decode_sns_payload in docs for clarity. Reuse parse_event from the webhooks overview; the SNS-specific composite is unwrap (when needed) + decode + parse — no verify_signature step:
Helper
Purpose
decode_sns_payload(message) -> bytes
Same as decode_sqs_payload: base64-decode then gzip-decompress when the result starts with the RFC 1952 gzip magic (1f 8b)
parse_sns(notification_body)
Unwrap SNS JSON envelope when needed, then parse_event(decode_sns_payload(inner_message))
function unwrapSnsNotificationBody(notificationBody) { const s = notificationBody.trim(); if (!s.startsWith("{")) return notificationBody; try { const env = JSON.parse(notificationBody); if (env && typeof env.Message === "string") return env.Message; } catch { /* not JSON envelope */ } return notificationBody;}function parseSns(notificationBody) { const message = unwrapSnsNotificationBody(notificationBody); return parseEvent(decodeSqsPayload(message));}const event = parseSns(notificationEnvelopeJsonOrMessage);
public static string UnwrapSnsNotificationBody(string notificationBody){ var s = notificationBody.TrimStart(); if (s.Length == 0 || s[0] != '{') return notificationBody; try { using var doc = JsonDocument.Parse(notificationBody); if (doc.RootElement.TryGetProperty("Message", out var m) && m.ValueKind == JsonValueKind.String) return m.GetString() ?? notificationBody; } catch (JsonException) { } return notificationBody;}public static JsonElement ParseSns(string notificationBody){ var inner = UnwrapSnsNotificationBody(notificationBody); var payload = DecodeSqsPayload(inner); return ParseEvent(payload);}// var ev = ParseSns(notification.Message);
public static String unwrapSnsNotificationBody(String notificationBody) { String s = notificationBody.strip(); if (!s.startsWith("{")) return notificationBody; try { var env = new ObjectMapper().readTree(notificationBody); if (env.hasNonNull("Message") && env.get("Message").isTextual()) { return env.get("Message").asText(); } } catch (Exception ignored) { } return notificationBody;}public static JsonNode parseSns(String notificationBody) throws Exception { String inner = unwrapSnsNotificationBody(notificationBody); byte[] payload = decodeSqsPayload(inner); return parseEvent(payload);}// var event = parseSns(notification.getMessage());
When event delivery fails after all retry attempts, Stream can automatically persist the failed event to a storage backend you control. This ensures no events are lost during outages in your endpoint.
Currently, Google Cloud Storage (GCS) is supported as a failover storage backend.
GCS credentials are validated when you save the configuration. Make sure the service account JSON key is valid and has write access to the specified bucket before updating your app settings.
Failover is currently supported for webhook hooks. Support for SQS and SNS hooks is planned.
Event failover honours the same app-level enable_hook_payload_compression flag described under Payload Compression. If you build a consumer that reads failed events from your GCS bucket, feed the payload field through the same verifyAndParseWebhook helper you use for live webhooks (or the manual gunzip_payload + parse_event primitives from the reference implementation) so your code transparently handles both the plain-JSON and the compressed cases.
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.
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.
Chat has additional hook types that are configured outside the event_hooks system: Before Message Send for modifying or moderating messages before delivery, Custom Commands for reacting to /slash commands, and pending messages for approval flows. Push delivery to user devices is covered under push notifications.