Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions docs/webhooks/webhooks_overview/webhooks_overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,73 @@ All webhook requests contain these headers:
| 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 |

## Compressed webhook bodies

GZIP compression can be enabled for hooks payloads from the Dashboard. Enabling compression reduces the payload size significantly (often 70–90% smaller) reducing your bandwidth usage on Stream. The computation overhead introduced by the decompression step is usually negligible and offset by the much smaller payload.

When payload compression is enabled, webhook HTTP requests will include the `Content-Encoding: gzip` header and the request body will be compressed with GZIP. Some HTTP servers and middleware (Rails, Django, Laravel, Spring Boot, ASP.NET) handle this transparently and strip the header before your handler runs — in that case the body you see is already raw JSON.

Before enabling compression, make sure that:

- Your backend integration is using a recent version of our official SDKs with compression support
- If you don't use an official SDK, make sure that your code supports receiving compressed payloads
- The payload signature check is done on the **uncompressed** payload

The .NET SDK exposes three composite helpers — `VerifyAndParseWebhook`, `ParseSqs`, `ParseSns` — for HTTP webhooks and for SQS/SNS payloads. `VerifyAndParseWebhook` inflates the payload when gzipped (detected from the body bytes per RFC 1952), verifies the `X-Signature` HMAC against the **uncompressed** JSON using constant-time comparison, and returns `EventResponse`. **`ParseSqs`** / **`ParseSns`** decode (and for SNS, unwrap the envelope when needed) — **no application-level HMAC**. All failure modes throw `StreamInvalidWebhookException` with message constants on that type (`SignatureMismatch`, `InvalidBase64`, `GzipFailed`, `InvalidJson`).

The same call works whether or not Stream is currently compressing payloads for your app, so handlers do not need to change when you flip the dashboard toggle.

You can reach the helpers in three ways:

- `IStreamClientFactory.VerifyAndParseWebhook` / `ParseSqs` / `ParseSns` — convenience wrappers on the top-level factory.
- `IAppClient` — the same surface scoped to the app client.
- `StreamChat.Clients.WebhookHelpers` — stateless statics when the API secret is supplied per call (`VerifyAndParseWebhook` only); **`ParseSqs` / `ParseSns`** take the message body alone.

### ASP.NET Core handler

```csharp
[ApiController]
[Route("webhooks/stream")]
public class StreamWebhookController : ControllerBase
{
private readonly IStreamClientFactory _stream;

public StreamWebhookController(IStreamClientFactory stream) => _stream = stream;

[HttpPost]
public async Task<IActionResult> ReceiveAsync()
{
// Read the raw bytes off the wire — do NOT bind to a model, you need the
// exact byte stream the server signed.
using var ms = new MemoryStream();
await HttpContext.Request.Body.CopyToAsync(ms);
var rawBody = ms.ToArray();

var signature = HttpContext.Request.Headers["X-Signature"].ToString();

try
{
EventResponse ev = _stream.VerifyAndParseWebhook(rawBody, signature);
// ...handle ev.Type, ev.Message, etc...
return Ok();
}
catch (StreamInvalidWebhookException)
{
return Unauthorized();
}
}
}
```

### SQS / SNS firehose

When events are delivered through SQS or SNS the (possibly gzipped) payload is wrapped in base64 so it stays valid UTF-8 over the queue. Pass the SQS `Body` string, or the SNS notification body (full envelope or pre-extracted `Message`). Stream does **not** attach an application-level `X-Signature` on these channels.

```csharp
EventResponse ev = _stream.ParseSqs(message.Body);
// EventResponse sns = _stream.ParseSns(notificationBodyOrMessage);
```

## Webhook types

In addition to the above there are 3 special webhooks.
Expand Down
22 changes: 13 additions & 9 deletions src/Clients/AppClient.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
using System;
using System.Collections.Generic;
using System.Net;
using System.Security.Cryptography;
using System.Text;
using System.Threading.Tasks;
using StreamChat.Models;
Expand Down Expand Up @@ -77,13 +76,18 @@ public async Task<ApiResponse> DeletePushProviderAsync(PushProviderType provider
HttpStatusCode.OK);

public bool VerifyWebhook(string requestBody, string xSignature)
{
using (var sha = new HMACSHA256(Encoding.UTF8.GetBytes(_apiSecret)))
{
var sigBytes = sha.ComputeHash(Encoding.UTF8.GetBytes(requestBody));
var sig = BitConverter.ToString(sigBytes).Replace("-", string.Empty).ToLowerInvariant();
return sig == xSignature;
}
}
=> requestBody != null
&& xSignature != null
&& WebhookHelpers.VerifySignature(
Encoding.UTF8.GetBytes(requestBody), xSignature, _apiSecret);

