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

# Call the API with the SDK client

> Every createFanvueClient method by namespace (chats, checkout, experiences, subscribers, vault, webhooks), with its REST operation, scope and errors.

`createFanvueClient(accessToken, apiBaseUrl, options?)` returns a typed client bound to one access token. Each method maps to one REST operation and returns a `Result`, and the tables list the scope each one needs. The client ships in the core entrypoint, `@fanvue/builder-sdk`, and runs anywhere `fetch` and WebCrypto exist; all you need is an access token.

## Create a client

Pass the access token and `null` to use `https://api.fanvue.com`, or a `fanvue.com` base URL. `options` accepts `apiVersion` (default `2025-06-26`) and `timeoutMs` (default 10,000).

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

const client = createFanvueClient(accessToken, null, { timeoutMs: 5_000 });
```

The token is fixed for the life of the client. An app that needs two tokens, for example a creator token for chats and an app-pooled token for the experience exchange, creates two clients.

Every request carries `Authorization: Bearer`, `X-Fanvue-API-Version` and `cache: "no-store"`. The transport aborts a request after the timeout. It retries at most once, and only for a `GET` that answered `5xx`; timeouts, network failures and non-`GET` methods are never retried.

Inside the Next.js entrypoints, `getAuthenticatedClient` builds the client from the session for you, so route code rarely calls `createFanvueClient` directly.

## Handle errors

Every method resolves to `ok(value)` or `err(ApiError)`, and the error's `code` tells you which arm you have.

| `code` | Fields | Meaning |
| - | - | - |
| `API_REQUEST_FAILED` | `statusCode` (`0`), `message` | No response: network failure or the caller aborted. `getCurrentUser()` also reports error statuses on this arm. |
| `API_TIMEOUT` | `timeoutMs`, `message` | The SDK aborted the request after `timeoutMs`. |
| `API_ERROR_RESPONSE` | `statusCode`, `reason`, `retryAfterSeconds`, `message` | The platform answered non-`2xx`. `reason` is the machine-readable reason when one was sent. `retryAfterSeconds` is parsed from `Retry-After` or `X-RateLimit-Reset`. |
| `API_JSON_PARSE_ERROR` | `rawText`, `message` | A `2xx` body that was not JSON. |
| `API_VALIDATION_ERROR` | `message` | A `2xx` body that failed its schema, or a call the SDK refused locally because its arguments could not be valid. |

`message` is upstream text, so log it and keep it out of response bodies.

```typescript theme={null}
const sent = await client.chats.sendMessage(fanUuid, { text: "Your download is ready." });

if (sent.isErr()) {
  const error = sent.error;
  if (error.code === "API_ERROR_RESPONSE" && error.statusCode === 429) {
    await sleep((error.retryAfterSeconds ?? 1) * 1000);
    return retry();
  }
  if (error.code === "API_ERROR_RESPONSE" && error.statusCode === 401) {
    return reconnectRequired();
  }
  console.error("sendMessage failed", error.code);
  return null;
}

