Agent skill
Whop Webhooks Skill
Receive and verify Whop webhooks. Use when setting up Whop webhook handlers, debugging Standard Webhooks signature verification (webhook-id, webhook-timestamp, webhook-signature), or handling payment.succeeded, payment.failed, membership.activated, membership.deactivated, and refund.created events.
Install this skill
npx skills add hookdeck/webhook-skills --skill whop-webhooks
When to Use This Skill
- How do I receive Whop webhooks?
- How do I verify Whop webhook signatures (Standard Webhooks,
ws_secret)? - How do I handle
payment.succeededormembership.activatedevents to grant access? - Why is my Whop webhook signature verification failing?
- How do I handle
membership.deactivatedandrefund.createdevents?
Verification (core)
Whop uses Standard Webhooks: headers webhook-id, webhook-timestamp, webhook-signature (v1,<base64>), HMAC-SHA256 over {webhook-id}.{webhook-timestamp}.{raw body}. The ws_... signing secret is the literal UTF-8 HMAC key: do not strip ws_ and do not base64-decode it.
// Manual verification (what the examples do).
// toleranceSec = 0 (default) skips the timestamp window, because Hookdeck retries and replays
// keep the original webhook-timestamp. A positive value opts in.
const crypto = require('crypto');
function verifyWhop(rawBody, headers, secret, toleranceSec = 0) {
const { 'webhook-id': id, 'webhook-timestamp': ts, 'webhook-signature': sigHeader } = headers;
if (!id || !/^\d+$/.test(ts ?? '') || !sigHeader) return false;
if (toleranceSec > 0 && Math.abs(Date.now() / 1000 - Number(ts)) > toleranceSec) return false;
const expected = crypto.createHmac('sha256', secret).update(`${id}.${ts}.${rawBody}`).digest('base64');
return sigHeader.split(' ').some((part) => {
const [version, sig] = part.split(',');
if (version !== 'v1' || !sig) return false;
const a = Buffer.from(sig), b = Buffer.from(expected);
return a.length === b.length && crypto.timingSafeEqual(a, b);
});
}
Why not the SDK helper? unwrapWebhook() (@whop/sdk/helpers) and unwrap() (whop_sdk) check the same signature, but they always enforce a 5-minute timestamp window with no way to turn it off. Hookdeck retries and replays carry the original webhook-timestamp, so the SDK rejects them. The examples verify manually and make the window opt-in with WHOP_ (default 0; a positive number of seconds turns it on; anything that isn't a whole number is treated as 0, with a warning at startup). If you never retry or replay late, the SDK is fine.
SDK usage, if the fixed window is acceptable: unwrapWebhook(rawBodyString, { headers, key: secret }) from @whop/sdk/helpers, or from whop_ then unwrap(raw_. If you use the generic standardwebhooks library directly, pass base64(utf8(secret)) as the key, because it base64-decodes its input (the Whop SDKs do this for you).
For complete handlers with tests, see examples/express/, examples/nextjs/, examples/fastapi/.
Common Event Types
| Event | Triggered When |
|---|---|
payment.succeeded | A payment completes (fulfill the order) |
payment.failed | A payment fails; check data.decline_code, data.failure_message, data.recovery_url |
membership.activated | A membership becomes active (grant access) |
membership.deactivated | A membership ends (revoke access) |
refund.created | A refund is issued |
Event names use resource.action. See references/overview.md for more events (payment.requires_action, membership.updated, dispute.created, ...).
Gotchas
- Use the raw body; parsing JSON first changes the bytes and breaks the signature.
- Pass the secret exactly as Whop issued it (
ws_...). Stripping the prefix or base64-decoding it fails verification. - The SDK helper rejects timestamps more than 5 minutes off, which also rejects Hookdeck retries and replays (they keep the original timestamp). The examples only check the window when
WHOP_is set. Retries reuse the sameTIMESTAMP_ TOLERANCE_ SECONDS webhook-id, so deduplicate on it. - Respond
2xxwithin 5 seconds; redirects count as failures. Endpoints that keep failing are disabled after 72 hours. - Legacy
v2/v5webhook formats are not Standard Webhooks signed; useapi_version: v1.
Environment Variables
# Developer tab -> Secret column (or `webhook_secret` from POST /api/v1/webhooks, shown once)
WHOP_WEBHOOK_SECRET=ws_your_signing_secret_here
WHOP_TIMESTAMP_TOLERANCE_SECONDS=0 # Optional replay window; a positive value opts in
Local Development
npx hookdeck-cli listen 3000 whop --path /webhooks/whop
No account required. Register the printed URL as the webhook endpoint in Whop (it rejects localhost URLs).
Reference Materials
- references/overview.md - What Whop webhooks are, events, payload structure
- references/setup.md - Create a webhook and get the signing secret
- references/verification.md - Signature details, manual verification, debugging