# 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/](https://github.com/hookdeck/webhook-skills/tree/main/skills/fanvue-webhooks/examples/express/)):

```javascript
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/](https://github.com/hookdeck/webhook-skills/tree/main/skills/fanvue-webhooks/examples/fastapi/)):

```python
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/](https://github.com/hookdeck/webhook-skills/tree/main/skills/fanvue-webhooks/examples/express/), [examples/nextjs/](https://github.com/hookdeck/webhook-skills/tree/main/skills/fanvue-webhooks/examples/nextjs/), [examples/fastapi/](https://github.com/hookdeck/webhook-skills/tree/main/skills/fanvue-webhooks/examples/fastapi/).

### The unsigned verification probe

`POST /webhooks/subscriptions` first sends one unsigned request to your URL:

```json
{ "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_at_period_end_changed` | Fan scheduled or reversed a cancellation |
| `creator.message.received` / `.sent` | Chat messages (broadcasts arrive only on `creator.message.mass_sent`) |
| `creator.follow.created` | New follower (`data.id` is always `null`) |
| `creator.refund.created` / `creator.dispute.created` | Money reversed or disputed |
| `checkout_link.payment.succeeded` / `.failed` | Checkout link sale outcome |
| `checkout_link.subscription.activated` / `.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](https://github.com/hookdeck/webhook-skills/blob/main/skills/fanvue-webhooks/references/overview.md) and the [Fanvue event catalogue](https://api.fanvue.com/docs/webhooks/event-catalog).

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

```bash
FANVUE_WEBHOOK_SECRET=your_fanvue_signing_secret   # Used verbatim, including any whsec_ prefix

```

## Local Development

```bash
# 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](https://github.com/hookdeck/webhook-skills/blob/main/skills/fanvue-webhooks/references/overview.md) - Event families, envelope, event catalogue, delivery behaviour
* [references/setup.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/fanvue-webhooks/references/setup.md) - Events tab, App Manifest and API registration, testing
* [references/verification.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/fanvue-webhooks/references/verification.md) - Header format, rotation, probe, gotchas