@fanvue/builder-sdk
v0.7.1
Published
OAuth authentication library for the Fanvue API
Downloads
906
Keywords
Readme
@fanvue/builder-sdk
Authentication for Fanvue apps. Every Fanvue app is either embedded or off-platform (which should I build?) — this SDK covers both:
| Building an... | Import | What you get | Docs |
|---|---|---|---|
| Embedded App (runs inside Fanvue, in an iframe) | @fanvue/builder-sdk/nextjs/embedded-app + @fanvue/builder-sdk/react | Session-token exchange handler, useEmbeddedAuth hook, Bearer sessions with auto refresh, analytics through the host page | Overview · Integration guide |
| Off-Platform App ("Login with Fanvue" on your own domain) | @fanvue/builder-sdk/nextjs/off-platform | Full-page redirect flow, httpOnly cookie sessions, auto token refresh | Auth overview · Implementation guide |
Building something else (Node, Deno, a custom server)? The core @fanvue/builder-sdk
entrypoint exposes the low-level OAuth primitives both flows are built on.
- Drop-in route handlers for Next.js -- a couple of files and you're done
- Handles token exchange, refresh, and session management so you don't have to
- Fully typed with TypeScript
[!IMPORTANT] Renamed from
@fanvue/auth. This package was previously published as@fanvue/auth(≤ 0.2.3). The API is unchanged — to migrate, swap the dependency and update import paths:- "@fanvue/auth": "^0.2.3" + "@fanvue/builder-sdk": "^0.3.0"- import { useEmbeddedAuth } from "@fanvue/auth/react"; + import { useEmbeddedAuth } from "@fanvue/builder-sdk/react";
@fanvue/authis deprecated on npm and will receive no further releases.
Installation
npm install @fanvue/builder-sdk
# or
pnpm add @fanvue/builder-sdk
# or
yarn add @fanvue/builder-sdkPeer dependencies (next, react) are optional -- only install what your project uses.
Quick Start
Embedded App (your app inside Fanvue)
When Fanvue opens your app in an iframe, it appends a short-lived session token
to your embed URL (?token=...). Exchange it server-side for real OAuth tokens
-- the SDK handles the whole delegated flow (PKCE, state, code exchange).
1. Add the session-exchange route
// app/api/fanvue/session/route.ts
import { createConfig, createSessionExchangeHandler } from "@fanvue/builder-sdk/nextjs/embedded-app";
export const { POST } = createSessionExchangeHandler(createConfig());2. Authenticate in your embedded page
// app/embedded/page.tsx
"use client";
import { AuthProvider, useAuth, useEmbeddedAuth } from "@fanvue/builder-sdk/react";
function Embedded() {
const { status, error, theme } = useEmbeddedAuth();
const { authFetch } = useAuth();
// `theme` is the creator's active colour scheme ("light" | "dark"), read
// from the iframe URL -- use it to match Fanvue. It's `null` when the app is
// opened outside Fanvue, so fall back to a sensible default.
return (
<div data-theme={theme ?? "light"}>
{status === "exchanging" && <p>Connecting to Fanvue…</p>}
{status === "error" && <p>Auth failed: {error}</p>}
<button onClick={() => authFetch("/api/me")}>Load my profile</button>
</div>
);
}
export default function Page() {
return (
<AuthProvider>
<Embedded />
</AuthProvider>
);
}3. Call the Fanvue API from your own routes
// app/api/me/route.ts
import { NextResponse } from "next/server";
import { HEADER_UPDATED_SESSION } from "@fanvue/builder-sdk";
import { createConfig, getAuthenticatedClient } from "@fanvue/builder-sdk/nextjs/embedded-app";
const config = createConfig();
export async function GET() {
const auth = await getAuthenticatedClient({ sessionSecret: config.sessionSecret, config });
if (!auth) return NextResponse.json({ error: "unauthorized" }, { status: 401 });
const user = await auth.client.getCurrentUser();
const res = NextResponse.json(user.isOk() ? user.value : { error: "api_failed" });
// When the access token was refreshed, hand the new session JWT back to the
// client -- `authFetch` stores it automatically.
if (auth.refreshedJwt) res.headers.set(HEADER_UPDATED_SESSION, auth.refreshedJwt);
return res;
}Hosting requirements for embedded apps:
- Serve over HTTPS with a browser-trusted certificate.
- Send
Content-Security-Policy: frame-ancestors https://www.fanvue.comso Fanvue can frame your page. - The session token lives ~60 seconds -- exchange it promptly (the
useEmbeddedAuthhook does this on mount). - To act on the creator's behalf after the iframe closes, persist the
refresh token from the
onTokenshook:
export const { POST } = createSessionExchangeHandler(createConfig(), {
onTokens: async ({ tokens, user }) => {
await db.saveRefreshToken(user.uuid, tokens.refresh_token);
},
});See the embedded apps integration guide for the full walkthrough, including app registration in the Builder.
Analytics (embedded apps)
Embedded apps can fire product analytics through the Fanvue page hosting them, so events land in Fanvue's analytics stitched into the viewer's session -- without your app ever handling identity data.
"use client";
import { useFanvueAnalytics } from "@fanvue/builder-sdk/react";
export function CreateCourseButton() {
const { track } = useFanvueAnalytics();
return <button onClick={() => track("course_created", { chapters: 4 })}>Create course</button>;
}That is the whole integration -- no provider, no configuration. track is
fire-and-forget: it never throws, and outside Fanvue (local dev, previews, a
standalone deployment) it silently does nothing, so you can call it
unconditionally.
Rules your events must follow:
- Event names match
/^[a-z0-9_]{1,64}$/. Fanvue emits them prefixed withembedded_app_, socourse_createdarrives asembedded_app_course_created, attributed to your app automatically. - Properties are a flat record of at most 20 keys (max 64 characters each) with
string(max 256 characters),numberorbooleanvalues. No nesting. - Identity is Fanvue's to set:
user_id,device_id,revenueand any$-prefixed key are rejected.
Analytics is granted per app by Fanvue, so track may legitimately be a no-op
for your app. isEnabled tells you which:
const { track, isEnabled } = useFanvueAnalytics();Destinations. Events go to Fanvue's Amplitude project by default. Pass
{ destination: "posthog" } to route an event (feedback, surveys) to Fanvue's
PostHog project instead:
track("feedback_submitted", { rating: "up" }, { destination: "posthog" });The same naming, property and identity rules apply either way. On Fanvue hosts that predate destinations the field is ignored and the event lands in Amplitude, so routing never breaks an app.
Outside React, connect to the bridge directly:
import { connectFanvueBridge } from "@fanvue/builder-sdk/bridge";
const result = await connectFanvueBridge();
if (result.isOk() && result.value.has("analytics")) {
await result.value.analytics.track("course_created", { chapters: 4 });
}connectFanvueBridge handshakes with the host page: your app announces itself,
Fanvue verifies your registered embed origin and replies with the capabilities
it granted plus a private MessagePort carrying all further traffic. It fails
with NOT_EMBEDDED when the app is not in an iframe and TIMEOUT when no
hello arrives within the timeout (default 3s) -- neither is exceptional, and
apps are expected to keep working standalone.
[!NOTE] Host support for the bridge is being rolled out per environment. Until it is enabled for the environment your app is running in, the handshake times out and every
trackcall no-ops -- exactly as it does outside Fanvue. So a timeout does not necessarily mean your app is misconfigured. If events are not arriving from a live embedded surface, confirm the bridge is enabled there before you go looking for a bug in your integration.
Off-Platform App ("Login with Fanvue")
The fastest path: create a config file and three route handlers.
1. Configure
// lib/auth.ts
import { createConfig } from "@fanvue/builder-sdk/nextjs/off-platform";
export const authConfig = {
...createConfig(), // reads OAUTH_* and SESSION_* env vars
afterLoginPath: "/dashboard",
afterLogoutPath: "/",
};2. Add route handlers
// app/api/oauth/login/route.ts
import { createLoginHandler } from "@fanvue/builder-sdk/nextjs/off-platform";
import { authConfig } from "@/lib/auth";
export const { GET } = createLoginHandler(authConfig);// app/api/oauth/callback/route.ts
import { createCallbackHandler } from "@fanvue/builder-sdk/nextjs/off-platform";
import { authConfig } from "@/lib/auth";
export const { GET, POST } = createCallbackHandler(authConfig);// app/api/oauth/logout/route.ts
import { createLogoutHandler } from "@fanvue/builder-sdk/nextjs/off-platform";
import { authConfig } from "@/lib/auth";
export const { POST } = createLogoutHandler(authConfig);3. Use the session
import { getSession, getAuthenticatedClient } from "@fanvue/builder-sdk/nextjs/off-platform";
import { authConfig } from "@/lib/auth";
// Read the session in any server component or route handler
const session = await getSession(authConfig.sessionSecret, authConfig.sessionCookieName);
// Or get an API client that automatically refreshes expired tokens
const client = await getAuthenticatedClient({
sessionSecret: authConfig.sessionSecret,
sessionCookieName: authConfig.sessionCookieName,
config: authConfig,
});
if (client) {
const user = await client.getCurrentUser();
if (user.isOk()) console.log(user.value);
}See the authentication docs for scopes, rate limits, and the underlying OAuth flow.
Core (advanced)
Use the core entrypoint when you need full control -- CLI tools, custom servers, or non-Next.js frameworks.
import {
createAuthorizationUrl,
exchangeCodeForToken,
refreshAccessToken,
createSessionJwt,
verifySessionJwt,
createFanvueClient,
} from "@fanvue/builder-sdk";
const config = {
clientId: "your-client-id",
clientSecret: "your-client-secret",
redirectUri: "http://localhost:3000/callback",
issuerUrl: null,
apiBaseUrl: null,
scopes: null,
responseMode: null,
prompt: null,
};
// 1. Build the authorization URL (PKCE is handled automatically)
const { url, codeVerifier, state } = await createAuthorizationUrl(config);
// Redirect the user to `url`...
// 2. Exchange the authorization code for tokens
const tokenResult = await exchangeCodeForToken(config, {
code: "code-from-callback",
codeVerifier,
redirectUri: null,
});
if (tokenResult.isErr()) throw new Error(tokenResult.error.message);
const tokens = tokenResult.value;
// 3. Make authenticated API calls
const client = createFanvueClient(tokens.access_token, null);
const userResult = await client.getCurrentUser();
if (userResult.isOk()) console.log(userResult.value);
// 4. Refresh an expired access token
const refreshResult = await refreshAccessToken(config, tokens.refresh_token);For the embedded flow, the equivalent core primitive is exchangeSessionToken(config, sessionToken),
which runs PKCE generation, the delegated authorize-on-behalf request, and the
code exchange in one call.
import { createSessionJwt, verifySessionJwt } from "@fanvue/builder-sdk";
// Create a signed session JWT (HS256, default 30-day expiry)
const jwt = await createSessionJwt("your-session-secret", {
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
expiresAt: Date.now() + tokens.expires_in * 1000,
tokenType: tokens.token_type,
scope: tokens.scope,
idToken: tokens.id_token,
userUuid: "...",
handle: "...",
displayName: "...",
isCreator: false,
avatarUrl: null,
});
// Verify a session JWT
const session = await verifySessionJwt("your-session-secret", jwt);Observability and Machine Auth
Privacy-first logging, Sentry scrubbing, machine (cron/queue) authentication, and a configuration readiness probe. All exported from the root entrypoint and dependency-free.
Safe structured logging
createSafeLogFields returns an allowlisting filter: unknown keys are dropped, only
string | number | boolean | null survives (an Error or a response body is never
serialised), UUID-bearing strings become [redacted-uuid] — any UUID version,
including v7 — and email-bearing strings become [redacted-email].
import { createLogEvent, createSafeLogFields, errorName } from "@fanvue/builder-sdk";
const safeLogFields = createSafeLogFields(["wheelId"]); // base keys + your own
const logEvent = createLogEvent({ prefix: "[spinwheel]", safeLogFields });
logEvent("error", "exchange.failed", {
wheelId: "wheel_1",
httpStatus: 503,
errorName: errorName(caught), // class name only, never err.message
accessToken: "dropped", // not allowlisted
});
// [spinwheel] { event: 'exchange.failed', wheelId: 'wheel_1', httpStatus: 503, errorName: 'TypeError' }Base allowlist (BASE_ALLOWED_LOG_KEYS): cause, code, creatorId, errorName,
httpStatus, reason, retryCount, status.
Sentry scrubbing
// sentry.server.config.ts
import { createSentryScrubber } from "@fanvue/builder-sdk";
Sentry.init({
dsn,
enabled: Boolean(dsn),
beforeSend: createSentryScrubber(/(prizedetail|fanhmac|seed)/i), // merged with the base pattern
});Recursive, and applies four rules to every node: values under a sensitive key become
[redacted], UUIDs inside strings become [redacted-uuid] (any version, and also when the UUID
sits behind a _ or another word character), a string that parses as a URL keeps its origin
and path but loses its query string, fragment and user:pass@ userinfo
(https://cdn/x?[redacted]), and email addresses inside strings become [redacted-email].
Base sensitive keys (BASE_SENSITIVE_KEY_PATTERN), matched as case-insensitive substrings:
authorization|cookie|token|secret|password|signedurl|api[-_]?key|credential|email|phone|ip[-_]?address.
Total by construction, because a beforeSend that throws loses the event and raises inside the
caller: a repeat visit yields [circular], a node deeper than 32 levels yields [max-depth],
and a property whose getter throws yields [unreadable]. All three fail closed — an untraversed
value cannot leak.
Machine auth (cron and queue routes)
requireMachineAuth returns authorized | unauthorized | not_configured. The third arm exists
so a deployment with no usable credential answers 503, never 401 — a broken deploy must not look
like ordinary auth noise. Bearer secrets must be at least 32 characters (MINIMUM_BEARER_SECRET_LENGTH);
a shorter one is treated as absent rather than compared. Comparison is constant-time over the
UTF-8 bytes, with a byte-length pre-check. The Authorization scheme is required but matched
case-insensitively (bearer and Bearer both work, a bare secret does not).
// app/api/cron/reconcile/route.ts
import { boundedBatchSize, requireMachineAuth } from "@fanvue/builder-sdk";
export async function POST(request: Request): Promise<Response> {
const auth = await requireMachineAuth(request, {
bearerSecret: process.env.CRON_SECRET ?? null,
signedRequestVerifier: null,
rawBody: null,
});
if (auth.status === "not_configured") return new Response(null, { status: 503 });
if (auth.status === "unauthorized") return new Response(null, { status: 401 });
const limit = boundedBatchSize(new URL(request.url).searchParams.get("limit"), 20, 100);
// ...
}A second strategy plugs in via SignedRequestVerifier. @upstash/qstash is deliberately not
a dependency of this package — the app owns it:
import { Receiver } from "@upstash/qstash";
import type { SignedRequestVerifier } from "@fanvue/builder-sdk";
const qstashVerifier: SignedRequestVerifier = {
verify: async (request, rawBody) => {
const signature = request.headers.get("upstash-signature");
const currentSigningKey = process.env.QSTASH_CURRENT_SIGNING_KEY;
const nextSigningKey = process.env.QSTASH_NEXT_SIGNING_KEY;
if (!signature || !currentSigningKey || !nextSigningKey) return false; // deny by default
return new Receiver({ currentSigningKey, nextSigningKey }).verify({
signature,
body: new TextDecoder().decode(rawBody),
url: request.url,
upstashRegion: request.headers.get("upstash-region") ?? undefined,
});
},
};
// Pass the exact received bytes — a reparsed body invalidates the signature.
const rawBody = new Uint8Array(await request.arrayBuffer());
const auth = await requireMachineAuth(request, {
bearerSecret: process.env.CRON_SECRET ?? null,
signedRequestVerifier: qstashVerifier,
rawBody,
});Configuration readiness
import { configurationReadiness } from "@fanvue/builder-sdk";
export function GET(): Response {
const checks = configurationReadiness(); // env is a parameter; defaults to process.env
const ready = checks.every((check) => check.ready);
return Response.json({ ready, checks }, { status: ready ? 200 : 503 });
}
// [{ name: 'app_url', ready: false, detail: 'set FANVUE_APP_BASE_URL to an https:// origin' },
// { name: 'fanvue_oauth', ready: true, detail: null }]app_url reads FANVUE_APP_BASE_URL (falling back to APP_BASE_URL) and requires an https
origin, because Fanvue refuses to embed anything else. fanvue_oauth requires FANVUE_APP_UUID,
FANVUE_CLIENT_ID, FANVUE_CLIENT_SECRET and FANVUE_OAUTH_REDIRECT_URI; details name the
missing variables and never echo a value.
Environment Variables
Add these to your .env.local (Next.js) or equivalent:
# Required
OAUTH_CLIENT_ID=your-client-id
OAUTH_CLIENT_SECRET=your-client-secret
OAUTH_REDIRECT_URI=http://localhost:3000/api/oauth/callback
SESSION_SECRET=at-least-32-characters-long-random-string
# Optional (shown with defaults)
OAUTH_ISSUER_BASE_URL=https://auth.fanvue.com
API_BASE_URL=https://api.fanvue.com
FANVUE_PLATFORM_URL=https://www.fanvue.com # embedded apps only
OAUTH_SCOPES="openid offline_access offline"
SESSION_COOKIE_NAME=fanvue_session
# OAUTH_RESPONSE_MODE= # query, form_post, etc.
# OAUTH_PROMPT= # login, consent, etc.For the Fanvue dev environment use https://auth.dev.fanvue.com,
https://api.dev.fanvue.com, and https://dev.fanvue.com respectively.
The createConfig() helper reads these automatically. You can also override any value programmatically:
const config = createConfig({
clientId: "explicit-id", // overrides OAUTH_CLIENT_ID
sessionCookieName: "my_session", // overrides SESSION_COOKIE_NAME
});Platform Contracts
Wire shapes, enums and environment validation shared by every Fanvue app, exported from the root entrypoint (@fanvue/builder-sdk). Everything here is runtime-agnostic — safe in a route handler, a worker, or the browser.
FANVUE_* environment
FANVUE_APP_UUID= # your app's UUID on Fanvue
FANVUE_CLIENT_ID=
FANVUE_CLIENT_SECRET=
FANVUE_OAUTH_REDIRECT_URI=
FANVUE_API_BASE_URL=https://api.fanvue.com # optional, shown with defaults
FANVUE_AUTH_BASE_URL=https://auth.fanvue.com
FANVUE_WEB_ORIGIN=https://www.fanvue.com
FANVUE_API_VERSION=2025-06-26
FANVUE_EXPERIENCE_URL_TEMPLATE= # optional share-link overrideimport { fanvueEnv, isFanvueConfigured, flagEnabled } from "@fanvue/builder-sdk";
// Parsed on first call and cached; a build never needs the secrets present.
const apiBaseUrl = fanvueEnv().FANVUE_API_BASE_URL;
// The four credentials default to "" so builds pass — check before an OAuth call.
if (!isFanvueConfigured()) {
return Response.json(
{ error: { code: "fanvue_not_configured", message: "Fanvue is not configured." } },
{ status: 503 },
);
}
// Case- and whitespace-insensitive flag reader; warns once on an unreadable value.
const devMode = flagEnabled("MY_APP_DEV_MODE");Call resetFanvueEnvCache() between tests that mutate process.env.
Experience postMessage protocol
import {
PUBLISH_REQUEST_MESSAGE,
isPublishResultMessage,
isFanvueOrigin,
} from "@fanvue/builder-sdk";
window.parent.postMessage({ type: PUBLISH_REQUEST_MESSAGE, token }, platformOrigin);
window.addEventListener("message", (event) => {
// Validate the origin as well as the shape: Fanvue replies with targetOrigin "*".
if (!isFanvueOrigin(event.origin) || !isPublishResultMessage(event.data)) return;
if (event.data.status === "published") onPublished(event.data.experienceId);
});Access modes and denial reasons
import { accessModeFromDenialReason } from "@fanvue/builder-sdk";
// The exchange fails closed: a denied fan gets a reason but no access mode.
const accessMode = accessModeFromDenialReason(reason); // 'SUBSCRIPTION' | 'PAID' | 'HIDDEN' | nullError bodies
import {
AppErrorEnvelopeSchema,
NON_SESSION_401_CODES,
parseFanvueErrorBody,
} from "@fanvue/builder-sdk";
// Normalises all four Fanvue API error-body shapes, preserving `reason`.
// `reason` is what separates an entitlement refusal from an app-binding mismatch.
const { message, reason, code } = parseFanvueErrorBody(await fanvueResponse.json());
// Client side, against your own app's `{ error: { code, message } }` envelope:
// a 401 with one of these codes means the Fanvue grant is gone, not the app
// session, so prompt a reconnect instead of signing the creator out.
const parsed = AppErrorEnvelopeSchema.safeParse(await appResponse.json());
const appCode = parsed.success ? parsed.data.error.code : null;
if (appResponse.status === 401 && (appCode === null || !NON_SESSION_401_CODES.has(appCode))) {
onSessionExpired();
}Pagination
import { cursorPageSchema, offsetPageSchema, clampPageSize } from "@fanvue/builder-sdk";
const SubscribersPage = offsetPageSchema(SubscriberSchema); // { data, pagination }
const PaymentsPage = cursorPageSchema(PaymentSchema); // { data, nextCursor }
const size = clampPageSize(requested); // the API 400s above 50Contracts API reference
| Export | Description |
|---|---|
| FanvueEnvSchema, parseFanvueEnv(source) | Zod schema and pure parser for the FANVUE_* contract |
| fanvueEnv(), resetFanvueEnvCache() | Lazily parsed, cached process.env view, and the test reset |
| isFanvueConfigured(env?) | Whether the four Fanvue credentials are all set |
| flagEnabled(name, source?) | Boolean env-flag reader; trims, lowercases, warns once per flag |
| PUBLISH_REQUEST_MESSAGE, PUBLISH_RESULT_MESSAGE, UNPUBLISH_REQUEST_MESSAGE, UNPUBLISH_RESULT_MESSAGE, EXPERIENCE_MESSAGE_TYPES | fanvue:experience:* message types |
| PublishRequestMessageSchema, PublishResultMessageSchema, UnpublishRequestMessageSchema, UnpublishResultMessageSchema, ExperienceMessageSchema | Zod schemas for the four messages and their discriminated union |
| isPublishResultMessage(v), isUnpublishResultMessage(v) | safeParse-backed type guards |
| isFanvueOrigin(origin) | https + fanvue.com / *.fanvue.com origin check |
| FANVUE_ACCESS_MODES, FanvueAccessModeSchema | FREE \| SUBSCRIPTION \| PAID \| HIDDEN |
| EXPERIENCE_DENIAL_REASONS, EXPERIENCE_ENTITLED_REASONS | Every reason the platform reports, documented |
| accessModeFromDenialReason(reason) | Recovers the access mode a denial implies, or null |
| MAX_PAGE_SIZE, DEFAULT_PAGE_SIZE, clampPageSize(n) | Offset page-size limits (50 / 15) and client-side clamping |
| OffsetPaginationSchema, HybridPaginationSchema | { page, size, hasMore } and the v0 subscribers variant |
| offsetPageSchema(item), cursorPageSchema(item) | List-response schema builders |
| parseFanvueErrorBody(body) | Normalises any Fanvue error body to { message, reason, code } |
| FanvueErrorBodySchema (+ the four per-shape schemas) | Zod schemas for the platform's error bodies |
| AppErrorEnvelopeSchema | The app's own { error: { code, message } } envelope |
| FANVUE_APP_ERROR_CODES, NON_SESSION_401_CODES | Canonical app-facing codes and the two grant-loss 401s |
| OAuthErrorBodySchema | OAuth error body, normalised to { error, errorDescription } |
Auth and Sessions
Encryption at rest, the token-persistence port, and the dual-audience app session JWTs. All exported from the root entrypoint (@fanvue/builder-sdk) and runtime-agnostic — the crypto is built on WebCrypto, not node:crypto, so it works on Node, edge runtimes and in the browser alike.
Encryption at rest
AES-256-GCM with a fresh 12-byte IV per call and a 16-byte tag. The wire format is {kid}.{iv}.{tag}.{ciphertext}, each part base64url, dot-joined — the key id travels with the ciphertext, so rotating a key is decrypt-with-old / encrypt-with-new rather than a migration.
import { createFanvueCrypto } from "@fanvue/builder-sdk";
const crypto = createFanvueCrypto({
currentKey: process.env.FANVUE_TOKEN_ENC_KEY ?? "", // "v2:<32-byte base64>" (bare key ⇒ kid "v1")
previousKeys: process.env.FANVUE_TOKEN_ENC_KEY_PREVIOUS ?? null, // "v1:<32-byte base64>,v0:<…>" — decrypt only
});
const sealed = await crypto.encrypt("refresh-token");
if (sealed.isOk()) await db.token.update({ data: { refreshTokenEnc: sealed.value } });
const opened = await crypto.decrypt(row.refreshTokenEnc);
if (opened.isErr()) console.warn("token unreadable", opened.error.code);Generate a key with openssl rand -base64 32. The two variables above (FANVUE_TOKEN_ENC_KEY, FANVUE_TOKEN_ENC_KEY_PREVIOUS) are the documented names for an app to use; the SDK takes the strings as configuration rather than reading process.env itself, so they are deliberately not part of FanvueEnvSchema — an app that never encrypts anything should not have to set them. Key material is validated by a base64 round-trip and a 32-byte length check on first use, not at module load — a build without secrets still succeeds. Errors are neverthrow err values (CRYPTO_KEY_INVALID, CRYPTO_KEY_UNKNOWN, CRYPTO_MALFORMED_CIPHERTEXT, CRYPTO_DECRYPT_FAILED, CRYPTO_ENCRYPT_FAILED) and never carry key material or plaintext.
Purpose separation derives an HKDF-SHA256 subkey per domain from the same configured key, so a ciphertext sealed for one domain cannot be opened by another. AAD binds a ciphertext to its context cryptographically; it is authenticated but not stored, so the caller supplies the same value to decrypt.
const tokens = crypto.withPurpose("fanvue-oauth-tokens"); // a narrow SecretCipher
const secrets = crypto.withPurpose("webhook-signing-secrets");
// AAD: a fan uuid that cannot be replayed into another wheel's token
await crypto.encrypt(fanUuid, { purpose: "fan-uuid", aad: wheelId });Migrating an app's existing at-rest values. Mentor stores
base64(iv|tag|ciphertext)with an implicit key and prize-wheel storeskid:base64(iv|tag|ciphertext); neither parses here, soencryptedSecretKeyId(stored)returnsnullfor them and you can dual-read on that, re-sealing each value as you touch it.Sidequest is the exception: its
v1.{iv}.{tag}.{ciphertext}is this wire format, with a fixed version prefix sitting where the key id goes. A sidequest value decrypts here unchanged given the same key configured under key idv1and nopurpose, andencryptedSecretKeyIdreturns'v1'for it rather thannull— so a sidequest app that wants purpose separation must dual-read onCRYPTO_DECRYPT_FAILED, not on the key id.
Token persistence with compare-and-swap refresh
Implement TokenStorageAdapter over your own database; the SDK owns the refresh logic.
import {
createFanvueCrypto,
createTokenStoreContext,
getAccessToken,
getAnyAppAccessToken,
storeTokenSet,
tokenSetFromOAuthResult,
} from "@fanvue/builder-sdk";
const ctx = createTokenStoreContext({
adapter: prismaTokenAdapter,
cipher: createFanvueCrypto(keys).withPurpose("fanvue-oauth-tokens"),
oauth: fanvueEmbeddedConfig(),
});
// After a session-token exchange:
const tokens = tokenSetFromOAuthResult(await exchangeSessionToken(config, sessionToken));
if (tokens.isOk()) {
const stored = await storeTokenSet(ctx, creator.id, tokens.value);
if (stored.isErr()) return jsonError(stored.error.code); // e.g. MISSING_REFRESH_TOKEN
}
// On every Fanvue call:
const token = await getAccessToken(ctx, creator.id);
if (token === null) return fanvueReconnectRequired();The adapter's third method is the important one:
async updateIfRefreshTokenMatches(subjectId, expectedRefreshTokenCiphertext, record) {
const result = await db.oauthToken.updateMany({
where: { creatorId: subjectId, refreshTokenEnc: expectedRefreshTokenCiphertext },
data: record,
});
return result.count > 0; // must be one atomic statement
}Why compare-and-swap. The read-refresh-write is not atomic, so two requests arriving near expiry both refresh the same token. Hydra rotates refresh tokens, so the loser's is already dead upstream — and an unconditional write let it land over a rotation that had already completed, leaving the row holding a retired refresh token and the creator unable to do anything but reconnect. This was a live production failure in prize-wheel, where two separate token paths both wrote unconditionally.
Matching on the refresh-token ciphertext that was read makes the write conditional on nothing having changed in between. That works because every encryption uses a fresh IV: two seals of the same token never produce the same bytes, so ciphertext equality can only mean "unchanged since this call read it". An at-rest scheme with a deterministic IV would silently break the guarantee.
Losing the race is success, not an error: the winner's tokens are live and the access token in hand is still good for this request.
A provider that enforces strict rotation (no reuse grace period) makes the loser fail earlier — its refresh call is refused with invalid_grant before any write is attempted. getAccessToken re-reads the row once on a refused refresh: if the stored refresh-token ciphertext has changed, a concurrent refresh won and the winner's access token is served instead of a spurious null. When the ciphertext is unchanged, the refusal was genuine (revoked consent, a dead grant) and null stands.
Other behaviour worth knowing:
getAccessTokenrefreshes when the token is within 60s of expiry (TOKEN_EXPIRY_MARGIN_MS), and returnsnull— never throws — on every failure, decrypt failure after a key rotation included. Surface a reconnect state.refreshed.refreshToken ?? currentcarries the stored refresh token forward, because Hydra only returns one when it rotates.storeTokenSetrefuses a set with no refresh token (MISSING_REFRESH_TOKEN): a connection without offline access dies at the first expiry with nothing to retry.getAnyAppAccessToken(ctx)walks the 5 most recent subjects (listRecentSubjectIds) until one yields a token. The fan experience-token exchange validates the token's OAuth client against the experience's app rather than a creator, and a fan arrives with no session — so until the platform issues app-level credentials, borrowing a connected creator's token is the only way to make that call.
mapOAuthErrorToAppCode(error) maps the six SDK OAuth/embedded-auth codes onto the stable app-facing codes in OAUTH_APP_ERROR_CODES (invalid_session_token, consent_required, state_mismatch, token_exchange_failed, token_refresh_failed, authorize_on_behalf_failed, and oauth_error for anything else).
Dual-audience app sessions
Short-lived bearer JWTs for your own app, carrying no Fanvue tokens. Separate from createSessionJwt/SessionPayload, which do; both exist.
import {
CreatorSessionClaimsSchema,
FanSessionClaimsSchema,
createAppSessions,
} from "@fanvue/builder-sdk";
import { z } from "zod";
export const sessions = createAppSessions({
issuer: "sidequest", // also the audience prefix
secret: process.env.SESSION_SECRET ?? "",
creatorTtlSeconds: null, // null ⇒ 8h
fanTtlSeconds: null, // null ⇒ 12h
creatorClaimsSchema: CreatorSessionClaimsSchema,
fanClaimsSchema: FanSessionClaimsSchema.extend({ bountyId: z.string().min(1) }),
});
const jwt = await sessions.signFanSession({
typ: "fan",
fanvueFanUuid: exchanged.fanUuid,
experienceUuid: exchanged.experience.uuid,
entitled: true,
preview: false,
bountyId: bounty.id,
});
const session = await sessions.getFanSession(request.headers.get("authorization"));
if (session === null) return unauthorized();- Audiences are
<issuer>:creatorand<issuer>:fan, pinned in thejoseverify options — a fan token can never verify as a creator token, and a token from another app with the same secret does not verify either. - HS256 is pinned; the algorithm is never read from the token's own header. The secret must be at least 32 characters or the factory throws at startup.
- Every payload is Zod-validated on the way out and on the way back. There are no type assertions anywhere: a claim your app did not put in its schema cannot be signed, and a token whose claims no longer match does not verify.
- All verification failures collapse to
null. Which check failed tells an attacker what to try next and a legitimate caller nothing it can use. - Claims are passed in full,
typincluded, so no cast is needed to add the discriminator.subis thesubjectId(creator) orfanvueFanUuid(fan);jti,iatandexpare set for you. - Sessions travel as bearer tokens rather than cookies because the fan iframe runs on an opaque origin.
bearerTokenFromAuthorizationHeader(value)is exported for the parsing, andverifyCreatorSessionToken/verifyFanSessionTokentake a bare token.
Auth API reference
| Export | Description |
|---|---|
| createFanvueCrypto(config) | AES-256-GCM encryption at rest over a key registry; encrypt, decrypt, withPurpose |
| parseKeyRegistry(config), DEFAULT_KEY_ID, AES_256_KEY_BYTES | Key-registry parsing, the default v1 key id, and the required key length |
| AES_GCM_IV_BYTES, AES_GCM_TAG_BYTES | 12 and 16 |
| encryptedSecretKeyId(encoded) | The key id of an SDK-format ciphertext, or null — the dual-read discriminator (sidequest's format is the same shape, so it reports v1) |
| encodeBase64Url, decodeBase64Url, decodeKeyMaterial, utf8Encode, utf8Decode | Canonicity-checking codecs, btoa/atob only |
| CryptoError, FanvueCrypto, SecretCipher, CipherOptions, FanvueCryptoConfig, KeyRegistry | Crypto types |
| TokenSet, tokenSetFromTokenResponse(res, now?), tokenSetFromOAuthResult(result, now?) | The internal token shape and its adapters from the SDK's OAuth results |
| OAUTH_APP_ERROR_CODES, mapOAuthErrorToAppCode(error) | The canonical six-code mapping to stable app-facing codes |
| TokenStorageAdapter, StoredTokenRecord, TokenStoreContext, TokenStoreError, RefreshTokenFn | The persistence port and its types |
| createTokenStoreContext(input) | Binds the adapter, cipher and OAuth config to the SDK's refresh call |
| storeTokenSet(ctx, subjectId, tokens) | Encrypts and upserts; refuses a set with no refresh token |
| getAccessToken(ctx, subjectId) | A usable token, refreshing via compare-and-swap; null on any failure |
| getAnyAppAccessToken(ctx) | The first usable token among the recent subjects, for app-bound calls |
| TOKEN_EXPIRY_MARGIN_MS, RECENT_SUBJECT_LIMIT | 60000 and 5 |
| createAppSessions(config) | The dual-audience signer/verifier factory |
| CreatorSessionClaimsSchema, FanSessionClaimsSchema | Base claims, .extend()-able for app claims |
| CreatorSessionClaims, FanSessionClaims, AppSessions, AppSessionsConfig | Session types |
| DEFAULT_CREATOR_TTL_SECONDS, DEFAULT_FAN_TTL_SECONDS | 8h and 12h |
| bearerTokenFromAuthorizationHeader(value) | Reads Authorization: Bearer … (scheme case-insensitive, per RFC 9110), or null |
Fanvue API Client
createFanvueClient(accessToken, apiBaseUrl, options?) returns a client bound to one
access token. Every request carries Authorization: Bearer, X-Fanvue-API-Version and
cache: "no-store", times out after 10 seconds, and retries at most once — only on a
5xx, and only for GET. A timeout is never retried: a second full budget doubles
user-facing latency for the same outcome.
import { createFanvueClient } from "@fanvue/builder-sdk";
const client = createFanvueClient(creatorAccessToken, null, {
apiVersion: process.env.FANVUE_API_VERSION, // defaults to API_VERSION
timeoutMs: 10_000,
});
const user = await client.getCurrentUser();The token is bound to the client. Apps that also hold an app-pooled token (the experience exchange is app-bound, not creator-bound) create a second client rather than passing a token per call:
const appClient = createFanvueClient(appPooledAccessToken, null);Failures are Result errs carrying an ApiError. API_ERROR_RESPONSE is the arm to read
for platform rejections: it keeps the machine-readable reason and the rate-limit advice.
if (result.isErr() && result.error.code === "API_ERROR_RESPONSE") {
log.warn("fanvue rejected", {
status: result.error.statusCode,
reason: result.error.reason, // e.g. "not_subscribed"
retryAfterSeconds: result.error.retryAfterSeconds, // 429s and 503s
});
}Experiences
// Creator surface: mint the token Fanvue's confirmation modal needs.
const publish = await client.experiences.mintPublishRequestToken({
appUuid: env.FANVUE_APP_UUID,
externalExperienceId: courseId,
title: "Photography 101",
description: "Six lessons, shot on film",
imageUrl: coverUrl, // public https, or null
proposedAccessMode: "SUBSCRIPTION",
});
const unpublish = await client.experiences.mintUnpublishRequestToken({
appUuid: env.FANVUE_APP_UUID,
experienceUuid,
title: "Photography 101",
});The exchange puts every mapped outcome in the ok channel, because "this fan is not
entitled" is something to render, not an error to handle. The err channel only ever carries
a 200 body the SDK could not read or validate.
const exchanged = await appClient.experiences.exchangeExperienceToken(launchToken);
if (exchanged.isErr()) return unavailable();
switch (exchanged.value.status) {
case "entitled":
return open(exchanged.value.experience, exchanged.value.fanUuid);
case "denied": // 403 with a reason — the fan's access lapsed
return locked(accessModeFromDenialReason(exchanged.value.reason));
case "binding_mismatch": // bare 403 — token minted for another app
return appMismatch();
case "expired": // 400 — token invalid or its ~600s TTL elapsed
return expiredLink();
case "unavailable": // 404 — unpublished or uninstalled since minting
return gone();
case "rate_limited":
return tooManyRequests(exchanged.value.retryAfterSeconds);
case "upstream_error": // cause: http_error | timeout | network_error
return unavailable();
}Vault media
client.vault.* covers the creator's vault: multipart uploads, listing, folders, and the
entitlement grants that let a fan see one item without a subscription.
Two things are worth knowing before the first call.
Variant URLs only appear when you ask for variants. An item fetched without
variants comes back with variants: null, which reads as "no renditions exist" and is
not what happened.
The AI tags block never leaves the SDK. The platform's finalised payload carries
free-text descriptions, NSFW categories, body parts and skin-colour labels. None of it is
needed to render or gate media and all of it is the kind of thing that ends up in a log
line, so the response schema reduces the whole block to isNsfw: boolean | null at the
parse boundary — the rest never exists as a value.
const page = await client.vault.listMedia({
folderName: "Summer 2024",
mediaType: "video",
status: ["ready"],
variants: ["main", "thumbnail"], // without this: no URLs
size: 50, // clamped to the platform's 50; over-large is not a 400
});
const item = await client.vault.getMedia(mediaUuid, { variants: ["main"] });Uploads are create-session → PUT each part direct to S3 → complete → poll. Validate the
file before creating the session: there is no public endpoint that abandons an unused
one.
const session = await client.vault.createUploadSession({
name: "Lesson 1",
filename: "lesson-1.mp4",
mediaType: "video",
sizeBytes: file.size, // pass it and `totalParts` is exact; omit it and compute
});
// per part: GET the presigned URL (text/plain upstream, wrapped for you), PUT, keep the ETag
const part = await client.vault.getUploadPartUrl(uploadId, 1);
await client.vault.completeUpload(uploadId, [{ ETag: etag, PartNumber: 1 }]);Grants are owner-scoped — call them with the owning creator's own token, never an
app-pooled one — and idempotent on (media, consumer, sourceRef). Note that source
is not part of that key, so sourceRef should be your id for the event that earned the
grant, not a fresh value per attempt.
await client.vault.grantMedia(mediaUuid, {
consumerId: fanUuid,
source: "spin_the_wheel_reward", // ^[a-z0-9_]+$, validated locally
sourceRef: spinId, // the idempotency key
});
const entitled = await client.vault.getEntitledMedia(mediaUuid, {
consumerId: fanUuid,
variants: ["main", "thumbnail"],
});getBulkMedia takes 1–20 uuids. An over-large request is refused as an
API_VALIDATION_ERROR rather than truncated — truncating looks like it worked and
silently drops the tail.
Every method here has a creator-scoped mirror under /v0/creators/{creatorUserUuid}/...
for agency-delegated access; the SDK binds the self-scoped routes and names the mirror in
each method's JSDoc.
For proxy routes that forward a browser's query string to the vault, parseListMediaQuery
and parseGetMediaOptions turn URLSearchParams into the typed query bags. They are
allowlists, not pass-throughs, which is the point — a verbatim forward also relays a
size=5000 the platform answers 400 to, and a purchasedBy naming somebody else's fan.
import { parseListMediaQuery } from "@fanvue/builder-sdk";
const query = parseListMediaQuery(new URL(request.url).searchParams);
const page = await client.vault.listMedia(query); // unrecognised params already droppedVault media helpers
Three patterns every app re-derived, extracted once.
import {
bestVariantUrl,
gateBulkMedia,
pollUntilReady,
resolveBulkMedia,
} from "@fanvue/builder-sdk";completeUpload leaves an item processing, so something has to poll. All three endings
are outcomes to render, not errors to handle:
const settled = await pollUntilReady(client.vault.getMedia, mediaUuid, {
intervalMs: 1_500,
timeoutMs: 300_000,
});
if (settled.isErr()) return unavailable();
switch (settled.value.status) {
case "ready": return publish(settled.value.item);
case "error": return rejectUpload(); // the platform's terminal verdict
case "timeout": return stillProcessing();
}bestVariantUrl is the one canonical resolver, defaulting to
main → thumbnail → thumbnail_gallery → blurred. A preferred variant with no URL is
skipped rather than ending the search, because the platform only signs the renditions the
request asked for.
bestVariantUrl(item); // full asset, else a preview
bestVariantUrl(item, ["thumbnail_gallery", "thumbnail"]); // grid: never the full assetThe two bulk resolvers batch by 20 and run five calls at a time. They differ on the one question that matters — what a failed batch means:
// Lenient: a failed batch's uuids are dropped, so they get *no* state rather than a
// state inferred from an answer that never arrived. The other 55 items still render.
const { urls, states } = await resolveBulkMedia(client.vault.getBulkMedia, uuids);
urls.get(uuid); // only ready items with a URL
states.get(uuid); // "ready" | "processing" | "unavailable", or absent: its batch failed
// Gating: propagates the failure, so the caller can say "try again shortly" instead of
// telling a creator to replace a file that is perfectly fine.
const gated = await gateBulkMedia(client.vault.getBulkMedia, uuids);
if (gated.isErr()) return tryAgainShortly();A uuid absent from a batch that did come back is a different thing again: that is an
answer, and the answer is unavailable.
Signed URLs are never cached inside the SDK. The platform's signing window is operator-configured and not visible from the client, so a cached URL is one whose remaining life is short by the age of the cache entry — survivable for a thumbnail, fatal part-way through a forty-minute video.
Subscribers
const page = await client.subscribers.list({ size: 50, cursor });GET /v0/subscribers is the platform's one hybrid-paginated route: page/size offset
pagination plus a nextCursor. Prefer the cursor for a long walk — offset pagination over
tens of thousands of rows gets slower with every page. nextCursor is always null when
sorting by name, and sortField/sortDirection must be passed together (refused locally
rather than spending a 400).
A 404 on the single-subscriber route means "not a subscriber", which is the most
ordinary possible answer to that question — so both single lookups put it in the ok
channel:
const check = await client.subscribers.get(fanUuid);
const active =
check.isOk() && check.value.subscribed &&
check.value.subscriber.subscription?.status === "active";
const identity = await client.subscribers.getIdentity(fanUuid); // ok(null) when not oneHandles, display names and avatar URLs are confidential and mutable. Resolve them at
view time, key your own records on uuid, and do not persist the copy.
Chats
await client.chats.sendMessage(fanUuid, { text: "Your prize is unlocked." });
await client.chats.sendMessage(fanUuid, {
text: "Behind the scenes",
mediaUuids: [mediaUuid],
price: 500, // minor units, at least 300
mediaPreviewUuid: teaserUuid, // free teaser for the priced message
});The body has five cross-field rules — a message needs something in it; a GIF cannot also carry media, a price or a template; a price needs something to unlock; a free preview needs a priced message with media. The SDK mirrors them exactly and applies them before the request, so a bad combination is a named refusal carrying the platform's own wording rather than a round trip and a string to grep.
When messaging several fans, send sequentially. One uncontactable fan answers 400
and must not sink the batch, and a burst of parallel sends spends the platform's whole
rate-limit window at once.
const page = await client.chats.listMessages(fanUuid, {
size: 50,
markAsRead: false, // the platform defaults this to TRUE
startDate: lastSeenAt,
});Pass markAsRead: false for anything that polls, or a background reader clears the
creator's unread badge on messages nobody looked at. Passing endDate suppresses
markAsRead entirely, whatever you set — an upper-bounded window excludes the newest
messages, so marking the chat read would clear unread state on messages the call never
returned.
Checkout links
Creating, listing, disabling and deleting are self-scoped. Payments exist only under
the creator mirror, which is why the payment methods take a creatorUserUuid and the link
methods do not.
const link = await client.checkout.createLink({
source: {
kind: "new_product", // or "existing_product" | "existing_price"
product: { name: "Spin the wheel — grand prize" },
price: { amount: 1999, currency: "USD", isRecurring: false },
},
redirectUrl: "https://app.example.com/thanks",
});Amounts are integer minor units throughout (1999 is $19.99). The Fanvue dashboard
shows major units; this API never does, and passing dollars where cents are expected mints
a link priced at a hundredth of the intended amount. A link is either free (0) or priced
between MIN_CHECKOUT_LINK_PRICE and MAX_CHECKOUT_LINK_PRICE — the platform's own schema
bound, so anything outside that (1–299, over the ceiling, negative or fractional) is
refused locally rather than round-tripped. The
recurring cross-field rules are not schema rules upstream and surface as 400 "Recurring
prices require a non-zero amount" / 400 "Recurring prices require cycleLength and
cycleUnit".
Read the 403 carefully — two entirely different problems share it, and only one of them
is the creator's to fix:
import { classifyCheckoutForbidden } from "@fanvue/builder-sdk";
if (result.isErr()) {
switch (classifyCheckoutForbidden(result.error)) {
case "not_enabled": // ops flag unset; the creator can do nothing
return unprocessable("checkout_not_enabled");
case "missing_scope": // token predates write:creator; they must reconnect
return fanvueReconnectRequired();
}
}Disabling is reversible and is the right tool for a price change — it stops the stale URL
taking money without deleting anything. deleteLink is terminal: a deleted link can never
be purchased or re-enabled.
await client.checkout.updateLinkStatus(linkUuid, "disabled");Payments are for reconciliation; webhooks remain the primary delivery mechanism.
invoiceNumber is the platform's own idempotency key — key fulfilment on it so a replayed
webhook and a reconciliation sweep cannot both deliver.
let cursor: string | undefined;
do {
const page = await client.checkout.listPayments(creatorUuid, {
cursor, // follow it, or you only ever see page one
limit: 100, // clamped to 100
clientReferenceId: orderId, // the reconciliation filter
status: "succeeded",
});
if (page.isErr()) break;
for (const payment of page.value.data) fulfil(payment.invoiceNumber, payment);
cursor = page.value.nextCursor ?? undefined;
} while (cursor !== undefined);purchaser.email is confidential: do not log it or forward it.
Paginating a list endpoint
paginateOffset walks an offset-paginated route, clamps the page size to the platform's
50, and on a 429 waits the interval the platform asked for and resumes the same page
(bounded — three waits by default, each capped at 60s).
import { paginateOffset } from "@fanvue/builder-sdk";
for await (const page of paginateOffset(fetchSubscribersPage, { size: 50 })) {
if (page.isErr()) break; // the walk stops; pages already yielded stand
for (const subscriber of page.value.data) index(subscriber);
}Links back into the Fanvue web shell
Pure builders that refuse rather than guess, so a fan never receives a link containing a
literal {placeholder}.
import {
creatorProfileUrl,
experienceDetailShareUrl,
experienceShareUrl,
} from "@fanvue/builder-sdk";
const profile = creatorProfileUrl(env.FANVUE_WEB_ORIGIN, handle);
const detail = experienceDetailShareUrl(env.FANVUE_WEB_ORIGIN, experienceUuid);
const share = experienceShareUrl(env.FANVUE_EXPERIENCE_URL_TEMPLATE, {
handle,
experienceUuid,
});
if (share.ok) render(share.url);
else if (share.refusal === "missing_template") render(detail.ok ? detail.url : null);Client API reference
| Export | Description |
|---|---|
| createFanvueClient(accessToken, apiBaseUrl, options?) | Authenticated client. options: { apiVersion?, timeoutMs? } |
| FanvueClient, FanvueClientOptions | The client surface and its options |
| client.getCurrentUser() | GET /users/me → Result<FanvueUser, ApiError> |
| client.experiences.mintPublishRequestToken(params) | POST /v0/experiences/request-token (publish) → Result<string, ApiError> |
| client.experiences.mintUnpublishRequestToken(params) | POST /v0/experiences/request-token (unpublish) → Result<string, ApiError> |
| client.chats.sendMessage(userUuid, body) | POST /v0/chats/{userUuid}/message → Result<MessageCreated, ApiError> |
| client.chats.listMessages(userUuid, query?) | GET /v0/chats/{userUuid}/messages → Result<ChatMessagesPage, ApiError> |
| client.checkout.createLink(input) | POST /v0/checkout-links → Result<CheckoutLink, ApiError> |
| client.checkout.listLinks(query?) | GET /v0/checkout-links → Result<CheckoutLinksPage, ApiError> |
| client.checkout.updateLinkStatus(uuid, status) | PATCH /v0/checkout-links/{uuid} → Result<CheckoutLinkStatusUpdate, ApiError> |
| client.checkout.deleteLink(uuid) | DELETE /v0/checkout-links/{uuid} (terminal) → Result<undefined, ApiError> |
| client.checkout.listPayments(creatorUserUuid, query?) | GET /v0/creators/{c}/checkout-links/payments → Result<CheckoutPaymentsPage, ApiError> |
| client.checkout.getPayment(creatorUserUuid, invoiceNumber) | GET /v0/creators/{c}/checkout-links/payments/{invoiceNumber} → Result<CheckoutPayment, ApiError> |
| client.experiences.exchangeExperienceToken(token) | POST /v0/experiences/token/exchange → Result<ExperienceExchangeResult, ApiError> |
| client.subscribers.list(query?) | GET /v0/subscribers (hybrid pagination) → Result<SubscribersPage, ApiError> |
| client.subscribers.get(userUuid) | GET /v0/subscribers/{userUuid}; 404 → ok({ subscribed: false }) |
| client.subscribers.getIdentity(userUuid) | Same route, loose projection; 404 → ok(null). Never persist the result |
| client.vault.createUploadSession(params) | POST /v0/media/uploads → Result<UploadSession, ApiError> |
| client.vault.getUploadPartUrl(uploadId, partNumber) | GET /v0/media/uploads/{uploadId}/parts/{n}/url (text/plain) → Result<UploadPartUrl, ApiError> |
| client.vault.completeUpload(uploadId, parts) | PATCH /v0/media/uploads/{uploadId} → Result<CompleteUploadResult, ApiError> |
| client.vault.getMedia(uuid, options?) | GET /v0/media/{uuid} → Result<VaultMediaItem, ApiError> |
| client.vault.listMedia(query?) | GET /v0/media → Result<VaultMediaPage, ApiError> |
| client.vault.getBulkMedia(uuids, variants?) | GET /v0/media/bulk, 1–20 uuids (over-large is refused, never truncated) |
| client.vault.listFolders(query?) | GET /v0/vault/folders → Result<VaultFolderPage, ApiError> |
| client.vault.listFolderMedia(folderName, query?) | GET /v0/vault/folders/{folderName}/media → Result<VaultMediaPage, ApiError> |
| client.vault.grantMedia(uuid, params) | POST /v0/media/{uuid}/grant; owner-scoped, idempotent on (media, consumer, sourceRef) |
| client.vault.getEntitledMedia(uuid, options) | GET /v0/media/{uuid}/entitled → Result<VaultMediaItem, ApiError> |
| parseListMediaQuery(params), parseGetMediaOptions(params) | Allowlist URLSearchParams → typed vault query bags, for proxy routes |
| pollUntilReady(getMedia, uuid, options?) | Waits for a terminal state; ready/error/timeout all land in the ok channel |
| bestVariantUrl(item, preference?) | The canonical variant resolver (main → thumbnail → thumbnail_gallery → blurred) |
| resolveBulkMedia(getBulkMedia, uuids, options?) | Lenient batch resolve; a failed batch's uuids are dropped, never mis-stated |
| gateBulkMedia(getBulkMedia, uuids, options?) | Gating batch classify; propagates a failed batch's error |
| classifyCheckoutForbidden(error) | Splits the ops-flag 403 (not_enabled) from the missing-scope 403 |
| VaultMediaItem, VaultVariant, VaultMediaPage, VaultFolder, VaultFolderPage, BulkMediaResult | Vault domain types (AI tags reduced to isNsfw) |
| Subscriber, SubscriberSubscription, SubscriberCheck, SubscriberIdentity, SubscribersPage | Subscriber domain types |
| ChatMessage, ChatMessagesPage, SendMessageBody, SendMessageGif, MessageCreated | Chat domain types |
| CheckoutLink, CheckoutPayment, CheckoutLinkSource, CheckoutLinkPriceInput, CheckoutPaymentsPage | Checkout domain types |
| VaultMediaState, VaultMediaEntry, BulkMediaResolution, PollUntilReadyResult | Media-helper result types |
| VAULT_MEDIA_TYPES, VAULT_MEDIA_STATUSES, VAULT_VARIANT_TYPES, BULK_MEDIA_MAX_UUIDS, MEDIA_GRANT_SOURCE_PATTERN | Vault enums and bounds |
| CHECKOUT_LINKS_NOT_ENABLED_MESSAGE, MIN_CHECKOUT_LINK_PRICE, MAX_CHECKOUT_LINK_PRICE, MAX_CHECKOUT_PAYMENTS_LIMIT | Checkout constants |
| CHAT_MESSAGE_MAX_CHARS, MIN_CHAT_MESSAGE_PRICE, SUBSCRIPTION_STATUSES, SUBSCRIBER_SORT_FIELDS | Chat and subscriber bounds |
| ExperienceExchangeResult, ExperienceExchangeFailureCause | The seven exchange outcomes and the three upstream causes |
| ExchangedExperienceSchema, ExchangedExperience | Zod schema and type for a successful exchange |
| EXPERIENCE_EXCHANGE_TIMEOUT_MS | The exchange's pinned 10s budget |
| requestJson(ctx, request, schema), requestText(ctx, request) | The shared transport, for building further resource modules |
| FanvueTransportContext, FanvueRequestOptions, FanvueHttpMethod | Transport types |
| DEFAULT_REQUEST_TIMEOUT_MS, parseRetryAfterSeconds(headers, nowMs) | Timeout default and Retry-After / X-RateLimit-Reset reader |
| paginateOffset(fetchPage, options?) | Async-iterator walk over an offset-paginated route |
| OffsetPage, OffsetPageRequest, OffsetPageFetcher, PaginateOffsetOptions | Pagination helper types |
| DEFAULT_MAX_RATE_LIMIT_WAITS, MAX_RATE_LIMIT_WAIT_MS, FALLBACK_RATE_LIMIT_WAIT_MS | Rate-limit wait bounds |
| creatorProfileUrl(webOrigin, handle) | The creator's public profile URL |
| experienceDetailShareUrl(webOrigin, experienceUuid) | The fan-facing experience detail page |
| experienceShareUrl(template, substitutions) | Operator-configured share link, with five typed refusals |
| ShellUrlResult, ShellUrlRefusal, ExperienceShareSubstitutions | Shell-URL result and refusal types |
| client.webhooks.createSubscription(params) | POST /v0/webhooks/subscriptions → Result<{ id, signingSecret }, ApiError> |
| client.webhooks.listSubscriptions() | GET /v0/webhooks/subscriptions → Result<WebhookSubscription[], ApiError> |
| client.webhooks.deleteSubscription(id) | DELETE /v0/webhooks/subscriptions/{id} → Result<void, ApiError>; 404 counts as success |
Webhooks
Everything an app needs to receive Fanvue webhooks safely: signature verification, topic
resolution, PII-stripping schemas, the receiver route, and the subscription lifecycle. The core
half is runtime-agnostic (WebCrypto only, no node: imports), so it verifies identically on
Node and on the edge.
Environment
# Required before any delivery is accepted — unset means every delivery answers 503.
FANVUE_WEBHOOK_SIGNATURE_PROFILE=fanvue-v0
# Optional (shown with defaults)
# FANVUE_WEBHOOK_SIGNATURE_HEADER=X-Fanvue-Signature
# FANVUE_WEBHOOK_SIGNATURE_TOLERANCE_SECONDS=300 # or `off` to disable the replay windowwebhookReadiness() turns that into a { name: 'webhook_signature', ready, detail } row for
your /api/readyz, alongside the SDK's other configuration checks.
The delivery contract
Fanvue signs deliveries as X-Fanvue-Signature: t=<unix>,v0=<hex> — HMAC-SHA256 over
<t>.<exact raw payload bytes> with the subscription's signingSecret. Three details matter:
- The timestamp is inside the signed content, not merely alongside it. Signing the bare
body fails every genuine delivery, and would leave the replay window unauthenticated — a
captured delivery could just have its
trefreshed. - Headers are not signed. Deliveries do carry
X-Fanvue-Topic, and the SDK deliberately ignores it: picking which sanitizer runs over a money event from an unsigned value would let anyone who captured a delivery re-file it as a different kind of event. The topic comes from the signed body instead — envelope deliveries carry it intype, legacy flat deliveries carry no discriminator and are duck-typed by field presence.resolveWebhookTopicreturnsnullrather than guessing on ambiguity. - A
v0value may carry more than one MAC. During a signing-secret rotation the platform comma-joins them inside one value (t=…,v0=<old>,<new>); verification accepts either.
import { fanvueSignatureContract, verifyWebhookHmac } from "@fanvue/builder-sdk";
const verified = await verifyWebhookHmac({
rawBody: new Uint8Array(await request.arrayBuffer()), // never a reparsed body
headers: Object.fromEntries(request.headers.entries()),
secret: signingSecret,
contract: fanvueSignatureContract(), // 300s replay window; pass `{ toleranceSeconds: null }` to disable
nowMs: null,
});The receiver route
// app/api/webhooks/fanvue/[subscriptionRef]/route.ts
// (also exported from @fanvue/builder-sdk/nextjs/off-platform)
import { createWebhookReceiverHandler } from "@fanvue/builder-sdk/nextjs/embedded-app";
import { webhookSignatureContractFromEnv } from "@fanvue/builder-sdk";
export const dynamic = "force-dynamic";
export const { POST } = createWebhookReceiverHandler({
findSubscriptionByRef: async (ref) => {
const row = await db.webhookSubscription.findUnique({
where: { subscriptionRef: ref },
include: { creator: { select: { status: true, fanvueUserUuid: true } } },
});
if (row === null) return null;
return {
id: row.id,
creatorUuid: row.creator.fanvueUserUuid,
topics: row.topics,
signingSecretCiphertext: row.signingSecretEnc,
status: row.status,
creatorStatus: row.creator.status,
};
},
decryptSigningSecret: (ciphertext) => decryptSecret(ciphertext),
insertEvent: (input) => storeEventIdempotently(input), // → { duplicate }
onEvent: ({ event }) => kickQueue(event), // optional
signatureContract: webhookSignatureContractFromEnv(),
});The status codes are a contract with the platform, not a preference:
| 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 — and beyond a size-capped probe check, the body is never read |
| Subscription broken / creator disconnected | 200 { accepted: false, discarded: true, reason: 'subscription_inactive' } |
| No signature contract, or an undecryptable secret | 503 — retryable, and the platform retries |
| Signature did not verify | 401 |
| Authentic but unusable (bad JSON, unknown topic, wrong shape, wrong tenant) | 200 { accepted: false, discarded: true, reason } |
| Stored | 200 { accepted: true, duplicate } |
That fifth row is the one apps get wrong. A 4xx on a malformed delivery makes the platform
retry something that can never succeed, and eventually auto-disables the subscription — taking
the creator's working events down with it.
No personal data reaches storage
Deliveries arrive carrying fan handles, display names, avatar URLs, message text and media
UUIDs. sanitizeWebhookDelivery parses the raw body with a .loose() wire schema, projects
the minimum field set, then re-parses that projection through a .strict() schema — so a
leak is a thrown error rather than a database row. Message text survives only as textLength.
const event = sanitizeWebhookDelivery("message.received", body);
// { topic, providerEventId, creatorUuid, fanUuid, occurredAt,
// payload: { messageUuid, createdAt, isAutomatedMessage, isMuted, textLength, hasMedia } }Ten topics are supported (APP_WEBHOOK_TOPICS); the full platform list is exported separately
as FANVUE_WEBHOOK_EVENTS.
Subscription lifecycle
import { APP_WEBHOOK_TOPICS, ensureWebhookSubscription } from "@fanvue/builder-sdk";
// Right after storing the creator's tokens, while the access token is certainly live:
const result = await ensureWebhookSubscription({
ownerId: creator.id,
api: createFanvueClient(accessToken, null).webhooks,
store, // findByOwner + upsertActiveSubscription
receiverBaseUrl: process.env.APP_BASE_URL ?? "", // must be https
receiverPath: "/api/webhooks/fanvue",
topics: APP_WEBHOOK_TOPICS,
encryptSigningSecret: (secret) => encryptSecret(secret),
});
// 'already_active' | 'created' | 'invalid_receiver_url' | 'create_failed'The path reference is an opaque randomUUID(), not the creator's id, so the
