# Guide to Fanvue Webhooks: Features and Best Practices

Fanvue webhooks tell your systems what is happening across a creator's business: a fan subscribes or renews, a paid message or tip clears, a message arrives in the creator's inbox, a checkout link sells, or a payout settles. If you are building an app on Fanvue, the creator subscription platform, webhooks are how that activity reaches your own services as it happens, without polling the API.

This guide covers how Fanvue webhooks are delivered and signed, the verification probe that catches out API-registered endpoints, the event families and their deprecated legacy predecessors, and the best practices for running a Fanvue receiver in production.

## What are Fanvue webhooks?

A Fanvue webhook is a signed HTTPS `POST` to an endpoint you own. Every delivery carries an `X-Fanvue-Signature` header in the form `t=<timestamp>,v0=<signature>`, where `v0` is an HMAC-SHA256 of the timestamp, a period, and the raw request body, encoded as lowercase hex.

The format will look familiar if you have verified Stripe webhooks, but it is Fanvue's own scheme rather than Standard Webhooks. The detail that matters most is that the signing secret is used exactly as Fanvue shows it, so a secret that starts with `whsec_` is not stripped or base64-decoded the way a Standard Webhooks secret would be.

Events come in four families that all arrive at the same endpoint. Creator events (`creator.*`) cover a creator's activity on the platform, checkout events (`checkout_link.*` and `payout.*`) cover checkout-link sales and creator withdrawals, and app events (`app.*`) cover your own app's sales and the priced actions inside its experiences. The fourth family is the deprecated flat legacy events, such as `purchase.new`, which still fire but use a different body with no envelope.

## Fanvue webhook features

| Feature | Details |
| --- | --- |
| Configuration | The Developer Area Events tab, `webhooks.destinations` in the App Manifest, or `POST /webhooks/subscriptions` with a creator's access token |
| Signature header | `X-Fanvue-Signature: t=<timestamp>,v0=<signature>` |
| Scheme | HMAC-SHA256, lowercase hex digest |
| Signed content | `{t}.{raw body}` |
| Secret | One per-app secret for the Events tab and the App Manifest. One per-subscription `signingSecret` (`whsec_` plus 64 hex characters) for the API path, returned once |
| Replay window | `t` is Unix seconds. Fanvue's own samples reject anything more than 300 seconds from now |
| Secret rotation | The header carries two MACs during the overlap, the second as a bare field: `t=<t>,v0=<old>,<new>` |
| Verification probe | An unsigned `fanvue.webhook.verification` request before `POST /webhooks/subscriptions` creates a subscription. The Events tab does not probe |
| Delivery | At least once, in no guaranteed order. Any 2xx counts as success |
| Retries | Failed deliveries are retried with backoff. Fanvue does not publish the schedule, and there is no manual replay once retries are exhausted |
| Limits | 20 destinations per app from the Events tab, and 20 API subscriptions per creator and client |
| SDK | The Fanvue Builder SDK (`@fanvue/builder-sdk`) includes a webhook receiver that answers the probe, verifies signatures and sanitises deliveries |

No source IP allowlist is published, so the signature is the credential.

## Common events

The event catalogue lists every topic with its family, the `data.object` it carries, and the OAuth scope it needs. Route on the body's `type` field.

