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

# Building a Fanvue Embedded App

> Register an embedded app, authenticate creators with the @fanvue/builder-sdk SDK, and call the Fanvue API on their behalf, including in the background.

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](/docs/app-store/embedded-apps/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`](https://www.npmjs.com/package/@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](#appendix-the-manual-flow-without-the-sdk).

## Step 1, Register your app

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

<Steps>
  <Step title="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`.
  </Step>

  <Step title="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](/docs/authentication/scopes).
  </Step>

  <Step title="Complete the listing">
    Fill in **App Details** (icon, tagline) and **Publish** (preview image, description, test credentials), then submit for review. See [Publishing Your App](/docs/app-store/publishing-your-app).
  </Step>
</Steps>

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

## Step 3, Authenticate with the SDK (recommended)

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.

<CodeGroup>
  ```bash npm theme={null}
  npm install @fanvue/builder-sdk
  ```

  ```bash pnpm theme={null}
  pnpm add @fanvue/builder-sdk
  ```

  ```bash yarn theme={null}
  yarn add @fanvue/builder-sdk
  ```
</CodeGroup>

<Steps>
  <Step title="Add the session-exchange route">
    ```ts app/api/fanvue/session/route.ts theme={null}
    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.
  </Step>

  <Step title="Authenticate in your embedded page">
    ```tsx app/embedded/page.tsx theme={null}
    "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](#match-the-creators-theme) below.
  </Step>

  <Step title="Call the Fanvue API from your own routes">
    ```ts app/api/me/route.ts theme={null}
    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;
    }
    ```
  </Step>
</Steps>

<Note>
  ##### 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](#appendix-the-manual-flow-without-the-sdk).
</Note>

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

```ts app/api/fanvue/session/route.ts theme={null}
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' | null`, `null` when your app is opened outside Fanvue (no `?theme=` present), so always fall back to a sensible default:

```tsx app/embedded/page.tsx theme={null}
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:

```ts theme={null}
import { getThemeFromUrl } from "@fanvue/builder-sdk";

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

## Environments

|              | Production                | Dev                           |
| ------------ | ------------------------- | ----------------------------- |
| Platform     | `https://www.fanvue.com`  | `https://dev.fanvue.com`      |
| Auth (Hydra) | `https://auth.fanvue.com` | `https://auth.dev.fanvue.com` |
| API          | `https://api.fanvue.com`  | `https://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

| Purpose                               | Request                                                                                                                                                                                     |
| ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Get an authorization code (delegated) | `POST {PLATFORM}/api/v1/app-integrations/authorize-on-behalf`, `Authorization: Bearer <session token>`, body `{ code_challenge, code_challenge_method: "S256", state }` → `{ code, state }` |
| Exchange code for tokens              | `POST {HYDRA}/oauth2/token`, `client_secret_basic`, `grant_type=authorization_code`, `code`, `redirect_uri`, `code_verifier` → `{ access_token, refresh_token, … }`                         |
| Call the Fanvue API                   | `GET {API}/users/me`, `Authorization: Bearer <access token>`                                                                                                                                |
| Refresh later                         | `POST {HYDRA}/oauth2/token`, `client_secret_basic`, `grant_type=refresh_token`, `refresh_token`                                                                                             |

`{PLATFORM}`, `{HYDRA}`, and `{API}` are the [environment base URLs](#environments) 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.

```html theme={null}
<!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):

```javascript theme={null}
res.writeHead(200, {
  "content-type": "text/html; charset=utf-8",
  "content-security-policy": "frame-ancestors https://www.fanvue.com",
});
```

<Warning>
  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.
</Warning>

### Backend (the delegated flow)

Node 18+ (built-in `fetch`/`crypto`), framework-agnostic:

```javascript theme={null}
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:

```javascript theme={null}
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

<CardGroup cols={2}>
  <Card title="Publishing Your App" icon="rocket" href="/docs/app-store/publishing-your-app">
    Submit your embedded app for review and go live on the App Store.
  </Card>

  <Card title="API Reference" icon="book" href="/docs/api-reference/overview">
    Every endpoint you can call once you hold an access token.
  </Card>
</CardGroup>
