---
title: "Embed API Reference"
nav_title: "Embed API"
description: "Embed Perspective interviews programmatically using the SDK, iframe, or JavaScript API."
tags: ["embed conversation", "website widget", "JavaScript SDK", "React SDK", "inline interview", "npm", "Google Tag Manager", "Google Analytics"]
date: "2026-10-08"
nav_order: 5
nav_display: true
---

# Embed API Reference

The Perspective Embed SDK lets you drop interviews into any web page with a script tag and a data attribute. For more control, use the JavaScript API to create and manage embed instances programmatically.

For the user-facing setup guide (choosing embed types, previewing, copying snippets from the dashboard), see [Embed Interviews on Your Website or App](/docs/guide/collect/embed-interviews).

## Quick Start

Add the SDK script and a trigger element to your page:

```html
<div data-perspective-widget="RESEARCH_ID"></div>
<script src="https://getperspective.ai/v1/perspective.js"></script>
```

Replace `RESEARCH_ID` with the ID of your perspective (visible in the dashboard URL or returned by the [Create Perspective API](/docs/build/api-overview#create-a-perspective)).

The script loads asynchronously and initializes all trigger elements on the page automatically.

## Embed Types

Each embed type uses a different data attribute on the trigger element:

| Type | Attribute | Element | Description |
|------|-----------|---------|-------------|
| **Widget** | `data-perspective-widget` | `<div>` | Inline embed that renders directly in the page flow. |
| **Popup** | `data-perspective-popup` | `<button>` | Modal overlay centered on screen. |
| **Slider** | `data-perspective-slider` | `<button>` | Side panel that slides in from the edge. |
| **Float** | `data-perspective-float` | `<div>` | Floating chat bubble anchored to the corner. |
| **Fullpage** | `data-perspective-fullpage` | `<div>` | Full-viewport interview experience. |

Button-based types (Popup, Slider) require a `<button>` element. The user clicks it to open the interview.

### Examples

**Inline widget:**
```html
<div data-perspective-widget="RESEARCH_ID"></div>
```

**Popup triggered by a button:**
```html
<button data-perspective-popup="RESEARCH_ID">
  Share your feedback
</button>
```

**Slider triggered by a button:**
```html
<button data-perspective-slider="RESEARCH_ID">
  Open interview
</button>
```

**Floating chat bubble:**
```html
<div data-perspective-float="RESEARCH_ID"></div>
```

## URL Parameters

Pass parameters via the `data-perspective-params` attribute as comma-separated `key=value` pairs:

```html
<div
  data-perspective-widget="RESEARCH_ID"
  data-perspective-params="source=homepage,user_id=123,plan=pro"
></div>
```

### Built-In Parameters

| Parameter | Type | Description |
|-----------|------|-------------|
| `email` | string | Pre-fill participant email. Also stored as an Input Param. |
| `name` | string | Pre-fill participant name. Also stored as an Input Param. |
| `returnUrl` | URL | Redirect URL after interview completion. |
| `voice` | `"0"` | Set to `"0"` to disable voice mode (text-only). |
| `scroll` | `"0"` | Set to `"0"` to disable scroll mode. |
| `hideProgress` | `"true"` | Hide the progress bar. |
| `hideGreeting` | `"true"` | Hide the welcome greeting at the start. |
| `hideBranding` | `"true"` | Hide the branding header (logo and company name). |
| `enableFullScreen` | `"true"` / `"false"` | Show or hide the fullscreen button. Defaults to `"true"`. |

Any unrecognized parameters are treated as custom key-value pairs. They are passed through to webhooks and data exports, making them useful for attribution and tracking (e.g., `campaign=summer2024`).

Custom parameters have no application-specific count or key-name length cap. Total URL and request size limits still apply. Values in agent prompts and MCP batch transcripts are sanitized and truncated to 50 characters. Stored values, exports, and webhooks retain the decoded URL values. See [Custom parameter limits](/docs/guide/design/url-parameters#custom-parameter-limits) for details.

The SDK also forwards search parameters from the parent page URL into the embed iframe, except for SDK-owned parameters such as `embed`, `embed_type`, `theme`, and `brand.*` color keys. Parameters passed explicitly through `data-perspective-params` or the JavaScript `params` option override forwarded parent-page values. Custom context values become Input Params, visible in the **Input** tab of conversation results. For details, see [Track Input Params](/docs/guide/collect/track-participant-context).

## Theming

### Color Mode

Force a color mode with `data-perspective-theme`:

```html
<div
  data-perspective-widget="RESEARCH_ID"
  data-perspective-theme="dark"
></div>
```

Accepted values: `dark`, `light`, `system` (default, follows OS preference).

### Brand Colors

Override brand colors with `data-perspective-brand` (light mode) and `data-perspective-brand-dark` (dark mode):

```html
<div
  data-perspective-widget="RESEARCH_ID"
  data-perspective-brand="primary=#7c3aed,bg=#ffffff"
  data-perspective-brand-dark="primary=#a78bfa,bg=#1a1a1a"
></div>
```

Available color tokens:

| Token | Description |
|-------|-------------|
| `primary` | Primary accent color — applied to buttons, the progress bar, links, the voice mic, and focus rings. |
| `bg` | Background behind the interview card. Only visible when no background scene is set; otherwise the scene covers it. |

Other tokens (such as `secondary` and `text`) are not applied and are ignored.

## Widget Appearance

The inline **Widget** renders as a centered, framed card by default — bordered, rounded, with a soft shadow and a themed background — so it looks finished without any custom CSS. The widget adapts to how you size its container:

- **Drop it in without sizing** and it renders as a centered card (default max width `480px`, min height `640px`).
- **Size the container yourself** — an explicit height, `height: 100%`, or a flex/grid cell — and it fills that space edge to edge with no framing.

Override the auto-detection and individual card styles with `data-perspective-frame`, a comma-separated list of `key=value` pairs:

```html
<div
  data-perspective-widget="RESEARCH_ID"
  data-perspective-frame="layout=card,maxWidth=720px,radius=4px,shadow=none,bg=#ffffff"
></div>
```

| Key | Description | Default |
|-----|-------------|---------|
| `layout` | `card` to always frame, `fill` to always fill edge to edge. Omit to auto-detect from the container's sizing. | auto |
| `maxWidth` | Card max width, e.g. `720px` or `none`. Card layout only. | `480px` |
| `minHeight` | Minimum height, e.g. `720px`. Applies in both layouts. | `640px` card / `500px` fill |
| `radius` | Card `border-radius`, e.g. `4px`. | theme radius |
| `border` | Card `border` shorthand, e.g. `1px solid #ddd` or `none`. | themed `1px` border |
| `shadow` | Card `box-shadow`, e.g. `none`. | soft shadow |
| `bg` | Card `background`, e.g. `#ffffff`. | theme background |

Every key except `layout` maps to a `--perspective-widget-*` CSS custom property (`--perspective-widget-max-width`, `--perspective-widget-min-height`, `--perspective-widget-radius`, `--perspective-widget-border`, `--perspective-widget-shadow`, `--perspective-widget-bg`), so you can also set these from your own stylesheet. `layout` has no CSS variable — it only controls the card-vs-fill decision. Inline `data-perspective-frame` values take precedence over stylesheet variables, which take precedence over the built-in defaults.

With the JavaScript API or NPM SDK, pass these as a `frame` object. The field names match the attribute keys above, except the background field is `background` (the shorter `bg` is only an alias accepted inside the `data-perspective-frame` string):

```javascript
Perspective.createWidget(container, {
  researchId: "RESEARCH_ID",
  frame: { layout: "card", maxWidth: "720px", shadow: "none", background: "#ffffff" },
});
```

In React, pass the `frame` prop to `<Widget>` with the same fields:

```tsx
<Widget researchId="RESEARCH_ID" frame={{ layout: "card", maxWidth: "720px" }} />
```

Frame options apply to the inline Widget only. Popup, Slider, Float, and Fullpage embeds manage their own layout.

## Advanced Attributes

Use these attributes when you need more control over SDK behavior:

| Attribute | Applies To | Description |
|-----------|------------|-------------|
| `data-perspective-frame` | Widget | Controls the inline widget's card vs. fill layout and card styling (max width, min height, border, radius, shadow, background). See [Widget Appearance](#widget-appearance). |
| `data-perspective-no-style` | Popup, Slider, Float | Prevents the SDK from applying default button styling to the trigger or launcher. |
| `data-perspective-disable-close` | Popup, Slider | Hides close controls and disables overlay click / Escape close behavior. |
| `data-perspective-slider-mode` | Slider | Set to `push` when the slider should take its own page column instead of overlaying the page. |
| `data-perspective-launcher-icon` | Float | Sets the launcher icon to `default`, `avatar`, or an image URL. |
| `data-perspective-launcher-style` | Float | Semicolon-separated CSS declarations for the float launcher, such as `width:64px;height:64px;border-radius:50%`. |
| `data-perspective-launcher-class` | Float | Adds CSS classes to the float launcher while keeping SDK classes. |
| `data-perspective-teaser` | Float | Set to `"false"` to disable the welcome teaser (bubble, chime, and notification dot). See [Welcome Teaser](#welcome-teaser-float-only). |
| `data-perspective-teaser-delay` | Float | Milliseconds after mount before the teaser bubble appears. Defaults to `3000`. |
| `data-perspective-teaser-sound` | Float | Set to `"false"` to mute the teaser chime while keeping the bubble. |
| `data-perspective-datalayer` | All SDK embeds | Set to `"false"` to stop this embed sending events to Google Tag Manager. See [Google Tag Manager and Google Analytics](#google-tag-manager-and-google-analytics). |
| `data-perspective-chat` | Float | Legacy alias for `data-perspective-float`. Prefer `data-perspective-float` for new embeds. |

## Slider Modes

Sliders support two display modes:

- **Overlay** - the default. The slider floats above the page with a backdrop, and outside clicks can close it.
- **Push** - the slider occupies its own column and shifts the page content aside. The rest of the page stays interactive, and outside clicks do not close the slider.

Push mode falls back to overlay on narrow screens where there is not enough room to keep the page usable.

For script embeds:

```html
<button
  data-perspective-slider="RESEARCH_ID"
  data-perspective-slider-mode="push"
>
  Ask a question
</button>
<script src="https://getperspective.ai/v1/perspective.js"></script>
```

For the JavaScript API:

```javascript
Perspective.openSlider({
  researchId: "RESEARCH_ID",
  sliderMode: "push",
});
```

For React:

```tsx
const { open } = useSlider({
  researchId: "RESEARCH_ID",
  sliderMode: "push",
});
```

## Auto-Open (Popup Only)

Trigger a popup automatically without a user click:

```html
<div
  data-perspective-popup="RESEARCH_ID"
  data-perspective-auto-open="timeout:5000"
  data-perspective-show-once="session"
  style="display:none"
></div>
```

**Auto-open triggers:**
- `timeout:N` -- Opens the popup after `N` milliseconds.
- `exit-intent` -- Opens when the user moves their cursor toward the browser chrome (desktop only).

**Show-once options:**
- `session` -- Show once per browser session.
- `visitor` -- Show once per visitor (persisted in localStorage).
- `false` -- Show every time.

## Welcome Teaser (Float Only)

Float embeds can show a **welcome teaser** — a message bubble that appears above the launcher button after a short delay, along with a chime sound and a notification dot. The teaser text comes from the interview's `welcomeMessage`.

Control the teaser with a `teaser` object through the JavaScript API or NPM SDK:

```javascript
Perspective.createFloatBubble({
  researchId: "RESEARCH_ID",
  welcomeMessage: "Have a question? I'm here to help.",
  teaser: {
    enabled: true, // false disables the whole welcome sequence (bubble, chime, dot)
    delay: 5000, // ms after mount before the teaser appears (default: 3000)
    sound: false, // mute the chime (default: true)
  },
});
```

- `enabled: false` turns off the teaser bubble, the chime sound, and the notification dot entirely.
- `delay` sets when the teaser bubble appears, in milliseconds.
- `sound: false` mutes the chime while keeping the teaser bubble.

For script embeds, set the teaser data attributes on the trigger element:

```html
<!-- Delay the teaser to 5s and mute the chime -->
<div
  data-perspective-float="RESEARCH_ID"
  data-perspective-teaser-delay="5000"
  data-perspective-teaser-sound="false"
></div>

<!-- Disable the welcome sequence entirely -->
<div
  data-perspective-float="RESEARCH_ID"
  data-perspective-teaser="false"
></div>
```

In React, pass the `teaser` prop to `<FloatBubble>` (or the `useFloatBubble` hook):

```tsx
<FloatBubble
  researchId="RESEARCH_ID"
  welcomeMessage="Have a question? I'm here to help."
  teaser={{ delay: 10000, sound: false }}
/>
```

## JavaScript API

For programmatic control, use the global `Perspective` object:

```javascript
// Create an inline widget
const container = document.querySelector("#perspective-widget");
const widget = Perspective.createWidget(container, {
  researchId: "RESEARCH_ID",
  params: { source: "app", user_id: "123" },
  onReady: () => console.log("Loaded"),
  onSubmit: () => console.log("Completed"),
  onNavigate: (url) => window.location.href = url,
  onClose: () => console.log("Closed"),
  onError: (err) => console.error(err),
});

// Open a popup
const popup = Perspective.openPopup({
  researchId: "RESEARCH_ID",
  onClose: () => console.log("Closed"),
});

// Open a slider
const slider = Perspective.openSlider({
  researchId: "RESEARCH_ID",
});

// Create a floating chat bubble
const float = Perspective.createFloatBubble({
  researchId: "RESEARCH_ID",
});

// Create a full-viewport embed
const fullpage = Perspective.createFullpage({
  researchId: "RESEARCH_ID",
});

// Clean up without changing persisted open/closed state
widget.unmount();

// Explicitly close and persist closed state for restorable embeds
popup.destroy();
```

All creation functions return a handle with `unmount()`, `destroy()`, and `update()` methods. Use `unmount()` for framework cleanup or route changes. Use `destroy()` when you intentionally want to close the embed; popup, slider, and float embeds can persist that closed state. Float handles also expose `open()`, `close()`, `toggle()`, and `isOpen`.

The browser global also exposes `Perspective.mount(containerOrSelector, config)` for selector-based mounting, `Perspective.init(config)` for non-widget embed types, `Perspective.destroy(researchId)`, `Perspective.destroyAll()`, `Perspective.autoInit()`, `Perspective.configure(config)`, and `Perspective.getConfig()`.

`Perspective` can also be called as a function with a command name, for example `Perspective("autoInit")`. Calls made before the script loads are queued and run in order once it does, which is how the [Google Tag Manager tag](#install-with-google-tag-manager) works. See [Commands](#commands) for the list.

### Callbacks

| Callback | Arguments | Description |
|----------|-----------|-------------|
| `onReady` | -- | Embed has loaded and is ready for interaction. |
| `onSubmit` | `data: { researchId: string }` | Participant completed the interview. |
| `onNavigate` | `url: string` | Embed requests a page navigation. If not provided, defaults to `window.location.href` (full page reload). |
| `onClose` | -- | User closed the embed (popup, slider, or float). |
| `onError` | `error: Error` | An error occurred during the embed lifecycle. |
| `onAuth` | `data: { researchId: string; token: string }` | Embed auth completed and returned a token for custom storage. |

## NPM JavaScript SDK

For TypeScript or bundler-based apps that do not need React components, install `@perspective-ai/sdk`:

```bash
npm install @perspective-ai/sdk
```

```ts
import {
  createWidget,
  openPopup,
  openSlider,
  createFloatBubble,
  createFullpage,
  configure,
  fetchEmbedConfig,
} from "@perspective-ai/sdk";

configure({ host: "https://getperspective.ai" });

const container = document.querySelector<HTMLElement>("#perspective-widget");
const widget = createWidget(container, {
  researchId: "RESEARCH_ID",
  params: { source: "app", user_id: "123" },
});

const popup = openPopup({ researchId: "RESEARCH_ID" });
const slider = openSlider({ researchId: "RESEARCH_ID" });
const float = createFloatBubble({ researchId: "RESEARCH_ID" });
const fullpage = createFullpage({ researchId: "RESEARCH_ID" });

const config = await fetchEmbedConfig("RESEARCH_ID");

widget.unmount();
popup.destroy();
float.open();
float.close();
```

`createWidget` accepts an `HTMLElement | null`. If you want selector-based mounting, use the browser global `Perspective.mount("#selector", config)` or resolve the element before calling the NPM function.

## React SDK

For React and Next.js apps, use `@perspective-ai/sdk-react` instead of the script tag. It provides typed components and hooks with full lifecycle control.

### Installation

```bash
npm install @perspective-ai/sdk-react
```

### Widget (Inline Embed)

Renders the interview directly inside a container element:

```tsx
import { Widget } from "@perspective-ai/sdk-react";

function FeedbackWidget() {
  return (
    <Widget
      researchId="RESEARCH_ID"
      params={{ source: "app", user_id: "123" }}
      onReady={() => console.log("Loaded")}
      onSubmit={() => console.log("Completed")}
    />
  );
}
```

The `Widget` component accepts all standard `div` props (`className`, `style`, etc.) for sizing and layout, plus a `frame` prop to control its card appearance — see [Widget Appearance](#widget-appearance).

### Popup

Use the `usePopup` hook for modal overlays:

```tsx
import { usePopup } from "@perspective-ai/sdk-react";

function FeedbackButton() {
  const { open, isOpen } = usePopup({
    researchId: "RESEARCH_ID",
    onSubmit: () => console.log("Completed"),
  });

  return <button onClick={open}>Give Feedback</button>;
}
```

### Slider

Use the `useSlider` hook for a side panel that slides in from the edge:

```tsx
import { useSlider } from "@perspective-ai/sdk-react";

function SliderTrigger() {
  const { open } = useSlider({ researchId: "RESEARCH_ID" });
  return <button onClick={open}>Open Survey</button>;
}
```

### Float Bubble

A floating chat bubble anchored to the corner of the page:

```tsx
import { FloatBubble } from "@perspective-ai/sdk-react";

function App() {
  return <FloatBubble researchId="RESEARCH_ID" onSubmit={handleSubmit} />;
}
```

Pass a `welcomeMessage` to show a teaser bubble above the launcher, and a `teaser` prop to control its timing and chime — see [Welcome Teaser](#welcome-teaser-float-only).

### Fullpage

Takes over the entire viewport:

```tsx
import { Fullpage } from "@perspective-ai/sdk-react";

function InterviewPage() {
  return <Fullpage researchId="RESEARCH_ID" />;
}
```

### Auto-Open

Trigger a popup automatically after a delay or on exit intent:

```tsx
import { useAutoOpen } from "@perspective-ai/sdk-react";

function AutoSurvey() {
  useAutoOpen({
    researchId: "RESEARCH_ID",
    trigger: { type: "timeout", delay: 5000 },
    showOnce: "session",
  });
  return null;
}
```

### React Callbacks

All components and hooks accept the same callback props as the JavaScript API: `onReady`, `onSubmit`, `onNavigate`, `onClose`, and `onError`. See the [Callbacks](#callbacks) table above for details.

React helpers also include `useFloatBubble`, `useEmbedConfig`, and `useThemeSync` for headless float control, config fetching, and theme synchronization.

## Google Tag Manager and Google Analytics

When the page already has a `window.dataLayer`, as every page running Google Tag Manager does, SDK embeds push what visitors do with the conversation to it. You can use those events to count finished conversations as conversions in Google Analytics. This works the same for the script tag, the npm package, the React package, and the Tag Manager install below.

On a page without one, the SDK sends nothing and doesn't create `window.dataLayer`. A [direct iframe embed](#direct-iframe-embed) doesn't load the SDK, so it doesn't send these events.

### Install with Google Tag Manager

You can add a conversation to your site from Tag Manager without changing your site's code. The tag works on regular websites and on single-page apps that change pages without a full reload.

The quickest way to get the tag is from the dashboard. Open your perspective, choose **Invite participants > Embed**, pick the embed type, and open **Google Tag Manager** on the Code tab. The copied tag has your perspective's ID and embed settings filled in, and it removes the conversation on pages where it no longer runs (see below).

1. In Tag Manager, go to **Tags > New > Tag Configuration > Custom HTML**.
2. Paste the tag from the dashboard. To write it yourself instead, start from the minimal tag below.
3. In the minimal tag, replace `RESEARCH_ID` on the last line with the ID of your perspective, and `float` with the embed type you want (see the table below).

```html
<script>
  (function (w, d, n, researchId, embedType) {
    w[n] =
      w[n] ||
      function () {
        (w[n].q = w[n].q || []).push(arguments);
      };
    var attr = "data-perspective-" + embedType;
    if (!d.querySelector("[" + attr + '="' + researchId + '"]')) {
      var el = d.createElement("div");
      el.setAttribute(attr, researchId);
      (d.body || d.documentElement).appendChild(el);
    }
    w[n]("autoInit");
    if (d.getElementById("perspective-sdk")) return;
    var s = d.createElement("script");
    s.id = "perspective-sdk";
    s.async = true;
    s.src = "https://getperspective.ai/v1/perspective.js";
    (d.head || d.documentElement).appendChild(s);
  })(window, document, "Perspective", "RESEARCH_ID", "float");
</script>
```

| Type | What visitors see | Notes |
|------|-------------------|-------|
| `float` | A chat bubble in the corner of the screen. | Works as is. |
| `fullpage` | The conversation fills the whole page. | Works as is. |
| `widget` | The conversation inline in the page. | The tag adds it at the bottom of the page. To place it elsewhere, add `<div data-perspective-widget="RESEARCH_ID"></div>` where you want it. |
| `popup` | A button that opens a modal. | Add the button to your page yourself, for example `<button data-perspective-popup="RESEARCH_ID">Share feedback</button>`. |
| `slider` | A button that opens a side panel. | Add the button to your page yourself, for example `<button data-perspective-slider="RESEARCH_ID">Share feedback</button>`. |

If the page already has a `data-perspective-*` element with the same ID and type, the tag uses it instead of adding another one.

Then choose when the tag runs:

1. Add a **DOM Ready** trigger that fires on all pages, or only on the pages that should show the conversation. Don't use **All Pages** or another **Page View** trigger. Those fire before the page's HTML has loaded, so the tag can't find a `data-perspective-*` element you placed yourself, and the conversation appears in the wrong place.
2. If your site changes pages without a full reload, also add a **History Change** trigger with the same page filter, so the tag runs again on each new page where the conversation belongs.
3. Use **Preview** to check the conversation appears where you expect, then **Publish**.

The tag can run before the Perspective script finishes downloading. Its calls wait and run once the script is ready. Running the tag again, for example on a History Change, doesn't show the conversation twice. If your app replaced the page content, the conversation moves to the new page, and a float that was open stays open.

On a site that changes pages without a full reload, the tag copied from the dashboard removes the conversation when the visitor moves to a page where it doesn't run again. That's why the **History Change** trigger is needed: without it, the conversation disappears after the first in-app navigation. The minimal tag doesn't do this, so a float or full-page conversation already on screen stays when the visitor moves to a page your triggers exclude. To remove it there, add a second Custom HTML tag that fires on **History Change** for those pages:

```html
<script>
  window.Perspective && window.Perspective("destroy", "RESEARCH_ID");
</script>
```

When the visitor returns to an included page, the first tag shows the conversation again.

### Track Conversations in Google Analytics

Each embed pushes these events to the `dataLayer`:

| Event | When it fires |
|-------|---------------|
| `perspective_widget_open` | A widget or full-page conversation appears on the page, or a visitor opens a popup, slider, or float. |
| `perspective_widget_ready` | The conversation has loaded and the visitor can use it. |
| `perspective_conversation_started` | The participant sent their first message. A conversation resumed after a reload doesn't send it again. |
| `perspective_conversation_completed` | The participant finished the conversation. This is the same moment `onSubmit` fires. |
| `perspective_widget_close` | The visitor closed the conversation, or your page removed it. Fires once per open. |

Each event also carries `perspective_research_id` and `perspective_embed_type` (`widget`, `popup`, `slider`, `float`, or `fullpage`). In Tag Manager, read them with a Data Layer Variable to break results down by research or embed type.

```javascript
{
  event: "perspective_conversation_completed",
  perspective_research_id: "RESEARCH_ID",
  perspective_embed_type: "float"
}
```

To count finished conversations as conversions in Google Analytics 4:

1. In Tag Manager, create a **Custom Event** trigger with the event name `perspective_conversation_completed`.
2. Create a **Google Analytics: GA4 Event** tag that fires on that trigger.
3. In Google Analytics, mark the event as a **key event**.

### Avoid Counting Conversions Twice

SDK 1.16.0 and later send these events on their own. If you already send your own Perspective events to Tag Manager, for example a `dataLayer.push` inside `onSubmit` or a Custom HTML tag that listens for `perspective:*` messages, remove that code. Otherwise each conversion is counted twice. If you want to keep your own tracking, turn the built-in events off instead.

The script at `https://getperspective.ai/v1/perspective.js` always serves the latest 1.x release, so script-tag installs already send these events.

### Turn Off the Events

| Scope | Install | How |
|-------|---------|-----|
| Whole site | Tag Manager | Add `window.Perspective("configure", { dataLayer: false });` on the line before the tag's `autoInit` call. |
| Whole site | npm or React | Call `configure({ dataLayer: false })` from `@perspective-ai/sdk` before creating embeds. |
| One embed | Script tag | Add `data-perspective-datalayer="false"` to its element. |
| One embed | npm or React | Pass `dataLayer: false` in its config, or `dataLayer={false}` to the React component or hook. |

In a React app, `configure` comes from the core package. Install it next to the React package, at the same version, so it configures the copy the components use:

```bash
npm install @perspective-ai/sdk @perspective-ai/sdk-react
```

```tsx
import { configure } from "@perspective-ai/sdk";
import { Widget } from "@perspective-ai/sdk-react";

// Every embed on the site
configure({ dataLayer: false });

// Just this one
<Widget researchId="RESEARCH_ID" dataLayer={false} />;
```

### Settings

`configure()` (or `Perspective("configure", { ... })`) accepts these options for Tag Manager:

| Option | Description |
|--------|-------------|
| `dataLayer` | Leave unset to send events only when the page already has `window.dataLayer`. `false` turns events off for every embed. `true` creates `window.dataLayer` if it's missing, then sends. |
| `dataLayerName` | The global array to push to, if your Tag Manager container renamed its data layer. Defaults to `dataLayer`. |
| `observe` | Script tag only. `true` watches the page and shows conversations whose `data-perspective-*` elements your app adds later, without the tag running again. |

### Commands

Instead of adding an element to the page, a tag or script can call these. Calls made before the script has loaded wait and run in order. Calls made while the page's HTML is still loading wait until it has finished, so `mount` can find an element further down the page.

| Call | What it does |
|------|--------------|
| `Perspective("configure", { ... })` | Change settings. Same as `Perspective.configure()`. |
| `Perspective("init", { researchId, type })` | Open a `popup`, `slider`, `float`, or `fullpage` conversation. |
| `Perspective("mount", selector, config)` | Show an inline widget inside the element that matches `selector`. |
| `Perspective("autoInit")` | Find the `data-perspective-*` elements on the page and show them. |
| `Perspective("destroy", researchId)` | Remove one conversation. |

After the script loads, `Perspective` keeps its usual methods, such as `Perspective.openPopup()` and `Perspective.destroy()`.

### Content Security Policy

If your site sends a `Content-Security-Policy` header, allow `https://getperspective.ai` in both `script-src` (the embed script) and `frame-src` (the conversation iframe).

Tag Manager runs a Custom HTML tag as an inline script, so a policy without `'unsafe-inline'` blocks the tag even when `https://getperspective.ai` is allowed. Google's guide to [using Tag Manager with a Content Security Policy](https://developers.google.com/tag-platform/security/guides/csp) explains how to allow it with a nonce instead. If your policy can't allow either, add the [script tag](#quick-start) to your site's HTML instead of using Tag Manager.

## Direct Iframe Embed

If you cannot use the SDK (e.g., in environments that restrict third-party scripts), embed the interview directly in an iframe:

```html
<iframe
  id="perspective-iframe"
  src="https://getperspective.ai/interview/RESEARCH_ID?email=user@example.com&returnUrl=https://example.com/thanks"
  style="width: 100%; height: 600px; border: none;"
  allow="microphone"
></iframe>
```

When using a direct iframe, you **must** add a `postMessage` listener to handle navigation and lifecycle events:

```javascript
var iframe = document.getElementById("perspective-iframe");
var allowedOrigin = new URL(iframe.src).origin;

window.addEventListener("message", (event) => {
  if (event.origin !== allowedOrigin) return;
  if (event.source !== iframe.contentWindow) return;
  if (typeof event.data?.type !== "string" || !event.data.type.startsWith("perspective:")) return;

  switch (event.data.type) {
    case "perspective:ready":
      // Embed loaded and ready
      break;
    case "perspective:conversation-start":
      // Participant sent their first message
      break;
    case "perspective:submit":
      // Interview completed
      break;
    case "perspective:redirect":
      // Navigate to the URL provided by the embed
      window.location.href = event.data.url;
      break;
    case "perspective:close":
      // User closed the embed
      break;
    case "perspective:resize":
      // Adjust iframe height: event.data.height
      iframe.style.height = event.data.height + "px";
      break;
    case "perspective:error":
      // Handle error: event.data.error
      break;
  }
});
```

### PostMessage Events

| Event | Data | Description |
|-------|------|-------------|
| `perspective:ready` | -- | Embed loaded and ready. |
| `perspective:conversation-start` | -- | Participant sent their first message. |
| `perspective:submit` | -- | Interview completed. |
| `perspective:redirect` | `{ url }` | Embed wants to navigate. You must handle this. If the page doesn't navigate away, the embed shows a link the participant can click to continue. |
| `perspective:close` | -- | User closed the embed. |
| `perspective:resize` | `{ height }` | Embed content height changed. |
| `perspective:error` | `{ error }` | An error occurred. |

### Microphone access in nested iframes

For a Perspective iframe you embed directly, `allow="microphone"` is all voice mode needs.

It gets more involved when that iframe sits inside **another** iframe (common with page builders like Wix or Squarespace, or your own wrapper): Permissions Policy only delegates the microphone into a frame when every ancestor iframe allows it. Because the Perspective iframe is a **different origin** than the wrapper, `allow="microphone"` is not sufficient _on the wrapper_ — the shorthand grants the microphone only to the wrapper's own origin. Set the wrapper to `allow="microphone *"` (or `allow="microphone https://getperspective.ai"` to scope it). The Perspective iframe keeps its own `allow="microphone"`, so once the wrapper delegates across origins, voice mode works.

## Best Practices

- **Load the script once**: If you have multiple embeds on the same page, include the SDK script tag only once. It discovers all trigger elements automatically.
- **Use the SDK over iframes**: The SDK handles responsive sizing, navigation, theming, and lifecycle events. Direct iframes require you to manage all of this yourself.
- **Pass user identity via params**: If your users are already authenticated, pass `email` and `name` so participants skip the identification step.
- **Prefer `onNavigate` over default behavior**: In single-page apps, handle `onNavigate` to use your router instead of a full page reload.
