Gareth Wilson Gareth Wilson

Guide to Whop Webhooks: Features and Best Practices

Published


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

FeatureDetails
ConfigurationPOST https://api.whop.com/api/v1/webhooks or the dashboard Developer tab
SubscriptionA list of events per webhook, owned by an account or app (resource_id), optionally including child_resource_events
Signature headerswebhook-id, webhook-timestamp, webhook-signature
SchemeStandard Webhooks: HMAC-SHA256, base64, v1,<signature>
Signed content{webhook-id}.{webhook-timestamp}.{raw body}
SecretA ws_ string, used as the literal HMAC key
Versioningapi_version (v1) for the envelope, api_version_date pin for the payload shape
Event IDBody id, equal to webhook-id, the same on every retry
Response windowA 2xx within 5 seconds. Redirects are not followed
Retries12 retries over about 71 hours
Auto-disableAfter 72 hours of failures with 10 or more failed deliveries
Delivery log30 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:

ResourceEvents
Paymentspayment.created, payment.pending, payment.authorized, payment.succeeded, payment.failed, payment.canceled, payment.requires_action
Membershipsmembership.activated, membership.updated, membership.trial_ending_soon
Membersmember.created, member.updated
Refunds and disputesrefund.created, refund.updated, dispute.created, dispute.updated, dispute_alert.created
Invoicesinvoice.created, invoice.paid, invoice.past_due, invoice.voided, invoice.marked_uncollectible
Products and plansproduct.created, product.updated, product.published, product.unpublished, product.deleted, plan.created, plan.updated, plan.deleted
Payoutspayout.created, payout.updated, payout.reversed, payout_account.status_updated, payout_method.created
OtherCards 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:

{
  "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:

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.

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:

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

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.

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 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 for free and handle Whop webhooks reliably in minutes.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.