feat(lib): restore webhook signature verification - #63
Merged
Conversation
The Stainless SDK shipped client.webhooks.unwrap through 0.0.41. Fern generates from OpenAPI paths and unwrap was never a path, so the Fern client dropped it and callers are left hand-rolling HMAC comparison. Adds whop_sdk.lib.verify_webhook.unwrap, ported from that method, alongside the existing verify_user_token helper. Verification only: the Stainless version also coerced into a union of 42 typed event models, which Fern does not generate, so the parsed body is returned as a dict. Unlike Ruby's helper this needs no generated re-export — src/whop_sdk/lib is a real package — but pyproject.toml is generated, so standardwebhooks is re-declared from extra_dependencies in the monorepo's generators.yml. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XV1533iUUxKJxfn4FXptWz
poetry.lock and requirements.txt are both generated from the dependency set, so they move with the pyproject declaration. Without the lock entry CI's `poetry install` fails version solving rather than warning. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XV1533iUUxKJxfn4FXptWz
antonwhop
marked this pull request as ready for review
August 22, 2026 23:58
Whop's backend signs with the literal bytes of the secret it issues — WebhooksManager::Create mints "ws_" + SecureRandom.hex(32), and SignWebhook hands webhook.webhook_secret straight to OpenSSL::HMAC. The helper passed that secret to StandardWebhooks, which strips a whsec_ prefix and base64-decodes the remainder to derive its key, so it derived the wrong key and rejected every genuine Whop delivery. Base64-encode inside the helper instead, cancelling that decode out. Callers now pass the secret exactly as Whop shows it, which is what the docs were papering over with a caller-side btoa/b64encode. The whole secret is encoded, prefix included, because the backend never strips one either. The tests generated their fixture with the same library they verified with — self-consistent, and wrong against the real producer. They now sign the way sign_webhook.rb does, over the raw body bytes, and add coverage for a multi-signature header and for the v1n nonce scheme. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XV1533iUUxKJxfn4FXptWz
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
The Stainless SDK shipped
client.webhooks.unwrapthrough 0.0.41. Fern generates from OpenAPI paths andunwrapwas never a path, so all three Fern SDKs silently lost it and consumers are hand-rolling HMAC comparison — exactly the thing not to hand-roll.What changed?
whop_sdk.lib.verify_webhook.unwrap(payload, *, headers, key), ported fromwhop-sdk0.0.41src/whop_sdk/resources/webhooks.py:321. Standalone, besideverify_user_token, so nothing generated is patched. Verification only — the Stainless version also coerced intoUnwrapWebhookEvent, a union of 42 generated event models Fern does not generate.standardwebhooksdeclared inpyproject.toml, re-emitted by whopio/whop-monorepo#24922 — merge that first, or the next regeneration strips it and importing the helper raisesImportError..fernignorealso keepstests/custom. Unlike Ruby, this repo'sci.ymlis Fern's, so the "did the declaration survive generation" guard is a test rather than a CI step; forking a generatedci.ymlfor a guard is a worse trade.Two deliberate divergences from the port, both in the diff:
payloadacceptsbytes. Frameworks hand yourequest.body; the library takes either.WebhookVerificationError.standardwebhookslets a malformedwebhook-signatureescape as a bareValueError("not-a-signature"and"v1,a,b"both do — see the parametrized test), and that header is attacker-controlled. Rejecting is right; the exception type was not.Verified by executing
pytest(107 passed, 4 skipped — 30 of them new),mypy .clean over 2923 files,ruff checkclean on the new files, andunwrapexercised from a fresh venv install of the built wheel — which is also what provesstandardwebhooksresolves from the declaration alone.