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

# Publish experiences

> Publish, update and unpublish fan experiences through the creator's confirmation dialog or directly from the API, and handle removing paid access.

Publishing puts an experience on the creator's profile for fans to open. Once it's live you can update or unpublish it, and a guard checks every change that takes away access fans have paid for. You need an on-platform app with a [fan surface](/docs/app-store/experiences/build-the-fan-surface), installed by the creator, and a creator token with `write:experience`.

There are two ways to publish, and they differ in who has the final say.

| | Request token and creator confirmation | Direct API |
| - | - | - |
| Who confirms | The creator, in Fanvue's dialog | Nobody; your `write:experience` grant is enough |
| Who sets prices | The creator; your prices are suggestions | Your app |
| Where it runs | Your creator surface, inside Fanvue | Any server holding the creator's token |

## Request token and creator confirmation

`POST /experiences/request-token` needs `write:experience` and counts against the [default rate limit](/docs/authentication/rate-limits). The body is discriminated on `action`; a publish request takes these fields.

| Field | Rule |
| - | - |
| `action` | `"publish"` |
| `appUuid` | Your app's uuid |
| `externalExperienceId` | Your stable id, 1 to 255 characters |
| `title` | Up to 100 characters |
| `description` | Up to 500 characters |
| `imageUrl` | Optional https URL up to 2048 characters; Fanvue downloads and re-hosts it when the creator confirms |
| `proposedAccessMode` | `FREE`, `SUBSCRIPTION`, `PAID` or `HIDDEN`; prefills the dialog |
| `allowedAccessModes` | Optional, 1 to 4 modes; must include `proposedAccessMode` and hold no duplicates, else 400 |
| `proposedActions` | Optional, up to 10; each has `externalActionId` (1 to 255), `title` (1 to 100), `description` (up to 500) and `suggestedPriceCents` (300 to 50000) |
| `proposedExperienceType` | Optional prefill; omit for `OTHER` |
| `deliveryMode` | `EMBEDDED` or `EXTERNAL` |
| `externalUrl` | For `EXTERNAL` |

`suggestedPriceCents` is only a prefill. The creator sets the amount a fan is charged, and may decline any action.

An unpublish request carries `action: "unpublish"`, `experienceUuid`, `title` (your label for the confirmation dialog) and `appUuid`.

The response is `{ token }`. The token is sealed and bound to your app and the acting creator. It's valid for 20 minutes and isn't single-use.

```bash theme={null}
curl -X POST https://api.fanvue.com/experiences/request-token \
  -H "Authorization: Bearer <token>" \
  -H "X-Fanvue-API-Version: 2025-06-26" \
  -H "Content-Type: application/json" \
  -d '{
    "action": "publish",
    "appUuid": "7c1e4b2a-0d3f-4e8b-9a6c-2f5d8e1b3c47",
    "externalExperienceId": "course-photography-101",
    "title": "Photography 101",
    "description": "Six lessons, shot on film.",
    "imageUrl": "https://example.com/covers/photography-101.jpg",
    "proposedAccessMode": "PAID",
    "allowedAccessModes": ["FREE", "PAID"],
    "proposedActions": [{ "externalActionId": "critique", "title": "Critique my photo", "suggestedPriceCents": 700 }],
    "proposedExperienceType": "VIDEO",
    "deliveryMode": "EMBEDDED"
  }'
```

Your creator surface then posts the token to Fanvue and waits for the reply.

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

window.parent.postMessage({ type: "fanvue:experience:publish-request", token }, "*");

