Skip to content

feat(lib): restore webhook signature verification - #63

Merged
antonwhop merged 3 commits into
mainfrom
anton/webhook-verify-helper
Aug 23, 2026
Merged

feat(lib): restore webhook signature verification#63
antonwhop merged 3 commits into
mainfrom
anton/webhook-verify-helper

Conversation

@antonwhop

Copy link
Copy Markdown
Collaborator

Why

The Stainless SDK shipped client.webhooks.unwrap through 0.0.41. Fern generates from OpenAPI paths and unwrap was 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 from whop-sdk 0.0.41 src/whop_sdk/resources/webhooks.py:321. Standalone, beside verify_user_token, so nothing generated is patched. Verification only — the Stainless version also coerced into UnwrapWebhookEvent, a union of 42 generated event models Fern does not generate.
  • standardwebhooks declared in pyproject.toml, re-emitted by whopio/whop-monorepo#24922 — merge that first, or the next regeneration strips it and importing the helper raises ImportError.
  • .fernignore also keeps tests/custom. Unlike Ruby, this repo's ci.yml is Fern's, so the "did the declaration survive generation" guard is a test rather than a CI step; forking a generated ci.yml for a guard is a worse trade.

Two deliberate divergences from the port, both in the diff:

  • payload accepts bytes. Frameworks hand you request.body; the library takes either.
  • Every rejection is a WebhookVerificationError. standardwebhooks lets a malformed webhook-signature escape as a bare ValueError ("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 check clean on the new files, and unwrap exercised from a fresh venv install of the built wheel — which is also what proves standardwebhooks resolves from the declaration alone.

antonwhop and others added 2 commits August 22, 2026 16:45
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
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
@antonwhop
antonwhop merged commit 3e2bcc1 into main Aug 23, 2026
8 checks passed
@antonwhop antonwhop mentioned this pull request Aug 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant