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 @@ -93,6 +93,73 @@ 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 `InvalidWebhookException` (a `StreamException` subclass) 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, (when compressed) gunzip, and parse the inner JSON:

```php
// $body — the SQS Body string (base64, optionally gzipped inside)
// $envelopeBody — the full SNS HTTP notification JSON, or the pre-extracted Message
$event = $client->parseSqs($body);
$event = $client->parseSns($envelopeBody);

// Stateless equivalents:
$event = Webhook::parseSqs($body);
$event = Webhook::parseSns($envelopeBody);
```

> [!NOTE]
> Stream does not attach an `X-Signature` to SQS or SNS deliveries. Those transports ride AWS-internal infrastructure (IAM-authenticated queues and AWS-signed SNS notifications), so the message is already authenticated by AWS — an additional HMAC layer would be theatre. For that reason the SQS / SNS helpers do **not** accept a signature or secret. Only the HTTP webhook path (`verifyAndParseWebhook`) performs HMAC verification.

Failures during base64 decoding, gzip inflation, or JSON parsing throw `InvalidWebhookException` (a `StreamException` subclass), so callers only need a single catch arm.

## Webhook types

Expand Down
94 changes: 92 additions & 2 deletions lib/GetStream/StreamChat/Client.php
Original file line number Diff line number Diff line change
Expand Up @@ -1224,13 +1224,103 @@ 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 InvalidWebhookError
*/
public static function gunzipPayload(string $body): string
{
return Webhook::gunzipPayload($body);
}

/** 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);
}

return $signature === $XSignature;
/** 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);
}

/** Delegates to {@see Webhook::parseSqs()}. No API secret involved. */
public function parseSqs(string $messageBody): array
{
return Webhook::parseSqs($messageBody);
}

/** Delegates to {@see Webhook::parseSns()}. */
public function parseSns(string $message): array
{
return Webhook::parseSns($message);
}

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

declare(strict_types=1);

namespace GetStream\StreamChat;

/**
* Raised by webhook verify/parse helpers when the HMAC does not match, or a
* gzip/base64/JSON envelope cannot be decoded.
*
* The message text identifies which failure mode fired; the class constants
* below are the canonical strings for callers that prefer exact-match
* filtering over substring matching.
*/
class InvalidWebhookError extends StreamException
{
public const SIGNATURE_MISMATCH = 'signature mismatch';
public const INVALID_BASE64 = 'invalid base64 encoding';
public const GZIP_FAILED = 'gzip decompression failed';
public const INVALID_JSON = 'invalid JSON payload';
}
165 changes: 165 additions & 0 deletions lib/GetStream/StreamChat/Webhook.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
<?php

declare(strict_types=1);

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`, `parseSqs`, `parseSns`).
* 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 InvalidWebhookError 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 InvalidWebhookError(InvalidWebhookError::GZIP_FAILED);
}
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 InvalidWebhookError 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 InvalidWebhookError(InvalidWebhookError::INVALID_BASE64);
}
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 InvalidWebhookError
*/
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 InvalidWebhookError 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 InvalidWebhookError(InvalidWebhookError::INVALID_JSON);
}
if (!is_array($event)) {
throw new InvalidWebhookError(InvalidWebhookError::INVALID_JSON);
}
return $event;
}

/** Decompress `$body` when gzipped, verify the HMAC `$signature`, and return
* the parsed event.
*
* @return array<string, mixed>
* @throws InvalidWebhookError 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 InvalidWebhookError(InvalidWebhookError::SIGNATURE_MISMATCH);
}
return self::parseEvent($inflated);
}

/** Decode the SQS `Body` (base64, then gzip-if-magic) and return the parsed event.
* Stream does not HMAC-sign SQS message bodies.
*
* @return array<string, mixed>
* @throws InvalidWebhookError
*/
public static function parseSqs(string $messageBody): array
{
$inflated = self::decodeSqsPayload($messageBody);

return self::parseEvent($inflated);
}

/** Decode an SNS payload (unwrap envelope when present). No HMAC verification.
*
* @return array<string, mixed>
* @throws InvalidWebhookError
*/
public static function parseSns(string $message): array
{
$inflated = self::decodeSnsPayload($message);

return self::parseEvent($inflated);
}
}
Loading
Loading