window.addEventListener("message", (event) => {
  if (!isFanvueOrigin(event.origin) || !isPublishResultMessage(event.data)) return;
  if (event.data.status === "published") {
    // Store event.data.experienceId and reconcile against event.data.experience.
    return;
  }
  // The 0.8.0 result schema has no reason field, so read it from the raw message.
  const { reason } = event.data as { reason?: string };
  if (reason === "request_expired" || reason === "request_invalid") {
    // Mint a fresh token and post again.
  } else if (reason === "upstream_refused") {
    // Do not resubmit the same request.
  }
});
```

In the dialog, the creator can:

* edit the title, description and type
* pick an [access mode](/docs/app-store/experiences/overview#access-modes), from the ones `allowedAccessModes` allows
* set the price, prefilled at 5 USD and between 300 and 50000 cents
* tick and price each proposed action

| Reply | Payload |
| - | - |
| `fanvue:experience:publish-result` | `{ status: "published" \| "cancelled", experienceId?, experience?, reason? }` where `experience` is `{ title, description, accessMode, experienceType, priceCents?, priceRecurring? }`, the fields the creator confirmed |
| `fanvue:experience:unpublish-result` | `{ status: "unpublished" \| "cancelled", reason? }` |

A `cancelled` result carries `reason` when the ending was not the creator's own decision. It is absent on a plain dismissal and on every result from a Fanvue build that predates the field, so act on `status` first. `experienceId` and `experience` come only with `published`. Treat a `reason` you don't recognise as a refusal: Fanvue may add values, and the SDK accepts any string.

| `reason` | Meaning |
| - | - |
| `blocked_active_subscriptions` | Fans hold live recurring access, so an unpublish, or a republish that hides the experience, cannot complete; see [Taking paid access away](#taking-paid-access-away) |
| `acknowledgement_declined` | The creator closed the dialog while it was asking them to acknowledge the paid impact |
| `request_expired`, `request_invalid` | Mint a fresh token and post a new request; expiry can land after the creator submits, and their edits are not kept |
| `verification_failed` | Retry later; the token is not known to be bad |
| `upstream_refused` | Fanvue refused the change and wrote nothing; the causes are listed under the table |

`upstream_refused` means one of these:

* the experience is gone, or the app no longer matches it
* the creator has not installed your app
* your app is suspended or archived
* your app is unapproved and the creator is not its owner with [unpublished access](/docs/get-started/test-your-app#install-and-open-your-draft-app)

Check `isFanvueOrigin` on every reply. It accepts only `https` origins on `fanvue.com` or `*.fanvue.com`.

The SDK ships `mintPublishRequestToken`, `mintUnpublishRequestToken`, `isPublishResultMessage`, `isUnpublishResultMessage` and `isFanvueOrigin`. The result does not include the per-action prices the creator set; read the live catalogue with [`GET /experiences/{experienceUuid}/actions`](/docs/app-store/experiences/payments#priced-actions). On 0.8.0 the result schema omits `reason`, so read it from the raw message.

## Direct API

Direct writes set the access mode, prices and actions without a creator dialog, on the strength of the `write:experience` grant. Every call is scoped to your app and to the creator whose token is calling.

| Call | Scope | Behaviour |
| - | - | - |
| `GET /experiences?appUuid=` | `read:experience` | Lists every experience for your app and this creator, published or not, newest first, as `{ data: AppExperience[] }` |
| `POST /experiences` | `write:experience` | Publishes, upserting on `externalExperienceId`; returns `{ experience }` |
| `PATCH /experiences/{experienceUuid}` | `write:experience` | Partial update; only the fields sent change; returns `{ experience }` |
| `POST /experiences/{experienceUuid}/unpublish` | `write:experience` | Body `{ appUuid, acknowledgePaidImpact?, acknowledgedImpact? }`; answers 204 and keeps the row for a republish |

Publish body fields:

| Field | Rule |
| - | - |
| `appUuid` | Your app's uuid |
| `externalExperienceId` | 1 to 255 characters |
| `title` | 1 to 100 characters |
| `description` | Up to 500 characters |
| `imageUrl` | Optional https URL; one that cannot be fetched is dropped |
| `accessMode` | `FREE`, `SUBSCRIPTION`, `PAID` or `HIDDEN` |
| `deliveryMode` | Optional, default `EMBEDDED` |
| `externalUrl` | https; Fanvue, localhost, IP and tunnel hosts answer 400 naming the field |
| `hidden` | Optional; implied by `HIDDEN` |
| `priceCents` | 300 to 50000, required for `PAID` |
| `priceRecurring` | Optional; with `priceCents`, bills monthly |
| `experienceType` | Optional; omit for `OTHER` |
| `actions` | Optional, up to 10; each has `externalActionId`, `title`, `description` and `priceCents` |
| `acknowledgePaidImpact`, `acknowledgedImpact` | Retry fields after a 409 |

The per-action price field differs between the two paths. The request-token body calls it `suggestedPriceCents`, because the creator may change it in the dialog. The direct publish body calls it `priceCents`, because your app sets the amount that is charged.

On `PATCH`, `priceCents` needs `accessMode: "PAID"` in the same call. `actions` re-prices the actions listed and withdraws every other active one, and declaring a new action takes a publish. An experience owned by another app answers 404.

```bash theme={null}
curl -X POST https://api.fanvue.com/experiences \
  -H "Authorization: Bearer <token>" \
  -H "X-Fanvue-API-Version: 2025-06-26" \
  -H "Content-Type: application/json" \
  -d '{
    "appUuid": "7c1e4b2a-0d3f-4e8b-9a6c-2f5d8e1b3c47",
    "externalExperienceId": "course-photography-101",
    "title": "Photography 101",
    "description": "Six lessons, shot on film.",
    "accessMode": "PAID",
    "priceCents": 1500,
    "experienceType": "VIDEO",
    "actions": [{ "externalActionId": "critique", "title": "Critique my photo", "priceCents": 700 }]
  }'