| Family | Events | Fires when |
| --- | --- | --- |
| Creator payments and rewards | `creator.payment.succeeded`, `creator.reward.created`, `creator.reward.reversed` | An on-platform payment to the creator succeeds, or an affiliate or referral commission is earned or clawed back |
| Creator subscriptions | `creator.subscription.activated`, `creator.subscription.renewed`, `creator.subscription.deactivated`, `creator.subscription.cancel_at_period_end_changed` | A fan's subscription starts, renews, ends, or has auto-renew turned off or back on |
| Experience subscriptions | `creator.experience_subscription.activated`, `creator.experience_subscription.deactivated` | Access to an app experience starts or stops. Delivered only to the app that owns the experience and the creator's own destinations |
| Messages and chat | `creator.message.received`, `creator.message.sent`, `creator.message.mass_sent`, `creator.message.read`, `creator.message.read_by_fan`, `creator.message.deleted`, `creator.message.reaction`, `creator.chat.marked_unread` | Messages arrive, are sent, read, deleted or reacted to, or the creator marks a conversation unread |
| Engagement and fans | `creator.follow.created`, `creator.post.liked`, `creator.post.commented`, `creator.fan.status_changed`, `creator.fan.presence_changed` | A user follows the creator or engages with a post, or a fan is muted, unmuted, comes online or goes offline |
| Creator refunds and disputes | `creator.refund.created`, `creator.dispute.flagged`, `creator.dispute.created` | An on-platform payment is refunded, an early chargeback alert is raised, or a formal dispute opens |
| Checkout links | `checkout_link.payment.pending`, `checkout_link.payment.succeeded`, `checkout_link.payment.failed`, `checkout_link.subscription.activated`, `checkout_link.subscription.deactivated`, `checkout_link.subscription.cancel_at_period_end_changed`, `checkout_link.refund.created`, `checkout_link.refund.requested`, `checkout_link.dispute.flagged`, `checkout_link.dispute.created`, `checkout_link.installment.paid`, `checkout_link.installment.failed`, `checkout_link.plan.completed` | Checkout-link payments, subscriptions, refunds, disputes and buy-now-pay-later repayments |
| Payouts | `payout.paid`, `payout.failed` | A creator payout settles or fails. Available through the API and the App Manifest only |
| App billing | `app.payment.pending`, `app.payment.succeeded`, `app.payment.failed`, `app.subscription.activated`, `app.subscription.cancel_at_period_end_changed`, `app.refund.created`, `app.dispute.flagged`, `app.dispute.created` | Charges, subscriptions, refunds and disputes on your own app's sales |
| Experience actions | `app.experience.action.payment.pending`, `app.experience.action.payment.succeeded`, `app.experience.action.payment.failed`, `app.experience.action.refund.created`, `app.experience.action.dispute.flagged`, `app.experience.action.dispute.created` | A fan pays for a priced action inside one of your experiences. Fulfil only on `succeeded` |

A few names in the catalogue are marked `reserved`: they validate in an App Manifest but are never emitted. They include `creator.payment.pending`, `creator.payment.failed` and `app.subscription.deactivated`, so a handler waiting on one of them waits forever. `app.payment.refunded` is a deprecated alias of `app.refund.created`, and Fanvue emits both for every reversal with distinct event ids.

The nine legacy flat events still fire. Each has a replacement, and you should subscribe to one or the other, never both, or every occurrence is counted twice.

| Legacy event | Replacement |
| --- | --- |
| `follow.new` | `creator.follow.created` |
| `purchase.new` | `creator.payment.succeeded` |
| `tip.new` | `creator.payment.succeeded` with `source: "tip"` |
| `subscription.new` | `creator.subscription.activated` |
| `subscription.renewed` | `creator.subscription.renewed` |
| `subscription.cancelled` | `creator.subscription.cancel_at_period_end_changed` |
| `subscription.expired` | `creator.subscription.deactivated` |
| `message.received` | `creator.message.received` |
| `message.read` | `creator.message.read` |

Every creator, checkout and app event arrives in the same envelope. Here is Fanvue's example for `creator.follow.created`:

```json
{
  "id": "f1a2b3c4-7777-4a2b-9c3d-aaaaaaaaaaaa",
  "type": "creator.follow.created",
  "timestamp": "2026-06-09T08:39:33.139Z",
  "data": {
    "object": "follow",
    "id": null,
    "created_at": "2026-06-09T08:39:33.139Z",
    "follower": {
      "uuid": "fan-uuid",
      "email": null,
      "handle": "janed",
      "display_name": "Jane D"
    },
    "creator": { "uuid": "creator-uuid" },
    "metadata": {},
    "tracking": {
      "link_url": "https://fanvue.com/creator-handle?fvc=summer-campaign",
      "metadata": { "campaign": "summer", "source": "x" }
    }
  }
}

```

`id` is the event id and stays the same on every retry. `timestamp` is when the delivery was built, so a retry carries a new one. Fields inside `data` are snake_case, and `data.object` names the resource. Money fields such as `gross` and `net` on payment events are in minor units, so `1999` is $19.99.

Legacy events have none of this. Their body is a flat set of camelCase fields with an `eventId` appended, and nothing in the body names the topic.

