# Guide to Whop Webhooks: Features and Best Practices

Whop webhooks tell your server when something happens to the products you sell on Whop: a payment succeeds or fails, a membership activates or a trial is about to end, a refund or dispute is opened, or a payout is created. If you grant access, provision accounts or run your own bookkeeping alongside Whop, webhooks are how you hear about those changes as they happen.

This guide covers how to create Whop webhooks, how to verify their Standard Webhooks signatures (and the one place Whop differs from the spec), how API version pins affect payloads, Whop's retry and disable rules, and the best practices for production.

## What are Whop webhooks?

A Whop webhook is a URL plus a list of events, owned by your account or by an app. Whop sends each event as a JSON `POST`, signed using the Standard Webhooks specification: a `webhook-id`, a `webhook-timestamp` and a `webhook-signature` header, with an HMAC-SHA256 over the ID, timestamp and raw body.

The difference from the spec is the key. Whop's secret is a `ws_...` string, and the HMAC key is that string's bytes as they are, prefix included. Standard Webhooks libraries expect a `whsec_` secret whose body they base64-decode, so passing a Whop secret straight to one of them fails. Whop's own SDKs handle this for you.

Each webhook is also pinned to an `api_version_date`, which fixes the shape of the payload, so an API change doesn't change what your handler receives.

## Whop webhook features

| Feature | Details |
| --- | --- |
| Configuration | `POST https://api.whop.com/api/v1/webhooks` or the dashboard **Developer** tab |
| Subscription | A list of events per webhook, owned by an account or app (`resource_id`), optionally including `child_resource_events` |
| Signature headers | `webhook-id`, `webhook-timestamp`, `webhook-signature` |
| Scheme | Standard Webhooks: HMAC-SHA256, base64, `v1,<signature>` |
| Signed content | `{webhook-id}.{webhook-timestamp}.{raw body}` |
| Secret | A `ws_` string, used as the literal HMAC key |
| Versioning | `api_version` (`v1`) for the envelope, `api_version_date` pin for the payload shape |
| Event ID | Body `id`, equal to `webhook-id`, the same on every retry |
| Response window | A `2xx` within 5 seconds. Redirects are not followed |
| Retries | 12 retries over about 71 hours |
| Auto-disable | After 72 hours of failures with 10 or more failed deliveries |
| Delivery log | 30 days, in the dashboard or `GET /api/v1/webhooks/{id}/deliveries` |

Whop rejects `localhost` and private-network URLs.

## Common events

Event names follow `resource.action`. Whop's events table lists about 80, each linking to a reference page with its payload schema. The most commonly used are:

| Resource | Events |
| --- | --- |
| Payments | `payment.created`, `payment.pending`, `payment.authorized`, `payment.succeeded`, `payment.failed`, `payment.canceled`, `payment.requires_action` |
| Memberships | `membership.activated`, `membership.updated`, `membership.trial_ending_soon` |
| Members | `member.created`, `member.updated` |
| Refunds and disputes | `refund.created`, `refund.updated`, `dispute.created`, `dispute.updated`, `dispute_alert.created` |
| Invoices | `invoice.created`, `invoice.paid`, `invoice.past_due`, `invoice.voided`, `invoice.marked_uncollectible` |
| Products and plans | `product.created`, `product.updated`, `product.published`, `product.unpublished`, `product.deleted`, `plan.created`, `plan.updated`, `plan.deleted` |
| Payouts | `payout.created`, `payout.updated`, `payout.reversed`, `payout_account.status_updated`, `payout_method.created` |
| Other | Cards and card transactions, transfers, deposits, setup intents, shipments, chat messages and reactions, courses, entries, verifications and ads |

`payment.requires_action` fires when a bank asks the buyer to authenticate an off-session charge. Send the buyer its `data.recovery_url` so they can complete 3D Secure. A failed subscription renewal also sends `payment.failed`, with `data.decline_code` and `data.failure_message` when available.

Every event has the same envelope. Here is Whop's example, with `data` elided as in the docs:

```json
{
  "id": "msg_bQPHmO2eBnHYtWWuxAN9K3Xd",
  "type": "payment.succeeded",
  "api_version": "v1",
  "api_version_date": "2026-08-14",
  "timestamp": "2026-08-10T17:03:24.291Z",
  "account_id": "biz_XXXXXXXX",
  "data": {
    "id": "pay_XXXXXXXX",
    "...": "the full payment object"
  }
}

```

Webhooks pinned before `2026-08-14`, and webhooks with no pin, receive `company_id` instead of `account_id`. On `account.updated`, `product.updated`, `plan.updated` and `shipment.updated`, an optional `previous_attributes` object holds the old values of the `data` fields that changed.

