# 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.succeeded` or `membership.activated` events to grant access?
* Why is my Whop webhook signature verification failing?
* How do I handle `membership.deactivated` and `refund.created` events?

## 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.

```javascript
// 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_TIMESTAMP_TOLERANCE_SECONDS` (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_sdk.lib.verify_webhook import unwrap` then `unwrap(raw_body, headers=dict(request.headers), key=secret)`. 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/](https://github.com/hookdeck/webhook-skills/tree/main/skills/whop-webhooks/examples/express/), [examples/nextjs/](https://github.com/hookdeck/webhook-skills/tree/main/skills/whop-webhooks/examples/nextjs/), [examples/fastapi/](https://github.com/hookdeck/webhook-skills/tree/main/skills/whop-webhooks/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](https://github.com/hookdeck/webhook-skills/blob/main/skills/whop-webhooks/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_TIMESTAMP_TOLERANCE_SECONDS` is set. Retries reuse the same `webhook-id`, so deduplicate on it.
* Respond `2xx` within 5 seconds; redirects count as failures. Endpoints that keep failing are disabled after 72 hours.
* Legacy `v2`/`v5` webhook formats are not Standard Webhooks signed; use `api_version: v1`.

## Environment Variables

```bash
# 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

```bash
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](https://github.com/hookdeck/webhook-skills/blob/main/skills/whop-webhooks/references/overview.md) - What Whop webhooks are, events, payload structure
* [references/setup.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/whop-webhooks/references/setup.md) - Create a webhook and get the signing secret
* [references/verification.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/whop-webhooks/references/verification.md) - Signature details, manual verification, debugging