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
64 changes: 64 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,70 @@ 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 are sent with the `Content-Encoding: gzip` header and the request body is GZIP-compressed. Some HTTP servers and middleware (Rails, Django, Laravel, Spring Boot, ASP.NET) decompress the body transparently before your handler runs — in that case the bytes you receive are already raw JSON. The PHP SDK detects compression from the [RFC 1952](https://datatracker.ietf.org/doc/html/rfc1952) gzip magic bytes (`1f 8b`), so the same handler works with or without that middleware.

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::verifyAndParseWebhook` to handle decompression, HMAC verification, and JSON parsing in one call. It returns the parsed event as an associative array, or throws `StreamException` if the signature is invalid or the body cannot be decompressed/parsed:

```php
// $rawBody — bytes read straight from the HTTP request body (php://input)
// $signature — value of the X-Signature header
$event = $client->verifyAndParseWebhook($rawBody, $signature);
// $event['type'], $event['message'], $event['user'], ...
```

The legacy `verifyWebhook($body, $signature): bool` helper still works for plain (uncompressed) bodies and is kept for backward compatibility.

If you don't have a `Client` instance handy (for example in a queue consumer or a Lambda), the same logic is exposed as a stateless static helper that takes the API secret as the third argument:

```php
use GetStream\StreamChat\Webhook;

$event = Webhook::verifyAndParseWebhook($rawBody, $signature, $apiSecret);
```

The composite is built from three primitives that you can also call individually:

```php
use GetStream\StreamChat\Webhook;

// 1. Inflate the body if it starts with the gzip magic; otherwise pass through.
$json = Webhook::gunzipPayload($rawBody);

// 2. Constant-time HMAC-SHA256 of the *uncompressed* body against the X-Signature header.
$valid = Webhook::verifySignature($json, $signature, $apiSecret);

// 3. Decode the JSON event into an associative array.
$event = Webhook::parseEvent($json);
```

#### SQS / SNS payloads

The same logic handles messages delivered through SQS or SNS. There the body is base64-wrapped so it stays valid UTF-8 over the queue, and the inner bytes may also be gzip-compressed. Use the dedicated composites — they base64-decode and (when compressed) gunzip before verifying:

```php
// $messageBody — the SQS Body / SNS Message string (base64, optionally gzipped inside)
// $signature — X-Signature message attribute value
$event = $client->verifyAndParseSqs($messageBody, $signature);
$event = $client->verifyAndParseSns($messageBody, $signature);

// Stateless equivalents:
$event = Webhook::verifyAndParseSqs($messageBody, $signature, $apiSecret);
$event = Webhook::verifyAndParseSns($messageBody, $signature, $apiSecret);
```

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

## Webhook types

Expand Down
108 changes: 106 additions & 2 deletions lib/GetStream/StreamChat/Client.php
Original file line number Diff line number Diff line change
Expand Up @@ -1224,13 +1224,117 @@ public function getRateLimits(bool $serverSide = false, bool $android = false, b
}

/** Verify the signature added to a webhook event.
*
* Backward-compatible boolean helper. New integrations should call
* {@see verifyAndParseWebhook()} (or the SQS / SNS variants), which also handle
* gzip payload compression and return the parsed event.
*
* @throws StreamException
*/
public function verifyWebhook(string $requestBody, string $XSignature): bool
{
$signature = hash_hmac("sha256", $requestBody, $this->apiSecret);
return Webhook::verifySignature($requestBody, $XSignature, $this->apiSecret);
}

/** Constant-time HMAC-SHA256 verification of `$signature` against the digest
* of `$body` using `$secret` as the key.
*
* Backward-compatible alias for {@see Webhook::verifySignature()}; new code
* should call the canonical helper directly.
*/
public static function verifySignature(string $body, string $signature, string $secret): bool
{
return Webhook::verifySignature($body, $signature, $secret);
}

/** Returns `$body` unchanged unless it starts with the gzip magic, in which
* case the gzip stream is inflated and the decompressed bytes are returned.
*
* Backward-compatible alias for {@see Webhook::gunzipPayload()}; new code
* should call the canonical helper directly.
*
* @throws StreamException
*/
public static function gunzipPayload(string $body): string
{
return Webhook::gunzipPayload($body);
}

return $signature === $XSignature;
/** Reverses the SQS firehose envelope (base64 + optional gzip).
*
* Backward-compatible alias for {@see Webhook::decodeSqsPayload()}; new code
* should call the canonical helper directly.
*
* @throws StreamException
*/
public static function decodeSqsPayload(string $body): string
{
return Webhook::decodeSqsPayload($body);
}

/** Identical to {@see decodeSqsPayload()}; exposed under both names so call
* sites read intent.
*
* Backward-compatible alias for {@see Webhook::decodeSnsPayload()}; new code
* should call the canonical helper directly.
*
* @throws StreamException
*/
public static function decodeSnsPayload(string $message): string
{
return Webhook::decodeSnsPayload($message);
}

/** Parse a JSON-encoded webhook event into an associative array.
*
* Backward-compatible alias for {@see Webhook::parseEvent()}; new code
* should call the canonical helper directly.
*
* @return array<string, mixed>
* @throws StreamException
*/
public static function parseEvent(string $payload): array
{
return Webhook::parseEvent($payload);
}

/** Decompress `$body` when gzipped, verify the HMAC `$signature`, and return
* the parsed event. Delegates to {@see Webhook::verifyAndParseWebhook()}
* with this client's API secret.
*
* @return array<string, mixed>
* @throws StreamException when the signature does not match or the gzip
* envelope is malformed.
*/
public function verifyAndParseWebhook(string $body, string $signature): array
{
return Webhook::verifyAndParseWebhook($body, $signature, $this->apiSecret);
}

/** Decode the SQS `Body` (base64, then gzip-if-magic), verify the HMAC
* `$signature` from the `X-Signature` message attribute, and return the
* parsed event. Delegates to {@see Webhook::verifyAndParseSqs()} with this
* client's API secret.
*
* @return array<string, mixed>
* @throws StreamException
*/
public function verifyAndParseSqs(string $messageBody, string $signature): array
{
return Webhook::verifyAndParseSqs($messageBody, $signature, $this->apiSecret);
}

/** Decode the SNS notification `Message` (identical to SQS handling), verify
* the HMAC `$signature` from the `X-Signature` message attribute, and return
* the parsed event. Delegates to {@see Webhook::verifyAndParseSns()} with
* this client's API secret.
*
* @return array<string, mixed>
* @throws StreamException
*/
public function verifyAndParseSns(string $message, string $signature): array
{
return Webhook::verifyAndParseSns($message, $signature, $this->apiSecret);
}

/** Searches for messages.
Expand Down
172 changes: 172 additions & 0 deletions lib/GetStream/StreamChat/Webhook.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
<?php

declare(strict_types=0);

namespace GetStream\StreamChat;

/**
* Stateless helpers implementing the cross-SDK webhook contract documented at
* https://getstream.io/chat/docs/node/webhooks_overview/.
*
* The composite functions (`verifyAndParseWebhook`, `verifyAndParseSqs`,
* `verifyAndParseSns`) are the recommended entry points. The primitives they
* compose (`gunzipPayload`, `decodeSqsPayload`, `decodeSnsPayload`,
* `verifySignature`, `parseEvent`) are exposed so callers can build custom
* flows or run individual steps in isolation.
*
* The PHP SDK currently returns the parsed JSON as an associative array; typed
* event classes will land in a future release.
*/
class Webhook
{
/** Constant-time HMAC-SHA256 verification of `$signature` against the digest of
* `$body` using `$secret` as the key.
*
* The signature is always computed over the **uncompressed** JSON bytes, so
* callers that decoded a gzipped or base64-wrapped payload must pass the
* inflated bytes here.
*/
public static function verifySignature(string $body, string $signature, string $secret): bool
{
return hash_equals(hash_hmac('sha256', $body, $secret), $signature);
}

/** Returns `$body` unchanged unless it starts with the gzip magic
* (`1f 8b`, per RFC 1952), in which case the gzip stream is inflated and
* the decompressed bytes are returned.
*
* Magic-byte detection (rather than relying on a header) keeps the same
* handler correct when middleware auto-decompresses the request before your
* code sees it.
*
* @throws StreamException when the body has the gzip magic but cannot be
* inflated.
*/
public static function gunzipPayload(string $body): string
{
if (substr($body, 0, 2) !== "\x1f\x8b") {
return $body;
}
$decoded = @gzdecode($body);
if ($decoded === false) {
throw new StreamException('failed to decompress gzip payload');
}
return $decoded;
}

/** Reverses the SQS firehose envelope: the message `Body` is base64-decoded
* and, when the result begins with the gzip magic, gzip-decompressed. The
* same call works whether or not Stream is currently compressing payloads.
*
* @throws StreamException when the input is not valid base64 or the inner
* gzip stream cannot be inflated.
*/
public static function decodeSqsPayload(string $body): string
{
$decoded = base64_decode($body, true);
if ($decoded === false) {
throw new StreamException('failed to base64-decode payload');
}
return self::gunzipPayload($decoded);
}

/** Reverses an SNS HTTP notification envelope. When `$notificationBody` is
* a JSON envelope (`{"Type":"Notification","Message":"..."}`), the inner
* `Message` field is extracted and run through the SQS pipeline
* (base64-decode, then gzip-if-magic). When the input is not a JSON
* envelope it is treated as the already-extracted `Message` string, so
* call sites that pre-unwrap continue to work.
*
* @throws StreamException
*/
public static function decodeSnsPayload(string $notificationBody): string
{
$inner = self::extractSnsMessage($notificationBody);
return self::decodeSqsPayload($inner ?? $notificationBody);
}

private static function extractSnsMessage(string $notificationBody): ?string
{
$trimmed = ltrim($notificationBody);
if ($trimmed === '' || $trimmed[0] !== '{') {
return null;
}
$parsed = json_decode($trimmed, true);
if (!is_array($parsed)) {
return null;
}
$message = $parsed['Message'] ?? null;
return is_string($message) ? $message : null;
}

/** Parse a JSON-encoded webhook event into an associative array.
*
* The PHP SDK currently returns the parsed JSON as an array; typed event
* classes will land in a future release. The function name matches the
* documented primitive so callers can swap in a typed parser later without
* changing call sites.
*
* @return array<string, mixed>
* @throws StreamException when the bytes are not valid JSON.
*/
public static function parseEvent(string $payload): array
{
try {
$event = json_decode($payload, true, 512, JSON_THROW_ON_ERROR);
} catch (\JsonException $e) {
throw new StreamException('failed to parse webhook event: ' . $e->getMessage());
}
if (!is_array($event)) {
throw new StreamException('failed to parse webhook event: top-level value is not an object');
}
return $event;
}

/** Decompress `$body` when gzipped, verify the HMAC `$signature`, and return
* the parsed event.
*
* @return array<string, mixed>
* @throws StreamException when the signature does not match or the gzip
* envelope is malformed.
*/
public static function verifyAndParseWebhook(string $body, string $signature, string $secret): array
{
$inflated = self::gunzipPayload($body);
if (!self::verifySignature($inflated, $signature, $secret)) {
throw new StreamException('invalid webhook signature');
}
return self::parseEvent($inflated);
}

/** Decode the SQS `Body` (base64, then gzip-if-magic), verify the HMAC
* `$signature` from the `X-Signature` message attribute, and return the
* parsed event.
*
* @return array<string, mixed>
* @throws StreamException
*/
public static function verifyAndParseSqs(string $messageBody, string $signature, string $secret): array
{
$inflated = self::decodeSqsPayload($messageBody);
if (!self::verifySignature($inflated, $signature, $secret)) {
throw new StreamException('invalid webhook signature');
}
return self::parseEvent($inflated);
}

/** Decode the SNS notification `Message` (identical to SQS handling), verify
* the HMAC `$signature` from the `X-Signature` message attribute, and return
* the parsed event.
*
* @return array<string, mixed>
* @throws StreamException
*/
public static function verifyAndParseSns(string $message, string $signature, string $secret): array
{
$inflated = self::decodeSnsPayload($message);
if (!self::verifySignature($inflated, $signature, $secret)) {
throw new StreamException('invalid webhook signature');
}
return self::parseEvent($inflated);
}
}
Loading
Loading