return sent.value.messageUuid;
```

`parseFanvueErrorBody(body)` normalises the platform's error body shapes into `{ code, message }`.

The Next.js error helpers emit three app-facing codes, exported as `FANVUE_APP_ERROR_CODES` and `NON_SESSION_401_CODES`:

| Code | Status |
| - | - |
| `fanvue_reconnect_required` | `401` |
| `fanvue_permission_denied` | `403` |
| `fanvue_api_error` | `502` |

The first two mean the Fanvue grant is gone or lacks a scope while your app session is still valid. Don't treat them as session expiry on the client.

## Current user

| Method | REST operation | Notes |
| - | - | - |
| `getCurrentUser()` | `GET /users/me` | Returns `FanvueUser`: `uuid`, `email`, `handle`, `displayName`, `isCreator`, `avatarUrl`, `bannerUrl`, `createdAt`, `updatedAt`. |

Every other binding calls a `/v0/...` path.

## Chats

| Method | REST operation | Notes |
| - | - | - |
| `chats.sendMessage(userUuid, body)` | `POST /v0/chats/{userUuid}/message` | Scope `write:chat`. Returns `{ messageUuid }`. The body is validated locally first. |
| `chats.listMessages(userUuid, query?)` | `GET /v0/chats/{userUuid}/messages` | Scope `read:chat`. Newest first. A `404` means no conversation with this fan. |

`sendMessage` checks the body against the platform's cross-field rules before sending, so an invalid combination comes back as an `API_VALIDATION_ERROR` with no round trip. `text` is 1 to 5,000 characters (`CHAT_MESSAGE_MAX_CHARS`), and `price` is in USD cents and at least 300 (`MIN_CHAT_MESSAGE_PRICE`). The cross-field rules are:

* a message needs text, media, a GIF or a template
* a GIF excludes media, price and template
* a price needs media to unlock
* a preview needs a priced message with media

`listMessages` marks the conversation read unless you pass `markAsRead: false`. Pass it on every background read, otherwise the creator's unread badge clears on messages nobody saw. `beforeMessageUuid` is a keyset anchor that takes precedence over `page`. Passing `endDate` suppresses `markAsRead` entirely.

To message several fans, send sequentially. A fan who can't be messaged answers `400`, so skip that fan; a `401` should stop the run.

## Checkout links

| Method | REST operation | Notes |
| - | - | - |
| `checkout.createLink(input)` | `POST /v0/checkout-links` | Scope `write:creator`. Answers `201`. |
| `checkout.listLinks(query?)` | `GET /v0/checkout-links` | Scope `read:creator`. |
| `checkout.updateLinkStatus(uuid, status)` | `PATCH /v0/checkout-links/{uuid}` | Scope `write:creator`. `status` is `active` or `disabled`. Reversible. |
| `checkout.deleteLink(uuid)` | `DELETE /v0/checkout-links/{uuid}` | Scope `write:creator`. Terminal: a deleted link cannot be re-enabled. |
| `checkout.listPayments(creatorUserUuid, query?)` | `GET /v0/creators/{creatorUserUuid}/checkout-links/payments` | Scope `read:creator`. No self-scoped route exists; pass the creator's own UUID. Paginated by cursor; `limit` is 1 to 100. |
| `checkout.getPayment(creatorUserUuid, invoiceNumber)` | `GET /v0/creators/{creatorUserUuid}/checkout-links/payments/{invoiceNumber}` | Scope `read:creator`. A `404` covers both "missing" and "another creator's". |

Prices are in USD cents. A link price is `0` or between 300 (`MIN_CHECKOUT_LINK_PRICE`) and 1,000,000 (`MAX_CHECKOUT_LINK_PRICE`).

`classifyCheckoutForbidden(error)` splits a `403` in two:

* `not_enabled`: checkout links are off for this creator, and only Fanvue can turn them on.
* `missing_scope`: the stored token predates `write:creator`, so the creator must reconnect.

For the full flow, see [Create a checkout link](/docs/checkout/create).

## Experiences

| Method | REST operation | Notes |
| - | - | - |
| `experiences.mintPublishRequestToken(params)` | `POST /v0/experiences/request-token` | Scope `write:experience`. Returns the token to forward to the creator surface for Fanvue's confirmation dialog. |
| `experiences.mintUnpublishRequestToken(params)` | `POST /v0/experiences/request-token` | Scope `write:experience`. |
| `experiences.exchangeExperienceToken(launchToken)` | `POST /v0/experiences/token/exchange` | Scope `read:experience`. App-bound: any of the app's stored creator tokens works. Pinned 10 s timeout, one retry on `5xx` only. |

`exchangeExperienceToken` maps every platform answer into the `ok` channel as an `ExperienceExchangeResult`. Its `status` is `entitled`, `denied` (with `reason`), `expired`, `binding_mismatch`, `unavailable`, `rate_limited` or `upstream_error`, and only an unreadable `200` body reaches the `err` channel. To show the right locked screen, `accessModeFromDenialReason(reason)` recovers the access mode from a denial.

### Direct experience writes

These four methods call the experiences write API, which ships together with the server-side operations `GET /v0/experiences`, `POST /v0/experiences`, `PATCH /v0/experiences/{uuid}` and `POST /v0/experiences/{uuid}/unpublish`.

| Method | REST operation | Notes |
| - | - | - |
| `experiences.list(appUuid)` | `GET /v0/experiences?appUuid=` | Scope `read:experience`. Every experience the app holds for this creator, published or not. |
| `experiences.publish(params)` | `POST /v0/experiences` | Scope `write:experience`. Publishing the same `externalExperienceId` again updates it. |
| `experiences.update(experienceUuid, params)` | `PATCH /v0/experiences/{uuid}` | Scope `write:experience`. Only the fields sent change. |
| `experiences.unpublish(experienceUuid, params)` | `POST /v0/experiences/{uuid}/unpublish` | Scope `write:experience`. The record is kept and can be published again. |

Each write resolves to a result whose `status` is either its own success arm or one of three shared outcomes.

| `status` | Returned by | Meaning |
| - | - | - |
| `published` | `publish` | Carries `experience: AppExperience`. |
| `updated` | `update` | Carries `experience: AppExperience`. |
| `unpublished` | `unpublish` | No payload. |
| `acknowledgement_required` | All three | Fans paid for access the write removes. Show the creator `impact`, then retry with `acknowledgement: { impact }`. |
| `blocked` | All three | Fans hold live recurring access. The write cannot proceed from the API; send the creator to Fanvue. |
| `refused` | All three | The platform named a reason from `EXPERIENCE_WRITE_REFUSAL_REASONS`. `message` explains. |

A `403` with no known reason is an `err`. Either the token belongs to another app or it lacks `write:experience`, and the platform's `message` says which.

`PublishExperienceParams` keys price on `accessMode`. `PAID` requires `priceCents` in USD cents and accepts `priceRecurring`, while `FREE`, `SUBSCRIPTION` and `HIDDEN` take no price. For the publish flow and the creator's confirmation dialog, see [Publish an experience](/docs/app-store/experiences/publish).

## Subscribers

| Method | REST operation | Notes |
| - | - | - |
| `subscribers.list(query?)` | `GET /v0/subscribers` | Scope `read:fan`. Hybrid pagination: `page` and `size`, or follow `pagination.nextCursor`. `sortField` and `sortDirection` must travel together. |
| `subscribers.get(userUuid)` | `GET /v0/subscribers/{userUuid}` | Scope `read:fan`. A `404` lands in the `ok` channel as `{ subscribed: false }`. |
| `subscribers.getIdentity(userUuid)` | `GET /v0/subscribers/{userUuid}` | Scope `read:fan`. Narrow projection: `uuid`, `handle`, `displayName`. A `404` is `ok(null)`. |

`get` still returns a subscriber whose `subscription.status` is `cancelled` or `paused`, so check the status before granting access.

Handles, display names and avatar URLs are confidential, and they change. Resolve them when you render, key your own records on `uuid`, and never persist them.

## Vault

| Method | REST operation | Notes |
| - | - | - |
| `vault.createUploadSession(params)` | `POST /v0/media/uploads` | Scope `write:media`. Returns `mediaUuid`, `uploadId`, `partSize`, `maxParts`, `totalParts`. |
| `vault.getUploadPartUrl(uploadId, partNumber)` | `GET /v0/media/uploads/{uploadId}/parts/{partNumber}/url` | Scope `write:media`. Returns `{ url }`, a presigned `PUT` target. |
| `vault.completeUpload(uploadId, parts)` | `PATCH /v0/media/uploads/{uploadId}` | Scope `write:media`. `parts` carry `PartNumber` and the `ETag` response header of each `PUT`. |
| `vault.getMedia(uuid, options?)` | `GET /v0/media/{uuid}` | Scope `read:media`. Pass `variants` or the item comes back with no signed URLs. |
| `vault.listMedia(query?)` | `GET /v0/media` | Scope `read:media`. `size` is clamped to 1 to 50. |
| `vault.getBulkMedia(uuids, variants?)` | `GET /v0/media/bulk` | Scope `read:media`. 1 to 20 uuids (`BULK_MEDIA_MAX_UUIDS`). More is refused locally, never truncated. |
| `vault.listFolders(query?)` | `GET /v0/vault/folders` | Scope `read:media`. |
| `vault.listFolderMedia(folderName, query?)` | `GET /v0/vault/folders/{folderName}/media` | Scope `read:media`. |
| `vault.grantMedia(uuid, params)` | `POST /v0/media/{uuid}/grant` | Scope `write:media`. Idempotent on `(media, consumerId, sourceRef)`. |
| `vault.getEntitledMedia(uuid, options)` | `GET /v0/media/{uuid}/entitled` | Scope `read:media`. Signs URLs only for a `consumerId` who holds a grant. |

Four helpers sit beside the namespace.

| Helper | What it does |
| - | - |
| `pollUntilReady(getMedia, uuid, options?)` | Polls every 1.5 s for up to 300 s by default and resolves `ready`, `error` or `timeout` in the `ok` channel. |
| `bestVariantUrl(item, preference?)` | Picks the first signed URL in the order `main`, `thumbnail`, `thumbnail_gallery`, `blurred`. |
| `resolveBulkMedia` | Drops the uuids of a failed batch. |
| `gateBulkMedia` | Propagates the batch error, for access decisions. |

Signed URLs stay valid only for a window the platform doesn't publish, so don't cache them.

## Webhook subscriptions

| Method | REST operation | Notes |
| - | - | - |
| `webhooks.createSubscription({ url, events })` | `POST /v0/webhooks/subscriptions` | Scope `read:self`. Returns `{ id, signingSecret }`. The secret is shown once and cannot be read back. |
| `webhooks.listSubscriptions()` | `GET /v0/webhooks/subscriptions` | Scope `read:self`. Cannot recover a lost signing secret. |
| `webhooks.deleteSubscription(id)` | `DELETE /v0/webhooks/subscriptions/{id}` | Scope `read:self`. A `404` counts as success. |

To receive deliveries and manage the subscription lifecycle, see [Receive webhooks with the SDK](/docs/app-store/sdk/webhooks).

## Paginate

`paginateOffset(fetchPage, options?)` walks any offset-paginated `/v0` route as an async generator of `Result` pages. It clamps `size` to the platform's 1 to 50 (default 15) and stops after the first `err`. On a `429` it waits the platform's `Retry-After` and resumes the same page, up to 3 waits per walk (`DEFAULT_MAX_RATE_LIMIT_WAITS`), each capped at 60 s.

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

const pages = paginateOffset(
  ({ page, size }) => client.chats.listMessages(fanUuid, { page, size, markAsRead: false }),
  { size: 50 },
);

for await (const page of pages) {
  if (page.isErr()) {
    console.warn("message walk stopped", page.error.code);
    break;
  }
  for (const message of page.value.data) index(message);
}
```

