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

# Analytics and dialogs through the Fanvue host

> Send analytics events and open host-rendered dialogs from inside a Fanvue iframe with the SDK hooks or connectFanvueBridge.

From inside a Fanvue iframe, the bridge lets your on-platform app send analytics events through Fanvue's pipeline and ask the creator a question in a dialog that Fanvue draws. It's a `MessagePort` channel between your iframe and the Fanvue page hosting it, and it exposes only the capabilities Fanvue has granted your app.

React apps use the hooks in `@fanvue/builder-sdk/react`; other surfaces call `connectFanvueBridge` from `@fanvue/builder-sdk/bridge`. You need a surface running inside Fanvue, set up as in [Run your app inside Fanvue](/docs/app-store/sdk/embedded).

## Capabilities and grants

Fanvue grants capabilities per app; contact Fanvue to request them. Until a capability is granted:

* `isEnabled` and `isDialogAvailable` stay `false`
* `track` does nothing
* `openDialog` resolves `{ status: "unavailable" }`

That means your code can call them unconditionally and fall back to its own UI.

| Capability | Method | React hook | Purpose |
| - | - | - | - |
| `analytics` | `analytics.track` | `useFanvueAnalytics` | Fires an event through the host page's analytics pipeline, attributed to your app and stitched into the viewer's session. No identity data crosses the frame boundary. |
| `dialog` | `dialog.open` | `useDialog` | Opens a confirm or form dialog drawn by Fanvue outside your iframe, with a host-controlled "requested by" line. |

The iframe sandbox blocks `window.alert`, `window.confirm` and `window.prompt`, so `dialog` is how you ask the creator a blocking question in Fanvue's own chrome.

## Send analytics events

`useFanvueAnalytics()` returns `{ track, isEnabled }`. `track(eventName, properties?, options?)` is fire-and-forget, so it never throws and never rejects. In development, a payload the host would reject logs a warning naming the event. In production it's dropped silently.

```tsx theme={null}
"use client";
import { useFanvueAnalytics } from "@fanvue/builder-sdk/react";

export function CheckoutButton() {
  const { track } = useFanvueAnalytics();
  return (
    <button onClick={() => track("checkout_opened", { plan: "pro", seats: 3 })}>
      Upgrade
    </button>
  );
}
```

The host prefixes every name with `embedded_app_`, so the example lands as `embedded_app_checkout_opened`. That prefix means an app can never emit a core platform event.

| Rule | Limit |
| - | - |
| Event name | `/^[a-z0-9_]{1,64}$/`, without the prefix |
| Properties per event | 20 |
| Property key | 1 to 64 characters |
| String value | 256 characters |
| Number value | Finite |
| Boolean value | Allowed |
| Nested values | Rejected |
| Reserved keys | `user_id`, `device_id`, `revenue`, and any key starting with `$` |
| `destination` | `amplitude` or `posthog` (`AnalyticsDestination`). Omitted means `amplitude`. A host that has not enabled the second value delivers to the default. |

The SDK validates the payload before sending, and the host validates it again. A local failure returns `invalid_payload`.

## Open a dialog

`useDialog(options?)` returns `{ openDialog, isDialogAvailable }`. `openDialog(payload)` resolves a `DialogOutcome` and never rejects. Callbacks in `options` fire only while the component is mounted.

```tsx theme={null}
"use client";
import { useState } from "react";
import { useDialog } from "@fanvue/builder-sdk/react";
import { LocalConfirm } from "@/components/local-confirm";

export function DeleteCourseButton({ onDelete }: { onDelete: () => void }) {
  const [showFallback, setShowFallback] = useState(false);
  const { openDialog, isDialogAvailable } = useDialog({ onDialogSuccess: onDelete });

  async function confirmDelete() {
    const outcome = await openDialog({
      variant: "confirm",
      title: "Delete this course?",
      description: "Fans lose access immediately.",
      confirmLabel: "Delete",
      tone: "destructive",
    });
    if (outcome.status === "unavailable") setShowFallback(true);
  }

  return (
    <>
      <button onClick={confirmDelete}>{isDialogAvailable ? "Delete" : "Delete (local)"}</button>
      {showFallback && <LocalConfirm onConfirm={onDelete} onCancel={() => setShowFallback(false)} />}
    </>
  );
}
```

A `confirm` dialog asks a yes or no question. A `form` dialog collects 1 to 10 fields and returns their values.

