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

# Run your app inside Fanvue (on-platform)

> Build an on-platform app's creator surface with the SDK: framing headers, session exchange, React hooks, theme and the iframe sandbox.

By the end of this page your creator surface loads inside Fanvue, signs the creator in without a login screen, and calls the Fanvue API from your server. Fanvue opens the surface in an iframe with a one-time session token in the URL, and the SDK trades that token for OAuth tokens on your server. The page fans see is a separate surface; for that, follow [Build the fan surface](/docs/app-store/experiences/build-the-fan-surface).

You need:

* an app in the Developer Area with the **On-platform** type available on your account ([Choose your app type](/docs/get-started/choose-your-app-type))
* a Next.js App Router project with React 18 or later, served over `https` with a browser-trusted certificate
* a domain you control, if you configure the app through an App Manifest

## Register your app

<Steps>
  <Step title="Create the app and save its credentials">
    In the Developer Area, click **Create app**. The dialog that opens shows the Client ID and the Client Secret, and the secret is shown only this once. Save it now; after the dialog closes, your only option is **Reset secret** on the **Authentication** tab.
  </Step>

  <Step title="Set the creator surface URL">
    Enter it in **Embed settings** on **App details**, or serve an App Manifest with an on-platform `access` block.

    ```json app-manifest.json theme={null}
    {
      "manifestVersion": 1,
      "access": {
        "type": "embedded",
        "url": "https://your-app.com/embedded"
      },
      "oauth": {
        "scopes": ["read:self", "read:creator"],
        "redirectUris": ["https://your-app.com/oauth/callback"]
      }
    }
    ```

    `access.url` is the creator surface URL Fanvue loads in the iframe. No browser ever visits `oauth.redirectUris[0]`, but it still matters. Fanvue binds the authorisation code to the first registered redirect URI, and the SDK sends `OAUTH_REDIRECT_URI` at exchange, so the two must be the same string. The [`access` reference](/docs/app-store/app-manifest/schema#access) defines every field.
  </Step>

  <Step title="Connect the domain and submit">
    Enter `your-app.com` in **App domain** on the **App details** tab and confirm the **Versions** tab shows **Up to date**. Complete the listing and click **Submit for review**. The `access` block takes effect on submission, and from then on the **Debug** tab previews the surface for you alone.
  </Step>
</Steps>

## Hosting

Serve the creator surface over `https` with a browser-trusted certificate, and serve the manifest at `https://<app domain>/app-manifest.json` with no redirect. The client secret stays on your server.

Fanvue can only frame a page whose `Content-Security-Policy` allows it. `withFanvueHeaders(nextConfig, options?)` adds the headers every surface needs and merges them with the rules your `next.config` already declares. Your own rules come after Fanvue's, so a `Content-Security-Policy` you set yourself wins.

```javascript next.config.mjs theme={null}
import { withFanvueHeaders } from "@fanvue/builder-sdk/nextjs/embedded-app";

export default withFanvueHeaders({
  reactStrictMode: true,
});
```

| Header | Value | Paths |
| - | - | - |
| `Content-Security-Policy` | `frame-ancestors 'self' https://fanvue.com https://*.fanvue.com` (`FANVUE_FRAME_ANCESTORS`) | `/:path*` |
| `Strict-Transport-Security` | `max-age=31536000; includeSubDomains` | `/:path*` |
| `Referrer-Policy` | `strict-origin-when-cross-origin` | `/:path*` |
| `X-Content-Type-Options` | `nosniff` | `/:path*` |
| `Permissions-Policy` | `camera=(), microphone=(), geolocation=(), payment=()` | `/:path*` |
| `Access-Control-Allow-Origin` and the other `Access-Control-*` headers | `*`, `Authorization` and `Content-Type` allowed, `WWW-Authenticate` exposed, 86400 s preflight cache | `/api/:path*` |

The `frame-ancestors` wildcard covers every Fanvue host that embeds apps, including the **Debug** tab preview and the non-`www` shell, so one value serves production and development. The CORS block on `/api/*` answers cross-origin calls to your API routes. A wildcard origin is safe here because sessions travel in the `Authorization` header, never in cookies. Pass `{ includeApiCors: false, apiPathPattern: null, extraAllowedHeaders: null, extraExposedHeaders: null }` only if your app answers CORS itself.

`fanvueHeaderRules(options?)` returns the same rules as an array for a `headers()` you compose by hand.

## Authenticate with the SDK

Install the package, then add one server route and one client component.

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

<Steps>
  <Step title="Add the session exchange route">
    `createConfig()` from the on-platform entrypoint reads the `OAUTH_*` variables plus `FANVUE_PLATFORM_URL`, which defaults to `https://www.fanvue.com`.

    ```typescript app/api/fanvue/session/route.ts theme={null}
    import { createConfig, createSessionExchangeHandler } from "@fanvue/builder-sdk/nextjs/embedded-app";

    export const { POST } = createSessionExchangeHandler(createConfig());
    ```

    The route accepts `POST { "token": "<session token>" }`, runs the exchange, fetches the creator's profile and answers `{ "jwt": "<session JWT>" }`. The JWT is signed with `SESSION_SECRET` and holds the Fanvue tokens.

    | Status | Body | Meaning |
    | - | - | - |
    | `400` | `{ "error": "invalid_request" }` | The body was not `{ token }`. |
    | `401` | `{ "error": "invalid_session_token" }` | The platform rejected the session token: expired, already used or malformed. Reopen the surface from Fanvue to receive a fresh one. |
    | `403` | `{ "error": "consent_required" }` | The creator has not approved, or has revoked, consent for this app. Consent is granted on the Fanvue consent screen; your app cannot create it. |
    | `502` | `{ "error": "exchange_failed" }` | Any other failure, including an `onTokens` callback that threw. |
  </Step>

  <Step title="Exchange the token on the client">
    `useEmbeddedAuth()` reads `?token=` on mount, posts it to `/api/fanvue/session`, and stores the returned JWT in `sessionStorage` under `fanvue:jwt`. `useAuth()` exposes the JWT and `authFetch`, which adds `Authorization: Bearer <jwt>` to your own API calls and stores any `X-Updated-Session` header it receives.

    ```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();

      return (
        <div data-theme={theme ?? "light"}>
          {status === "exchanging" && <p>Connecting to Fanvue</p>}
          {status === "error" && <p>Sign-in failed: {error}</p>}
          {status === "authenticated" && (
            <button onClick={() => authFetch("/api/me")}>Load my profile</button>
          )}
        </div>
      );
    }

    export default function Page() {
      return (
        <AuthProvider>
          <Embedded />
        </AuthProvider>
      );
    }
    ```

    `status` is one of `idle` (no token in the URL and no stored JWT), `exchanging`, `authenticated` or `error`. On `error`, the `error` field holds one of:

    * the route's error string
    * `exchange_failed`, when the response carried neither `error` nor `jwt`
    * `network_error`, when the request never completed

    The hook runs the exchange once per page load, because the session token is single-use.
  </Step>

  <Step title="Call the Fanvue API from your own routes">
    `getAuthenticatedClient({ sessionSecret, config })` from the on-platform entrypoint reads the Bearer JWT, verifies it and returns `{ client, refreshedJwt }`. When the access token was within 30 seconds of expiry, the SDK refreshes it and returns the re-signed JWT as `refreshedJwt`. Set it on the response as `X-Updated-Session` (`HEADER_UPDATED_SESSION`) so `authFetch` stores it.

    ```typescript 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" });
      if (auth.refreshedJwt) res.headers.set(HEADER_UPDATED_SESSION, auth.refreshedJwt);
      return res;
    }
    ```
  </Step>
</Steps>

### Outside Next.js

`useEmbeddedAuth` posts to `/api/fanvue/session` and expects `{ jwt }` back. On another server framework, implement that route with `exchangeSessionToken(config, token)` and `createSessionJwt(secret, payload)` from the core entrypoint, and pass `exchangePath` if the route lives elsewhere.

```tsx theme={null}
const { status } = useEmbeddedAuth({ exchangePath: "/auth/fanvue" });
```

Without React, read the token with `getSessionTokenFromUrl(window.location.href)` and post it yourself. For a server that can't run the SDK at all, follow [the manual flow](#appendix-the-manual-flow).

### Session routes for apps with a token store

`createCreatorSessionHandler(deps)` and `createFanSessionHandler(deps)` are alternative bootstrap routes for apps that keep an encrypted token store and issue their own app sessions. [Tokens, encryption and logging](/docs/app-store/sdk/storage-and-security) covers them alongside the token store.

Both apply a per-IP rate limit through the `RateLimiter` you inject. The defaults are SDK defaults, not platform limits, and `deps.rateLimit` overrides them.

| Handler | Default limit | Constant |
| - | - | - |
| `createCreatorSessionHandler` | 10 requests per minute per IP | `DEFAULT_CREATOR_SESSION_RATE_LIMIT` |
| `createFanSessionHandler` | 30 requests per minute per IP | `DEFAULT_FAN_SESSION_RATE_LIMIT` |

`createInMemoryRateLimiter()` is the process-local default and tracks at most 10,000 keys (`MAX_TRACKED_RATE_LIMIT_KEYS`). On serverless hosts each instance counts separately, so implement `RateLimiter` over a shared store when the limit must hold. `createSessionExchangeHandler` applies no limiter.

## Work on the creator's behalf in the background

`getAuthenticatedClient` refreshes tokens only inside a live Bearer session. To call the API after the iframe closes, persist the refresh token when the exchange completes by passing `onTokens` to `createSessionExchangeHandler`.

```typescript app/api/fanvue/session/route.ts theme={null}
import { tokenSetFromTokenResponse } from "@fanvue/builder-sdk";
import { createConfig, createSessionExchangeHandler } from "@fanvue/builder-sdk/nextjs/embedded-app";
import { saveTokens } from "@/lib/tokens";

export const { POST } = createSessionExchangeHandler(createConfig(), {
  onTokens: async ({ tokens, user }) => {
    await saveTokens(user.uuid, tokenSetFromTokenResponse(tokens));
  },
});
```

`onTokens` receives the raw `TokenResponse` and the `FanvueUser`. If it throws, the exchange fails with `502`, so the creator never gets a session whose tokens weren't stored. Encrypt the tokens at rest and refresh them with compare-and-swap through the SDK's [token store](/docs/app-store/sdk/storage-and-security#store-tokens).

## Match the creator's theme

Fanvue appends the creator's resolved colour scheme to both the creator and fan surface URLs as `?theme=light` or `?theme=dark`. `useEmbeddedAuth` returns it as `theme`, typed `FanvueTheme | null`. It's `null` when the page is opened outside Fanvue, so fall back to a default.

```tsx theme={null}
const { theme } = useEmbeddedAuth();
return <div data-theme={theme ?? "light"}>{children}</div>;
```

Outside React, `getThemeFromUrl(window.location.href)` from the core entrypoint returns the same value. Unlike the session token, the theme parameter is not single-use, so any client code can read it from the URL.

## How authentication works

Fanvue opens your creator surface URL with two query parameters. `?token=` carries a short-lived, single-use **session token**, which proves that this creator is using your app inside Fanvue right now. `?theme=` carries the creator's colour scheme.

Your server trades the session token for OAuth access and refresh tokens through the delegated authorise-on-behalf flow:

1. Your server generates a PKCE verifier and `state`, then calls `POST /api/v1/app-integrations/authorize-on-behalf` on the platform with the session token as a Bearer token and the PKCE challenge.
2. The platform runs the OAuth authorisation as the creator and returns an authorisation code bound to your challenge.
3. Your server exchanges the code at the token endpoint with your client secret and verifier.

Fanvue never sees your client secret, verifier or tokens, and your app never forges a creator's identity. The SDK's `exchangeSessionToken` runs all three steps, and `createSessionExchangeHandler` wraps it in a route.

The platform adds `openid` and `offline_access` to the grant itself, so the tokens you receive include a refresh token. The grant carries the scopes registered on your app in the Developer Area. The `authorize-on-behalf` request sends no scopes, and `OAUTH_SCOPES` applies only to off-platform login.

## What your iframe can and cannot do

Fanvue renders every creator surface and fan surface in a sandboxed iframe. The sandbox is platform policy, and no app can widen it.

| Sandbox token | Present | Effect |
| - | - | - |
| `allow-scripts` | Yes | Scripts run. |
| `allow-forms` | Yes | Form submission fires `submit` and sends the request. |
| `allow-popups` | Yes | `target="_blank"` and `window.open` work, for links to external resources. |
| `allow-same-origin` | Added at render time when your surface is cross-origin to Fanvue, which it always is on the live surfaces | Your page keeps its own origin, storage and cookies. |
| `allow-downloads` | No | Downloads are blocked, including through a popup opened from the frame. |
| `allow-modals` | No | `window.alert`, `window.confirm` and `window.prompt` do nothing. Use [host-rendered dialogs](/docs/app-store/sdk/bridge) or your own UI. |

## Environments

Set the base URLs through the variables `createConfig` reads. Production values are the defaults.

| Service | Production | Development | Variable |
| - | - | - | - |
| Platform | `https://www.fanvue.com` | `https://dev.fanvue.com` | `FANVUE_PLATFORM_URL` |
| Authorisation server | `https://auth.fanvue.com` | `https://auth.dev.fanvue.com` | `OAUTH_ISSUER_BASE_URL` |
| API | `https://api.fanvue.com` | `https://api.dev.fanvue.com` | `API_BASE_URL` |

## Checklist

Check these before you go live.

* PKCE is mandatory. An authorise-on-behalf request without an S256 `code_challenge` is rejected. The SDK generates it.
* `OAUTH_REDIRECT_URI` equals the first redirect URI registered on your app. The redirect URI is never visited.
* A `403 consent_required` means the creator has not approved the in-platform consent screen, or revoked it. Only the Fanvue UI can grant consent.
* The session token is short-lived and single-use. Exchange it immediately, send it only to your own server, and never store it. If a creator leaves the surface open and acts much later, reopening the surface issues a fresh token.
* `state` is verified by the SDK on every exchange. The client secret and PKCE verifier never leave your server.

## Appendix: the manual flow

This is everything `exchangeSessionToken` does, written out for a server that can't run the SDK.

### Endpoints

| Step | Request |
| - | - |
| Get an authorisation code | `POST {PLATFORM}/api/v1/app-integrations/authorize-on-behalf` with `Authorization: Bearer <session token>` and body `{ "code_challenge", "code_challenge_method": "S256", "state" }`. Returns `{ "code", "state" }`. |
| Exchange the code | `POST {AUTH}/oauth2/token` with `client_secret_basic`, `grant_type=authorization_code`, `code`, `redirect_uri`, `code_verifier`. Returns `access_token`, `refresh_token`, `expires_in`, `scope`. |
| Call the API | `GET {API}/users/me` with `Authorization: Bearer <access token>` and `X-Fanvue-API-Version: 2025-06-26`. |
| Refresh later | `POST {AUTH}/oauth2/token` with `client_secret_basic`, `grant_type=refresh_token`, `refresh_token`. |

`{PLATFORM}`, `{AUTH}` and `{API}` are the base URLs in the Environments table.

`redirect_uri` at the token exchange must equal the first redirect URI registered on your app, because the platform binds the code to that value. The authorise-on-behalf endpoint answers these errors:

| Status | `error` | Meaning |
| - | - | - |
| `401` | `missing_bearer_token` | No `Authorization: Bearer` header. |
| `401` | `invalid_session_token` | The session token is expired, used or malformed. |
| `400` | `invalid_request` | The body failed validation. `detail` says why. |
| `404` | `app_not_found` | The token's app has no OAuth client. |
| `412` | `client_has_no_redirect_uri` | The app has no registered redirect URI. |
| `403` | `consent_required` | The creator has not approved consent for this app. |
| `502` | `authorize_failed` | Any other failure. |

### Frontend

Read `token` and `theme` from the URL. Send the token to your own server and nowhere else.

```html theme={null}
<!doctype html>
<meta charset="utf-8" />
<button id="run">Load my profile</button>
<pre id="out"></pre>
<script>
  const params = new URLSearchParams(location.search);
  const token = params.get("token");
  document.documentElement.dataset.theme = params.get("theme") ?? "light";
  document.getElementById("run").onclick = async () => {
    const response = await fetch("/api/run", {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ token }),
    });
    document.getElementById("out").textContent = JSON.stringify(await response.json(), null, 2);
  };
</script>
```

Serve the page with the framing header.

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

### Backend

The server generates PKCE and `state`, asks the platform for a code, then exchanges it. Node 18 or later.

```javascript theme={null}
import { createHash, randomBytes } from "node:crypto";

const { PLATFORM, AUTH, API, CLIENT_ID, CLIENT_SECRET, REDIRECT_URI } = process.env;
const b64url = (buf) => buf.toString("base64url");
const basic = Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString("base64");

async function authorizeOnBehalf(sessionToken) {
  const codeVerifier = b64url(randomBytes(32));
  const codeChallenge = b64url(createHash("sha256").update(codeVerifier).digest());
  const state = b64url(randomBytes(16));

  const codeRes = 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 (!codeRes.ok) throw new Error(`authorize-on-behalf ${codeRes.status}: ${await codeRes.text()}`);
  const { code, state: returnedState } = await codeRes.json();
  if (returnedState !== state) throw new Error("state mismatch");

  const tokenRes = await fetch(`${AUTH}/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,
      code_verifier: codeVerifier,
    }),
  });
  if (!tokenRes.ok) throw new Error(`token exchange ${tokenRes.status}: ${await tokenRes.text()}`);
  return tokenRes.json();
}

async function refresh(refreshToken) {
  const res = await fetch(`${AUTH}/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();
}

export async function handleRun(req, res) {
  const tokens = await authorizeOnBehalf(req.body.token);
  // Store tokens.refresh_token encrypted, keyed by the creator, for background work.
  const me = await fetch(`${API}/users/me`, {
    headers: { authorization: `Bearer ${tokens.access_token}`, "x-fanvue-api-version": "2025-06-26" },
  });
  res.json({ scope: tokens.scope, profile: await me.json() });
}
```

A refresh response can carry a rotated `refresh_token`. Store the new value when it is present, and keep the old one when it is absent; the SDK's session helpers do exactly that. [Refresh tokens rotate and are single-use](/docs/authentication/overview#refresh-tokens-rotate-and-are-single-use) has the rotation rules.

## See also

* [Test your app](/docs/get-started/test-your-app)


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