> ## Documentation Index
> Fetch the complete documentation index at: https://api.fanvue.com/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Receive webhooks with the SDK

> Verify, store and sanitise Fanvue webhook deliveries with the SDK receiver or its core helpers, manage subscriptions and test with fixtures.

The SDK's webhook receiver verifies each Fanvue delivery, stores it once, strips the personal data and answers with the status code the platform expects. In a Next.js app you mount it as one route; on any other server you assemble the same steps from the core helpers. You need a store for subscriptions and events, and an encryption key for the signing secrets.

The receiver is exported from both Next.js entrypoints. The helpers live in the core entrypoint and use WebCrypto only, so they verify identically on Node and on the edge.

## Mount the receiver

`createWebhookReceiverHandler(ports)` returns `{ POST }`. Mount it at a dynamic segment named `subscriptionRef`, which receives the opaque reference that `ensureWebhookSubscription` mints for each subscription. Because the reference is opaque, nobody can enumerate your endpoints or link one to a creator.

```typescript app/api/webhooks/fanvue/[subscriptionRef]/route.ts theme={null}
import { createFanvueCrypto, webhookSignatureContractFromEnv } from "@fanvue/builder-sdk";
import { createWebhookReceiverHandler } from "@fanvue/builder-sdk/nextjs/embedded-app";
import { db } from "@/lib/db";
import { enqueue } from "@/lib/queue";

export const dynamic = "force-dynamic";

const secrets = createFanvueCrypto({
  currentKey: process.env.TOKEN_ENC_KEY ?? "",
  previousKeys: process.env.TOKEN_ENC_PREVIOUS_KEYS ?? null,
}).withPurpose("fanvue-webhook-secrets");

export const { POST } = createWebhookReceiverHandler({
  findSubscriptionByRef: (ref) => db.webhookSubscriptions.findByRef(ref),
  decryptSigningSecret: async (ciphertext) => {
    const secret = await secrets.decrypt(ciphertext);
    if (secret.isErr()) throw new Error(secret.error.code);
    return secret.value;
  },
  insertEvent: (input) => db.webhookEvents.insertIdempotent(input),
  onEvent: ({ event, duplicate }) => {
    if (!duplicate) enqueue(event);
  },
  signatureContract: webhookSignatureContractFromEnv(),
});
```

`WebhookReceiverPorts` is everything the receiver needs from your app:

| Port | Type | Role |
| - | - | - |
| `findSubscriptionByRef` | `(ref) => Promise<WebhookReceiverSubscription \| null>` | Looks up your subscription row: `id`, `creatorUuid`, `topics`, `signingSecretCiphertext`, `status` (`ACTIVE` or `BROKEN`), `creatorStatus` (`ACTIVE` or `DISCONNECTED`). |
| `decryptSigningSecret` | `(ciphertext) => string \| Promise<string>` | Opens the stored signing secret. A throw becomes `503`, so a key rotation you have not migrated is retried, not dropped. |
| `insertEvent` | `(InsertWebhookEventInput) => Promise<InsertWebhookEventResult>` | Stores the event idempotently on `(subscriptionId, providerEventId)` and reports `duplicate`. |
| `onEvent` | optional | Runs after a successful insert. A throw is logged and swallowed because the event is already stored. |
| `signatureContract` | `HmacContract \| null` | The scheme to verify against. `null` makes every delivery a `503`. Pass `webhookSignatureContractFromEnv()`. |
| `logger` | optional `WebhookReceiverLogger` | `warn` for discards, `error` for failures. Defaults to `console`. |
| `nowMs` | optional `() => number` | Clock for the replay window. |

## Status-code contract

The receiver's responses are a contract with the platform, not a preference. It checks the rows in this order and answers with the first that applies.

| Situation | Response |
| - | - |
| The platform's URL verification probe (`{ "type": "fanvue.webhook.verification" }`, unsigned, sent before the subscription exists) | `200 { "accepted": false, "verification": true }`. Anything else and `createSubscription` fails. |
| Unknown subscription reference | `404`. Beyond a size-capped probe check, the body is never read. |
| Subscription `BROKEN` or creator `DISCONNECTED` | `200 { "accepted": false, "discarded": true, "reason": "subscription_inactive" }` |
| No signature contract, or a signing secret that cannot be decrypted | `503`. Retryable, and Fanvue retries failed deliveries with backoff. |
| Signature did not verify | `401` |
| Authentic but unusable (bad JSON, unknown topic, wrong shape, wrong creator) | `200 { "accepted": false, "discarded": true, "reason": <WebhookDiscardReason> }` |
| Stored | `200 { "accepted": true, "duplicate": <boolean> }` |

If you write your own receiver, answer `200` to an unusable delivery too. A `4xx` makes the platform retry something that can never succeed, and a subscription that keeps failing is eventually disabled, taking the creator's working events with it.

`WebhookDiscardReason` is `subscription_inactive`, `invalid_json`, `unsupported_topic`, `schema_mismatch` or `creator_mismatch`.

