Skip to main content
This guide takes you from zero to a working embedded app: registered in the Builder, rendering inside Fanvue, and calling the Fanvue API as the creator using it. New to embedded apps? Start with the overview.

How authentication works

An embedded app renders inside Fanvue in an iframe. On load, Fanvue injects a short-lived session token that proves “this creator is using your app inside Fanvue right now.” Your backend trades that token for real OAuth access + refresh tokens through a delegated authorize-on-behalf flow, so you can call the Fanvue API on the creator’s behalf — including in the background, after they’ve closed the iframe. The security model is a capability split: Fanvue owns the authorize half (the creator’s identity + consent), your app owns the exchange half (your client secret + PKCE verifier). Neither side sees the other’s secrets — Fanvue hands you an authorization code but never sees your client secret, PKCE verifier, or the resulting tokens; your app never forges a creator’s identity. The recommended way to implement this is the @fanvue/builder-sdk SDK, which handles the whole exchange (PKCE, state, code exchange, refresh) so it’s hard to get wrong. If you can’t use the SDK, the full manual flow is in the appendix.

Step 1 — Register your app

In Fanvue, go to App Store → Builder → Create App:
1

Set the app type

Choose App Type → Embedded App, and set the Embed URL to the page Fanvue should load, e.g. https://your-app.com/embedded.
2

Configure authentication

  • Copy the Client ID.
  • Reset secret and copy the Client Secret — it’s shown once.
  • Add a redirect URI (e.g. https://your-app.com/oauth/callback). It’s never visited by a browser — it only binds the authorization code — but it must match exactly what you send at token exchange.
  • Add the scopes your app needs (e.g. read:self, read:creator). See Scopes.
3

Complete the listing

Fill in App Details (icon, tagline) and Publish (preview image, description, test credentials), then submit for review. See Publishing Your App.

Step 2 — Hosting requirements

  • Serve your embed URL over HTTPS with a browser-trusted certificate.
  • Send a Content-Security-Policy: frame-ancestors https://www.fanvue.com header so Fanvue can frame your page (use https://dev.fanvue.com against the dev environment).
  • Keep the client secret server-side only — never ship it to the browser.
When Fanvue opens your app in the iframe, it appends the session token to your embed URL (?token=...). The SDK exchanges it server-side for OAuth tokens and manages the session from there.
npm install @fanvue/builder-sdk
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());
createConfig() reads your OAUTH_* and SESSION_* environment variables — set your Client ID, Client Secret, and registered redirect URI there.
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. It's `null` outside Fanvue, so fall back to a 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>
  );
}
useEmbeddedAuth reads the session token from the URL and exchanges it on mount, well inside the token’s ~60-second lifetime. It also returns theme — see Match the creator’s theme below.
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;
}
Not on Next.js?
@fanvue/builder-sdk/react works with any React framework, and the core @fanvue/builder-sdk entrypoint exposes the low-level OAuth primitives (including getThemeFromUrl) for Node, Deno, or a custom server. If you can’t use the SDK at all, implement the flow yourself per the appendix.

Working on the creator’s behalf in the background

To act for the creator after the iframe closes (scheduled jobs, webhooks, automation), persist the refresh token when the exchange completes:
app/api/fanvue/session/route.ts
export const { POST } = createSessionExchangeHandler(createConfig(), {
  onTokens: async ({ tokens, user }) => {
    await db.saveRefreshToken(user.uuid, tokens.refresh_token);
  },
});
The SDK’s API client refreshes expired access tokens automatically. Once you hold the refresh token, you no longer need the iframe open.

Match the creator’s theme

To feel like a native part of Fanvue, your app should render in the same light or dark theme the creator is currently using. When Fanvue opens your iframe, it appends the creator’s active colour scheme to your embed URL as ?theme=light or ?theme=dark, alongside the ?token= session token. useEmbeddedAuth reads it for you and returns theme: 'light' | 'dark' | nullnull when your app is opened outside Fanvue (no ?theme= present), so always fall back to a sensible default:
app/embedded/page.tsx
const { theme } = useEmbeddedAuth();

// Drive your styling from the creator's active scheme.
return <div data-theme={theme ?? "light"}>{/* your app */}</div>;
Unlike the session token, the theme isn’t single-use — it’s read straight from the URL, so you can pick it up anywhere on the client. Outside React (or in a plain server/iframe page), use the core helper directly:
import { getThemeFromUrl } from "@fanvue/builder-sdk";

const theme = getThemeFromUrl(window.location.href); // "light" | "dark" | null

Environments

ProductionDev
Platformhttps://www.fanvue.comhttps://dev.fanvue.com
Auth (Hydra)https://auth.fanvue.comhttps://auth.dev.fanvue.com
APIhttps://api.fanvue.comhttps://api.dev.fanvue.com

Gotchas / checklist

  • PKCE is mandatory — an authorize-on-behalf request without a code_challenge (S256) is rejected. The SDK handles this for you.
  • redirect_uri at token exchange must exactly equal a registered redirect URI.
  • consent_required (403) from authorize-on-behalf means the creator hasn’t approved the in-platform consent screen yet (or revoked it) — your app can’t create that consent; it happens in the Fanvue UI.
  • Request offline_access-capable scopes at consent time if you want refresh tokens (the in-platform consent grants it so one approval covers both the live surface and offline use).
  • Verify and track state, keep the client secret and PKCE verifier server-side, and never forward the session token anywhere but your own backend.
  • The session token has a ~60s TTL — if a user leaves the iframe open and clicks much later, re-read a fresh token (reopening the surface re-issues one).

Appendix — the manual flow (without the SDK)

