Skip to content
Closed
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
26 changes: 26 additions & 0 deletions docs/webhooks/webhooks_overview/webhooks_overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,32 @@ 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

When webhook compression is enabled on your app (`webhook_compression_algorithm` set to `gzip`), Stream sends the request body gzipped and adds `Content-Encoding: gzip`. The `X-Signature` value is always computed over the **uncompressed** JSON, so handlers must decompress before verifying the signature.

Use `App.verifyAndDecodeWebhook` to do both in one call. It decompresses (when needed), verifies the HMAC, and returns the raw JSON bytes ready to parse:

```java
// rawBody — bytes read straight from the HTTP request body
// signature — value of the X-Signature header
// contentEncoding — value of the Content-Encoding header (null when absent)
byte[] json = App.verifyAndDecodeWebhook(rawBody, signature, contentEncoding);
// json now contains the uncompressed JSON; parse it as usual.
```

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

```java
byte[] json = App.decompressWebhookBody(rawBody, contentEncoding);
boolean valid = App.verifyWebhookSignature(apiSecret, json, signature);
```

This SDK supports `gzip` only — gzip uses the JDK and adds no external dependencies. Any other `Content-Encoding` value raises an `IllegalStateException`; if you see one in production, set `webhook_compression_algorithm` back to `gzip` (or `""` to disable compression) on the app via `App.update()` or the dashboard.

Webservers and frameworks that auto-decompress request bodies (for example nginx with `gunzip on;`, Cloud Run, Spring Boot with `server.compression.enabled`, ASP.NET `RequestDecompression`) typically strip the `Content-Encoding` header before your handler runs. In that case the body you see is already raw JSON and the existing `App.verifyWebhook(body, signature)` call works unchanged.

## Webhook types