`decideWebhookPreflight(input)` is the pure function behind the first three rows, and `isWebhookVerificationBody(raw)` recognises the probe. Both are exported for receivers you write yourself.

## Verify signatures

Fanvue signs each delivery with HMAC-SHA256 over `<t>.<raw body>`, using the subscription's signing secret; [Verify webhook signatures](/docs/webhooks/signature-verification) describes the scheme. The SDK exposes it through these constants:

| Fact | Value |
| - | - |
| Header | `X-Fanvue-Signature` (`FANVUE_SIGNATURE_HEADER`) |
| Value | `t=<unix seconds>,v0=<hex mac>`. During a secret rotation two MACs arrive as `t=...,v0=<mac1>,<mac2>`; any one matching passes. |
| Signed content | `<t>.<raw body bytes>` with HMAC-SHA256 |
| Replay window | 300 s (`DEFAULT_SIGNATURE_TOLERANCE_SECONDS`) |
| Topic header | `X-Fanvue-Topic` is sent but not signed. Nothing in the SDK reads it. |

`verifyWebhookHmac({ rawBody, headers, secret, contract, nowMs })` returns `true` only when a candidate MAC matches and the timestamp sits inside the window. Every failure, from a missing header to a mismatch, returns `false`, so the endpoint never explains why.

| Function | Returns |
| - | - |
| `fanvueSignatureContract(options?)` | The platform contract |
| `rawHexSignatureContract(headerName)` | A legacy bare-hex profile |
| `parseFanvueSignatureHeader(value)` | The header split into its parts |
| `webhookSignatureContractFromEnv(env?)` | The contract read from three variables, or `null` when the configuration is incomplete; the receiver turns `null` into `503` |

`webhookSignatureContractFromEnv` reads these variables:

| Variable | Value |
| - | - |
| `FANVUE_WEBHOOK_SIGNATURE_PROFILE` | `fanvue-v0` for the platform contract. Required. |
| `FANVUE_WEBHOOK_SIGNATURE_HEADER` | Header override. Optional under `fanvue-v0`. |
| `FANVUE_WEBHOOK_SIGNATURE_TOLERANCE_SECONDS` | Replay window as a non-negative integer, or `off`. Any other value yields `null`. |

## Resolve and sanitise

`resolveWebhookTopic(raw)` works out the topic from the signed body. It uses the envelope `type` when present, and otherwise falls back to field-presence rules for the flat legacy payloads. It never reads `X-Fanvue-Topic`.

`sanitizeWebhookDelivery(topic, raw)` parses the body against its wire schema, projects out only the fields an app may keep, and re-validates the projection against `SanitizedWebhookEventSchema`. It drops handles, display names, avatar URLs, message text and media UUIDs, so a message delivery keeps `textLength` but not the text. On a shape mismatch it throws, and the receiver turns that into a `schema_mismatch` discard.

The SDK sanitises ten topics, listed in `APP_WEBHOOK_TOPICS`: `subscription.new`, `subscription.renewed`, `subscription.cancelled`, `subscription.expired`, `purchase.new`, `message.received`, `tip.new`, `creator.refund.created`, `creator.dispute.flagged` and `creator.dispute.created`. A delivery for any other topic is discarded as `unsupported_topic`.

`FANVUE_WEBHOOK_EVENTS` lists the 44 topics the SDK type-checks a subscription request against. The server accepts more, including `checkout_link.refund.requested`, `creator.experience_subscription.*`, `creator.message.mass_sent` and `creator.chat.marked_unread`, so treat the [event catalogue](/docs/webhooks/event-catalog) as the authoritative list. To subscribe to a topic the SDK doesn't know, pass it as a string cast.

## Receive on another server

Any server can use the core helpers. This example uses Express with a raw body parser, because verification needs the exact bytes.

```typescript server.ts theme={null}
import express from "express";
import {
  fanvueSignatureContract,
  isWebhookVerificationBody,
  resolveWebhookTopic,
  sanitizeWebhookDelivery,
  verifyWebhookHmac,
} from "@fanvue/builder-sdk";

const app = express();
const contract = fanvueSignatureContract();

app.post("/webhooks/fanvue/:ref", express.raw({ type: "*/*" }), async (req, res) => {
  const rawBody = new Uint8Array(req.body as Buffer);
  const headers = Object.fromEntries(
    Object.entries(req.headers).map(([key, value]) => [key, Array.isArray(value) ? value[0] : value]),
  );

  let raw: unknown;
  try {
    raw = JSON.parse(new TextDecoder().decode(rawBody));
  } catch {
    raw = null;
  }
  if (headers[contract.headerName.toLowerCase()] === undefined && isWebhookVerificationBody(raw)) {
    return res.status(200).json({ accepted: false, verification: true });
  }

  const subscription = await loadSubscription(req.params.ref);
  if (!subscription) return res.status(404).end();

  const verified = await verifyWebhookHmac({
    rawBody,
    headers,
    secret: subscription.signingSecret,
    contract,
    nowMs: null,
  });
  if (!verified) return res.status(401).end();

  const topic = resolveWebhookTopic(raw);
  if (topic === null) return res.status(200).json({ accepted: false, discarded: true, reason: "unsupported_topic" });

  let event;
  try {
    event = sanitizeWebhookDelivery(topic, raw);
  } catch {
    return res.status(200).json({ accepted: false, discarded: true, reason: "schema_mismatch" });
  }
  if (event.creatorUuid !== subscription.creatorUuid) {
    return res.status(200).json({ accepted: false, discarded: true, reason: "creator_mismatch" });
  }

  const { duplicate } = await storeEvent(subscription.id, event);
  return res.status(200).json({ accepted: true, duplicate });
});
```

