Skip to content
Open
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
46 changes: 46 additions & 0 deletions docs/webhooks/webhooks_overview/webhooks_overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,52 @@ All webhook requests contain these headers:
| 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 |
| Content-Encoding | Compression algorithm applied to the request body. Only set when webhook compression is enabled on the app | `gzip` |

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

Use `Client::verifyAndDecodeWebhook` to handle decompression and signature verification in one call. It returns the raw JSON string ready to parse:

```php
// $rawBody — bytes read straight from the HTTP request body (php://input)
// $signature — value of the X-Signature header
// $contentEncoding — value of the Content-Encoding header (null when absent)
$json = $client->verifyAndDecodeWebhook($rawBody, $signature, $contentEncoding);
$event = json_decode($json, true);
```

If you prefer to handle the steps yourself, the primitives are also exposed:

```php
$json = $client->decompressWebhookBody($rawBody, $contentEncoding);
$valid = $client->verifyWebhook($json, $signature);
```

This SDK only supports `gzip`. Any other `Content-Encoding` value raises a `StreamException`; if you see one in production, set `webhook_compression_algorithm` back to `gzip` (or `""` to disable compression) on the app via `updateAppSettings()` or the dashboard.

#### SQS / SNS payloads

The same helper handles compressed messages delivered through SQS or SNS. There the compressed body is base64-wrapped so it stays valid UTF-8 over the queue. Pass the encoding values that arrived with the message (typically as message attributes such as `Content-Encoding`, `payload_encoding`, and `X-Signature`) as the extra `$payloadEncoding` argument:

```php
// $body — the SQS Body / SNS Message string
// $signature — X-Signature attribute value
// $contentEncoding — "gzip" when compression is enabled, otherwise null
// $payloadEncoding — "base64" for SQS / SNS firehose payloads
$json = $client->verifyAndDecodeWebhook($body, $signature, $contentEncoding, $payloadEncoding);
```

The signature is always computed over the innermost (uncompressed, base64-decoded) JSON, regardless of transport.

## Webhook types

Expand Down
99 changes: 98 additions & 1 deletion lib/GetStream/StreamChat/Client.php
Original file line number Diff line number Diff line change
Expand Up @@ -1224,13 +1224,110 @@ public function getRateLimits(bool $serverSide = false, bool $android = false, b
}

/** Verify the signature added to a webhook event.
*
* The signature is always computed over the uncompressed JSON body. When webhook
* compression is enabled on the app the request body must be decompressed (via
* {@see decompressWebhookBody()} or {@see verifyAndDecodeWebhook()}) before being
* passed to this method.
* @throws StreamException
*/
public function verifyWebhook(string $requestBody, string $XSignature): bool
{
$signature = hash_hmac("sha256", $requestBody, $this->apiSecret);

return $signature === $XSignature;
return hash_equals($signature, $XSignature);
}

/** Decompress the body of an outbound Stream message (webhook / SQS / SNS).
*
* The returned string is the uncompressed JSON the server signed. Decode order
* matches the inverse of how the server built the message:
* 1. If `$payloadEncoding` is `"base64"`, base64-decode the body. Stream uses this
* wrapper for SQS / SNS firehose so the message stays valid UTF-8 over transport.
* 2. If `$contentEncoding` is `"gzip"`, gunzip the result.
*
* This SDK only supports `gzip` for compression and `base64` for the transport
* wrapper. Any other value raises a {@see StreamException} so callers can surface
* a clear error and the operator can flip the app back to `gzip` on the dashboard.
* `null` / `""` for either argument is a no-op, so the HTTP webhook path is
* identical to before this method existed.
*
* @throws StreamException
*/
public function decompressWebhookBody(
string $body,
?string $contentEncoding,
?string $payloadEncoding = null
): string {
$working = $body;

if ($payloadEncoding !== null) {
$pe = strtolower(trim($payloadEncoding));
if ($pe !== '') {
if ($pe !== 'base64' && $pe !== 'b64') {
throw new StreamException(
'unsupported webhook payload_encoding: ' . $payloadEncoding
. '. This SDK only supports base64.'
);
}
$decoded = base64_decode($working, true);
if ($decoded === false) {
throw new StreamException(
'failed to base64-decode webhook body (payload_encoding: '
. $payloadEncoding . ')'
);
}
$working = $decoded;
}
}

if ($contentEncoding === null) {
return $working;
}
$encoding = strtolower(trim($contentEncoding));
if ($encoding === '') {
return $working;
}
if ($encoding !== 'gzip') {
throw new StreamException(
'unsupported webhook Content-Encoding: ' . $contentEncoding
. '. This SDK only supports gzip; set webhook_compression_algorithm to "gzip"'
. ' on the app config.'
);
}
$decoded = @gzdecode($working);
if ($decoded === false) {
throw new StreamException('failed to gzip-decode webhook body');
}
return $decoded;
}

