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

# Build the fan surface

> Serve the fan surface Fanvue loads for an experience: exchange the launch token, branch on entitlement, open as a page or dialog, or hand off externally.

By the end of this page your fan surface turns a launch token into a verified fan session and renders the right screen: the experience for an entitled fan, a locked screen for everyone else. Fanvue loads your fan experience URL in an iframe with a fresh launch token in the query string, and your server exchanges it for the fan's entitlement before rendering anything.

You need:

* an on-platform app whose creator surface already works, as in [Run your app inside Fanvue](/docs/app-store/sdk/embedded)
* a stored app access token with the `read:experience` scope, as in [Store tokens](/docs/app-store/sdk/storage-and-security#store-tokens)
* a public `https` domain of your own for the fan surface

## Declare the URL

Declare your fan experience URL before the first publish. Set it in **Embed settings** in the Developer Area, or in your manifest as `access.fanExperienceUrl` with a `surfaces[]` entry whose `surface` is `fan_experience`. Fans can't open the surface until an experience exists, which is what [Publish experiences](/docs/app-store/experiences/publish) covers.

The URL must be `https` on a public domain of your own. Fanvue refuses Fanvue domains, `localhost`, IP addresses, dev tunnels and preview deploys.

## Query parameters

| Parameter | Values | Meaning |
| - | - | - |
| `token` | opaque string | Server-minted launch token, new on every visit, valid for 10 minutes |
| `theme` | `light` or `dark` | The colour scheme the fan sees on Fanvue |
| `presentation` | `dialog` | Present only when Fanvue opened your surface in a dialog; absent on the page |

The SDK reads the first two with `getSessionTokenFromUrl` and `getThemeFromUrl`. It has no reader for `presentation`, so read that one with `URLSearchParams`.

## Exchange the token

Exchange the token from your server with `POST /experiences/token/exchange`. The call needs the `read:experience` scope, and the token works only with the credentials of the app that owns the experience. The SDK version below also signs a fan session for your own routes.

<CodeGroup>
  ```bash curl theme={null}
  curl -X POST https://api.fanvue.com/experiences/token/exchange \
    -H "Authorization: Bearer <token>" \
    -H "X-Fanvue-API-Version: 2025-06-26" \
    -H "Content-Type: application/json" \
    -d '{ "token": "<launch token from the query string>" }'
  ```

  ```ts app/api/experience/session/route.ts theme={null}
  import {
    createAppSessions,
    createFanvueClient,
    CreatorSessionClaimsSchema,
    FanSessionClaimsSchema,
    fanvueEnv,
    getAnyAppAccessToken,
    isFanvueConfigured,
  } from "@fanvue/builder-sdk";
  import {
    createFanSessionHandler,
    createInMemoryRateLimiter,
  } from "@fanvue/builder-sdk/nextjs/embedded-app";

  import { db } from "@/lib/db"; // your database client
  import { tokenStore } from "@/lib/fanvue"; // your createTokenStoreContext(...)

  const sessions = createAppSessions({
    issuer: "my-app",
    secret: process.env.SESSION_SECRET ?? "",
    creatorTtlSeconds: null,
    fanTtlSeconds: null,
    creatorClaimsSchema: CreatorSessionClaimsSchema,
    fanClaimsSchema: FanSessionClaimsSchema,
  });

  export const POST = createFanSessionHandler({
    sessions,
    appUuid: fanvueEnv().FANVUE_APP_UUID,
    getAppAccessToken: () => getAnyAppAccessToken(tokenStore),
    exchangeExperienceToken: (appToken, launchToken) =>
      createFanvueClient(appToken, null).experiences.exchangeExperienceToken(launchToken),
    hooks: {
      resolveExperienceBinding: async ({ exchanged }) => {
        const course = await db.course.findUnique({
          where: { publicId: exchanged.experience.externalExperienceId },
        });
        if (course === null) {
          return { ok: false, status: 404, code: "course_not_found", message: "Gone" };
        }
        return {
          ok: true,
          claims: {
            typ: "fan",
            fanvueFanUuid: exchanged.fanUuid,
            experienceUuid: exchanged.experience.uuid,
            entitled: true,
            preview: false,
          },
        };
      },
    },
    rateLimiter: createInMemoryRateLimiter(),
    rateLimit: null,
    isConfigured: isFanvueConfigured,
    previewStrategy: null,
    devStrategy: null,
  });
  ```
</CodeGroup>

```json 200 response theme={null}
{
  "experience": {
    "uuid": "e5a1c3d7-9b2f-4e6a-8c4d-1f7b3a9e2d58",
    "appUuid": "7c1e4b2a-0d3f-4e8b-9a6c-2f5d8e1b3c47",
    "creatorUuid": "b3f9d2e1-6a4c-4f7e-8d1b-9c0a2e5f7d31",
    "externalExperienceId": "course-photography-101",
    "title": "Photography 101",
    "description": "Six lessons, shot on film.",
    "accessMode": "PAID",
    "experienceType": "VIDEO",
    "hidden": false
  },
  "fanUuid": "4d8f2b6e-1c3a-4b9d-a7e5-6f0c2d8b1a93",
  "entitlement": { "isEntitled": true, "mode": "PAID", "reason": "purchased" },
  "walletBalance": 2500
}
```

`walletBalance` is the fan's wallet balance in USD cents, or `null`. `null` means the fan has no wallet at all, not an empty one, so don't offer a wallet charge in that case. The balance is a snapshot, never an authorisation.

| Status | Body | Meaning |
| - | - | - |
| 400 | `{ message: "Invalid or expired experience token" }`, or the version and validation error body | Token malformed, expired or minted by another environment |
| 401 | `{ error }` | Missing or invalid bearer token |
| 403 | `{ error }` | Token lacks `read:experience` |
| 403 | `{ message: "Fan is not entitled to this experience", reason }` | Fan not entitled; the payload is withheld and `reason` is a denial value |
| 403 | `{ message }` with no `reason` | Token minted for another app |
| 404 | `{ message: "Experience not found" }` | Experience unpublished, or app unavailable to the creator |
| 502 | `{ message }` | Upstream exchange failed; retry once |
| 503 | `{ message }` | Developer API upstream not configured |

`HIDDEN` experiences skip the entitlement check at exchange, because holding a valid token is the share-link capability. Their `entitlement.reason` reads `hidden_token_exchange`.

The SDK differs from the raw endpoint in four ways:

* `createFanSessionHandler` answers a denial as 200 `{ entitled: false, accessMode, reason }`, so your page can render a locked screen.
* It limits the route to 30 requests per minute per IP. The creator session route allows 10.
* `client.experiences.exchangeExperienceToken` returns one of seven outcomes: `entitled`, `denied`, `expired`, `binding_mismatch`, `unavailable`, `rate_limited` and `upstream_error`.
* The SDK schema drops `walletBalance`, so call the endpoint directly if you need the balance.

Protect every later request with `requireFanSession(request, { sessions })`, which rejects a missing or revoked fan session with 401.

## Branch on entitlement

Branch on `isEntitled`, not on `reason`, because the list of reasons grows without an API version change.

| `isEntitled` | `reason` values |
| - | - |
| `true` | `owner`, `free`, `entitled_via_free`, `entitled_via_share`, `entitled_via_subscription`, `subscribed`, `purchased`, `app_subscribed`, `purchased_before_mode_change`, `app_subscribed_before_mode_change`, `hidden_token_exchange` |
| `false` | `not_subscribed`, `not_purchased`, `no_share_grant` |

Fanvue reads purchases from the payment ledger on every launch, so a refund or chargeback revokes access on the fan's next launch.

## Sandbox and headers

Fanvue renders your surface with the sandbox `allow-scripts allow-forms allow-popups allow-same-origin`. It adds `allow-same-origin` at render time, after checking that your origin isn't a Fanvue origin, so your cookies, storage and same-origin API calls work.

`allow-modals` and `allow-downloads` are absent. `window.alert` and `window.confirm` do nothing, and downloads are blocked inside the frame and in any tab it opens.

Your response must carry `Content-Security-Policy: frame-ancestors 'self' https://fanvue.com https://*.fanvue.com`. [Hosting](/docs/app-store/sdk/embedded#hosting) lists the full header set.

## Page or dialog

When a fan clicks an experience card on a chat or a profile, `presentation` on your `fan_experience` surface decides whether it opens as a full page or in a dialog over the chat or profile.

```json app-manifest.json excerpt theme={null}
{
  "surface": "fan_experience",
  "presentation": { "type": "dialog", "desktopWidth": 360, "desktopHeight": 420 }
}
```

* `type` is `page` (the default) or `dialog`.
* `desktopWidth` and `desktopHeight` are integers from 240 to 1600 CSS pixels, both or neither. Both omitted means as large as the viewport allows.
* Phones always show a dialog full screen.
* On desktop the sizes describe your content. Fanvue adds its own chrome and clamps the dialog to the viewport, so measure your real size from the window rather than assuming the declared one.
* Direct links and the detail page always open the page.

To close the dialog from inside, post `{ type: "fanvue:experience:close-request" }` to `window.parent`. Fanvue honours it only in a dialog, under the same origin and rate rules as the other fan bridges. Send it when the fan presses Escape inside your frame, because key events in a cross-origin iframe never reach Fanvue.

```ts theme={null}
const params = new URLSearchParams(window.location.search);
const isDialog = params.get("presentation") === "dialog";

if (isDialog) {
  window.addEventListener("keydown", (event) => {
    if (event.key === "Escape") {
      // The message carries nothing sensitive, so any parent origin may receive it.
      window.parent.postMessage({ type: "fanvue:experience:close-request" }, "*");
    }
  });
}
```

Without a manifest, set the same thing in the Developer Area. **Embed settings** has an **Open as** select with **Page** or **Dialog**, and for a dialog a choice between as large as possible and **Fixed size**. When your manifest manages the surface, those fields are read-only and point you to `presentation` in the manifest.

## External delivery

`EXTERNAL` experiences show an **Open** button with a note that your app opens in a new tab outside Fanvue. The fan's click opens `externalUrl` in a new tab with `?token=` appended. Exchange the token the same way; nothing else differs.

## Test before approval

Install your draft app from **App details**, open it as the creator and publish an experience. [Test your app](/docs/get-started/test-your-app) walks through the install flow, and [Test before approval](/docs/app-store/experiences/publish#test-before-approval) explains who can reach a draft experience.


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