Everything the SDK does, by hand. Use this if you’re not on Node/React, or you want to understand exactly what’s happening.

The endpoints

PurposeRequest
Get an authorization code (delegated)POST {PLATFORM}/api/v1/app-integrations/authorize-on-behalfAuthorization: Bearer <session token>, body { code_challenge, code_challenge_method: "S256", state }{ code, state }
Exchange code for tokensPOST {HYDRA}/oauth2/tokenclient_secret_basic, grant_type=authorization_code, code, redirect_uri, code_verifier{ access_token, refresh_token, … }
Call the Fanvue APIGET {API}/users/meAuthorization: Bearer <access token>
Refresh laterPOST {HYDRA}/oauth2/tokenclient_secret_basic, grant_type=refresh_token, refresh_token
{PLATFORM}, {HYDRA}, and {API} are the environment base URLs above.

Frontend (the iframe page)

Fanvue loads your Embed URL with the session token as a query param (?token=…), plus the creator’s active colour scheme as ?theme=light|dark. Read the token and hand it to your backend — and nowhere else. The theme is yours to use client-side for styling.
<!doctype html><meta charset="utf-8">
<h1>My Embedded App</h1>
<button id="run">Do something on my behalf</button>
<pre id="out"></pre>
<script>
  const params = new URLSearchParams(location.search);
  const token = params.get("token");
  // Match Fanvue's light/dark theme (absent when opened outside Fanvue).
  document.documentElement.dataset.theme = params.get("theme") ?? "light";
  document.getElementById("run").onclick = async () => {
    // Never send the session token anywhere except YOUR backend.
    const r = await fetch("/api/run", {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ token }),
    });
    document.getElementById("out").textContent = JSON.stringify(await r.json(), null, 2);
  };
</script>
Serve it with the CSP header (Node example):
res.writeHead(200, {
  "content-type": "text/html; charset=utf-8",
  "content-security-policy": "frame-ancestors https://www.fanvue.com",
});
The session token is short-lived (~60s) and single-purpose. Use it promptly to get tokens; don’t store it. Once you have the access + refresh tokens, you no longer need the iframe open.

Backend (the delegated flow)

Node 18+ (built-in fetch/crypto), framework-agnostic:
import { createHash, randomBytes } from "node:crypto";

const { PLATFORM, HYDRA, API, CLIENT_ID, CLIENT_SECRET, REDIRECT_URI } = process.env;
const b64url = (buf) => buf.toString("base64url");

// Exchange a platform session token for OAuth tokens (the "offline" flow).
async function authorizeOnBehalf(sessionToken) {
  // 1. PKCE + state — generated and held by YOU.
  const codeVerifier  = b64url(randomBytes(32));
  const codeChallenge = b64url(createHash("sha256").update(codeVerifier).digest());
  const state         = b64url(randomBytes(16));

  // 2. Ask Fanvue to run the authorize as the creator -> get a code back.
  const aobRes = await fetch(`${PLATFORM}/api/v1/app-integrations/authorize-on-behalf`, {
    method: "POST",
    headers: { authorization: `Bearer ${sessionToken}`, "content-type": "application/json" },
    body: JSON.stringify({ code_challenge: codeChallenge, code_challenge_method: "S256", state }),
  });
  if (!aobRes.ok) throw new Error(`authorize-on-behalf ${aobRes.status}: ${await aobRes.text()}`);
  const { code, state: returnedState } = await aobRes.json();
  if (returnedState !== state) throw new Error("state mismatch");

  // 3. Exchange the code with YOUR secret + verifier (client_secret_basic).
  const basic = Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString("base64");
  const tokenRes = await fetch(`${HYDRA}/oauth2/token`, {
    method: "POST",
    headers: { "content-type": "application/x-www-form-urlencoded", authorization: `Basic ${basic}` },
    body: new URLSearchParams({
      grant_type: "authorization_code",
      code,
      redirect_uri: REDIRECT_URI,   // must equal the registered redirect
      code_verifier: codeVerifier,
    }),
  });
  if (!tokenRes.ok) throw new Error(`token exchange ${tokenRes.status}: ${await tokenRes.text()}`);
  return tokenRes.json(); // { access_token, refresh_token, expires_in, scope, ... }
}

// Example route: POST /api/run  { token }
async function handleRun(req, res) {
  const { token } = req.body;                       // the session token from the iframe
  const tokens = await authorizeOnBehalf(token);

  // Persist tokens.refresh_token keyed by creator (decode access_token `sub`)
  // so you can act on their behalf later without the iframe.

  const me = await fetch(`${API}/users/me`, {
    headers: {
      authorization: `Bearer ${tokens.access_token}`,
      "x-fanvue-api-version": "2025-06-26",        // pin an API version
    },
  });
  res.json({ scope: tokens.scope, profile: await me.json() });
}

Using and refreshing tokens later (background work)

Store the refresh token per creator. When the access token expires, mint a new one — no iframe, no session token needed:
async function refresh(refreshToken) {
  const basic = Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString("base64");
  const res = await fetch(`${HYDRA}/oauth2/token`, {
    method: "POST",
    headers: { "content-type": "application/x-www-form-urlencoded", authorization: `Basic ${basic}` },
    body: new URLSearchParams({ grant_type: "refresh_token", refresh_token: refreshToken }),
  });
  if (!res.ok) throw new Error(`refresh ${res.status}: ${await res.text()}`);
  return res.json(); // new access_token (+ usually a rotated refresh_token — store it)
}

Next steps

Publishing Your App

Submit your embedded app for review and go live on the App Store.

API Reference

Every endpoint you can call once you hold an access token.