Agent skill
Fanvue Webhooks Skill
Receive and verify Fanvue webhooks. Use when setting up Fanvue webhook handlers, debugging X-Fanvue-Signature verification (HMAC-SHA256 hex over "<t>.<raw body>", two MACs during secret rotation), answering the unsigned fanvue.webhook.verification probe, or handling events like creator.payment.succeeded, creator.subscription.activated, creator.message.received, checkout_link.payment.succeeded, payout.paid or app.payment.succeeded.
Install this skill
npx skills add hookdeck/webhook-skills --skill fanvue-webhooks
When to Use This Skill
- Setting up a Fanvue webhook endpoint for a Developer Area app
- Verifying the
X-Fanvue-Signatureheader (or debugging why it fails) - Why does
POST /webhooks/subscriptionsfail against my endpoint? (the unsigned probe) - Handling creator, checkout link, payout or app billing events
- Migrating from legacy flat events (
purchase.new,tip.new,subscription.new) tocreator.*
Verification (core)
Every delivery carries:
X-Fanvue-Signature: t=<unix seconds>,v0=<hex>
X-Fanvue-Signature: t=<unix seconds>,v0=<old hex>,<new hex> # during secret rotation
The signature is HMAC-SHA256 over <t>.<raw body>, hex encoded, keyed with the signing secret exactly as Fanvue gives it (don't strip whsec_). The rotation MAC arrives as a bare field with no =, so parse the header statefully and accept when any MAC matches. Reject t more than 300 seconds from now, and respond 401 on failure.
Node (from examples/express/):
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 separator === -1 this is the whole bare field.
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,
toleranceSeconds = TOLERANCE_SECONDS,
nowSeconds = Math.floor(Date.now() / 1000)
) {
if (!header) {
return { valid: false, error: 'Missing X-Fanvue-Signature header' };
}
const { timestamp, timestampCount, macs } = parseSignatureHeader(header);
if (timestampCount !== 1 || !/^[0-9]+$/.test(timestamp) || macs.length === 0) {
return { valid: false, error: 'Malformed X-Fanvue-Signature header' };
}
// `t` is inside the signed content, so it can't be refreshed on a replay.
if (Math.abs(nowSeconds - Number(timestamp)) > toleranceSeconds) {
return { valid: false, error: 'Timestamp outside tolerance' };
}
const expected = crypto
.createHmac('sha256', secret)
.update(`${timestamp}.`)
.update(rawBody)
.digest('hex');
// Accept when ANY MAC matches (two MACs are sent during secret rotation).
const matched = macs.some(
(mac) =>
mac.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(mac), Buffer.from(expected))
);
return matched ? { valid: true } : { valid: false, error: 'Invalid signature' };
}
Python (from examples/fastapi/):
def parse_signature_header(header: str) -> tuple[str | None, int, list[str]]:
key = None
timestamp = None
timestamp_count = 0
macs: list[str] = []
for field in header.split(","):
if "=" in field:
key, _, value = field.partition("=")
key = key.strip()
else:
value = field
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 | None,
secret: str,
tolerance_seconds: int = TOLERANCE_SECONDS,
now: int | None = None,
) -> tuple[bool, str | None]:
if not header:
return False, "Missing X-Fanvue-Signature header"
timestamp, timestamp_count, macs = parse_signature_header(header)
if timestamp_count != 1 or not timestamp or not _DECIMAL.fullmatch(timestamp) or not macs:
return False, "Malformed X-Fanvue-Signature header"
# t is inside the signed content, so it can't be refreshed on a replay.
now = int(time.time()) if now is None else now
if abs(now - int(timestamp)) > tolerance_seconds:
return False, "Timestamp outside tolerance"
expected = hmac.new(
secret.encode("utf-8"),
f"{timestamp}.".encode("utf-8") + raw_body,
hashlib.sha256,
).hexdigest()
# Accept when ANY MAC matches (two MACs are sent during secret rotation).
if any(hmac.compare_digest(expected, mac) for mac in macs):
return True, None
return False, "Invalid signature"
(TOLERANCE_SECONDS = 300; _.)
For complete handlers with tests, see examples/express/, examples/nextjs/, examples/fastapi/.
The unsigned verification probe
POST /webhooks/subscriptions first sends one unsigned request to your URL:
{ "type": "fanvue.webhook.verification", "timestamp": "2026-06-09T08:39:33.139Z" }
Any 2xx passes; anything else (including a redirect or a 401) fails the subscribe call. Answer 2xx to a body whose type is fanvue.webhook.verification before checking signatures, process nothing on that path, and return 401 to every other unsigned request. The Events tab does not probe, so a handler that rejects the probe works from the Events tab and breaks only when registered through the API.
Which signing secret
The two registration paths don't share a secret:
| Registered via | Secret | Where to get it |
|---|---|---|
Developer Area Events tab, or webhooks.destinations in the App Manifest | One per app, shared by every endpoint on the app | View signing secret on the Events tab, any time |
POST /webhooks/subscriptions | One per subscription (whsec_ + 64 hex) | signingSecret in the create response, once |
If you register through the API for several creators, store a secret per subscription id and select the right one when verifying.
Don't trust X-Fanvue-Topic
Deliveries also carry an X-Fanvue-Topic header, but the signature covers only <t>.<raw body>. Route on the signed body's type (or field presence on legacy events), never on the header.
Common Event Types
Creator, Checkout and App events share one envelope: { id, type, timestamp, data }, with data.object naming the resource and snake_case fields inside data.
| Event | Description |
|---|---|
creator.payment.succeeded | A fan paid a creator (tips, PPV, subscriptions; data.source says which) |
creator.subscription.activated / .renewed / .deactivated | Fan subscription lifecycle |
creator.subscription.cancel_ | Fan scheduled or reversed a cancellation |
creator.message.received / .sent | Chat messages (broadcasts arrive only on creator.message.mass_) |
creator.follow.created | New follower (data.id is always null) |
creator.refund.created / creator.dispute.created | Money reversed or disputed |
checkout_ / .failed | Checkout link sale outcome |
checkout_ / .deactivated | Checkout link subscription lifecycle |
payout.paid / payout.failed | Creator payout (API and manifest only, not in the Events tab picker) |
app.payment.succeeded | Your app's own sale |
app.subscription.activated | Your app's subscription started (app.subscription.deactivated is never emitted) |
For every topic with its live/reserved/deprecated status, see references/overview.md and the Fanvue event catalogue.
Delivery and Idempotency
- At least once, in no guaranteed order. Return
2xxonce the event is stored, then process asynchronously. - Deduplicate on
id(envelope) oreventId(legacy). Foldtimestampinto the key forapp.subscription.*and legacymessage.read. - Return
2xxfor unknown event types: a4xxcounts as a failure and walks the endpoint towards auto-disable. Events missed while disabled are not backfilled.
Environment Variables
FANVUE_WEBHOOK_SECRET=your_fanvue_signing_secret # Used verbatim, including any whsec_ prefix
Local Development
# Start tunnel (no account needed)
npx hookdeck-cli listen 3000 fanvue --path /webhooks/fanvue
The Events tab disables localhost destinations, so register the public tunnel URL, then click Test on the row to send a signed test event.
Reference Materials
- references/overview.md - Event families, envelope, event catalogue, delivery behaviour
- references/setup.md - Events tab, App Manifest and API registration, testing
- references/verification.md - Header format, rotation, probe, gotchas