/** Decompresses and verifies the HMAC signature of an outbound Stream message,
* returning the raw JSON body when the signature matches.
*
* This is the recommended entry point for handlers, regardless of transport:
*
* - HTTP webhooks: `$body` is the request body, `$signature` comes from `X-Signature`,
* `$contentEncoding` from `Content-Encoding`, `$payloadEncoding` is `null`.
* - SQS / SNS firehose: `$body` is the SQS `Body` / SNS `Message`, the other three
* come from the corresponding message attributes.
*
* The signature is always computed over the innermost (uncompressed,
* base64-decoded) JSON, so the verification rule is invariant across transports.
*
* @throws StreamException if the signature does not match or the body cannot be decoded
*/
public function verifyAndDecodeWebhook(
string $body,
string $signature,
?string $contentEncoding,
?string $payloadEncoding = null
): string {
$decoded = $this->decompressWebhookBody($body, $contentEncoding, $payloadEncoding);
if (!$this->verifyWebhook($decoded, $signature)) {
throw new StreamException('invalid webhook signature');
}
return $decoded;
}

/** Searches for messages.
Expand Down
220 changes: 220 additions & 0 deletions tests/unit/WebhookCompressionTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
<?php

declare(strict_types=0);

namespace GetStream\Unit;

use GetStream\StreamChat\Client;
use GetStream\StreamChat\StreamException;
use PHPUnit\Framework\TestCase;

class WebhookCompressionTest extends TestCase
{
private const API_KEY = 'key';
private const API_SECRET = 'tsec2';
private const JSON_BODY = '{"type":"message.new","message":{"text":"the quick brown fox"}}';

private Client $client;

public function setUp(): void
{
$this->client = new Client(self::API_KEY, self::API_SECRET);
}

private function sign(string $body): string
{
return hash_hmac('sha256', $body, self::API_SECRET);
}

public function testDecompressWebhookBodyPassthroughWhenEncodingNull(): void
{
$this->assertSame(self::JSON_BODY, $this->client->decompressWebhookBody(self::JSON_BODY, null));
}

public function testDecompressWebhookBodyPassthroughWhenEncodingEmpty(): void
{
$this->assertSame(self::JSON_BODY, $this->client->decompressWebhookBody(self::JSON_BODY, ''));
$this->assertSame(self::JSON_BODY, $this->client->decompressWebhookBody(self::JSON_BODY, ' '));
}

public function testDecompressWebhookBodyRoundTripsGzip(): void
{
$compressed = gzencode(self::JSON_BODY);
$this->assertNotFalse($compressed);
$this->assertNotEquals(self::JSON_BODY, $compressed);

$this->assertSame(self::JSON_BODY, $this->client->decompressWebhookBody($compressed, 'gzip'));
}

public function testDecompressWebhookBodyHandlesEncodingCaseInsensitively(): void
{
$compressed = gzencode(self::JSON_BODY);
$this->assertSame(self::JSON_BODY, $this->client->decompressWebhookBody($compressed, 'GZIP'));
$this->assertSame(self::JSON_BODY, $this->client->decompressWebhookBody($compressed, ' gzip '));
}

/**
* @dataProvider nonGzipEncodings
*/
public function testDecompressWebhookBodyRejectsEveryNonGzipEncoding(string $encoding): void
{
try {
$this->client->decompressWebhookBody(self::JSON_BODY, $encoding);
$this->fail("expected StreamException for encoding '$encoding'");
} catch (StreamException $e) {
$this->assertStringContainsString('unsupported', $e->getMessage());
$this->assertStringContainsString('gzip', $e->getMessage());
}
}

public static function nonGzipEncodings(): array
{
return [
'brotli short' => ['br'],
'brotli long' => ['brotli'],
'zstd' => ['zstd'],
'deflate' => ['deflate'],
'compress' => ['compress'],
'lz4' => ['lz4'],
];
}

public function testDecompressWebhookBodyThrowsOnInvalidGzipBytes(): void
{
$this->expectException(StreamException::class);
$this->expectExceptionMessageMatches('/failed to gzip-decode/');
$this->client->decompressWebhookBody('not actually gzip', 'gzip');
}

