From af021eda9ca4a8f96e7a05f78620bdd4609fff3f Mon Sep 17 00:00:00 2001 From: icepaq <288199041+antonwhop@users.noreply.github.com> Date: Mon, 24 Aug 2026 14:40:08 -0700 Subject: [PATCH 1/2] feat(helpers): verify webhook signatures without a Standard Webhooks library MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. Standalone under com.whop.api.helpers rather than a method on WhopApiClient, so nothing generated has to be patched. javax.crypto.Mac, java.util.Base64 and java.security.MessageDigest are all in the JDK, so the HMAC is computed over the raw ws_ secret directly — sidestepping the base64-decoded-key mismatch that made the Ruby, Python and TypeScript helpers reject every genuine delivery until each learned to base64-encode the whole secret to cancel it out. No dependency is added, which matters because build.gradle stays Fern's. .fernignore was a single comment line and protected nothing; it now keeps the helper and its tests, and ci.yml fails if either the files or the entries go missing. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01XV1533iUUxKJxfn4FXptWz --- .fernignore | 33 ++ .github/workflows/ci.yml | 13 + .../helpers/WebhookVerificationException.java | 27 ++ .../com/whop/api/helpers/WebhookVerifier.java | 217 ++++++++++ .../whop/api/helpers/WebhookVerifierTest.java | 381 ++++++++++++++++++ 5 files changed, 671 insertions(+) create mode 100644 src/main/java/com/whop/api/helpers/WebhookVerificationException.java create mode 100644 src/main/java/com/whop/api/helpers/WebhookVerifier.java create mode 100644 src/test/java/com/whop/api/helpers/WebhookVerifierTest.java 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..93e32d0a --- /dev/null +++ b/src/main/java/com/whop/api/helpers/WebhookVerifier.java @@ -0,0 +1,217 @@ +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(), System.getenv("WHOP_WEBHOOK_SECRET"));
+ * }
+ * + *

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)); + } +} From bcaf7bac65eb4c34a329216ef5bcab9b60090c7e Mon Sep 17 00:00:00 2001 From: icepaq <288199041+antonwhop@users.noreply.github.com> Date: Mon, 24 Aug 2026 14:53:39 -0700 Subject: [PATCH 2/2] docs(helpers): keep System.getenv out of the usage example The e2e suite counts System.getenv references under src/main and asserts the SDK reads no environment variables. A javadoc example is a match, so the example now names the secret as a parameter. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01XV1533iUUxKJxfn4FXptWz --- src/main/java/com/whop/api/helpers/WebhookVerifier.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/whop/api/helpers/WebhookVerifier.java b/src/main/java/com/whop/api/helpers/WebhookVerifier.java index 93e32d0a..d87b8ec8 100644 --- a/src/main/java/com/whop/api/helpers/WebhookVerifier.java +++ b/src/main/java/com/whop/api/helpers/WebhookVerifier.java @@ -15,9 +15,12 @@ * Verifies the Standard Webhooks signature Whop sends on every webhook delivery, then parses the body. * *
{@code
- * JsonNode event = WebhookVerifier.unwrap(rawBody, request.getHeaders(), System.getenv("WHOP_WEBHOOK_SECRET"));
+ * 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