## Setting up Fanvue webhooks

The quickest path is the Developer Area:

1. Open your app in the Developer Area, go to the Events tab, and click Add webhook.
2. Enter your HTTPS endpoint URL and tick the events you want.
3. Add the scope each event needs (`read:creator`, `read:chat`, `read:post`, `read:experience` or `read:self`) on the Authentication tab or in your App Manifest.
4. Click View signing secret and store the value server-side, for example as `FANVUE_WEBHOOK_SECRET`.
5. Use the row's Test action to send a signed sample delivery. Test payloads carry `TEST-` prefixed ids inside `data`.

The Test action refuses to send until the app owner has authorised the app with the event's scope, so sign in to your own app and grant it first.

To register a webhook on a creator's behalf, call `POST /webhooks/subscriptions` with that creator's access token. Before the subscription is created, Fanvue sends one unsigned probe to the URL:

```json
{ "type": "fanvue.webhook.verification", "timestamp": "2026-06-09T08:39:33.139Z" }

```

Any 2xx within 8 seconds passes. A redirect fails the call, so submit the final URL. The response includes a `signingSecret` shaped `whsec_` followed by 64 hex characters, and no endpoint reads it back afterwards. If you lose it, delete the subscription and create a new one. App events (`app.*`) are not available through this path.

## Securing Fanvue webhooks

Split `X-Fanvue-Signature` on commas and keep track of the current key as you go, because a field with no `=` continues the previous key's list. That is how the second MAC arrives during a secret rotation. Reject the request if `t` is missing, repeated, or not a plain decimal number, or if no MAC is present. Then recompute HMAC-SHA256 over `{t}.{raw body}` with the secret exactly as Fanvue gave it, encode it as hex, and accept the request if any MAC in the header matches.

Answer the unsigned verification probe before you check signatures, and verify against the raw bytes Fanvue sent, never re-serialised JSON.

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

const TOLERANCE_SECONDS = 300;

function parseSignatureHeader(header) {
  let key;
  let timestamp;
  let timestampCount = 0;
  const macs = [];

  for (const field of header.split(',')) {
    const separator = field.indexOf('=');
    if (separator !== -1) key = field.slice(0, separator).trim();
    // With no "=", this is the whole bare field: the rotation MAC joins v0.
    const value = field.slice(separator + 1).trim();
    if (key === 't') {
      timestamp = value;
      timestampCount += 1;
    } else if (key === 'v0' && value) {
      macs.push(value.toLowerCase());
    }
  }
  return { timestamp, timestampCount, macs };
}

function verifyFanvueSignature(rawBody, header, secret) {
  if (!header || !secret) return false; // fail closed

  const { timestamp, timestampCount, macs } = parseSignatureHeader(header);
  if (timestampCount !== 1 || !/^[0-9]+$/.test(timestamp) || macs.length === 0) {
    return false;
  }
  if (Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp)) > TOLERANCE_SECONDS) {
    return false;
  }

  // The secret is the HMAC key as given, whsec_ prefix included. No decoding.
  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${timestamp}.`)
    .update(rawBody)
    .digest('hex');

  // Accept ANY match, so deliveries signed with the new secret verify during a rotation.
  return macs.some(
    (mac) =>
      mac.length === expected.length &&
      crypto.timingSafeEqual(Buffer.from(mac), Buffer.from(expected))
  );
}

const app = express();

app.post('/webhooks/fanvue', express.raw({ type: 'application/json' }), (req, res) => {
  let event;
  try {
    event = JSON.parse(req.body.toString('utf8'));
  } catch {
    return res.sendStatus(400);
  }

  // The probe is unsigned. Answer it first, and process nothing on this path.
  if (event.type === 'fanvue.webhook.verification') {
    return res.sendStatus(200);
  }

  if (!verifyFanvueSignature(req.body, req.get('x-fanvue-signature'), process.env.FANVUE_WEBHOOK_SECRET)) {
    return res.sendStatus(401);
  }

  // Route on the signed body, never on the unsigned X-Fanvue-Topic header.
  // Persist the event under event.id (or event.eventId on legacy events), then process async.
  res.sendStatus(200);
});

```

The same check in Python:

