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.
app/api/webhooks/fanvue/[subscriptionRef]/route.ts
WebhookReceiverPorts is everything the receiver needs from your app:
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.
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 describes the scheme. The SDK exposes it through these constants:
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.
webhookSignatureContractFromEnv reads these variables:
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 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.server.ts
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.
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 betweenFAILEDwith anextAttemptAtandDEAD_LETTER.selectReplayableEventspicks stored events to replay through the same pipeline.
(subscriptionId, providerEventId). Fanvue can deliver an event more than once, so insertEvent must treat a repeat as duplicate: true; see Delivery and idempotency.
Test with fixtures
The core entrypoint ships one fixture builder per sanitised topic inWEBHOOK_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.
webhooks.test.ts