## Upload a file to the vault

An upload takes four moves: open a session, `PUT` each part to its presigned URL, complete the upload, then poll until the platform finishes processing.

```typescript theme={null}
import { pollUntilReady, bestVariantUrl } from "@fanvue/builder-sdk";

async function uploadToVault(file: { name: string; bytes: Uint8Array }) {
  const session = await client.vault.createUploadSession({
    name: file.name,
    filename: file.name,
    mediaType: "image",
    sizeBytes: file.bytes.byteLength,
  });
  if (session.isErr()) return session;

  const { uploadId, mediaUuid, partSize, totalParts } = session.value;
  const parts = [];
  for (let n = 1; n <= (totalParts ?? 1); n += 1) {
    const target = await client.vault.getUploadPartUrl(uploadId, n);
    if (target.isErr()) return target;
    const chunk = file.bytes.slice((n - 1) * partSize, n * partSize);
    const put = await fetch(target.value.url, { method: "PUT", body: chunk });
    parts.push({ PartNumber: n, ETag: put.headers.get("etag") });
  }

  const completed = await client.vault.completeUpload(uploadId, parts);
  if (completed.isErr()) return completed;

  const settled = await pollUntilReady(client.vault.getMedia, mediaUuid);
  if (settled.isErr()) return settled;
  if (settled.value.status !== "ready") return settled;

  return { mediaUuid, url: bestVariantUrl(settled.value.item) };
}
```

A browser can't read the `ETag` header off a cross-origin `fetch` response, so browser uploads need `XMLHttpRequest` or a server-side relay. For the whole media workflow, see [Upload media](/docs/tutorials/uploading-media).

## Shell URLs

`creatorProfileUrl(webOrigin, handle)`, `experienceDetailShareUrl(webOrigin, experienceUuid)` and `experienceShareUrl(template, substitutions)` build links back into Fanvue. Each returns `{ ok: true, url }` or `{ ok: false, refusal }`, and none ever emits a URL with an unresolved placeholder. `webOrigin` is `FANVUE_WEB_ORIGIN`. The template is `FANVUE_EXPERIENCE_URL_TEMPLATE`, and it must contain `{experienceUuid}`.

## See also

* [API reference overview](/docs/v1/api-reference/overview)
* [Errors](/docs/api-reference/errors)


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