public function testVerifyWebhookUsesConstantTimeComparison(): void
{
$sig = $this->sign(self::JSON_BODY);
$this->assertTrue($this->client->verifyWebhook(self::JSON_BODY, $sig));
$this->assertFalse($this->client->verifyWebhook(self::JSON_BODY, 'deadbeef'));
}

public function testVerifyAndDecodeWebhookGzipHappyPath(): void
{
$compressed = gzencode(self::JSON_BODY);
$sig = $this->sign(self::JSON_BODY);

$decoded = $this->client->verifyAndDecodeWebhook($compressed, $sig, 'gzip');
$this->assertSame(self::JSON_BODY, $decoded);
}

public function testVerifyAndDecodeWebhookPassthroughHappyPath(): void
{
$sig = $this->sign(self::JSON_BODY);

$this->assertSame(
self::JSON_BODY,
$this->client->verifyAndDecodeWebhook(self::JSON_BODY, $sig, null)
);
$this->assertSame(
self::JSON_BODY,
$this->client->verifyAndDecodeWebhook(self::JSON_BODY, $sig, '')
);
}

public function testVerifyAndDecodeWebhookThrowsOnSignatureMismatch(): void
{
$compressed = gzencode(self::JSON_BODY);

$this->expectException(StreamException::class);
$this->expectExceptionMessageMatches('/invalid webhook signature/');
$this->client->verifyAndDecodeWebhook($compressed, 'deadbeef', 'gzip');
}

public function testVerifyAndDecodeWebhookRejectsSignatureOverCompressedBytes(): void
{
$compressed = gzencode(self::JSON_BODY);
$sigOverCompressed = hash_hmac('sha256', $compressed, self::API_SECRET);

$this->expectException(StreamException::class);
$this->expectExceptionMessageMatches('/invalid webhook signature/');
$this->client->verifyAndDecodeWebhook($compressed, $sigOverCompressed, 'gzip');
}

public function testDecompressWebhookBodyRoundTripsBase64Gzip(): void
{
$compressed = gzencode(self::JSON_BODY);
$wrapped = base64_encode($compressed);

$this->assertSame(
self::JSON_BODY,
$this->client->decompressWebhookBody($wrapped, 'gzip', 'base64')
);
$this->assertSame(
self::JSON_BODY,
$this->client->decompressWebhookBody($wrapped, 'GZIP', 'BASE64')
);
$this->assertSame(
self::JSON_BODY,
$this->client->decompressWebhookBody($wrapped, 'gzip', 'b64')
);
}

public function testDecompressWebhookBodyRoundTripsBase64Only(): void
{
$wrapped = base64_encode(self::JSON_BODY);

$this->assertSame(
self::JSON_BODY,
$this->client->decompressWebhookBody($wrapped, null, 'base64')
);
$this->assertSame(
self::JSON_BODY,
$this->client->decompressWebhookBody($wrapped, '', 'base64')
);
}

/**
* @dataProvider unsupportedPayloadEncodings
*/
public function testDecompressWebhookBodyRejectsUnsupportedPayloadEncoding(string $payloadEncoding): void
{
try {
$this->client->decompressWebhookBody(self::JSON_BODY, null, $payloadEncoding);
$this->fail("expected StreamException for payload_encoding '$payloadEncoding'");
} catch (StreamException $e) {
$this->assertStringContainsString('payload_encoding', $e->getMessage());
}
}

public static function unsupportedPayloadEncodings(): array
{
return [
'hex' => ['hex'],
'url' => ['url'],
'binary' => ['binary'],
];
}

public function testDecompressWebhookBodyThrowsOnInvalidBase64(): void
{
$this->expectException(StreamException::class);
$this->expectExceptionMessageMatches('/base64-decode/');
$this->client->decompressWebhookBody('not!valid!base64', null, 'base64');
}

public function testVerifyAndDecodeWebhookBase64GzipHappyPath(): void
{
$compressed = gzencode(self::JSON_BODY);
$wrapped = base64_encode($compressed);
$sig = $this->sign(self::JSON_BODY);

$decoded = $this->client->verifyAndDecodeWebhook($wrapped, $sig, 'gzip', 'base64');
$this->assertSame(self::JSON_BODY, $decoded);
}

public function testVerifyAndDecodeWebhookRejectsSignatureOverWrappedBytes(): void
{
$compressed = gzencode(self::JSON_BODY);
$wrapped = base64_encode($compressed);
$sigOverWrapped = hash_hmac('sha256', $wrapped, self::API_SECRET);

$this->expectException(StreamException::class);
$this->expectExceptionMessageMatches('/invalid webhook signature/');
$this->client->verifyAndDecodeWebhook($wrapped, $sigOverWrapped, 'gzip', 'base64');
}
}
Loading