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_ | A fan's subscription starts, renews, ends, or has auto-renew turned off or back on |
| Experience subscriptions | creator.experience_, creator.experience_ | 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_, creator.message.read, creator.message.read_, creator.message.deleted, creator.message.reaction, creator.chat.marked_ | 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_, creator.fan.presence_ | 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_, checkout_, checkout_, checkout_, checkout_, checkout_, checkout_, checkout_, checkout_, checkout_, checkout_, checkout_, checkout_ | 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_, 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_ |
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:
{
"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:
- Open your app in the Developer Area, go to the Events tab, and click Add webhook.
- Enter your HTTPS endpoint URL and tick the events you want.
- Add the scope each event needs (
read:creator,read:chat,read:post,read:experienceorread:self) on the Authentication tab or in your App Manifest. - Click View signing secret and store the value server-side, for example as
FANVUE_WEBHOOK_SECRET. - Use the row's Test action to send a signed sample delivery. Test payloads carry
TEST-prefixed ids insidedata.
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:
{ "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.
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:
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 verifies the
X-Fanvue-Signatureheader, 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 return401for 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
readMessagesCountonmessage.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_ 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.
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.
Subscribe to mass_sent as well as sent
A mass message is delivered on creator.message.mass_ 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 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 for free and handle Fanvue webhooks reliably in minutes.