# Webhooks

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

<Admonition type="info">

Event types and payloads are documented per product: [Chat events](https://getstream.io/chat/docs/node/webhook-events/), [Feeds events](https://getstream.io/activity-feeds/docs/node/events/), [Video events](https://getstream.io/video/docs/api/webhooks/events/) and [Moderation events](https://getstream.io/moderation/docs/node/content-moderation/webhooks/).

</Admonition>

## Configuring Hooks

Here's how to quickly set up webhooks using the `event_hooks` configuration:

### Subscribe to Specific Events

<Tabs>

```js label="JavaScript"
// Subscribe to message.new and message.updated events only
await client.updateAppSettings({
  event_hooks: [
    {
      enabled: true,
      hook_type: "webhook",
      webhook_url: "https://example.com/webhooks/stream/messages",
      event_types: ["message.new", "message.updated"],
    },
  ],
});
```

```python label="Python"
from getstream.models import EventHook

# Subscribe to message.new and message.updated events only
client.update_app(
    event_hooks=[
        EventHook(
            enabled=True,
            hook_type="webhook",
            webhook_url="https://example.com/webhooks/stream/messages",
            event_types=["message.new", "message.updated"],
        )
    ]
)
```

```ruby label="Ruby"
require 'getstream_ruby'
Models = GetStream::Generated::Models

# Subscribe to message.new and message.updated events only
client.common.update_app(Models::UpdateAppRequest.new(
  event_hooks: [
    {
      'enabled' => true,
      'hook_type' => 'webhook',
      'webhook_url' => 'https://example.com/webhooks/stream/messages',
      'event_types' => ['message.new', 'message.updated']
    }
  ]
))
```

```php label="PHP"
// Subscribe to message.new and message.updated events only
$client->updateApp(new Models\UpdateAppRequest(
    eventHooks: [
        new Models\EventHook(
            enabled: true,
            hookType: "webhook",
            webhookUrl: "https://example.com/webhooks/stream/messages",
            eventTypes: ["message.new", "message.updated"],
        ),
    ],
));
```

```go label="Go"
// Subscribe to message.new and message.updated events only
client.UpdateApp(ctx, &getstream.UpdateAppRequest{
  EventHooks: getstream.PtrTo([]getstream.EventHook{
    {
      HookType:   getstream.PtrTo("webhook"),
      Enabled:    getstream.PtrTo(true),
      EventTypes: []string{"message.new", "message.updated"},
      WebhookUrl: getstream.PtrTo("https://example.com/webhooks/stream/messages"),
    },
  }),
})
```

```csharp label="C#"
// Subscribe to message.new and message.updated events only
var webhookHook = new EventHook
{
    HookType = "webhook",
    Enabled = true,
    EventTypes = new List<string> { "message.new", "message.updated" },
    WebhookUrl = "https://example.com/webhooks/stream/messages",
};

await client.UpdateAppAsync(new UpdateAppRequest
{
    EventHooks = new List<EventHook> { webhookHook },
});
```

```java label="Java"
// Subscribe to message.new and message.updated events only
var webhookHook = EventHook.builder()
    .hookType("webhook")
    .enabled(true)
    .eventTypes(List.of("message.new", "message.updated"))
    .webhookUrl("https://example.com/webhooks/stream/messages")
    .build();

client.updateApp(UpdateAppRequest.builder()
    .eventHooks(List.of(webhookHook))
    .build()).execute();
```

</Tabs>

### Subscribe to All Events

Use an empty `event_types` array to receive all existing and future events:

<Tabs>

```js label="JavaScript"
// 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
    },
  ],
});
```

```python label="Python"
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
        )
    ]
)
```

```ruby label="Ruby"
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
    }
  ]
))
```

```php label="PHP"
// 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
        ),
    ],
));
```

```go label="Go"
// Subscribe to all events (empty slice = all events)
client.UpdateApp(ctx, &getstream.UpdateAppRequest{
  EventHooks: getstream.PtrTo([]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"),
    },
  }),
})
```

```csharp label="C#"
// 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 },
});
```

```java label="Java"
// 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();
```

</Tabs>

<Admonition type="caution">

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.

</Admonition>

<Admonition type="info">

For reliable event delivery, you can also configure [SQS](#sqs) or [SNS](#sns) instead of webhooks.

</Admonition>

### Adding a Hook Without Deleting Existing Ones

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.

<Tabs>

```js label="JavaScript"
// 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
const response = await client.getAppSettings();
console.log("Current event hooks:", response.event_hooks);

// STEP 2: Add webhook hook while preserving existing hooks
const 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 hooks
await client.updateAppSettings({
  event_hooks: [...existingHooks, newWebhookHook],
});

// Test the webhook connection
await client.testWebhookSettings({
  webhook_url: "https://example.com/webhooks/stream/push",
});
```

```python label="Python"
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 hooks
response = 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 hooks
new_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 hooks
client.update_app(
    event_hooks=existing_hooks + [new_webhook_hook]
)

# Test webhook delivery using the Stream Dashboard
```

```ruby label="Ruby"
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 hooks
response = client.common.get_app
existing_hooks = response.app.event_hooks || []
puts "Current event hooks:", existing_hooks

# STEP 2: Add webhook hook while preserving existing hooks
new_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 hooks
client.common.update_app(Models::UpdateAppRequest.new(
  event_hooks: existing_hooks + [new_webhook_hook]
))

# Test the webhook connection
client.common.check_push(Models::CheckPushRequest.new)
```

```php label="PHP"
// 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]),
));
```

```go label="Go"
// 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
settings, 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 hooks
newWebhookHook := 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 hooks
allHooks := append(existingHooks, newWebhookHook)
_, err = client.UpdateApp(ctx, &getstream.UpdateAppRequest{
    EventHooks: getstream.PtrTo(allHooks),
})
if err != nil {
    log.Fatal(err)
}

// Test the webhook connection
client.CheckPush(ctx, &getstream.CheckPushRequest{})
```

```csharp label="C#"
// 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
var 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 hooks
var 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 hooks
var allHooks = new List<EventHook>(existingHooks) { newWebhookHook };
await client.UpdateAppAsync(new UpdateAppRequest
{
    EventHooks = allHooks,
});

// Test the webhook connection
await client.CheckPushAsync(new CheckPushRequest
{
    WebhookUrl = "https://example.com/webhooks/stream/push",
});
```

```java label="Java"
// 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
var 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 hooks
var 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 hooks
var allHooks = new ArrayList<>(existingHooks);
allHooks.add(newWebhookHook);
client.updateApp(UpdateAppRequest.builder()
    .eventHooks(allHooks)
    .build()).execute();

// Test the webhook connection
client.checkPush(CheckPushRequest.builder()
    .build()).execute();
```

</Tabs>

### Debugging webhook requests with NGROK

The easiest way to debug webhooks is with NGROK.

1. Start NGROK

```bash
brew install ngrok
ngrok http 8000
```

2. Update your webhook URL to the NGROK url

3. Trigger a webhook

4. Open up the ngrok inspector

[http://127.0.0.1:4040/inspect/http](http://127.0.0.1:4040/inspect/http)

## Handling the Webhook

Your webhook handler needs to follow these rules:

- accept HTTP POST requests with JSON payload
- 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                                                            | ca978112ca1bbdcafac231b39a23dc4da786eff8147c4e72b9807785afee48bb |

## Verifying and Parsing Events

To verify and parse the event, call `verifyAndParseWebhook` on your Stream client. It transparently decompresses the body when [Payload Compression](#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:

| Language   | Error class                                | Failure-mode messages                                                                                |
| ---------- | ------------------------------------------ | ---------------------------------------------------------------------------------------------------- |
| JavaScript | `InvalidWebhookError`                      | `signature mismatch`, `invalid base64 encoding`, `gzip decompression failed`, `invalid JSON payload` |
| Python     | `InvalidWebhookError`                      | same                                                                                                 |
| Ruby       | `StreamChat::Webhook::InvalidWebhookError` | same                                                                                                 |
| PHP        | `InvalidWebhookException`                  | 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.

<Tabs>

```js label="JavaScript"
// 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
}
```

```python label="Python"
# Django request
event = client.verify_and_parse_webhook(
    request.body,
    request.META["HTTP_X_SIGNATURE"],
)

# Flask request
event = client.verify_and_parse_webhook(
    request.data,
    request.headers["X-Signature"],
)

# event.type, event.user, ...
```

```ruby label="Ruby"
# 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, ...
```

```php label="PHP"
// $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'], ...
```

```go label="Go"
// 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)
```

```csharp label="C#"
// 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, ...
```

```java label="Java"
// 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(), ...
```

</Tabs>

### Where each argument comes from

| Argument           | Source                                                     | Example                                                           |
| ------------------ | ---------------------------------------------------------- | ----------------------------------------------------------------- |
| `body` / `rawBody` | Raw HTTP request body **bytes** (not a parsed JSON object) | `req.rawBody`, `request.body`, `request.data`, `r.Body` (drained) |
| `signature`        | `X-Signature` request header                               | `req.headers["x-signature"]`                                      |

The API secret comes from the client you constructed at startup, so it's never passed into the helper.

<Admonition type="caution">

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.

</Admonition>

<Admonition type="note">

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

```js
// JavaScript (getstream-node)
import { Webhook } from "@getstream/node";
const event = Webhook.verifyAndParseWebhook(rawBody, signature, secret);
```

```python
# Python (getstream)
from getstream import webhook
event = webhook.verify_and_parse_webhook(body, signature, secret)
```

```ruby
# Ruby (getstream_ruby): static helper exposed under the StreamChat::Webhook module
event = StreamChat::Webhook.verify_and_parse_webhook(body, signature, secret)
```

```php
// PHP (getstream-php)
$event = \GetStream\Webhook::verifyAndParseWebhook($body, $signature, $secret);
```

```go
// Go (getstream-go): bytes form
event, 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)
```

```csharp
// C# (getstream-dotnet)
var ev = GetStream.Webhook.VerifyAndParseWebhook(body, signature, secret);
```

```java
// Java (getstream-java)
var event = io.getstream.Webhook.verifyAndParseWebhook(body, signature, secret);
```

</Admonition>

Building this without a Stream SDK? Expand the per-language reference implementation below. Language tabs cover JavaScript, Python, Ruby, PHP, Go, Java and C#.

<Disclosure label="Reference implementation (no SDK)">

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](https://datatracker.ietf.org/doc/html/rfc1952) 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`.

<Tabs>

```js label="JavaScript"
const crypto = require("crypto");
const zlib = require("zlib");

const GZIP_MAGIC = Buffer.from([0x1f, 0x8b]);

function gunzipPayload(rawBody) {
  const body = Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(rawBody);
  if (body.length >= 2 && body.subarray(0, 2).equals(GZIP_MAGIC)) {
    return zlib.gunzipSync(body);
  }
  return body;
}

function verifySignature(body, signature, secret) {
  const expected = crypto
    .createHmac("sha256", secret)
    .update(body)
    .digest("hex");
  const sig = Buffer.from(signature, "utf8");
  const exp = Buffer.from(expected, "utf8");
  return sig.length === exp.length && crypto.timingSafeEqual(sig, exp);
}

function verifyAndParseWebhook(rawBody, signature, secret) {
  const body = gunzipPayload(rawBody);
  if (!verifySignature(body, signature, secret)) {
    throw new Error("invalid webhook signature");
  }
  return JSON.parse(body.toString("utf8"));
}
```

```python label="Python"
import gzip, hmac, hashlib, json

GZIP_MAGIC = b"\x1f\x8b"

def gunzip_payload(body: bytes) -> bytes:
    if isinstance(body, str):
        body = body.encode("utf-8")
    return gzip.decompress(body) if body[:2] == GZIP_MAGIC else body

def verify_signature(body: bytes, signature: str, secret: str) -> bool:
    expected = hmac.new(secret.encode("utf-8"), body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature)

def verify_and_parse_webhook(body, signature, secret):
    body = gunzip_payload(body)
    if not verify_signature(body, signature, secret):
        raise ValueError("invalid webhook signature")
    return json.loads(body)
```

```ruby label="Ruby"
require 'json'
require 'openssl'
require 'zlib'
require 'stringio'

GZIP_MAGIC = "\x1f\x8b".b.freeze

def gunzip_payload(body)
  body = body.b
  body.start_with?(GZIP_MAGIC) ? Zlib::GzipReader.new(StringIO.new(body)).read : body
end

def verify_signature(body, signature, secret)
  expected = OpenSSL::HMAC.hexdigest('SHA256', secret, body)
  OpenSSL.fixed_length_secure_compare(expected, signature)
end

def verify_and_parse_webhook(body, signature, secret)
  body = gunzip_payload(body)
  raise 'invalid webhook signature' unless verify_signature(body, signature, secret)
  JSON.parse(body)
end
```

```php label="PHP"
function gunzipPayload(string $body): string {
    if (substr($body, 0, 2) === "\x1f\x8b") {
        $decoded = gzdecode($body);
        if ($decoded === false) {
            throw new RuntimeException('gzip decode failed');
        }
        return $decoded;
    }
    return $body;
}

function verifySignature(string $body, string $signature, string $secret): bool {
    return hash_equals(hash_hmac('sha256', $body, $secret), $signature);
}

function verifyAndParseWebhook(string $body, string $signature, string $secret): array {
    $body = gunzipPayload($body);
    if (!verifySignature($body, $signature, $secret)) {
        throw new RuntimeException('invalid webhook signature');
    }
    return json_decode($body, true, flags: JSON_THROW_ON_ERROR);
}
```

```go label="Go"
package webhook

import (
    "bytes"
    "compress/gzip"
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
    "encoding/json"
    "errors"
    "io"
)

var gzipMagic = []byte{0x1f, 0x8b}

func GunzipPayload(body []byte) ([]byte, error) {
    if len(body) < 2 || !bytes.Equal(body[:2], gzipMagic) {
        return body, nil
    }
    gz, err := gzip.NewReader(bytes.NewReader(body))
    if err != nil {
        return nil, err
    }
    defer gz.Close()
    return io.ReadAll(gz)
}

func VerifySignature(body []byte, signature, secret string) bool {
    mac := hmac.New(sha256.New, []byte(secret))
    mac.Write(body)
    expected := []byte(hex.EncodeToString(mac.Sum(nil)))
    return hmac.Equal(expected, []byte(signature))
}

func VerifyAndParseWebhook(body []byte, signature, secret string) (map[string]any, error) {
    body, err := GunzipPayload(body)
    if err != nil {
        return nil, err
    }
    if !VerifySignature(body, signature, secret) {
        return nil, errors.New("invalid webhook signature")
    }
    var event map[string]any
    if err := json.Unmarshal(body, &event); err != nil {
        return nil, err
    }
    return event, nil
}
```

```csharp label="C#"
using System;
using System.IO;
using System.IO.Compression;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;

public static byte[] GunzipPayload(byte[] body)
{
    if (body.Length >= 2 && body[0] == 0x1f && body[1] == 0x8b)
    {
        using var gz = new GZipStream(new MemoryStream(body), CompressionMode.Decompress);
        using var ms = new MemoryStream();
        gz.CopyTo(ms);
        return ms.ToArray();
    }
    return body;
}

public static bool VerifySignature(byte[] body, string signature, string secret)
{
    using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
    var expected = Encoding.UTF8.GetBytes(Convert.ToHexString(hmac.ComputeHash(body)).ToLowerInvariant());
    return CryptographicOperations.FixedTimeEquals(expected, Encoding.UTF8.GetBytes(signature));
}

public static JsonElement VerifyAndParseWebhook(byte[] body, string signature, string secret)
{
    var payload = GunzipPayload(body);
    if (!VerifySignature(payload, signature, secret))
    {
        throw new InvalidOperationException("invalid webhook signature");
    }
    return JsonSerializer.Deserialize<JsonElement>(payload);
}
```

```java label="Java"
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HexFormat;
import java.util.zip.GZIPInputStream;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;

public static byte[] gunzipPayload(byte[] body) throws Exception {
    if (body.length >= 2 && body[0] == 0x1f && (body[1] & 0xff) == 0x8b) {
        try (var gz = new GZIPInputStream(new ByteArrayInputStream(body));
             var out = new ByteArrayOutputStream()) {
            gz.transferTo(out);
            return out.toByteArray();
        }
    }
    return body;
}

public static boolean verifySignature(byte[] body, String signature, String secret) throws Exception {
    Mac mac = Mac.getInstance("HmacSHA256");
    mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
    byte[] expected = HexFormat.of().formatHex(mac.doFinal(body)).getBytes(StandardCharsets.UTF_8);
    return MessageDigest.isEqual(expected, signature.getBytes(StandardCharsets.UTF_8));
}

public static JsonNode verifyAndParseWebhook(byte[] body, String signature, String secret) throws Exception {
    byte[] payload = gunzipPayload(body);
    if (!verifySignature(payload, signature, secret)) {
        throw new SecurityException("invalid webhook signature");
    }
    return new ObjectMapper().readTree(payload);
}
```

</Tabs>

</Disclosure>

## Retries

When a delivery fails, Stream retries it immediately, with no backoff. Retries stop as soon as any one of these limits is reached:

| Limit                                         | Value      |
| --------------------------------------------- | ---------- |
| Attempts per event                            | 5          |
| Timeout per attempt                           | 6 seconds  |
| Total time per event, including all retries   | 15 seconds |
| Retryable response errors (408, 429, any 5xx) | 3          |
| Network errors                                | 2          |

Attempt timeouts have a ceiling of their own, set to 5, but with these values the 15 second total budget is always reached first. Two attempts that time out consume 12 seconds, and a third starts and is cut short when the budget runs out, so an endpoint that never answers sees three attempts rather than five.

Any other non-2xx response is treated as final and is not retried. `Retry-After` is not honoured. Every retry carries the same `X-Webhook-Id` with an incrementing `X-Webhook-Attempt`, so deduplicate on the ID rather than on event contents.

These limits are per hook and identical for every product: chat, feeds, video and moderation events all follow them.

When delivery fails after all retry attempts, [event failover](#event-failover) can persist the failed event to a storage backend you control.

## Payload Compression

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.

<Admonition type="info">

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.

</Admonition>

### Enabling compression

<Tabs>

```js label="JavaScript"
await client.updateAppSettings({
  enable_hook_payload_compression: true,
});
```

```python label="Python"
client.update_app(enable_hook_payload_compression=True)
```

```ruby label="Ruby"
require 'getstream_ruby'
Models = GetStream::Generated::Models

client.common.update_app(Models::UpdateAppRequest.new(
  enable_hook_payload_compression: true,
))
```

```php label="PHP"
$client->updateApp(new Models\UpdateAppRequest(
    enableHookPayloadCompression: true,
));
```

```go label="Go"
client.UpdateApp(ctx, &getstream.UpdateAppRequest{
  EnableHookPayloadCompression: getstream.PtrTo(true),
})
```

```csharp label="C#"
await client.UpdateAppAsync(new UpdateAppRequest
{
    EnableHookPayloadCompression = true,
});
```

```java label="Java"
client.updateApp(UpdateAppRequest.builder()
    .enableHookPayloadCompression(true)
    .build()).execute();
```

</Tabs>

The flag applies to every transport: HTTP webhooks, SQS and SNS. It also covers the [event failover](#event-failover) hook and chat's [Before Message Send hook](https://getstream.io/chat/docs/node/before-message-send-webhook/).

### Before enabling compression in production

- **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](https://datatracker.ietf.org/doc/html/rfc1952)).
- **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 threshold is 256 bytes. The composite helpers handle both cases transparently.

## SQS

Stream can send payloads of all events from your application to an [Amazon SQS](https://aws.amazon.com/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.

### Authentication

There are 2 ways to configure authentication on your SQS queue:

1. By providing a key and secret

2. 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):

<Tabs>

```json label="JSON"
{
  "Sid": "AllowStreamProdAccount",
  "Effect": "Allow",
  "Principal": {
    "AWS": "arn:aws:iam::185583345998:root"
  },
  "Action": "SQS:SendMessage",
  "Resource": "arn:aws:sqs:us-west-2:1111111111:customer-sqs-for-stream"
}
```

</Tabs>

To configure an SQS queue, use the `event_hooks` array and the Update App Settings method:

<Tabs>

```js label="JavaScript"
// 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
const response = await client.getAppSettings();
console.log("Current event hooks:", response.event_hooks);

// STEP 2: Add SQS hook while preserving existing hooks
const 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 hooks
await client.updateAppSettings({
  event_hooks: [...existingHooks, newSQSHook],
});

// Test the SQS connection
await client.testSQSSettings();
```

```python label="Python"
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 hooks
response = 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 hooks
new_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 hooks
client.update_app(
    event_hooks=existing_hooks + [new_sqs_hook]
)

# Test the SQS connection
client.check_sqs(sqs_key="yourkey", sqs_secret="yoursecret", sqs_url="https://sqs.us-east-1.amazonaws.com/123456789012/MyQueue")
```

```ruby label="Ruby"
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 hooks
response = client.common.get_app
existing_hooks = response.app.event_hooks || []
puts "Current event hooks:", existing_hooks

# STEP 2: Add SQS hook while preserving existing hooks
new_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 hooks
client.common.update_app(Models::UpdateAppRequest.new(
  event_hooks: existing_hooks + [new_sqs_hook]
))

# Test the SQS connection
client.common.check_sqs(Models::CheckSQSRequest.new(
  sqs_key: 'yourkey',
  sqs_secret: 'yoursecret',
  sqs_url: 'https://sqs.us-east-1.amazonaws.com/123456789012/MyQueue'
))
```

```php label="PHP"
// 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",
));
```

```go label="Go"
// 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
settings, err := client.GetApp(ctx, &getstream.GetAppRequest{})
if err != nil {
    log.Fatal(err)
}
existingHooks := settings.Data.App.EventHooks
fmt.Printf("Current event hooks: %+v\n", existingHooks)

// STEP 2: Add SQS hook while preserving existing hooks
newSQSHook := 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 hooks
allHooks := append(existingHooks, newSQSHook)
_, err = client.UpdateApp(ctx, &getstream.UpdateAppRequest{
    EventHooks: getstream.PtrTo(allHooks),
})
if err != nil {
    log.Fatal(err)
}

// Test the SQS connection
client.CheckSQS(ctx, &getstream.CheckSQSRequest{
    SqsUrl:    getstream.PtrTo("https://sqs.us-east-1.amazonaws.com/123456789012/MyQueue"),
    SqsKey:    getstream.PtrTo("yourkey"),
    SqsSecret: getstream.PtrTo("yoursecret"),
})
```

```csharp label="C#"
// 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
var 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 hooks
var 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 hooks
var allHooks = new List<EventHook>(existingHooks) { newSQSHook };
await client.UpdateAppAsync(new UpdateAppRequest
{
    EventHooks = allHooks,
});

// Test the SQS connection
await client.CheckSQSAsync(new CheckSQSRequest
{
    SqsKey = "yourkey",
    SqsSecret = "yoursecret",
    SqsUrl = "https://sqs.us-east-1.amazonaws.com/123456789012/MyQueue",
});
```

```java label="Java"
// 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
var 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 hooks
var 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 hooks
var allHooks = new ArrayList<>(existingHooks);
allHooks.add(newSQSHook);
client.updateApp(UpdateAppRequest.builder()
    .eventHooks(allHooks)
    .build()).execute();

// Test the SQS connection
client.checkSQS(CheckSQSRequest.builder()
    .sqsKey("yourkey")
    .sqsSecret("yoursecret")
    .sqsUrl("https://sqs.us-east-1.amazonaws.com/123456789012/MyQueue")
    .build()).execute();
```

</Tabs>

### Configuration Options

The following options are available when configuring an SQS event hook:

| Option        | Type    | Description                                                                             | Required                                                                           |
| ------------- | ------- | --------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- |
| id            | string  | Unique identifier for the event hook                                                    | No. If empty, it will generate an ID.                                              |
| enabled       | boolean | Boolean flag to enable/disable the hook                                                 | Yes                                                                                |
| hook_type     | string  | Must be set to `"sqs"`                                                                  | Yes                                                                                |
| sqs_queue_url | string  | The AWS SQS queue URL                                                                   | Yes                                                                                |
| sqs_region    | string  | The AWS region where the SQS queue is located (e.g., "us-east-1")                       | Yes                                                                                |
| sqs_auth_type | string  | Authentication type: `"keys"` for access key/secret or `"resource"` for role-based auth | Yes                                                                                |
| sqs_key       | string  | AWS access key ID (required if auth_type is "keys")                                     | Yes if using key auth                                                              |
| sqs_secret    | string  | AWS secret access key (required if auth_type is "keys")                                 | Yes if using key auth                                                              |
| event_types   | array   | Array of event types this hook should handle                                            | No. Not provided or empty array means subscribe to all existing and future events. |

### SQS Permissions

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:

<Tabs>

```json label="JSON"
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "Stmt1459523779000",
      "Effect": "Allow",
      "Action": [
        "sqs:GetQueueUrl",
        "sqs:SendMessage",
        "sqs:SendMessageBatch",
        "sqs:GetQueueAttributes"
      ],
      "Resource": ["arn:aws:sqs:region:acc_id:queue_name"]
    }
  ]
}
```

</Tabs>

Here's an example list of messages read from your SQS queue:

<Disclosure label="Response">

```json
{
  "type": "message.new",
  "cid": "messaging:fun-d5f396e3-fbaf-469c-9b45-8837b4f75baa",
  "message": {
    "id": "8bffc454-e1da-4d91-8b88-a87853dfb41c",
    "text": "Welcome to the Community!",
    "html": "<p>Welcome to the Community!</p>\n",
    "type": "regular",
    "user": {
      "id": "tommaso-52ec3a5f-e916-469f-bf54-b53b5247a4b0",
      "role": "user",
      "created_at": "2020-03-30T07:54:46.207332Z",
      "updated_at": "2020-03-30T07:54:46.207719Z",
      "banned": false,
      "online": false
    },
    "attachments": [],
    "latest_reactions": [],
    "own_reactions": [],
    "reaction_counts": null,
    "reaction_scores": {},
    "reply_count": 0,
    "created_at": "2020-03-30T07:54:46.277381Z",
    "updated_at": "2020-03-30T07:54:46.277382Z",
    "mentioned_users": []
  },
  "user": {
    "id": "tommaso-52ec3a5f-e916-469f-bf54-b53b5247a4b0",
    "role": "user",
    "created_at": "2020-03-30T07:54:46.207332Z",
    "updated_at": "2020-03-30T07:54:46.207719Z",
    "banned": false,
    "online": false,
    "channel_unread_count": 0,
    "channel_last_read_at": "2020-03-30T07:54:46.270208768Z",
    "total_unread_count": 0,
    "unread_channels": 0,
    "unread_count": 0
  },
  "created_at": "2020-03-30T07:54:46.295138Z",
  "members": [
    {
      "user_id": "thierry-735d0d44-8bf1-40df-81db-fa83363ac790",
      "user": {
        "id": "tommaso-52ec3a5f-e916-469f-bf54-b53b5247a4b0",
        "role": "user",
        "created_at": "2020-03-30T07:54:46.207332Z",
        "updated_at": "2020-03-30T07:54:46.207719Z",
        "banned": false,
        "online": false
      },
      "created_at": "2020-03-30T07:54:46.255628Z",
      "updated_at": "2020-03-30T07:54:46.255628Z"
    }
  ],
  "channel_type": "messaging",
  "channel_id": "fun-d5f396e3-fbaf-469c-9b45-8837b4f75baa"
}
```

</Disclosure>

### Reading messages from SQS

SQS honours the same `enable_hook_payload_compression` flag (see [Payload Compression](#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:

```json
{
  "content_encoding": "gzip",
  "content_type": "application/json",
  "payload_encoding": "base64"
}
```

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

<Admonition type="note">

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

</Admonition>

<Tabs>

```js label="JavaScript"
// message is the SQS Message object you received from ReceiveMessageCommand
const event = client.parseSqs(message.Body);
// event.type, event.user, ...
```

```python label="Python"
# message is an SQS message dict from boto3 receive_message
event = client.parse_sqs(message["Body"])
# event.type, event.user, ...
```

```ruby label="Ruby"
event = client.parse_sqs(message.body)
```

```php label="PHP"
$event = $client->parseSqs($message['Body']);
```

```go label="Go"
event, err := client.ParseSqs(*message.Body)
```

```csharp label="C#"
var ev = client.ParseSqs(message.Body);
```

```java label="Java"
var event = client.parseSqs(message.body());
```

</Tabs>

`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](#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.

<Disclosure label="Reference implementation (no SDK)">

SQS adds one primitive on top of the building blocks from the [webhooks overview](#verifying-and-parsing-events):

| Helper                              | Purpose                                                                                                                                                                 |
| ----------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `decode_sqs_payload(body) -> bytes` | Base64-decode the SQS message body, then gzip-decompress when the result starts with the [RFC 1952](https://datatracker.ietf.org/doc/html/rfc1952) 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.

<Tabs>

```js label="JavaScript"
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);
```

```python label="Python"
import base64, gzip, json

GZIP_MAGIC = b"\x1f\x8b"

def decode_sqs_payload(body: str) -> bytes:
    try:
        buf = base64.b64decode(body, validate=True)
    except Exception:
        buf = body.encode("utf-8")
    if buf[:2] == GZIP_MAGIC:
        buf = gzip.decompress(buf)
    return buf

def parse_sqs(message_body):
    return parse_event(decode_sqs_payload(message_body))

# Usage with boto3 sqs.receive_message():
event = parse_sqs(message["Body"])
```

```ruby label="Ruby"
require 'base64'
require 'json'
require 'zlib'
require 'stringio'

GZIP_MAGIC = "\x1f\x8b".b

def decode_sqs_payload(body)
  begin
    buf = Base64.strict_decode64(body)
  rescue ArgumentError
    buf = body.b
  end
  buf.start_with?(GZIP_MAGIC) ? Zlib::GzipReader.new(StringIO.new(buf)).read : buf
end

def parse_sqs(message_body)
  parse_event(decode_sqs_payload(message_body))
end

# Usage with the AWS SDK for Ruby:
event = parse_sqs(message.body)
```

```php label="PHP"
function decodeSqsPayload(string $body): string {
    $buf = base64_decode($body, true);
    if ($buf === false) {
        $buf = $body;
    }
    if (substr($buf, 0, 2) === "\x1f\x8b") {
        $buf = gzdecode($buf);
        if ($buf === false) {
            throw new RuntimeException('gzip decode failed');
        }
    }
    return $buf;
}

function parseSqs(string $messageBody): array {
    return parseEvent(decodeSqsPayload($messageBody));
}

# Usage with the AWS SDK for PHP:
$event = parseSqs($message['Body']);
```

```go label="Go"
package webhook

import (
    "bytes"
    "compress/gzip"
    "encoding/base64"
    "io"
)

var gzipMagic = []byte{0x1f, 0x8b}

func DecodeSqsPayload(body string) ([]byte, error) {
    buf, err := base64.StdEncoding.DecodeString(body)
    if err != nil {
        buf = []byte(body)
    }
    if len(buf) >= 2 && bytes.Equal(buf[:2], gzipMagic) {
        gz, err := gzip.NewReader(bytes.NewReader(buf))
        if err != nil {
            return nil, err
        }
        defer gz.Close()
        return io.ReadAll(gz)
    }
    return buf, nil
}

func ParseSqs(messageBody string) (map[string]any, error) {
    payload, err := DecodeSqsPayload(messageBody)
    if err != nil {
        return nil, err
    }
    return ParseEvent(payload)
}

// Usage with aws-sdk-go-v2 sqs.ReceiveMessage:
// event, err := ParseSqs(*message.Body)
```

```csharp label="C#"
public static byte[] DecodeSqsPayload(string body)
{
    byte[] buf;
    try { buf = Convert.FromBase64String(body); }
    catch (FormatException) { buf = Encoding.UTF8.GetBytes(body); }

    if (buf.Length >= 2 && buf[0] == 0x1f && buf[1] == 0x8b)
    {
        using var gz = new GZipStream(new MemoryStream(buf), CompressionMode.Decompress);
        using var ms = new MemoryStream();
        gz.CopyTo(ms);
        return ms.ToArray();
    }
    return buf;
}

public static JsonElement ParseSqs(string messageBody)
{
    var payload = DecodeSqsPayload(messageBody);
    return ParseEvent(payload);
}

// Usage with AWSSDK.SQS:
// var ev = ParseSqs(message.Body);
```

```java label="Java"
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.util.Base64;
import java.util.zip.GZIPInputStream;

public static byte[] decodeSqsPayload(String body) throws Exception {
    byte[] buf;
    try {
        buf = Base64.getDecoder().decode(body);
    } catch (IllegalArgumentException e) {
        buf = body.getBytes(java.nio.charset.StandardCharsets.UTF_8);
    }
    if (buf.length >= 2 && buf[0] == 0x1f && (buf[1] & 0xff) == 0x8b) {
        try (var gz = new GZIPInputStream(new ByteArrayInputStream(buf));
             var out = new ByteArrayOutputStream()) {
            gz.transferTo(out);
            return out.toByteArray();
        }
    }
    return buf;
}

public static JsonNode parseSqs(String messageBody) throws Exception {
    byte[] payload = decodeSqsPayload(messageBody);
    return parseEvent(payload);
}

// Usage with software.amazon.awssdk.services.sqs:
// var event = parseSqs(message.body());
```

</Tabs>

</Disclosure>

### SQS Best practices and Assumptions

- Set the maximum message size set to 256 KB.

Messages bigger than the maximum message size will be dropped.

- Set up a dead-letter queue for your main queue.

This queue will hold the messages that couldn't be processed successfully and is useful for debugging your application.

- Set up alerts in case the size of the queue is growing too fast.

## SNS

Stream can send payloads of all events from your application to an [Amazon SNS](https://aws.amazon.com/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.

### Authentication

There are 2 ways to configure authentication on your SNS topic:

1. By providing a key and secret

2. 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):

<Tabs>

```json label="JSON"
{
  "Sid": "AllowStreamProdAccount",
  "Effect": "Allow",
  "Principal": {
    "AWS": "arn:aws:iam::185583345998:root"
  },
  "Action": "SNS:Publish",
  "Resource": "arn:aws:sns:us-west-2:1111111111:customer-sns-topic"
}
```

</Tabs>

To configure an SNS topic, use the `event_hooks` array and the Update App Settings method:

<Tabs>

```js label="JavaScript"
// 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
const response = await client.getAppSettings();
console.log("Current event hooks:", response.event_hooks);

// STEP 2: Add SNS hook while preserving existing hooks
const 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 hooks
await client.updateAppSettings({
  event_hooks: [...existingHooks, newSNSHook],
});

// Test the SNS connection
await client.testSNSSettings({
  sns_topic_arn: "arn:aws:sns:us-east-1:123456789012:sns-topic",
  sns_key: "yourkey",
  sns_secret: "yoursecret",
});
```

```python label="Python"
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 hooks
response = 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 hooks
new_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 hooks
client.update_app(
    event_hooks=existing_hooks + [new_sns_hook]
)

# Test the SNS connection
client.check_sns(sns_key="yourkey", sns_secret="yoursecret", sns_topic_arn="arn:aws:sns:us-east-1:123456789012:sns-topic")
```

```ruby label="Ruby"
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 hooks
response = client.common.get_app
existing_hooks = response.app.event_hooks || []
puts "Current event hooks:", existing_hooks

# STEP 2: Add SNS hook while preserving existing hooks
new_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 hooks
client.common.update_app(Models::UpdateAppRequest.new(
  event_hooks: existing_hooks + [new_sns_hook]
))

# Test the SNS connection
client.common.check_sns(Models::CheckSNSRequest.new(
  sns_key: 'yourkey',
  sns_secret: 'yoursecret',
  sns_topic_arn: 'arn:aws:sns:us-east-1:123456789012:sns-topic'
))
```

```php label="PHP"
// 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",
));
```

```go label="Go"
// 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
settings, err := client.GetApp(ctx, &getstream.GetAppRequest{})
if err != nil {
    log.Fatal(err)
}
existingHooks := settings.Data.App.EventHooks
fmt.Printf("Current event hooks: %+v\n", existingHooks)

// STEP 2: Add SNS hook while preserving existing hooks
newSNSHook := 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 hooks
allHooks := append(existingHooks, newSNSHook)
_, err = client.UpdateApp(ctx, &getstream.UpdateAppRequest{
    EventHooks: getstream.PtrTo(allHooks),
})
if err != nil {
    log.Fatal(err)
}

// Test the SNS connection
client.CheckSNS(ctx, &getstream.CheckSNSRequest{
    SnsTopicArn: getstream.PtrTo("arn:aws:sns:us-east-1:123456789012:sns-topic"),
    SnsKey:      getstream.PtrTo("yourkey"),
    SnsSecret:   getstream.PtrTo("yoursecret"),
})
```

```csharp label="C#"
// 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
var 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 hooks
var 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 hooks
var allHooks = new List<EventHook>(existingHooks) { newSNSHook };
await client.UpdateAppAsync(new UpdateAppRequest
{
    EventHooks = allHooks,
});

// Test the SNS connection
await client.CheckSNSAsync(new CheckSNSRequest
{
    SnsKey = "yourkey",
    SnsSecret = "yoursecret",
    SnsTopicArn = "arn:aws:sns:us-east-1:123456789012:sns-topic",
});
```

```java label="Java"
// 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
var 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 hooks
var 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 hooks
var allHooks = new ArrayList<>(existingHooks);
allHooks.add(newSNSHook);
client.updateApp(UpdateAppRequest.builder()
    .eventHooks(allHooks)
    .build()).execute();

// Test the SNS connection
client.checkSNS(CheckSNSRequest.builder()
    .snsKey("yourkey")
    .snsSecret("yoursecret")
    .snsTopicArn("arn:aws:sns:us-east-1:123456789012:sns-topic")
    .build()).execute();
```

</Tabs>

### Configuration Options

The following options are available when configuring an SNS event hook:

| Option        | Type    | Description                                                                             | Required                                                                           |
| ------------- | ------- | --------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- |
| id            | string  | Unique identifier for the event hook                                                    | No. If empty, it will generate an ID.                                              |
| enabled       | boolean | Boolean flag to enable/disable the hook                                                 | Yes                                                                                |
| hook_type     | string  | Must be set to `"sns"`                                                                  | Yes                                                                                |
| sns_topic_arn | string  | The AWS SNS topic ARN                                                                   | Yes                                                                                |
| sns_region    | string  | The AWS region where the SNS topic is located (e.g., "us-east-1")                       | Yes                                                                                |
| sns_auth_type | string  | Authentication type: `"keys"` for access key/secret or `"resource"` for role-based auth | Yes                                                                                |
| sns_key       | string  | AWS access key ID (required if auth_type is "keys")                                     | Yes if using key auth                                                              |
| sns_secret    | string  | AWS secret access key (required if auth_type is "keys")                                 | Yes if using key auth                                                              |
| event_types   | array   | Array of event types this hook should handle                                            | No. Not provided or empty array means subscribe to all existing and future events. |

### Reading notifications from SNS

SNS honours the same `enable_hook_payload_compression` flag (see [Payload Compression](#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.

<Admonition type="note">

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

</Admonition>

<Tabs>

```js label="JavaScript"
const event = client.parseSns(notification.Message);
// event.type, event.user, ...
```

```python label="Python"
event = client.parse_sns(notification["Message"])
# event.type, event.user, ...
```

```ruby label="Ruby"
event = client.parse_sns(notification['Message'])
```

```php label="PHP"
$event = $client->parseSns($notification['Message']);
```

```go label="Go"
event, err := client.ParseSns(notification.Message)
```

```csharp label="C#"
var ev = client.ParseSns(notification.Message);
```

```java label="Java"
var event = client.parseSns(notification.getMessage());
```

</Tabs>

`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](#verifying-and-parsing-events) for per-language imports).

Building this without a Stream SDK? Expand the per-language reference implementation below.

<Disclosure label="Reference implementation (no SDK)">

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](#verifying-and-parsing-events); 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](https://datatracker.ietf.org/doc/html/rfc1952) gzip magic (`1f 8b`) |
| `parse_sns(notification_body)`         | Unwrap SNS JSON envelope when needed, then `parse_event(decode_sns_payload(inner_message))`                                                                                     |

<Tabs>

```js label="JavaScript"
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);
```

```python label="Python"
import json


def unwrap_sns_notification_body(notification_body: str) -> str:
    s = notification_body.strip()
    if not s.startswith("{"):
        return notification_body
    try:
        env = json.loads(notification_body)
    except json.JSONDecodeError:
        return notification_body
    msg = env.get("Message")
    return msg if isinstance(msg, str) else notification_body


def parse_sns(notification_body: str):
    inner = unwrap_sns_notification_body(notification_body)
    return parse_event(decode_sqs_payload(inner))


event = parse_sns(notification["Message"])
```

```ruby label="Ruby"
require 'json'

def unwrap_sns_notification_body(notification_body)
  s = notification_body.strip
  return notification_body unless s.start_with?('{')

  env = JSON.parse(notification_body)
  msg = env['Message']
  msg.is_a?(String) ? msg : notification_body
rescue JSON::ParserError
  notification_body
end

def parse_sns(notification_body)
  inner = unwrap_sns_notification_body(notification_body)
  parse_event(decode_sqs_payload(inner))
end

event = parse_sns(notification['Message'])
```

```php label="PHP"
function unwrapSnsNotificationBody(string $notificationBody): string {
    $s = ltrim($notificationBody);
    if ($s === '' || $s[0] !== '{') {
        return $notificationBody;
    }
    try {
        $env = json_decode($notificationBody, true, 512, JSON_THROW_ON_ERROR);
    } catch (JsonException) {
        return $notificationBody;
    }
    return is_string($env['Message'] ?? null) ? $env['Message'] : $notificationBody;
}

function parseSns(string $notificationBody): array {
    return parseEvent(decodeSqsPayload(unwrapSnsNotificationBody($notificationBody)));
}

$event = parseSns($notification['Message']);
```

```go label="Go"
package webhook

import (
	"bytes"
	"compress/gzip"
	"encoding/base64"
	"encoding/json"
	"io"
	"strings"
)

var gzipMagic = []byte{0x1f, 0x8b}

func UnwrapSnsNotificationBody(notificationBody string) string {
	s := strings.TrimSpace(notificationBody)
	if len(s) == 0 || s[0] != '{' {
		return notificationBody
	}
	var env struct {
		Message string `json:"Message"`
	}
	if json.Unmarshal([]byte(notificationBody), &env) != nil {
		return notificationBody
	}
	if env.Message != "" {
		return env.Message
	}
	return notificationBody
}

func DecodeSqsPayload(body string) ([]byte, error) {
	buf, err := base64.StdEncoding.DecodeString(body)
	if err != nil {
		buf = []byte(body)
	}
	if len(buf) >= 2 && bytes.Equal(buf[:2], gzipMagic) {
		gz, err := gzip.NewReader(bytes.NewReader(buf))
		if err != nil {
			return nil, err
		}
		defer gz.Close()
		return io.ReadAll(gz)
	}
	return buf, nil
}

func ParseSns(notificationBody string) (map[string]any, error) {
	payload, err := DecodeSqsPayload(UnwrapSnsNotificationBody(notificationBody))
	if err != nil {
		return nil, err
	}
	return ParseEvent(payload)
}

// event, err := ParseSns(notification.Message)
```

```csharp label="C#"
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);
```

```java label="Java"
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());
```

</Tabs>

</Disclosure>

### SNS Best practices and Assumptions

- Set the maximum message size set to 256 KB.

Messages bigger than the maximum message size will be dropped.

## Event Failover

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.

Google Cloud Storage (GCS) and Amazon S3 are supported as failover storage backends. Failover can be configured on webhook, SQS, and SNS hooks.

### How it Works

1. Stream attempts to deliver an event to your endpoint
2. If all delivery retries are exhausted, the event is written to your configured storage bucket
3. The event is stored as a JSON file containing the full event payload, metadata about the failure, and the original endpoint

You can then process these failed events at your own pace by reading them from your bucket.

Failover is configured on the hook, not per product, so a hook subscribed to `feeds.*`, `call.*` or `moderation.*` event types behaves exactly like the chat example below.

### Configuration

To enable failover, add a `failover_config` to your event hook:

<Tabs>

```js label="JavaScript"
await client.updateAppSettings({
  event_hooks: [
    {
      enabled: true,
      hook_type: "webhook",
      webhook_url: "https://example.com/webhooks/stream",
      event_types: ["message.new"],
      failover_config: {
        type: "gcs",
        gcs_bucket: "my-failover-bucket",
        gcs_path: "stream/failed-events",
        gcs_credentials: '{"type":"service_account","project_id":"..."}',
      },
    },
  ],
});
```

</Tabs>

<Admonition type="caution">

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.

</Admonition>

The same `failover_config` works on SQS and SNS hooks. This example uses an S3 bucket as the failover destination instead:

<Tabs>

```js label="JavaScript"
await client.updateAppSettings({
  event_hooks: [
    {
      enabled: true,
      hook_type: "sqs",
      sqs_queue_url: "https://sqs.us-east-1.amazonaws.com/1111111111/my-queue",
      sqs_auth_type: "role",
      sqs_role_arn: "arn:aws:iam::1111111111:role/stream-events",
      event_types: ["message.new"],
      failover_config: {
        type: "s3",
        s3_bucket: "my-failover-bucket",
        s3_path: "stream/failed-events",
        s3_region: "us-east-1",
        // Recommended: a role in your account that Stream assumes
        // (see S3 Permissions below for the trust policy). Alternatively
        // set s3_api_key + s3_secret to use a dedicated access key pair.
        s3_role_arn: "arn:aws:iam::1111111111:role/stream-event-failover",
      },
    },
  ],
});
```

</Tabs>

### Configuration Options

| Option          | Type   | Description                                                                                                                    | Required                                     |
| --------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------- |
| type            | string | Storage backend type: `"gcs"` or `"s3"`                                                                                        | Yes                                          |
| gcs_bucket      | string | The name of your GCS bucket                                                                                                    | With `type: "gcs"`                           |
| gcs_credentials | string | GCS service account JSON key as a string                                                                                       | With `type: "gcs"`                           |
| gcs_path        | string | Optional prefix for the object path inside the bucket                                                                          | No                                           |
| s3_bucket       | string | The name of your S3 bucket                                                                                                     | With `type: "s3"`                            |
| s3_region       | string | AWS region of the bucket, e.g. `us-east-1`                                                                                     | With `type: "s3"`                            |
| s3_role_arn     | string | ARN of a role in your AWS account that Stream assumes to write failed events. Cannot be combined with `s3_api_key`/`s3_secret` | One auth mode: `s3_role_arn` or the key pair |
| s3_api_key      | string | AWS access key ID. Set together with `s3_secret`                                                                               | One auth mode: `s3_role_arn` or the key pair |
| s3_secret       | string | AWS secret access key. Set together with `s3_api_key`                                                                          | With `s3_api_key`                            |
| s3_path         | string | Optional prefix for the object path inside the bucket                                                                          | No                                           |

### GCS Permissions

The service account used in `gcs_credentials` needs the following permissions on the target bucket:

- `storage.objects.create`
- `storage.objects.get`

The simplest way is to grant the **Storage Object Creator** (`roles/storage.objectCreator`) role to the service account on the bucket.

### S3 Permissions

S3 supports two authentication modes. They are mutually exclusive — configure exactly one:

- **IAM role** (recommended): set `s3_role_arn` to a role in your AWS account. Stream assumes it with your app ID as the STS External ID, so no long-lived credentials change hands and you can revoke access at any time by editing the role's trust policy.
- **Access keys**: set `s3_api_key` and `s3_secret` to a key pair whose IAM policy allows `s3:PutObject` on the bucket.

#### IAM role setup

Create a role in your account with a permissions policy allowing `s3:PutObject` on the bucket, and the following trust policy. The `sts:ExternalId` condition value is your numeric app ID (shown in the [Stream Dashboard](https://getstream.io/signin/) URL and returned by Get App Settings); it protects the role against use on behalf of any other Stream application:

```json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AllowStreamEventFailover",
      "Effect": "Allow",
      "Principal": {
        "AWS": "arn:aws:iam::185583345998:root"
      },
      "Action": "sts:AssumeRole",
      "Condition": {
        "StringEquals": {
          "sts:ExternalId": "YOUR_APP_ID"
        }
      }
    }
  ]
}
```

<Admonition type="caution">

Unlike GCS credentials, S3 access is not probed when you save the configuration. A misconfigured trust policy or key pair only surfaces when the first failed event is written, so verify bucket access independently before relying on failover. A missing bucket or an access denial on the upload itself is not retried, but failures assuming your role are retried for several hours — so fixing a trust policy promptly still lets already-captured events land.

</Admonition>

### Storage Format

Failed events are stored as JSON files in your bucket with the following path structure (the prefix is your `gcs_path` or `s3_path`, omitted when unset):

```text
{path}/{yyyy}/{mm}/{dd}/{timestamp}-{event_type}-{webhook_id}.json
```

`webhook_id` is the per-delivery identifier — the `X-Webhook-ID` header value for webhook deliveries, or a generated UUID for SQS and SNS — not the ID of the hook itself. The hook's ID is inside the envelope as `original_hook_id`.

For example:

```text
stream/failed-events/2026/04/03/1743667200-message.new-a1b2c3d4.json
```

Each file contains a JSON envelope with the following fields:

| Field                | Description                                                                                                       |
| -------------------- | ----------------------------------------------------------------------------------------------------------------- |
| original_hook_id     | The ID of the event hook that failed                                                                              |
| original_target      | The endpoint that was unreachable: the webhook URL, SQS queue URL, or SNS topic ARN                               |
| original_webhook_url | Deprecated alias of `original_target`, kept for consumers of existing archives                                    |
| hook_type            | The transport of the failed hook: `webhook`, `sqs`, or `sns`. Absent on events archived before this field existed |
| event_type           | The type of event that failed to deliver                                                                          |
| error_message        | The error returned by the last delivery attempt                                                                   |
| failed_at            | ISO 8601 timestamp of when the failure was recorded                                                               |
| payload              | The full event payload that would have been delivered                                                             |

Example envelope:

```json
{
  "original_hook_id": "hook-123",
  "original_target": "https://example.com/webhooks/stream",
  "original_webhook_url": "https://example.com/webhooks/stream",
  "hook_type": "webhook",
  "event_type": "message.new",
  "error_message": "HTTP 500: Internal Server Error",
  "failed_at": "2026-04-03T12:00:00Z",
  "payload": {
    "type": "message.new",
    "cid": "messaging:general",
    "message": { ... },
    "user": { ... }
  }
}
```

Two behaviours to design your consumer around:

- A **403** from your endpoint is read as a deliberate rejection rather than an outage. That event is dropped and is not written to failover storage. Return a 5xx, or time out, for anything you want kept for replay.
- `payload` holds the bare event JSON, not the HTTP envelope. Envelope-level fields that live delivery adds, such as `request_info`, are absent from the stored copy.

The stored `payload` is always plain event JSON, regardless of the app-level `enable_hook_payload_compression` flag described under [Payload Compression](#payload-compression) — failover archives the event before wire compression is applied, so a consumer reading failed events from your bucket never needs to gzip-decompress. You can still feed the `payload` field through the same `verifyAndParseWebhook` helper you use for live webhooks (see the [reference implementation](#verifying-and-parsing-events)); it passes plain JSON through unchanged.

### Disabling Failover

To remove the failover configuration, update the hook without the `failover_config` field:

<Tabs>

```js label="JavaScript"
await client.updateAppSettings({
  event_hooks: [
    {
      enabled: true,
      hook_type: "webhook",
      webhook_url: "https://example.com/webhooks/stream",
      event_types: [],
      // no failover_config = failover disabled
    },
  ],
});
```

</Tabs>

## Request info

<Admonition type="info">

Treat `request_info` as optional. Chat, feeds and moderation events carry it, but only when the event came from an HTTP request. Events emitted by background work ship without the field entirely rather than with an empty object, as do the copies written by [event failover](#event-failover), which store the bare event JSON.

</Admonition>

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, since the option differs by SDK and the server-side SDKs do not expose custom request headers at all. For feeds, see [Adding your own data to `ext`](https://getstream.io/activity-feeds/docs/node/events/#adding-your-own-data-to-ext).

<Tabs>

```json label="JSON"
"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"
}
```

</Tabs>

For example, in Javascript, you can set the value like this:

<Tabs>

```js label="JavaScript"
client = new StreamChat(apiKey, {
  axiosRequestConfig: {
    headers: {
      "x-stream-ext": "device-id=123",
    },
  },
});
```

</Tabs>

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.

## Restricting Access to Webhook

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.

| Region                           | IP addresses                                                                                                              |
| -------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
| us-east4 (US-East)               | 34.225.10.29/32<br/>34.198.125.61/32<br/>52.22.78.160/32<br/>3.215.161.238/32<br/>34.181.149.154/32<br/>34.145.214.228/32 |
| us-east5 (Ohio)                  | 3.14.163.216/32<br/>3.15.245.3/32<br/>3.141.116.179/32<br/>34.186.254.218/32<br/>34.186.255.37/32                         |
| us-west1 (Oregon)                | 52.25.165.25/32<br/>44.237.58.11/32<br/>52.10.213.81/32<br/>8.229.163.173/32<br/>136.117.241.8/32                         |
| northamerica-northeast1 (Canada) | 35.183.141.98/32<br/>52.60.71.231/32<br/>3.97.253.35/32<br/>34.19.168.136/32<br/>34.19.229.75/32                          |
| europe-west4 (EU-West)           | 52.212.14.212/32<br/>52.17.43.232/32<br/>34.241.110.177/32<br/>35.204.6.219/32<br/>34.158.68.88/32                        |
| europe-west2 (London)            | 34.153.181.194/32<br/>34.142.62.7/32                                                                                      |
| europe-west3                     | 34.179.201.229/32<br/>35.242.238.42/32                                                                                    |
| asia-south1 (Mumbai)             | 65.1.48.87/32<br/>15.206.221.25/32<br/>13.233.48.78/32<br/>34.93.113.218/32<br/>34.47.139.79/32                           |
| asia-southeast1 (Singapore)      | 13.229.11.158/32<br/>52.74.225.150/32<br/>52.76.180.70/32<br/>35.187.232.235/32<br/>35.185.180.233/32                     |
| australia-southeast1 (Sydney)    | 54.252.193.245/32<br/>13.55.254.141/32<br/>3.24.48.104/32<br/>34.151.136.126/32<br/>34.116.110.249/32                     |
| europe-north2                    | 34.51.202.72/32<br/>34.51.213.37/32                                                                                       |

## Product-Specific Hooks

Chat has additional hook types that are configured outside the `event_hooks` system: [Before Message Send](https://getstream.io/chat/docs/node/before-message-send-webhook/) for modifying or moderating messages before delivery, [Custom Commands](https://getstream.io/chat/docs/node/custom-commands-webhook/) for reacting to /slash commands, and [pending messages](https://getstream.io/chat/docs/node/pending-messages/) for approval flows. Push delivery to user devices is covered under [push notifications](https://getstream.io/docs/platform/push-notifications/).


---

This page was last updated at 2026-09-08T17:14:10.447Z.

For the most recent version of this documentation, visit [https://getstream.io/docs/platform/webhooks/](https://getstream.io/docs/platform/webhooks/).