```python
import hashlib
import hmac
import os
import re
import time

TOLERANCE_SECONDS = 300

def parse_signature_header(header: str):
    key = None
    timestamp = None
    timestamp_count = 0
    macs = []
    for field in header.split(","):
        if "=" in field:
            key, _, value = field.partition("=")
            key = key.strip()
        else:
            value = field  # a bare field continues the previous key's list
        value = value.strip()
        if key == "t":
            timestamp = value
            timestamp_count += 1
        elif key == "v0" and value:
            macs.append(value.lower())
    return timestamp, timestamp_count, macs

def verify_fanvue_signature(raw_body: bytes, header: str, secret: str) -> bool:
    if not header or not secret:
        return False  # fail closed

    timestamp, timestamp_count, macs = parse_signature_header(header)
    if timestamp_count != 1 or not timestamp or not re.fullmatch(r"[0-9]+", timestamp) or not macs:
        return False
    if abs(int(time.time()) - int(timestamp)) > TOLERANCE_SECONDS:
        return False

    expected = hmac.new(
        secret.encode("utf-8"),  # used verbatim, whsec_ prefix included
        f"{timestamp}.".encode("utf-8") + raw_body,
        hashlib.sha256,
    ).hexdigest()
    return any(hmac.compare_digest(expected, mac) for mac in macs)

# In FastAPI: raw_body = await request.body()
# verify_fanvue_signature(raw_body, request.headers.get("x-fanvue-signature"),
#                         os.environ["FANVUE_WEBHOOK_SECRET"])

```

Fanvue's documented check compares MACs as given. Lowercasing them first, as above, costs nothing and also accepts an uppercase hex digest.

If you build on the Fanvue Builder SDK, its webhook receiver answers the probe, verifies signatures and sanitises deliveries for you.

> Make Fanvue webhooks production-ready. [Hookdeck Event Gateway](/event-gateway) verifies the `X-Fanvue-Signature` header, accepts both MACs during a secret rotation, and durably queues every creator, checkout and app event before your service sees it.

## Fanvue webhook limitations and pain points

### The rotation MAC hides in a bare field

The Problem: Every delivery signed with the new secret starts failing verification the moment you rotate, and keeps failing for the whole overlap period.

Why It Happens: During a rotation the header is `t=<timestamp>,v0=<old>,<new>`. The second MAC has no `v0=` in front of it. A parser that splits each field on `=` and builds a dictionary keeps only the first MAC, which was signed with the secret you are retiring.

Workarounds:

* Parse statefully: carry the last key forward and append a bare field to its list.
* Accept the delivery when any MAC matches, not just the first.
* Add a test with a two-MAC header before your first rotation.

How Hookdeck Can Help: Hookdeck parses the full MAC list and accepts any match, so a rotation does not turn into a verification outage in your service.

### The probe rejects receivers that are correctly strict

The Problem: A receiver that answers `401` to every unsigned request works when it is registered from the Events tab, then fails when the same URL is registered through `POST /webhooks/subscriptions`.

Why It Happens: Only the API path sends the unsigned `fanvue.webhook.verification` probe, and it needs a 2xx within 8 seconds. A handler that verifies first rejects it, so the subscription is never created and no event is ever delivered.

Workarounds:

* Check for `type === "fanvue.webhook.verification"` and return 200 before verifying anything else.
* Keep that branch narrow. Match the exact `type`, process nothing on it, and return `401` for every other unsigned request.
* Register the final URL, since redirects fail the probe.

How Hookdeck Can Help: Hookdeck's delivery log shows every request that reached your source, so you can see the probe that failed the subscription.

### A `whsec_` secret is not a Standard Webhooks secret

The Problem: Code adapted from a Standard Webhooks example strips the `whsec_` prefix and base64-decodes the rest, and every signature then fails.

Why It Happens: API subscription secrets look like Standard Webhooks secrets, but Fanvue uses the whole string as the HMAC key. Fanvue's own Node and Python samples pass the environment variable straight to the HMAC function.

Workarounds:

* Use the secret exactly as Fanvue returned it, prefix included.
* Store one secret per API subscription. The Events tab's per-app secret does not sign API-registered deliveries, and the API secrets do not sign Events tab deliveries.