public EventResponse VerifyAndParseWebhook(byte[] body, string signature)
=> WebhookHelpers.VerifyAndParseWebhook(body, signature, _apiSecret);

public EventResponse ParseSqs(string messageBody)
=> WebhookHelpers.ParseSqs(messageBody);

public EventResponse ParseSns(string message)
=> WebhookHelpers.ParseSns(message);
}
}
30 changes: 30 additions & 0 deletions src/Clients/IAppClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -73,5 +73,35 @@ public interface IAppClient
/// <param name="requestBody">The request body to validate.</param>
/// <param name="xSignature">The signature provided in X-Signature header.</param>
bool VerifyWebhook(string requestBody, string xSignature);

/// <summary>
/// Verify and parse an HTTP webhook event.
/// </summary>
/// <remarks>
/// Decompresses <paramref name="body"/> when gzipped (detected from the
/// body bytes), verifies the <c>X-Signature</c> header against the
/// client's API secret, and returns the parsed <see cref="EventResponse"/>.
/// The same call works whether or not Stream is currently compressing
/// payloads for this app, and stays correct behind middleware that
/// auto-decompresses the request.
/// </remarks>
/// <param name="body">Raw HTTP request body bytes Stream signed.</param>
/// <param name="signature">Value of the <c>X-Signature</c> header.</param>
/// <exception cref="StreamChat.Exceptions.InvalidWebhookError">
/// Thrown when the signature does not match or the gzip envelope is malformed.
/// </exception>
EventResponse VerifyAndParseWebhook(byte[] body, string signature);

/// <summary>
/// Parse an SQS-delivered event (decode only; no HMAC).
/// </summary>
/// <param name="messageBody">SQS message <c>Body</c> string.</param>
EventResponse ParseSqs(string messageBody);

/// <summary>
/// Parse an SNS-delivered event (unwrap envelope when needed; no HMAC).
/// </summary>
/// <param name="message">Raw SNS POST body or pre-extracted <c>Message</c> string.</param>
EventResponse ParseSns(string message);
}
}
21 changes: 21 additions & 0 deletions src/Clients/IStreamClientFactory.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
using StreamChat.Models;

namespace StreamChat.Clients
{
/// <summary>
Expand All @@ -12,6 +14,25 @@ public interface IStreamClientFactory
/// <remarks>https://getstream.io/chat/docs/dotnet-csharp/app_setting_overview/?language=csharp</remarks>
IAppClient GetAppClient();

/// <summary>
/// Verify and parse an HTTP webhook event using this factory's API secret.
/// </summary>
/// <remarks>
/// Convenience wrapper around <see cref="IAppClient.VerifyAndParseWebhook(byte[], string)"/>
/// so callers that already hold the top-level factory don't need to reach
/// for <see cref="GetAppClient"/> first. See
/// https://getstream.io/chat/docs/dotnet-csharp/webhooks_overview/.
/// </remarks>
/// <param name="body">Raw HTTP request body bytes Stream signed.</param>
/// <param name="signature">Value of the <c>X-Signature</c> header.</param>
EventResponse VerifyAndParseWebhook(byte[] body, string signature);

/// <inheritdoc cref="IAppClient.ParseSqs(string)"/>
EventResponse ParseSqs(string messageBody);

/// <inheritdoc cref="IAppClient.ParseSns(string)"/>
EventResponse ParseSns(string message);

/// <summary>
/// Returns an <see cref="IBlocklistClient"/> instance. The returned client can be used as a singleton in your application.
/// </summary>
Expand Down
13 changes: 13 additions & 0 deletions src/Clients/StreamClientFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,19 @@ public StreamClientFactory(string apiKey, string apiSecret, Action<ClientOptions
}

public IAppClient GetAppClient() => _appClient;

/// <inheritdoc/>
public EventResponse VerifyAndParseWebhook(byte[] body, string signature)
=> _appClient.VerifyAndParseWebhook(body, signature);

/// <inheritdoc/>
public EventResponse ParseSqs(string messageBody)
=> _appClient.ParseSqs(messageBody);

/// <inheritdoc/>
public EventResponse ParseSns(string message)
=> _appClient.ParseSns(message);

public IBlocklistClient GetBlocklistClient() => _blocklistClient;
public IChannelClient GetChannelClient() => _channelClient;
public IChannelTypeClient GetChannelTypeClient() => _channelTypeClient;
Expand Down
Loading
Loading