## Manage subscriptions

`ensureWebhookSubscription(ctx)` creates a subscription for a creator when none is active. It mints the reference, builds the receiver URL from `receiverBaseUrl` plus `receiverPath`, and calls `createSubscription`. It then encrypts the returned secret with your `encryptSigningSecret` and writes the row through your `WebhookSubscriptionStore` (`findByOwner`, `upsertActiveSubscription`). Call it right after you store the creator's tokens, while the access token is certainly valid. The result `status` is `already_active`, `created`, `invalid_receiver_url` or `create_failed`.

`createSubscription` returns the signing secret once and it can't be read back, so store its ciphertext and the remote id in one transaction. If you lose it, delete the subscription and create another. To subscribe without the SDK, see [Subscribe to webhooks](/docs/webhooks/subscribing).

`disconnectWithWebhookCleanup(ctx)` tears down in privacy-safe order: the remote subscription first, then your local grant. When a live subscription exists but no usable token does, it returns `reconnect_required` and changes nothing. Deleting the tokens first would leave Fanvue delivering that creator's events to an endpoint neither side can remove. The other outcomes are `disconnected` and `remote_delete_failed`.

## Process stored events

The receiver only stores and acknowledges; your queue does the work. Three helpers govern that queue, not Fanvue's delivery retries:

* `webhookRetryDelayMs(attempt, baseSeconds, maximumSeconds?)` gives exponential backoff capped at one hour.
* `webhookFailureDisposition({ attempt, maximumAttempts, baseSeconds, now })` decides between `FAILED` with a `nextAttemptAt` and `DEAD_LETTER`.
* `selectReplayableEvents` picks stored events to replay through the same pipeline.

Idempotency is on `(subscriptionId, providerEventId)`. Fanvue can deliver an event more than once, so `insertEvent` must treat a repeat as `duplicate: true`; see [Delivery and idempotency](/docs/webhooks/delivery-and-idempotency).

## Test with fixtures

The core entrypoint ships one fixture builder per sanitised topic in `WEBHOOK_DELIVERY_FIXTURES`, plus `signWebhookFixture`, which signs `<t>.<payload>` with the same primitive the verifier uses. Every builder plants `FIXTURE_PII_CANARY` (`must-not-persist`) in the fields the sanitiser must drop, so your test can assert the canary never reaches storage.

```typescript webhooks.test.ts theme={null}
import { describe, expect, it } from "vitest";
import {
  FIXTURE_PII_CANARY,
  fanvueSignatureContract,
  signWebhookFixture,
  tipDelivery,
} from "@fanvue/builder-sdk";
import { createWebhookReceiverHandler } from "@fanvue/builder-sdk/nextjs/embedded-app";

const signingSecret = "test-signing-secret";
const stored: unknown[] = [];

const { POST } = createWebhookReceiverHandler({
  findSubscriptionByRef: async () => ({
    id: "sub_1",
    creatorUuid: "creator-00000000-0000-4000-8000-000000000001",
    topics: ["tip.new"],
    signingSecretCiphertext: "plain",
    status: "ACTIVE",
    creatorStatus: "ACTIVE",
  }),
  decryptSigningSecret: () => signingSecret,
  insertEvent: async (input) => {
    stored.push(input.event);
    return { duplicate: false };
  },
  signatureContract: fanvueSignatureContract(),
});

describe("receiver", () => {
  it("stores a signed tip without personal data", async () => {
    const body = JSON.stringify(tipDelivery());
    const signature = await signWebhookFixture({ payload: body, signingSecret });
    const request = new Request("https://app.example/api/webhooks/fanvue/ref-1", {
      method: "POST",
      headers: { "content-type": "application/json", [signature.name]: signature.value },
      body,
    });

    const response = await POST(request, { params: Promise.resolve({ subscriptionRef: "ref-1" }) });

    expect(response.status).toBe(200);
    expect(await response.json()).toEqual({ accepted: true, duplicate: false });
    expect(JSON.stringify(stored).includes(FIXTURE_PII_CANARY)).toBe(false);
  });
});
```


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.