| Field | `confirm` | `form` | Limit |
| - | - | - | - |
| `title` | Required | Required | 1 to 100 characters |
| `description` | Optional | Optional | 500 characters |
| `confirmLabel`, `cancelLabel` | Optional | Optional | 1 to 40 characters |
| `tone` | Optional | none | `default` or `destructive` |
| `fields` | none | Required | 1 to 10 fields with unique `key` values |

Every field carries `key` (`/^[a-z0-9_]{1,64}$/`), `label` (1 to 80 characters), optional `required` and optional `helperText` (200 characters). The host renders all copy as plain text.

| Field `type` | Extra properties | Value returned |
| - | - | - |
| `text` | `placeholder` (120), `defaultValue` (256), `maxLength` (1 to 256) | string |
| `textarea` | `placeholder` (120), `defaultValue` (2000), `maxLength` (1 to 2000) | string |
| `select` | `options` (1 to 20 of `{ value, label }`), `defaultValue` matching an option | string |
| `checkbox` | `defaultValue` boolean | boolean |

| `DialogOutcome.status` | Meaning |
| - | - |
| `confirmed` | The creator accepted a `confirm` dialog. |
| `submitted` | The creator accepted a `form` dialog. `values` carries the answers. |
| `dismissed` | Cancelled, closed or dismissed by backdrop. |
| `unavailable` | Not inside Fanvue, or `dialog` not granted. Render your own fallback. |
| `error` | The host could not serve the dialog. `error` is a `BridgeRequestError`. |

One dialog is open at a time. A second `openDialog` while one is pending is refused with `busy`.

## Errors

`BridgeRequestError` carries a `code` and a `message`. The host returns five codes, and the SDK adds two of its own.

| `code` | Source | Meaning |
| - | - | - |
| `capability_denied` | Host or SDK | The app was not granted the capability. |
| `invalid_payload` | Host or SDK | The payload failed the schema, or the host's answer could not be read. |
| `rate_limited` | Host | The app exceeded the host's budget for the method. |
| `busy` | Host | A dialog is already open. Only sent for `dialog.open`. |
| `internal` | Host | The host handler failed. |
| `TIMEOUT` | SDK | No answer within the request timeout. A torn-down host surfaces as `TIMEOUT`. |
| `PORT_CLOSED` | SDK | The message could not be posted, usually because the payload was not structured-cloneable. |

## Use the bridge without React

`connectFanvueBridge({ timeoutMs? })` resolves a `Result<FanvueBridge, BridgeConnectError>`. A connected bridge exposes `capabilities`, `has(capability)`, `analytics.track` and `dialog.open`, each returning a `Result`. Calling a capability without checking `has` is safe and returns `capability_denied`.

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

const connection = await connectFanvueBridge();
if (connection.isErr()) {
  console.info("standalone mode:", connection.error.code);
} else {
  const bridge = connection.value;
  if (bridge.has("analytics")) {
    await bridge.analytics.track("page_viewed", { page: "settings" });
  }
  if (bridge.has("dialog")) {
    const answer = await bridge.dialog.open({ variant: "confirm", title: "Publish now?" });
    if (answer.isOk() && answer.value.status === "confirmed") publish();
  }
}
```

Call `connectFanvueBridge` again after the iframe reloads. The host closes the previous port and completes a new handshake.

## Handshake

The SDK runs this handshake for you, and the details matter when a connection fails. Your frame posts `fanvue:bridge:ready` to `window.parent` with `{ v: 1, caps }`, where `caps` names the capabilities this SDK build understands. The host verifies the message came from the mounted iframe at your registered surface URL's origin, then answers `fanvue:bridge:hello` with `{ capabilities }` and transfers a `MessagePort`. All later traffic runs over the port.

| Fact | Value |
| - | - |
| Connect timeout | 3,000 ms (`timeoutMs` option) |
| `ready` re-announce interval | 500 ms until `hello` arrives |
| Per-request timeout | 3,000 ms |
| Dialog timeout | 300,000 ms (`DIALOG_OPEN_TIMEOUT_MS`) |
| Protocol version | `BRIDGE_VERSION` = `1` |

`BridgeConnectError` has two codes:

* `NOT_EMBEDDED` means the page is not in an iframe.
* `TIMEOUT` means no `hello` arrived. Either something other than Fanvue is framing the page, or the host refused the handshake because the frame's origin doesn't match the registered surface URL.

The host also refuses a frame on an opaque origin, such as a sandbox without `allow-same-origin`.

The React hooks share one connection per page. A `TIMEOUT` is retried on the next call, while a connected bridge and `NOT_EMBEDDED` are cached until reload.


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