## Setting up Whop webhooks

Create the webhook through the API:

```bash
curl -X POST "https://api.whop.com/api/v1/webhooks" \
  -H "Authorization: Bearer $WHOP_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/webhooks/whop",
    "events": ["payment.succeeded", "membership.activated"]
  }'

```

The response contains `webhook_secret`. The API shows it only once, so store it straight away as `WHOP_WEBHOOK_SECRET`, exactly as given. Set `api_version_date` when you create the webhook to pin the payload shape.

Or use the dashboard: open the **Developer** tab, select **Create webhook**, enter your URL and events, then copy the secret from the **Secret** column.

To test, open the webhook's menu in the dashboard and pick a test event, or call `POST /api/v1/webhooks/{id}/test`. Whop sends a sample payload and shows you your server's response status and body. For local development, `hookdeck listen 3000 whop --path /webhooks/whop` gives you a public URL, since Whop won't send to `localhost`.

## Securing Whop webhooks

Build the string `{webhook-id}.{webhook-timestamp}.{raw body}`, compute an HMAC-SHA256 with your `ws_...` secret as the key, base64-encode it, and compare it in constant time with each `v1,` entry in `webhook-signature`.

```javascript
const crypto = require('crypto');
const express = require('express');

function verifyWhopWebhook(rawBody, headers, secret) {
  const msg_id = headers['webhook-id'];
  const timestamp = headers['webhook-timestamp'];
  const signature_header = headers['webhook-signature'];
  if (!msg_id || !/^\d+$/.test(timestamp || '') || !signature_header) return false;

  // The key is the ws_ secret exactly as given: no stripping, no base64 decoding.
  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${msg_id}.${timestamp}.`)
    .update(rawBody)
    .digest();

  return signature_header.split(' ').some((entry) => {
    const [version, signature] = entry.split(',');
    if (version !== 'v1' || !signature) return false;
    const received = Buffer.from(signature, 'base64');
    return received.length === expected.length && crypto.timingSafeEqual(received, expected);
  });
}

const app = express();

app.post('/webhooks/whop', express.raw({ type: '*/*' }), (req, res) => {
  const secret = process.env.WHOP_WEBHOOK_SECRET;
  if (!secret) return res.sendStatus(500); // fail closed

  if (!verifyWhopWebhook(req.body, req.headers, secret)) {
    return res.sendStatus(401);
  }

  // Optional: reject a webhook-timestamp more than 5 minutes from now, as Whop recommends.
  // Leave it off if you retry or replay events later, because they keep the original timestamp.

  const event = JSON.parse(req.body.toString('utf8'));
  // Deduplicate on event.id, then route on event.type asynchronously.
  res.sendStatus(200);
});

```

The same check in Python:

```python
import base64
import hashlib
import hmac

def verify_whop_webhook(raw_body: bytes, headers, secret: str) -> bool:
    msg_id = headers.get("webhook-id")
    timestamp = headers.get("webhook-timestamp")
    signature_header = headers.get("webhook-signature")
    if not (msg_id and timestamp and timestamp.isdigit() and signature_header):
        return False

    # The ws_ secret is the literal key.
    expected = hmac.new(secret.encode("utf-8"), f"{msg_id}.{timestamp}.".encode() + raw_body, hashlib.sha256).digest()

    for entry in signature_header.split(" "):
        version, _, signature = entry.partition(",")
        if version == "v1" and signature:
            if hmac.compare_digest(base64.b64decode(signature), expected):
                return True
    return False

```

To check your verifier, use Whop's example secret `ws_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef`, a `webhook-id` of `msg_bQPHmO2eBnHYtWWuxAN9K3Xd`, a `webhook-timestamp` of `1786381404` and this exact body:

```
{"id":"msg_bQPHmO2eBnHYtWWuxAN9K3Xd","type":"payment.succeeded","api_version":"v1"}

```

The signature header should be:

```
v1,046B11XXOpx7zrQSTM+XYW8FwYZ+NqHCuvMDAF5Wr/A=