Expand Down
137 changes: 127 additions & 10 deletions src/main/java/io/getstream/chat/java/models/App.java
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,20 @@
import io.getstream.chat.java.models.framework.StreamResponseObject;
import io.getstream.chat.java.services.AppService;
import io.getstream.chat.java.services.framework.Client;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.security.InvalidKeyException;
import java.security.Key;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Date;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.zip.GZIPInputStream;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import lombok.*;
Expand Down Expand Up @@ -1460,12 +1466,41 @@ public boolean verifyWebhook(@NotNull String body, @NotNull String signature) {
*/
public static boolean verifyWebhookSignature(
@NotNull String apiSecret, @NotNull String body, @NotNull String signature) {
return verifyWebhookSignature(apiSecret, body.getBytes(StandardCharsets.UTF_8), signature);
}

/**
* Validates if hmac signature is correct for message body.
*
* @param body the message body
* @param signature the signature provided in X-Signature header
* @return true if the signature is valid
*/
public static boolean verifyWebhookSignature(@NotNull String body, @NotNull String signature) {
String apiSecret = Client.getInstance().getApiSecret();
return verifyWebhookSignature(apiSecret, body, signature);
}

/**
* Validates if hmac signature is correct for the raw (uncompressed) body bytes.
*
* <p>Stream computes {@code X-Signature} over the uncompressed JSON, so when webhook compression
* is enabled callers must decompress the request body first (see {@link
* #decompressWebhookBody(byte[], String)}) and pass the resulting bytes here.
*
* @param apiSecret the app's API secret
* @param body the uncompressed JSON body bytes
* @param signature the signature provided in {@code X-Signature} header
* @return true if the signature matches
*/
public static boolean verifyWebhookSignature(
@NotNull String apiSecret, @NotNull byte[] body, @NotNull String signature) {
try {
Key sk = new SecretKeySpec(apiSecret.getBytes(), "HmacSHA256");
Key sk = new SecretKeySpec(apiSecret.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
Mac mac = Mac.getInstance(sk.getAlgorithm());
mac.init(sk);
final byte[] hmac = mac.doFinal(body.getBytes(StandardCharsets.UTF_8));
return bytesToHex(hmac).equals(signature);
final byte[] hmac = mac.doFinal(body);
return constantTimeEquals(bytesToHex(hmac), signature);
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("Should not happen. Could not find HmacSHA256", e);
} catch (InvalidKeyException e) {
Expand All @@ -1474,15 +1509,97 @@ public static boolean verifyWebhookSignature(
}

/**
* Validates if hmac signature is correct for message body.
* Decompresses an outbound webhook body according to the {@code Content-Encoding} header.
*
* @param body the message body
* @param signature the signature provided in X-Signature header
* @return true if the signature is valid
* <p>This SDK only supports {@code gzip} compression. A {@code null} or empty encoding returns
* the body unchanged. Any other value (including {@code br} / {@code zstd}) raises an {@link
* IllegalStateException} so callers can surface a clear error and the operator can flip the app
* back to {@code gzip} on the dashboard.
*
* @param body raw HTTP request body
* @param contentEncoding value of the {@code Content-Encoding} header (case-insensitive); pass
* {@code null} or {@code ""} when no encoding was set
* @return uncompressed body bytes
*/
public static boolean verifyWebhookSignature(@NotNull String body, @NotNull String signature) {
String apiSecret = Client.getInstance().getApiSecret();
return verifyWebhookSignature(apiSecret, body, signature);
public static byte[] decompressWebhookBody(
@NotNull byte[] body, @Nullable String contentEncoding) {
if (contentEncoding == null || contentEncoding.isEmpty()) {
return body;
}
String encoding = contentEncoding.trim().toLowerCase(Locale.ROOT);
if (encoding.isEmpty()) {
return body;
}
if (!"gzip".equals(encoding)) {
throw new IllegalStateException(
"unsupported webhook Content-Encoding: "
+ contentEncoding
+ ". This SDK only supports gzip; set webhook_compression_algorithm to \"gzip\" on"
+ " the app config.");
}
try (GZIPInputStream in = new GZIPInputStream(new ByteArrayInputStream(body))) {
return readAll(in);
} catch (IOException e) {
throw new IllegalStateException(
"failed to decompress webhook body (Content-Encoding: " + contentEncoding + ")", e);
}
}

/**
* Decompresses (when {@code Content-Encoding} is set) and verifies the HMAC signature of an
* outbound webhook request, returning the raw JSON bytes when the signature matches.
*
* <p>This is the recommended entry point for webhook handlers: it handles every value of {@code
* Content-Encoding} Stream may send and keeps signature verification on the uncompressed body.
*
* @param apiSecret the app's API secret
* @param body raw HTTP request body bytes
* @param signature value of the {@code X-Signature} header
* @param contentEncoding value of the {@code Content-Encoding} header; {@code null} when absent
* @return the uncompressed JSON body bytes
* @throws SecurityException if the signature does not match
*/
public static byte[] verifyAndDecodeWebhook(
@NotNull String apiSecret,
@NotNull byte[] body,
@NotNull String signature,
@Nullable String contentEncoding) {
byte[] decompressed = decompressWebhookBody(body, contentEncoding);
if (!verifyWebhookSignature(apiSecret, decompressed, signature)) {
throw new SecurityException("invalid webhook signature");
}
return decompressed;
}

/**
* Decompresses and verifies a webhook using the API secret of the configured singleton {@link
* Client}.
*
* @param body raw HTTP request body bytes
* @param signature value of the {@code X-Signature} header
* @param contentEncoding value of the {@code Content-Encoding} header; {@code null} when absent
* @return the uncompressed JSON body bytes
* @throws SecurityException if the signature does not match
*/
public static byte[] verifyAndDecodeWebhook(
@NotNull byte[] body, @NotNull String signature, @Nullable String contentEncoding) {
return verifyAndDecodeWebhook(
Client.getInstance().getApiSecret(), body, signature, contentEncoding);
}

private static byte[] readAll(InputStream in) throws IOException {
ByteArrayOutputStream out = new ByteArrayOutputStream();
byte[] buf = new byte[4096];
int n;
while ((n = in.read(buf)) != -1) {
out.write(buf, 0, n);
}
return out.toByteArray();
}

private static boolean constantTimeEquals(@NotNull String a, @NotNull String b) {
return MessageDigest.isEqual(
a.getBytes(StandardCharsets.UTF_8), b.getBytes(StandardCharsets.UTF_8));
}

private static String bytesToHex(byte[] hash) {
Expand Down
159 changes: 159 additions & 0 deletions src/test/java/io/getstream/chat/java/WebhookCompressionTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
package io.getstream.chat.java;

import io.getstream.chat.java.models.App;
import java.io.ByteArrayOutputStream;
import java.nio.charset.StandardCharsets;
import java.util.zip.GZIPOutputStream;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

public class WebhookCompressionTest {

private static final String API_SECRET = "tsec2";
private static final String JSON_BODY =
"{\"type\":\"message.new\",\"message\":{\"text\":\"the quick brown fox\"}}";

private static byte[] gzip(byte[] raw) throws Exception {
ByteArrayOutputStream out = new ByteArrayOutputStream();
try (GZIPOutputStream gz = new GZIPOutputStream(out)) {
gz.write(raw);
}
return out.toByteArray();
}

private static String hmacSHA256Hex(String secret, byte[] body) throws Exception {
javax.crypto.Mac mac = javax.crypto.Mac.getInstance("HmacSHA256");
mac.init(
new javax.crypto.spec.SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
byte[] hmac = mac.doFinal(body);
StringBuilder hex = new StringBuilder(2 * hmac.length);
for (byte b : hmac) {
String h = Integer.toHexString(0xff & b);
if (h.length() == 1) {
hex.append('0');
}
hex.append(h);
}
return hex.toString();
}

@Test
@DisplayName("decompressWebhookBody returns body unchanged when Content-Encoding is empty")
void decompressWebhookBody_passthroughWhenEncodingEmpty() {
byte[] raw = JSON_BODY.getBytes(StandardCharsets.UTF_8);
Assertions.assertArrayEquals(raw, App.decompressWebhookBody(raw, null));
Assertions.assertArrayEquals(raw, App.decompressWebhookBody(raw, ""));
}

@Test
@DisplayName("decompressWebhookBody round-trips gzip bytes")
void decompressWebhookBody_gzipRoundTrip() throws Exception {
byte[] raw = JSON_BODY.getBytes(StandardCharsets.UTF_8);
byte[] compressed = gzip(raw);
Assertions.assertTrue(
compressed.length > 0 && compressed.length != raw.length,
"fixture sanity: gzipped bytes should differ from raw");
Assertions.assertArrayEquals(raw, App.decompressWebhookBody(compressed, "gzip"));
}

@Test
@DisplayName("decompressWebhookBody handles Content-Encoding case-insensitively")
void decompressWebhookBody_caseInsensitiveEncoding() throws Exception {
byte[] raw = JSON_BODY.getBytes(StandardCharsets.UTF_8);
byte[] compressed = gzip(raw);
Assertions.assertArrayEquals(raw, App.decompressWebhookBody(compressed, "GZIP"));
Assertions.assertArrayEquals(raw, App.decompressWebhookBody(compressed, " gzip "));
}

@Test
@DisplayName("decompressWebhookBody rejects every non-gzip Content-Encoding")
void decompressWebhookBody_nonGzipRejected() {
byte[] raw = JSON_BODY.getBytes(StandardCharsets.UTF_8);
for (String encoding : new String[] {"br", "brotli", "zstd", "deflate", "compress", "lz4"}) {
IllegalStateException ex =
Assertions.assertThrows(
IllegalStateException.class,
() -> App.decompressWebhookBody(raw, encoding),
"encoding " + encoding + " should be rejected");
Assertions.assertTrue(
ex.getMessage().contains("unsupported"),
"error for " + encoding + " should mention 'unsupported'; got: " + ex.getMessage());
Assertions.assertTrue(
ex.getMessage().contains("gzip"),
"error for "
+ encoding
+ " should point operators back to gzip; got: "
+ ex.getMessage());
}
}

@Test
@DisplayName("decompressWebhookBody throws when the payload is not actually gzip")
void decompressWebhookBody_invalidGzipBytes() {
byte[] notGzip = "not actually gzip".getBytes(StandardCharsets.UTF_8);
IllegalStateException ex =
Assertions.assertThrows(
IllegalStateException.class, () -> App.decompressWebhookBody(notGzip, "gzip"));
Assertions.assertTrue(ex.getMessage().contains("failed to decompress"));
}

@Test
@DisplayName("verifyWebhookSignature accepts byte[] body and matches the string overload")
void verifyWebhookSignature_bytesOverload() throws Exception {
byte[] raw = JSON_BODY.getBytes(StandardCharsets.UTF_8);
String sig = hmacSHA256Hex(API_SECRET, raw);
Assertions.assertTrue(App.verifyWebhookSignature(API_SECRET, raw, sig));
Assertions.assertTrue(App.verifyWebhookSignature(API_SECRET, JSON_BODY, sig));
Assertions.assertFalse(App.verifyWebhookSignature(API_SECRET, raw, "deadbeef"));
}

@Test
@DisplayName(
"verifyAndDecodeWebhook decompresses gzip and returns raw JSON when signature matches")
void verifyAndDecodeWebhook_gzipHappyPath() throws Exception {
byte[] raw = JSON_BODY.getBytes(StandardCharsets.UTF_8);
byte[] compressed = gzip(raw);
String sig = hmacSHA256Hex(API_SECRET, raw);

byte[] decoded = App.verifyAndDecodeWebhook(API_SECRET, compressed, sig, "gzip");
Assertions.assertArrayEquals(raw, decoded);
}

@Test
@DisplayName("verifyAndDecodeWebhook works for uncompressed bodies (no Content-Encoding)")
void verifyAndDecodeWebhook_passthroughHappyPath() throws Exception {
byte[] raw = JSON_BODY.getBytes(StandardCharsets.UTF_8);
String sig = hmacSHA256Hex(API_SECRET, raw);

byte[] decoded = App.verifyAndDecodeWebhook(API_SECRET, raw, sig, null);
Assertions.assertArrayEquals(raw, decoded);

byte[] decodedEmpty = App.verifyAndDecodeWebhook(API_SECRET, raw, sig, "");
Assertions.assertArrayEquals(raw, decodedEmpty);
}

@Test
@DisplayName("verifyAndDecodeWebhook throws SecurityException on signature mismatch")
void verifyAndDecodeWebhook_badSignature() throws Exception {
byte[] raw = JSON_BODY.getBytes(StandardCharsets.UTF_8);
byte[] compressed = gzip(raw);

Assertions.assertThrows(
SecurityException.class,
() -> App.verifyAndDecodeWebhook(API_SECRET, compressed, "00", "gzip"));
}

@Test
@DisplayName(
"verifyAndDecodeWebhook rejects gzip body when signature was computed over compressed bytes")
void verifyAndDecodeWebhook_signatureMustBeOverUncompressed() throws Exception {
byte[] raw = JSON_BODY.getBytes(StandardCharsets.UTF_8);
byte[] compressed = gzip(raw);
String sigOverCompressed = hmacSHA256Hex(API_SECRET, compressed);

Assertions.assertThrows(
SecurityException.class,
() -> App.verifyAndDecodeWebhook(API_SECRET, compressed, sigOverCompressed, "gzip"));
}
}
Loading