How Hookdeck Can Help: Hookdeck stores the signing secret on the source and handles verification there, so the key is configured in one place.

### Legacy events don't say what they are

The Problem: A handler that routes on `type` silently drops every legacy delivery, and one subscribed to both a legacy event and its replacement counts the same purchase twice.

Why It Happens: Legacy bodies have no envelope and nothing in the body names the topic. The `X-Fanvue-Topic` header is present, but it is outside the signature, so Fanvue says not to route on it.

Workarounds:

* Migrate to the `creator.*` replacements, which keep firing after the legacy migration window closes.
* Until then, tell legacy events apart by their fields, for example `readMessagesCount` on `message.read`, and keep them on a separate code path.
* Never subscribe to a legacy event and its replacement at the same time.

How Hookdeck Can Help: Hookdeck filters let you route enveloped and legacy deliveries to different destinations, so the migration can happen one consumer at a time.

### Exhausted retries are gone for good

The Problem: An outage that outlasts Fanvue's retries loses events permanently, and an endpoint that keeps failing is switched off.

Why It Happens: Fanvue retries with backoff, then stops. There is no manual replay, and events that occur while a destination is disabled are not backfilled. Subscriptions created through the API get no notification when they are disabled, and `GET /webhooks/subscriptions` has no enabled flag.

Workarounds:

* Persist and acknowledge fast, so slow processing never turns into a failed delivery.
* Answer 2xx for event types you do not recognise. Rejecting them counts as a failure and moves a healthy endpoint towards auto-disable.
* Monitor for silence on API subscriptions, then delete and recreate the subscription and reconcile the gap from the API.

How Hookdeck Can Help: Hookdeck accepts each delivery as soon as it arrives and keeps it, then retries against your service on a schedule you control and lets you replay any event, so your own downtime does not count against Fanvue's retry budget.

## Best practices

### Verify against the raw body

The signature covers the exact bytes Fanvue sent. In Express, mount `express.raw({ type: 'application/json' })` on the webhook route and parse only after verification. In Next.js route handlers, read `await request.text()`. In FastAPI, read `await request.body()`. Parsing and re-serialising the JSON changes the bytes and the signature will not match.

### Route on the verified body

`X-Fanvue-Topic` is not covered by the signature, so anyone who can reach your endpoint can set it. Route on `type` from the verified body. Where two topics share a resource, `data.object` alone is not enough: `creator.payment.succeeded` and `checkout_link.payment.succeeded` both carry a `payment`.

### Deduplicate on the event id, with two exceptions

Delivery is at least once, so store an idempotency key before you process. Use `event.id` on enveloped events and `event.eventId` on legacy ones. Two documented exceptions need the delivery `timestamp` folded in: legacy `message.read`, which reuses one `eventId` for every read receipt in a conversation, and `app.subscription.*`, where a repeated transition on the same subscription reuses the earlier id. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Acknowledge fast and process asynchronously

Fanvue counts a timeout as a failure and retries it, which is how slow handlers produce duplicates. Verify, persist the event, return 2xx, and do the real work afterwards. Delivery order is not guaranteed either, so check an event against the state you already hold before you apply it. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

### Subscribe to `mass_sent` as well as `sent`

A mass message is delivered on `creator.message.mass_sent` and never on `creator.message.sent`. If you mirror a creator's outbound messages, subscribe to both, or every broadcast goes missing. The two topics do not overlap, so this produces no duplicates.

### Expect test payloads to differ from production

Test deliveries use `TEST-` prefixed ids and populate `purchaser.email` and `transaction_id` on payment events, while production `creator.payment.*` deliveries set both to `null`. Do not build on fields that only test payloads fill in.

## Conclusion

Fanvue signs every delivery with a hex HMAC-SHA256 over `{t}.{raw body}` in the `X-Fanvue-Signature` header, using the signing secret exactly as issued. Parse the header statefully so the bare rotation MAC is not lost, answer the unsigned probe before verifying, route on the body's `type`, and deduplicate on the event id with the two documented exceptions.

[Hookdeck Event Gateway](https://hookdeck.com) sits in front of your receiver, checks every `X-Fanvue-Signature`, and holds each event until your service has processed it, so an outage on your side costs latency instead of lost events.

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