```

Whop's SDKs (`unwrapWebhook` in `@whop/sdk/helpers`, and `unwrap` in the Python `whop_sdk`) verify and parse in one call, and apply Whop's 5 minute timestamp limit automatically. If you use a generic Standard Webhooks library instead, pass it the base64 encoding of the `ws_` string as the secret, because it base64-decodes whatever it is given.

> **Make Whop webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) verifies Whop's Standard Webhooks signatures with your `ws_` secret, answers inside the 5 second window, and durably queues every payment and membership event so a long outage never disables your webhook.

## Whop webhook limitations and pain points

### A `ws_` secret in a `whsec_` world

**The Problem:** Every delivery fails verification when you use a standard library, even though the secret is right.

**Why It Happens:** Standard Webhooks libraries take a `whsec_` secret and base64-decode it to get the key. Whop's key is the `ws_` string itself. Passing it unchanged makes the library throw or derive the wrong key, and stripping the prefix doesn't help either.

**Workarounds:**

* Use Whop's SDK helpers, or verify manually with the secret as a plain string.
* With a generic library, pass `base64(utf8(secret))`.
* Test against the vector in the previous section.

**How Hookdeck Can Help:** Hookdeck's Whop source type takes the `ws_` secret exactly as Whop gives it and handles the key derivation, so you paste it in once and every delivery is checked.

### Replays and the 5 minute window

**The Problem:** Events you replay after fixing a bug are rejected by your own verifier.

**Why It Happens:** Whop recommends rejecting any request whose `webhook-timestamp` is more than 5 minutes from the current time, and its SDK helpers enforce that with no way to turn it off. An event replayed from a log keeps its original timestamp.

**Workarounds:**

* Verify manually and make the timestamp check optional.
* Deduplicate on `webhook-id` instead, which protects against replays without a time limit.

**How Hookdeck Can Help:** Hookdeck verifies the signature without enforcing a timestamp window, so retries and [replays](/docs/use-cases/receive-webhooks#replay-for-debugging-and-recovery) from Hookdeck reach your service. If your own code enforces the window, keep it off on routes behind Hookdeck.

### Disabled webhooks don't catch up

**The Problem:** After a long outage, the webhook is switched off, and the events from that period never arrive.

**Why It Happens:** Whop emails a warning after 24 hours of failures. If failures continue for 72 hours with 10 or more failed deliveries, it disables the webhook and sends a second email. Re-enabling it resets the failure history, but Whop doesn't send the events that occurred while it was off.

**Workarounds:**

* Watch for Whop's warning email and act on it within the first day.
* After re-enabling with `PATCH /api/v1/webhooks/{id}` and `"enabled": true`, read the API for what you missed.

**How Hookdeck Can Help:** Hookdeck accepts deliveries while your service is down, so Whop never counts failures and never disables the webhook. Events wait in Hookdeck until your service is back, and [Issues](/docs/issues) tell you that deliveries to it are failing.

### Pins change the envelope as well as the data

**The Problem:** Code that reads `account_id` gets `undefined` on an older webhook.

**Why It Happens:** `api_version_date` fixes the shape of the envelope as well as `data`. Pins before `2026-08-14`, and unpinned webhooks, use `company_id`. Some resources that exist only on the current API, such as cards, transfers, deposits and exports, always get the latest `data` shape whatever the pin.

**Workarounds:**

* Pin every webhook explicitly, and read `account_id ?? company_id`.
* Move pins forward deliberately and test with a test event first.

**How Hookdeck Can Help:** A Hookdeck [transformation](/docs/transformations) can normalise `company_id` to `account_id` before delivery, so one handler serves webhooks on different pins.

## Best practices

### Verify against the raw body

Whop's own examples pass the raw body to the verifier and warn that parsing first changes the bytes. Use `express.raw()`, `await request.text()` in Next.js, or `await request.body()` in FastAPI.

### Deduplicate on `webhook-id`

Whop delivers at least once, and every retry of a delivery has the same `webhook-id`, which also appears as the body `id`. Store it and skip repeats. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Don't assume order

A newer event can arrive before an older one. Process each event on its own, and read the current state from the API when the sequence matters.

### Acknowledge fast and process asynchronously

Whop counts anything slower than 5 seconds as a failure. Verify, queue the work and return `200`, as Whop's own examples do. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

### Don't redirect

Whop doesn't follow redirects, and a `3xx` counts as a failure. Register the final URL, including any trailing slash your framework expects.

### Use v1 only

Legacy `api_version` `v2` and `v5` webhooks don't use Standard Webhooks signatures and won't verify with the code above. Create new webhooks on `v1`.

## Conclusion

Whop signs webhooks with the Standard Webhooks scheme, keyed with the literal `ws_` secret rather than a decoded `whsec_` value. Verify `{webhook-id}.{webhook-timestamp}.{raw body}` with that key, deduplicate on `webhook-id`, pin `api_version_date`, and answer within 5 seconds, because Whop disables a webhook after three days of failures and doesn't resend what it missed.

[Hookdeck Event Gateway](https://hookdeck.com) verifies Whop signatures with your `ws_` secret, keeps your webhook healthy through outages, and holds every event until your service has processed it.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Whop webhooks reliably in minutes.