```

An `AppExperience` has the fields `uuid`, `appUuid`, `creatorUuid`, `externalExperienceId`, `title`, `description`, `imageUrl`, `accessMode`, `experienceType`, `deliveryMode`, `externalUrl`, `hidden`, `priceCents`, `priceRecurring` and `publishedAt`. `publishedAt` is `null` while unpublished.

| Status | Body | Meaning |
| - | - | - |
| 403 | `{ error: "app_not_installed" \| "app_not_available", message }`, or a third `error` value when fan experience publishing is not enabled for the creator | A refusal you can act on: the creator's account does not have fan experience publishing enabled, has not installed your app, or your app is not approved, suspended or archived |
| 403 | `{ error }` | Token lacks `write:experience` |
| 403 | `{ message: "Forbidden" }` | `appUuid` is not the calling app |
| 409 | `{ error: "paid_impact_acknowledgement_required" \| "blocked_active_subscriptions", message, impact }` | Fans paid for access the write takes away |

`impact` is `{ affectedFanCount, activeSubscriptionCount, pendingSubscriptionInvoiceCount, oneTimePurchaseCount, pendingPurchaseCount }`. After `paid_impact_acknowledgement_required`, retry with `acknowledgePaidImpact: true` and the `impact` echoed back as `acknowledgedImpact`.

```json theme={null}
{
  "appUuid": "7c1e4b2a-0d3f-4e8b-9a6c-2f5d8e1b3c47",
  "acknowledgePaidImpact": true,
  "acknowledgedImpact": {
    "affectedFanCount": 3,
    "activeSubscriptionCount": 0,
    "pendingSubscriptionInvoiceCount": 0,
    "oneTimePurchaseCount": 3,
    "pendingPurchaseCount": 0
  }
}
```

The SDK exposes `client.experiences.list`, `publish`, `update` and `unpublish`. `PublishExperienceResult` is `published`, `acknowledgement_required`, `blocked` or `refused`.

```ts theme={null}
const result = await client.experiences.publish({ appUuid, externalExperienceId, title, description, imageUrl: null, accessMode: "PAID", priceCents: 1500 });
if (result.isOk() && result.value.status === "acknowledgement_required") {
  const retry = await client.experiences.publish({ ...params, acknowledgement: { impact: result.value.impact } });
}
```

## Taking paid access away

A guard runs on every change that removes paid access, and what it does depends on the kind of access fans hold.

* **Recurring access blocks the change.** When `activeSubscriptionCount` or `pendingSubscriptionInvoiceCount` is above 0, no acknowledgement is possible. Switch the experience to `FREE` and let the subscriptions run out instead.
* **One-off access needs acknowledging.** When `oneTimePurchaseCount` or `pendingPurchaseCount` is above 0, echo every count back. A count that grew since the 409 is a fresh rejection.

Pending invoices older than 7 days are ignored. These changes trigger the guard:

| Trigger | Guarded |
| - | - |
| Unpublish of a published experience | yes |
| Update to `HIDDEN`, or `hidden: true` | yes |
| Republish that hides the experience | yes |
| App uninstall by the creator | yes |
| Settings save that removes the fan surface: switching to off-platform or API-only, or clearing the fan experience URL | yes |
| Developer archive of the app | yes |
| Repointing the fan experience URL or `externalUrl` | no |

The creator's dialog shows up to 50 affected subscriber handles, while the API returns counts only.

## Test before approval

Install your draft app from **App details**, open it as the creator and publish. Other fans can't reach a draft experience until the app is approved.

If the creator's account doesn't have fan experience publishing enabled, writes answer 403 with an `error` saying publishing is not enabled for the creator, and the confirmation dialog leaves out proposed actions.

## See also

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


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