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-Signature header (or debugging why it fails)
  • Why does POST /webhooks/subscriptions fail 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) to creator.*

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; _DECIMAL = re.compile(r"[0-9]+").)

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 viaSecretWhere to get it
Developer Area Events tab, or webhooks.destinations in the App ManifestOne per app, shared by every endpoint on the appView signing secret on the Events tab, any time
POST /webhooks/subscriptionsOne 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.

EventDescription
creator.payment.succeededA fan paid a creator (tips, PPV, subscriptions; data.source says which)
creator.subscription.activated / .renewed / .deactivatedFan subscription lifecycle
creator.subscription.cancel_at_period_end_changedFan scheduled or reversed a cancellation
creator.message.received / .sentChat messages (broadcasts arrive only on creator.message.mass_sent)
creator.follow.createdNew follower (data.id is always null)
creator.refund.created / creator.dispute.createdMoney reversed or disputed
checkout_link.payment.succeeded / .failedCheckout link sale outcome
checkout_link.subscription.activated / .deactivatedCheckout link subscription lifecycle
payout.paid / payout.failedCreator payout (API and manifest only, not in the Events tab picker)
app.payment.succeededYour app's own sale
app.subscription.activatedYour 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 2xx once the event is stored, then process asynchronously.
  • Deduplicate on id (envelope) or eventId (legacy). Fold timestamp into the key for app.subscription.* and legacy message.read.
  • Return 2xx for unknown event types: a 4xx counts 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


Repository

hookdeck/webhook-skills

v0.1.0 · MIT · Updated Oct 19, 2026

View on GitHub →