diff --git a/.fernignore b/.fernignore
index 528a3cc2..0b829e9c 100644
--- a/.fernignore
+++ b/.fernignore
@@ -50,9 +50,42 @@
# LICENSE: Fern does not carry over a LICENSE, and the POM this repo publishes declares
# Apache-2.0 — the same license the python-release and ruby-release groups pass to Fern.
# Without this entry the first regeneration would delete the file the POM points at.
+#
+# src/main/java/com/whop/api/helpers holds the hand-written helpers in this SDK, and
+# src/test/java/com/whop/api/helpers holds their tests. Fern owns both src/main and
+# src/test, so without these two entries a regeneration prunes the helper and, separately,
+# prunes the tests that would have caught the helper going missing. Directory entries are
+# what the sibling repos use for the same job (whopsdk-ruby's lib/whop_sdk/helpers,
+# whopsdk-typescript's src/helpers, whopsdk-python's src/whop_sdk/lib), and whopsdk-ruby's
+# survived a real regeneration in whopsdk-ruby#59.
+#
+# - WebhookVerifier / WebhookVerificationException — the webhook signature verifier. It
+# restores the verification half of `client.webhooks.unwrap`, which the Stainless SDK
+# shipped and Fern cannot generate: Fern generates from OpenAPI paths and `unwrap` was
+# never a path.
+#
+# Unlike whopsdk-ruby, whopsdk-typescript and whopsdk-python, nothing about this helper has
+# to be restored from generators.yml, and that is deliberate rather than lucky.
+#
+# - There is no entry point to re-emit. A require/import list is what those three restore
+# with requirePaths / packageJson exports; Java resolves com.whop.api.helpers off the
+# classpath, and the generator writes no module-info.java, no package index and no
+# service file that would have to name the package. The jar carries it because it is
+# under src/main/java, full stop.
+# - There is no dependency to re-declare. Those three each pull in `standardwebhooks`,
+# whose key handling is the reason each needed a workaround: the library base64-DECODES
+# its key while WebhooksManager::SignWebhook HMACs with the literal bytes of the `ws_…`
+# secret. javax.crypto.Mac, java.util.Base64 and java.security.MessageDigest are all in
+# the JDK, so this helper computes the HMAC over the raw secret itself and needs nothing
+# added to build.gradle — which matters, because build.gradle is Fern's and this repo
+# deliberately keeps it that way (see release.gradle above). Jackson, the only non-JDK
+# type in the helper's signature, is already one of the four `api` dependencies the
+# generator writes for its own models.
.github/workflows/ci.yml
.github/workflows/publish-main.yml
.github/release.gradle
.github/stamp-version.py
gradle.properties
LICENSE
+src/main/java/com/whop/api/helpers
+src/test/java/com/whop/api/helpers
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 95a9404d..63ea6db9 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -52,5 +52,18 @@ jobs:
with:
validate-wrappers: true
+ - name: Assert the hand-written helpers survived regeneration
+ run: |
+ set -euo pipefail
+ status=0
+ for dir in src/main/java/com/whop/api/helpers src/test/java/com/whop/api/helpers; do
+ grep -qxF "$dir" .fernignore || { echo "::error::$dir is not listed in .fernignore, so the next regeneration will prune it"; status=1; }
+ [ -d "$dir" ] || { echo "::error::$dir is gone: a regeneration pruned it"; status=1; }
+ done
+ for file in src/main/java/com/whop/api/helpers/WebhookVerifier.java src/test/java/com/whop/api/helpers/WebhookVerifierTest.java; do
+ [ -f "$file" ] || { echo "::error::$file is gone: a regeneration pruned it"; status=1; }
+ done
+ exit $status
+
- name: Test
run: ./gradlew --no-daemon test
diff --git a/src/main/java/com/whop/api/helpers/WebhookVerificationException.java b/src/main/java/com/whop/api/helpers/WebhookVerificationException.java
new file mode 100644
index 00000000..720c2fc8
--- /dev/null
+++ b/src/main/java/com/whop/api/helpers/WebhookVerificationException.java
@@ -0,0 +1,27 @@
+package com.whop.api.helpers;
+
+/**
+ * Thrown by {@link WebhookVerifier} when a delivery cannot be trusted: a
+ * signature header is missing or malformed, the timestamp is outside the
+ * tolerance window, or no signature matches.
+ *
+ *
Unchecked, matching {@code com.whop.api.core.WhopApiException} — the
+ * generated client's own errors are unchecked, and a webhook handler is
+ * normally a request handler whose framework already maps a thrown exception to
+ * a status code.
+ *
+ *
A caller that must distinguish "this delivery is not authentic" from "I
+ * called the helper wrong" can: the second is an {@link IllegalArgumentException}
+ * and never this.
+ */
+public final class WebhookVerificationException extends RuntimeException {
+ private static final long serialVersionUID = 1L;
+
+ public WebhookVerificationException(String message) {
+ super(message);
+ }
+
+ public WebhookVerificationException(String message, Throwable cause) {
+ super(message, cause);
+ }
+}
diff --git a/src/main/java/com/whop/api/helpers/WebhookVerifier.java b/src/main/java/com/whop/api/helpers/WebhookVerifier.java
new file mode 100644
index 00000000..d87b8ec8
--- /dev/null
+++ b/src/main/java/com/whop/api/helpers/WebhookVerifier.java
@@ -0,0 +1,220 @@
+package com.whop.api.helpers;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.util.Base64;
+import java.util.Collection;
+import java.util.Iterator;
+import java.util.Map;
+import javax.crypto.Mac;
+import javax.crypto.spec.SecretKeySpec;
+
+/**
+ * Verifies the Standard Webhooks signature Whop sends on every webhook delivery, then parses the body.
+ *
+ *
{@code
+ * JsonNode event = WebhookVerifier.unwrap(rawBody, request.getHeaders(), webhookSigningSecret);
+ * }
+ *
+ * The secret is a parameter, never read from the environment: this SDK reads no environment variables, and
+ * whopsdk-java's e2e suite asserts that it reads none.
+ *
+ * This is the verification half of the {@code client.webhooks.unwrap} the Stainless-generated SDK shipped. Fern
+ * generates from OpenAPI paths and {@code unwrap} was never a path, so the generated client has no equivalent. It is a
+ * standalone class rather than a method on {@code WhopApiClient} so that nothing generated has to be patched: it
+ * depends only on the JDK and Jackson, never on generated client code, so it survives the client being replaced.
+ *
+ *
It does NOT coerce the parsed body into a typed event model, which the Stainless version did through a union of
+ * 42 of them. Fern generates no webhook event models — {@code CreateWebhooksRequestEventsItem} is the enum of event
+ * names a webhook subscribes to, not a payload type — so there is nothing to coerce into. The parsed
+ * {@link JsonNode} is returned as-is; {@code new ObjectMapper().treeToValue(event, YourType.class)} from there.
+ *
+ *
Why this computes the HMAC itself
+ *
+ * The signature Whop sends is {@code base64(HMAC-SHA256(secret, ".."))}, and
+ * the key is the literal bytes of the {@code ws_…} secret — {@code WebhooksManager::SignWebhook} passes the
+ * stored secret straight to {@code OpenSSL::HMAC}, prefix included. Every Standard Webhooks client library instead
+ * base64-decodes whatever key it is handed, so handing one a {@code ws_…} secret derives the wrong key and
+ * every genuine delivery fails to verify. The SDKs that use such a library have to base64-encode the whole
+ * secret to cancel that decode out.
+ *
+ * Java needs none of that: {@link Mac} with {@code HmacSHA256} and {@link Base64} are both in the JDK, so the HMAC
+ * is computed over the raw secret directly and the mismatch cannot arise. That is also why this helper adds no
+ * dependency to {@code build.gradle} — which matters, because {@code build.gradle} is generated.
+ */
+public final class WebhookVerifier {
+
+ /**
+ * How far the {@code webhook-timestamp} header may be from now, in either direction, before the delivery is
+ * refused as a possible replay.
+ */
+ public static final long TOLERANCE_SECONDS = 5 * 60;
+
+ public static final String MISSING_KEY_MESSAGE =
+ "Cannot verify a webhook without a key. Pass the endpoint's signing secret as `key`.";
+
+ private static final String ID_HEADER = "webhook-id";
+ private static final String TIMESTAMP_HEADER = "webhook-timestamp";
+ private static final String SIGNATURE_HEADER = "webhook-signature";
+
+ private static final String SUPPORTED_VERSION = "v1";
+ private static final String HMAC_ALGORITHM = "HmacSHA256";
+
+ private static final ObjectMapper MAPPER = new ObjectMapper();
+
+ private WebhookVerifier() {}
+
+ /**
+ * Verifies {@code payload} against the signature headers and returns the parsed body.
+ *
+ * @param payload the raw, unmodified request body. The signature covers the exact bytes Whop sent, so a body that
+ * has been through a JSON parse and re-serialize will not verify — read the request as bytes, not as a model.
+ * @param headers the request headers. Only {@code webhook-id}, {@code webhook-timestamp} and
+ * {@code webhook-signature} are read, and the lookup is case-insensitive. Values may be a {@link String} or a
+ * {@link Collection} of them, so both a {@code Map} and the {@code Map>}
+ * that {@code java.net.http.HttpHeaders#map()} hands back are accepted.
+ * @param key the endpoint's signing secret, exactly as Whop shows it — a {@code ws_}-prefixed string. Pass it
+ * verbatim; do not strip the prefix and do not pre-encode it.
+ * @return the parsed body.
+ * @throws IllegalArgumentException if {@code payload}, {@code headers} or {@code key} is missing.
+ * @throws WebhookVerificationException if a signature header is missing, the timestamp is outside the tolerance
+ * window, or no signature matches.
+ */
+ public static JsonNode unwrap(byte[] payload, Map headers, String key) {
+ if (key == null || key.isEmpty()) {
+ throw new IllegalArgumentException(MISSING_KEY_MESSAGE);
+ }
+ if (payload == null) {
+ throw new IllegalArgumentException("Cannot verify a webhook without the raw request body.");
+ }
+ if (headers == null) {
+ throw new IllegalArgumentException("Cannot verify a webhook without the request headers.");
+ }
+
+ String id = requireHeader(headers, ID_HEADER);
+ String timestamp = requireHeader(headers, TIMESTAMP_HEADER);
+ String signatures = requireHeader(headers, SIGNATURE_HEADER);
+
+ // Ahead of the comparison, so a replayed delivery is refused FOR THE TIMESTAMP. Back-dating the header also
+ // invalidates the signature, and "no matching signature" would not say which check fired.
+ checkTimestamp(timestamp);
+
+ byte[] expected = sign(key, id, timestamp, payload);
+ if (!matches(signatures, expected)) {
+ throw new WebhookVerificationException("No matching " + SUPPORTED_VERSION
+ + " signature in the webhook-signature header. The body must be the exact bytes received.");
+ }
+
+ try {
+ return MAPPER.readTree(payload);
+ } catch (Exception e) {
+ throw new WebhookVerificationException("The webhook body is correctly signed but is not valid JSON.", e);
+ }
+ }
+
+ /**
+ * Verifies {@code payload} against the signature headers and returns the parsed body.
+ *
+ * The string is signed as UTF-8, which is what Whop sends. Prefer {@link #unwrap(byte[], Map, String)} where
+ * the raw bytes are available: decoding to a string and back is lossless only for well-formed UTF-8, and a body
+ * that arrived as anything else would silently stop matching its signature.
+ *
+ * @see #unwrap(byte[], Map, String)
+ */
+ public static JsonNode unwrap(String payload, Map headers, String key) {
+ if (payload == null) {
+ throw new IllegalArgumentException("Cannot verify a webhook without the raw request body.");
+ }
+ return unwrap(payload.getBytes(StandardCharsets.UTF_8), headers, key);
+ }
+
+ private static void checkTimestamp(String timestamp) {
+ long sent;
+ try {
+ sent = Long.parseLong(timestamp.trim());
+ } catch (NumberFormatException e) {
+ throw new WebhookVerificationException(
+ "The webhook-timestamp header is not a unix timestamp: " + abbreviate(timestamp), e);
+ }
+ long now = System.currentTimeMillis() / 1000L;
+ long drift = Math.abs(now - sent);
+ if (drift > TOLERANCE_SECONDS) {
+ throw new WebhookVerificationException("The webhook-timestamp header is " + drift
+ + "s away from now, outside the " + TOLERANCE_SECONDS + "s tolerance window.");
+ }
+ }
+
+ private static byte[] sign(String key, String id, String timestamp, byte[] payload) {
+ try {
+ Mac mac = Mac.getInstance(HMAC_ALGORITHM);
+ mac.init(new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), HMAC_ALGORITHM));
+ mac.update((id + "." + timestamp + ".").getBytes(StandardCharsets.UTF_8));
+ return mac.doFinal(payload);
+ } catch (Exception e) {
+ throw new WebhookVerificationException("Could not compute the expected webhook signature.", e);
+ }
+ }
+
+ /**
+ * The header is a space-separated list of {@code ,} entries. Unknown versions are ignored rather
+ * than refused, so a future scheme signed alongside {@code v1} does not break a handler that only knows
+ * {@code v1}. Every {@code v1} entry is compared, and every comparison is constant-time.
+ */
+ private static boolean matches(String signatures, byte[] expected) {
+ boolean matched = false;
+ for (String entry : signatures.split("\\s+")) {
+ int comma = entry.indexOf(',');
+ if (comma < 0 || !SUPPORTED_VERSION.equals(entry.substring(0, comma))) {
+ continue;
+ }
+ byte[] candidate;
+ try {
+ candidate = Base64.getDecoder().decode(entry.substring(comma + 1));
+ } catch (IllegalArgumentException e) {
+ continue;
+ }
+ // Not short-circuited: returning on the first hit would leak, through timing, which entry matched.
+ matched |= MessageDigest.isEqual(expected, candidate);
+ }
+ return matched;
+ }
+
+ private static String requireHeader(Map headers, String name) {
+ String value = lookup(headers, name);
+ if (value == null || value.trim().isEmpty()) {
+ throw new WebhookVerificationException("The " + name
+ + " header is missing. Whop sends webhook-id, webhook-timestamp and webhook-signature on every"
+ + " delivery.");
+ }
+ return value;
+ }
+
+ /**
+ * HTTP header names are case-insensitive, and Whop sends these three lowercase — but a framework may hand them
+ * back capitalized, so the lookup cannot assume either.
+ */
+ private static String lookup(Map headers, String name) {
+ for (Map.Entry header : headers.entrySet()) {
+ if (header.getKey() == null
+ || !name.equalsIgnoreCase(header.getKey().trim())) {
+ continue;
+ }
+ Object value = header.getValue();
+ if (value instanceof Collection) {
+ Iterator> values = ((Collection>) value).iterator();
+ value = values.hasNext() ? values.next() : null;
+ }
+ if (value != null) {
+ return value.toString();
+ }
+ }
+ return null;
+ }
+
+ private static String abbreviate(String text) {
+ String flat = text.replace('\n', ' ');
+ return flat.length() <= 64 ? flat : flat.substring(0, 64) + "...";
+ }
+}
diff --git a/src/test/java/com/whop/api/helpers/WebhookVerifierTest.java b/src/test/java/com/whop/api/helpers/WebhookVerifierTest.java
new file mode 100644
index 00000000..b40a3023
--- /dev/null
+++ b/src/test/java/com/whop/api/helpers/WebhookVerifierTest.java
@@ -0,0 +1,381 @@
+package com.whop.api.helpers;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import java.nio.charset.StandardCharsets;
+import java.util.Arrays;
+import java.util.Base64;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import javax.crypto.Mac;
+import javax.crypto.spec.SecretKeySpec;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Every fixture here is signed THE WAY THE BACKEND SIGNS, by
+ * {@link #backendSignature}, and never by the class under test. Signing and
+ * verifying with the same code is self-consistent and proves nothing: it is
+ * exactly how the other SDKs' helpers shipped agreeing with themselves while
+ * rejecting every genuine Whop delivery.
+ *
+ * So that the oracle itself is not just this file agreeing with this file,
+ * {@link #theFixtureSignerMatchesAnIndependentlyComputedVector()} pins it
+ * against a vector computed in Python — {@code base64(hmac_sha256(secret,
+ * b".." + body))} — before any other test relies on it.
+ */
+class WebhookVerifierTest {
+
+ /** The format WebhooksManager::Create issues: "ws_" + SecureRandom.hex(32). */
+ private static final String KEY = "ws_" + repeat("3f2a", 16);
+
+ private static final String OTHER_KEY = "ws_" + repeat("c17b", 16);
+
+ private static final String BODY =
+ "{\"id\":\"msg_9Fq1\",\"type\":\"product.created\",\"data\":{\"id\":\"prod_7Kd2\"}}";
+
+ private static final String ID = "msg_9Fq1";
+
+ private static final ObjectMapper MAPPER = new ObjectMapper();
+
+ // ---------------------------------------------------------------- the oracle
+
+ /**
+ * backend/app/services/webhooks_manager/sign_webhook.rb, transcribed:
+ *
+ *
+ * payload = "#{id}.#{timestamp}.#{body_json}"
+ * raw_sig = OpenSSL::HMAC.digest("sha256", secret, payload)
+ * signature = Base64.strict_encode64(raw_sig)
+ * header = "v1,#{signature}"
+ *
+ *
+ * The secret is the RAW HMAC key, prefix and all — it is not decoded, not
+ * stripped and not re-encoded.
+ */
+ private static String backendSignature(String key, String id, String timestamp, byte[] body) {
+ try {
+ Mac mac = Mac.getInstance("HmacSHA256");
+ mac.init(new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
+ mac.update((id + "." + timestamp + ".").getBytes(StandardCharsets.UTF_8));
+ return Base64.getEncoder().encodeToString(mac.doFinal(body));
+ } catch (Exception e) {
+ throw new IllegalStateException(e);
+ }
+ }
+
+ @Test
+ void theFixtureSignerMatchesAnIndependentlyComputedVector() {
+ // A body with characters outside ASCII, so the vector pins the UTF-8
+ // BYTES that are signed rather than a sequence of Java chars: 85
+ // characters, 89 bytes.
+ String body = "{\"id\":\"msg_9Fq1\",\"type\":\"product.created\","
+ + "\"data\":{\"id\":\"prod_7Kd2\",\"title\":\"Caf\u00e9 \ud83c\udf1f\"}}";
+ byte[] raw = body.getBytes(StandardCharsets.UTF_8);
+
+ assertEquals(86, body.length());
+ assertEquals(89, raw.length);
+ assertEquals(
+ "1n/ft+WOoFeNWuarlpjbJiFzqiFs93ZXYzFp+ynUDAE=", backendSignature(KEY, "msg_9Fq1", "1787000000", raw));
+ }
+
+ // ---------------------------------------------------------------- fixtures
+
+ private static Map signedHeaders() {
+ return signedHeaders(BODY.getBytes(StandardCharsets.UTF_8), KEY, ID, now());
+ }
+
+ private static Map signedHeaders(byte[] body, String key, String id, long timestamp) {
+ Map headers = new LinkedHashMap<>();
+ headers.put("webhook-id", id);
+ headers.put("webhook-timestamp", Long.toString(timestamp));
+ headers.put("webhook-signature", "v1," + backendSignature(key, id, Long.toString(timestamp), body));
+ return headers;
+ }
+
+ private static Map withoutHeader(Map headers, String name) {
+ Map copy = new LinkedHashMap<>(headers);
+ copy.remove(name);
+ return copy;
+ }
+
+ private static Map withHeader(Map headers, String name, String value) {
+ Map copy = new LinkedHashMap<>(headers);
+ copy.put(name, value);
+ return copy;
+ }
+
+ private static long now() {
+ return System.currentTimeMillis() / 1000L;
+ }
+
+ private static String repeat(String unit, int times) {
+ StringBuilder text = new StringBuilder(unit.length() * times);
+ for (int i = 0; i < times; i++) {
+ text.append(unit);
+ }
+ return text.toString();
+ }
+
+ // ---------------------------------------------------------------- accepts
+
+ @Test
+ void returnsTheParsedBodyForAValidSignature() {
+ JsonNode event = WebhookVerifier.unwrap(BODY, signedHeaders(), KEY);
+
+ assertEquals("msg_9Fq1", event.get("id").asText());
+ assertEquals("product.created", event.get("type").asText());
+ assertEquals("prod_7Kd2", event.get("data").get("id").asText());
+ }
+
+ @Test
+ void acceptsTheRawBytesOfANonAsciiBody() {
+ String body = "{\"id\":\"msg_9Fq1\",\"note\":\"Caf\u00e9 \ud83c\udf1f\"}";
+ byte[] raw = body.getBytes(StandardCharsets.UTF_8);
+ Map headers = signedHeaders(raw, KEY, ID, now());
+
+ assertEquals(
+ "Caf\u00e9 \ud83c\udf1f",
+ WebhookVerifier.unwrap(raw, headers, KEY).get("note").asText());
+ }
+
+ @Test
+ void acceptsHeadersWhoseNamesAreCapitalized() {
+ Map headers = new LinkedHashMap<>();
+ for (Map.Entry header : signedHeaders().entrySet()) {
+ String[] words = header.getKey().split("-");
+ StringBuilder name = new StringBuilder();
+ for (String word : words) {
+ if (name.length() > 0) {
+ name.append('-');
+ }
+ name.append(Character.toUpperCase(word.charAt(0))).append(word.substring(1));
+ }
+ headers.put(name.toString(), header.getValue());
+ }
+
+ assertEquals("Webhook-Id", headers.keySet().iterator().next());
+ assertEquals(
+ "msg_9Fq1", WebhookVerifier.unwrap(BODY, headers, KEY).get("id").asText());
+ }
+
+ /** What {@code java.net.http.HttpHeaders#map()} hands back. */
+ @Test
+ void acceptsMultiValuedHeaderMaps() {
+ Map> headers = new LinkedHashMap<>();
+ for (Map.Entry header : signedHeaders().entrySet()) {
+ headers.put(header.getKey(), Collections.singletonList(header.getValue()));
+ }
+
+ assertEquals(
+ "msg_9Fq1", WebhookVerifier.unwrap(BODY, headers, KEY).get("id").asText());
+ }
+
+ /**
+ * The header can carry a space-separated list of versioned entries. An
+ * unknown version is ignored rather than refused, so a scheme added
+ * alongside v1 does not break a handler that only knows v1.
+ */
+ @Test
+ void acceptsAValidV1EntryWhereverItAppearsInTheList() {
+ Map headers = signedHeaders();
+ String valid = headers.get("webhook-signature");
+ String filler = repeat("A", 44);
+
+ for (String value : Arrays.asList(
+ "v1," + filler + " " + valid,
+ valid + " v1," + filler,
+ "v0," + repeat("B", 44) + " " + valid + " v2," + repeat("C", 44),
+ "v1n,not-base64!! " + valid)) {
+ JsonNode event = WebhookVerifier.unwrap(BODY, withHeader(headers, "webhook-signature", value), KEY);
+
+ assertEquals("msg_9Fq1", event.get("id").asText(), "expected \"" + value + "\" to verify on its v1 entry");
+ }
+ }
+
+ @Test
+ void acceptsATimestampAtTheEdgeOfTheToleranceWindow() {
+ long edge = now() - WebhookVerifier.TOLERANCE_SECONDS + 10;
+ Map headers = signedHeaders(BODY.getBytes(StandardCharsets.UTF_8), KEY, ID, edge);
+
+ assertEquals(
+ "msg_9Fq1", WebhookVerifier.unwrap(BODY, headers, KEY).get("id").asText());
+ }
+
+ // ---------------------------------------------------------------- refuses
+
+ @Test
+ void refusesATamperedBody() {
+ String tampered = BODY.replace("prod_7Kd2", "prod_0000");
+
+ assertThrows(WebhookVerificationException.class, () -> WebhookVerifier.unwrap(tampered, signedHeaders(), KEY));
+ }
+
+ /**
+ * The signature covers the exact bytes sent, so a body that has been parsed
+ * and written back out does not verify even though it carries the same
+ * data. In Java that is the live risk: round-tripping a request through a
+ * Map or a model and verifying the result would fail on every delivery.
+ */
+ @Test
+ void refusesABodyReserializedWithTheSameContent() throws Exception {
+ String reserialized = MAPPER.writerWithDefaultPrettyPrinter().writeValueAsString(MAPPER.readTree(BODY));
+
+ assertNotEquals(BODY, reserialized);
+ assertEquals(MAPPER.readTree(BODY), MAPPER.readTree(reserialized));
+ assertThrows(
+ WebhookVerificationException.class, () -> WebhookVerifier.unwrap(reserialized, signedHeaders(), KEY));
+ }
+
+ @Test
+ void refusesASignatureMadeWithADifferentKey() {
+ Map headers = signedHeaders(BODY.getBytes(StandardCharsets.UTF_8), OTHER_KEY, ID, now());
+
+ assertThrows(WebhookVerificationException.class, () -> WebhookVerifier.unwrap(BODY, headers, KEY));
+ }
+
+ /**
+ * The backend HMACs the stored secret as-is, so a secret and that same
+ * secret minus its prefix are two different keys. Stripping either — which
+ * the Standard Webhooks libraries do for {@code whsec_} — silently derives
+ * the wrong one.
+ */
+ @Test
+ void usesTheSecretVerbatimWithoutStrippingAPrefix() {
+ String prefixed = "whsec_" + repeat("9d4e", 16);
+ String bare = prefixed.substring("whsec_".length());
+ Map headers = signedHeaders(BODY.getBytes(StandardCharsets.UTF_8), prefixed, ID, now());
+
+ assertEquals(
+ "msg_9Fq1",
+ WebhookVerifier.unwrap(BODY, headers, prefixed).get("id").asText());
+ assertThrows(WebhookVerificationException.class, () -> WebhookVerifier.unwrap(BODY, headers, bare));
+ }
+
+ @Test
+ void refusesASignatureBoundToADifferentMessageId() {
+ Map headers = withHeader(signedHeaders(), "webhook-id", "msg_replaced");
+
+ assertThrows(WebhookVerificationException.class, () -> WebhookVerifier.unwrap(BODY, headers, KEY));
+ }
+
+ @Test
+ void refusesEachMissingSignatureHeader() {
+ for (String name : Arrays.asList("webhook-id", "webhook-timestamp", "webhook-signature")) {
+ Map headers = withoutHeader(signedHeaders(), name);
+
+ WebhookVerificationException error = assertThrows(
+ WebhookVerificationException.class,
+ () -> WebhookVerifier.unwrap(BODY, headers, KEY),
+ "expected a missing " + name + " to be refused");
+
+ assertTrue(error.getMessage().contains(name), error.getMessage());
+ }
+ }
+
+ @Test
+ void refusesAnEmptyOrBlankSignatureHeader() {
+ for (String value : Arrays.asList("", " ")) {
+ Map headers = withHeader(signedHeaders(), "webhook-signature", value);
+
+ assertThrows(WebhookVerificationException.class, () -> WebhookVerifier.unwrap(BODY, headers, KEY));
+ }
+ }
+
+ @Test
+ void refusesAMalformedSignatureHeader() {
+ for (String value : Arrays.asList(
+ "not-a-signature", "v1,", "v1," + repeat("A", 44), "v2,abc", "v1,!!!not base64!!!", ",")) {
+ Map headers = withHeader(signedHeaders(), "webhook-signature", value);
+
+ assertThrows(
+ WebhookVerificationException.class,
+ () -> WebhookVerifier.unwrap(BODY, headers, KEY),
+ "expected \"" + value + "\" to be refused");
+ }
+ }
+
+ @Test
+ void refusesATimestampOutsideTheToleranceWindow() {
+ for (long timestamp : Arrays.asList(now() - 3600, now() + 3600)) {
+ Map headers = signedHeaders(BODY.getBytes(StandardCharsets.UTF_8), KEY, ID, timestamp);
+
+ WebhookVerificationException error =
+ assertThrows(WebhookVerificationException.class, () -> WebhookVerifier.unwrap(BODY, headers, KEY));
+
+ assertTrue(error.getMessage().contains("webhook-timestamp"), error.getMessage());
+ }
+ }
+
+ /**
+ * Back-dating the header on an otherwise genuine delivery also invalidates
+ * the signature, so "it was refused" alone would not say which check fired.
+ * The error has to name the timestamp, which is what shows the tolerance
+ * window is enforced AHEAD of the comparison — the shape the replay defence
+ * actually needs, and the one the e2e suite asserts against a captured
+ * delivery.
+ */
+ @Test
+ void refusesABackDatedHeaderForTheTimestampRatherThanTheSignature() {
+ Map headers = withHeader(signedHeaders(), "webhook-timestamp", Long.toString(now() - 3600));
+
+ WebhookVerificationException error =
+ assertThrows(WebhookVerificationException.class, () -> WebhookVerifier.unwrap(BODY, headers, KEY));
+
+ assertTrue(error.getMessage().contains("webhook-timestamp"), error.getMessage());
+ }
+
+ @Test
+ void refusesASignatureBoundToADifferentTimestamp() {
+ long timestamp = now();
+ Map headers = withHeader(
+ signedHeaders(BODY.getBytes(StandardCharsets.UTF_8), KEY, ID, timestamp),
+ "webhook-timestamp",
+ Long.toString(timestamp - 60));
+
+ assertThrows(WebhookVerificationException.class, () -> WebhookVerifier.unwrap(BODY, headers, KEY));
+ }
+
+ @Test
+ void refusesAnUnparsableTimestamp() {
+ Map headers = withHeader(signedHeaders(), "webhook-timestamp", "not-a-timestamp");
+
+ WebhookVerificationException error =
+ assertThrows(WebhookVerificationException.class, () -> WebhookVerifier.unwrap(BODY, headers, KEY));
+
+ assertTrue(error.getMessage().contains("webhook-timestamp"), error.getMessage());
+ }
+
+ // ---------------------------------------------------------------- misuse
+
+ @Test
+ void raisesAClearErrorWhenTheKeyIsMissing() {
+ for (String key : Arrays.asList(null, "")) {
+ IllegalArgumentException error = assertThrows(
+ IllegalArgumentException.class, () -> WebhookVerifier.unwrap(BODY, signedHeaders(), key));
+
+ assertEquals(WebhookVerifier.MISSING_KEY_MESSAGE, error.getMessage());
+ }
+ }
+
+ /** Before anything is read off the headers, so a misuse is never reported as a verification failure. */
+ @Test
+ void raisesBeforeVerifyingWhenTheKeyIsMissing() {
+ assertThrows(
+ IllegalArgumentException.class,
+ () -> WebhookVerifier.unwrap(BODY, Collections.emptyMap(), null));
+ }
+
+ @Test
+ void raisesWhenTheBodyOrTheHeadersAreMissing() {
+ assertThrows(IllegalArgumentException.class, () -> WebhookVerifier.unwrap((String) null, signedHeaders(), KEY));
+ assertThrows(IllegalArgumentException.class, () -> WebhookVerifier.unwrap((byte[]) null, signedHeaders(), KEY));
+ assertThrows(IllegalArgumentException.class, () -> WebhookVerifier.unwrap(BODY, null, KEY));
+ }
+}