diff --git a/docs/webhooks/webhooks_overview/webhooks_overview.md b/docs/webhooks/webhooks_overview/webhooks_overview.md index 527ccca..ebb2d8b 100644 --- a/docs/webhooks/webhooks_overview/webhooks_overview.md +++ b/docs/webhooks/webhooks_overview/webhooks_overview.md @@ -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 diff --git a/lib/GetStream/StreamChat/Client.php b/lib/GetStream/StreamChat/Client.php index 4464fbf..bba9e21 100644 --- a/lib/GetStream/StreamChat/Client.php +++ b/lib/GetStream/StreamChat/Client.php @@ -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 + * @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 + * @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 + * @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 + * @throws StreamException + */ + public function verifyAndParseSns(string $message, string $signature): array + { + return Webhook::verifyAndParseSns($message, $signature, $this->apiSecret); } /** Searches for messages. diff --git a/lib/GetStream/StreamChat/Webhook.php b/lib/GetStream/StreamChat/Webhook.php new file mode 100644 index 0000000..cd9cac4 --- /dev/null +++ b/lib/GetStream/StreamChat/Webhook.php @@ -0,0 +1,172 @@ + + * @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 + * @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 + * @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 + * @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); + } +} diff --git a/tests/unit/WebhookCompressionTest.php b/tests/unit/WebhookCompressionTest.php new file mode 100644 index 0000000..00c40aa --- /dev/null +++ b/tests/unit/WebhookCompressionTest.php @@ -0,0 +1,367 @@ +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 testGunzipPayloadPassthroughPlainBytes(): void + { + $this->assertSame(self::JSON_BODY, Client::gunzipPayload(self::JSON_BODY)); + } + + public function testGunzipPayloadInflatesGzipBytes(): void + { + $compressed = gzencode(self::JSON_BODY); + $this->assertNotFalse($compressed); + $this->assertSame(self::JSON_BODY, Client::gunzipPayload($compressed)); + } + + public function testGunzipPayloadEmptyInput(): void + { + $this->assertSame('', Client::gunzipPayload('')); + } + + public function testGunzipPayloadShortInputBelowMagicLength(): void + { + $this->assertSame('ab', Client::gunzipPayload('ab')); + } + + public function testGunzipPayloadThrowsOnTruncatedGzipMagic(): void + { + $bad = "\x1f\x8b\x08\x00\x00\x00"; + $this->expectException(StreamException::class); + $this->expectExceptionMessageMatches('/decompress gzip/'); + Client::gunzipPayload($bad); + } + + public function testDecodeSqsPayloadBase64Only(): void + { + $this->assertSame( + self::JSON_BODY, + Client::decodeSqsPayload(base64_encode(self::JSON_BODY)) + ); + } + + public function testDecodeSqsPayloadBase64Plusgzip(): void + { + $compressed = gzencode(self::JSON_BODY); + $this->assertSame( + self::JSON_BODY, + Client::decodeSqsPayload(base64_encode($compressed)) + ); + } + + public function testDecodeSqsPayloadThrowsOnMalformedBase64(): void + { + $this->expectException(StreamException::class); + $this->expectExceptionMessageMatches('/base64-decode/'); + Client::decodeSqsPayload('!!!not-base64!!!'); + } + + public function testDecodeSnsPayloadPreExtractedMessageMatchesDecodeSqsPayload(): void + { + $compressed = gzencode(self::JSON_BODY); + $wrapped = base64_encode($compressed); + $this->assertSame( + Client::decodeSqsPayload($wrapped), + Client::decodeSnsPayload($wrapped) + ); + } + + public function testDecodeSnsPayloadUnwrapsFullSnsEnvelope(): void + { + $compressed = gzencode(self::JSON_BODY); + $wrapped = base64_encode($compressed); + $envelope = $this->snsEnvelope($wrapped); + $this->assertSame(self::JSON_BODY, Client::decodeSnsPayload($envelope)); + } + + public function testDecodeSnsPayloadHandlesEnvelopeWithWhitespacePrefix(): void + { + $compressed = gzencode(self::JSON_BODY); + $wrapped = base64_encode($compressed); + $envelope = "\n " . $this->snsEnvelope($wrapped); + $this->assertSame(self::JSON_BODY, Client::decodeSnsPayload($envelope)); + } + + private function snsEnvelope(string $innerMessage): string + { + return json_encode([ + 'Type' => 'Notification', + 'MessageId' => '22b80b92-fdea-4c2c-8f9d-bdfb0c7bf324', + 'TopicArn' => 'arn:aws:sns:us-east-1:123456789012:stream-webhooks', + 'Message' => $innerMessage, + 'Timestamp' => '2026-05-11T10:00:00.000Z', + 'SignatureVersion' => '1', + 'MessageAttributes' => [ + 'X-Signature' => ['Type' => 'String', 'Value' => ''], + ], + ]); + } + + public function testVerifySignatureMatching(): void + { + $sig = $this->sign(self::JSON_BODY); + $this->assertTrue(Client::verifySignature(self::JSON_BODY, $sig, self::API_SECRET)); + } + + public function testVerifySignatureMismatched(): void + { + $this->assertFalse( + Client::verifySignature(self::JSON_BODY, str_repeat('0', 64), self::API_SECRET) + ); + } + + public function testVerifySignatureRejectsSignatureOverCompressedBytes(): void + { + $compressed = gzencode(self::JSON_BODY); + $sigOverCompressed = hash_hmac('sha256', $compressed, self::API_SECRET); + $this->assertFalse( + Client::verifySignature(self::JSON_BODY, $sigOverCompressed, self::API_SECRET) + ); + } + + public function testParseEventKnownEventType(): void + { + $event = Client::parseEvent(self::JSON_BODY); + $this->assertSame('message.new', $event['type']); + $this->assertSame('the quick brown fox', $event['message']['text']); + } + + public function testParseEventUnknownTypeStillParses(): void + { + $event = Client::parseEvent('{"type":"a.future.event","custom":42}'); + $this->assertSame('a.future.event', $event['type']); + $this->assertSame(42, $event['custom']); + } + + public function testParseEventMalformedJsonThrows(): void + { + $this->expectException(StreamException::class); + $this->expectExceptionMessageMatches('/parse webhook event/'); + Client::parseEvent('not json'); + } + + public function testVerifyAndParseWebhookPlain(): void + { + $sig = $this->sign(self::JSON_BODY); + $event = $this->client->verifyAndParseWebhook(self::JSON_BODY, $sig); + $this->assertSame('message.new', $event['type']); + } + + public function testVerifyAndParseWebhookGzip(): void + { + $compressed = gzencode(self::JSON_BODY); + $sig = $this->sign(self::JSON_BODY); + $event = $this->client->verifyAndParseWebhook($compressed, $sig); + $this->assertSame('message.new', $event['type']); + } + + public function testVerifyAndParseWebhookSignatureMismatch(): void + { + $this->expectException(StreamException::class); + $this->expectExceptionMessageMatches('/invalid webhook signature/'); + $this->client->verifyAndParseWebhook(self::JSON_BODY, str_repeat('0', 64)); + } + + public function testVerifyAndParseWebhookRejectsSignatureOverCompressedBytes(): 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->verifyAndParseWebhook($compressed, $sigOverCompressed); + } + + public function testVerifyAndParseSqsBase64Only(): void + { + $wrapped = base64_encode(self::JSON_BODY); + $sig = $this->sign(self::JSON_BODY); + $event = $this->client->verifyAndParseSqs($wrapped, $sig); + $this->assertSame('message.new', $event['type']); + } + + public function testVerifyAndParseSqsBase64Plusgzip(): void + { + $compressed = gzencode(self::JSON_BODY); + $wrapped = base64_encode($compressed); + $sig = $this->sign(self::JSON_BODY); + $event = $this->client->verifyAndParseSqs($wrapped, $sig); + $this->assertSame('message.new', $event['type']); + } + + public function testVerifyAndParseSqsRejectsSignatureOverWrappedBytes(): 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->verifyAndParseSqs($wrapped, $sigOverWrapped); + } + + public function testVerifyAndParseSnsPreExtractedMessage(): void + { + $compressed = gzencode(self::JSON_BODY); + $wrapped = base64_encode($compressed); + $sig = $this->sign(self::JSON_BODY); + $event = $this->client->verifyAndParseSns($wrapped, $sig); + $this->assertSame('message.new', $event['type']); + } + + public function testVerifyAndParseSnsMatchesSqsForPreExtractedMessage(): void + { + $compressed = gzencode(self::JSON_BODY); + $wrapped = base64_encode($compressed); + $sig = $this->sign(self::JSON_BODY); + $this->assertSame( + $this->client->verifyAndParseSqs($wrapped, $sig), + $this->client->verifyAndParseSns($wrapped, $sig) + ); + } + + public function testVerifyAndParseSnsFullEnvelope(): void + { + $compressed = gzencode(self::JSON_BODY); + $wrapped = base64_encode($compressed); + $envelope = $this->snsEnvelope($wrapped); + $sig = $this->sign(self::JSON_BODY); + $event = $this->client->verifyAndParseSns($envelope, $sig); + $this->assertSame('message.new', $event['type']); + } + + public function testVerifyAndParseSnsRejectsSignatureOverEnvelope(): void + { + $compressed = gzencode(self::JSON_BODY); + $wrapped = base64_encode($compressed); + $envelope = $this->snsEnvelope($wrapped); + $sigOverEnvelope = hash_hmac('sha256', $envelope, self::API_SECRET); + $this->expectException(StreamException::class); + $this->expectExceptionMessageMatches('/invalid webhook signature/'); + $this->client->verifyAndParseSns($envelope, $sigOverEnvelope); + } + + public function testVerifyWebhookBackwardCompatibility(): 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 testWebhookStaticPrimitivesMatchClient(): void + { + $compressed = gzencode(self::JSON_BODY); + $wrapped = base64_encode($compressed); + $this->assertSame(Client::gunzipPayload($compressed), Webhook::gunzipPayload($compressed)); + $this->assertSame(Client::decodeSqsPayload($wrapped), Webhook::decodeSqsPayload($wrapped)); + $this->assertSame(Client::decodeSnsPayload($wrapped), Webhook::decodeSnsPayload($wrapped)); + $sig = $this->sign(self::JSON_BODY); + $this->assertTrue(Webhook::verifySignature(self::JSON_BODY, $sig, self::API_SECRET)); + $this->assertSame(Client::parseEvent(self::JSON_BODY), Webhook::parseEvent(self::JSON_BODY)); + } + + public function testWebhookVerifyAndParseWebhookStaticPlain(): void + { + $sig = $this->sign(self::JSON_BODY); + $event = Webhook::verifyAndParseWebhook(self::JSON_BODY, $sig, self::API_SECRET); + $this->assertSame('message.new', $event['type']); + } + + public function testWebhookVerifyAndParseWebhookStaticGzip(): void + { + $compressed = gzencode(self::JSON_BODY); + $sig = $this->sign(self::JSON_BODY); + $event = Webhook::verifyAndParseWebhook($compressed, $sig, self::API_SECRET); + $this->assertSame('message.new', $event['type']); + } + + public function testWebhookVerifyAndParseWebhookStaticSignatureMismatch(): void + { + $this->expectException(StreamException::class); + $this->expectExceptionMessageMatches('/invalid webhook signature/'); + Webhook::verifyAndParseWebhook(self::JSON_BODY, str_repeat('0', 64), self::API_SECRET); + } + + public function testWebhookVerifyAndParseSqsStatic(): void + { + $compressed = gzencode(self::JSON_BODY); + $wrapped = base64_encode($compressed); + $sig = $this->sign(self::JSON_BODY); + $event = Webhook::verifyAndParseSqs($wrapped, $sig, self::API_SECRET); + $this->assertSame('message.new', $event['type']); + } + + public function testWebhookVerifyAndParseSnsStaticMatchesSqs(): void + { + $compressed = gzencode(self::JSON_BODY); + $wrapped = base64_encode($compressed); + $sig = $this->sign(self::JSON_BODY); + $this->assertSame( + Webhook::verifyAndParseSqs($wrapped, $sig, self::API_SECRET), + Webhook::verifyAndParseSns($wrapped, $sig, self::API_SECRET) + ); + } + + public function testClientInstanceCompositesDelegateToWebhook(): void + { + $compressed = gzencode(self::JSON_BODY); + $wrapped = base64_encode($compressed); + $sig = $this->sign(self::JSON_BODY); + $this->assertSame( + Webhook::verifyAndParseWebhook($compressed, $sig, self::API_SECRET), + $this->client->verifyAndParseWebhook($compressed, $sig) + ); + $this->assertSame( + Webhook::verifyAndParseSqs($wrapped, $sig, self::API_SECRET), + $this->client->verifyAndParseSqs($wrapped, $sig) + ); + $this->assertSame( + Webhook::verifyAndParseSns($wrapped, $sig, self::API_SECRET), + $this->client->verifyAndParseSns($wrapped, $sig) + ); + } + + public function testDecodeSqsPayloadHelloWorldBase64Fixture(): void + { + $this->assertSame('helloworld', Webhook::decodeSqsPayload('aGVsbG93b3JsZA==')); + } + + public function testDecodeSqsPayloadHelloWorldBase64GzipFixture(): void + { + $this->assertSame( + 'helloworld', + Webhook::decodeSqsPayload('H4sIAGrYAWoAA8tIzcnJL88vykkBAK0g6/kKAAAA') + ); + } + + public function testGunzipPayloadHelloWorldFixture(): void + { + $this->assertSame( + 'helloworld', + Webhook::gunzipPayload(base64_decode('H4sIAGrYAWoAA8tIzcnJL88vykkBAK0g6/kKAAAA')) + ); + } +}