Skip to main content
Use this when you want to embed a scenario on a third-party site and either the scenario is private, or you want to pass per-user identity, or you want to lock the iframe to specific origins. If your scenario is public and you don’t need any of the above, you can skip this and use the tokenless URL from the Studio Share modal — see Embedding a scenario on your website.

When to use this endpoint

Tokens are short-lived (default 10 min, max 30 min) and origin-locked. Mint one server-side per session so your wai_ key never reaches the browser.

One-call DX

The response gives you the token, the fully formed iframe URL, and a copy-pasteable <iframe> snippet. No client-side URL assembly.
Response:
Drop iframe_html into your page server-rendered and you’re done.

Prerequisites

Before this endpoint works for a scenario:
  1. Embedding must be enabled on the scenario (embed_enabled: true). Toggle it in the Studio Share modal or via the Scenarios API.
  2. Allowed origins must be set on the scenario (embed_allowed_origins) so the iframe is only renderable from your domains. The token won’t validate against any origin that isn’t on this list.
If either is missing the endpoint returns 403.

Request body

string
required
UUID of the scenario to embed. Must be owned by the caller’s membership and have embed_enabled: true.
string
End-user identity. The token’s identity key. Resolves to a per-creator participant record so the same end user across multiple meetings is recognised as one identity (enables cross-session participant memory, accurate analytics, and a coherent dashboard view). Also resolves the {{USER_EMAIL}} personalization token in the meeting script. Omit for a fully anonymous embed — the session still runs, but there’s no persistent identity and no cross-meeting memory.
string
UUID of a participant you pre-created via POST /v1/participants (advanced). Use when you want to control the participant record explicitly — for example, building a roster ahead of time. Wins over email if both are sent. Must belong to the scenario’s organisation.
string
Metadata only — not used for identity resolution. Your local user ID on the partner side. Stored on the participant record as external_ref so you can reconcile our participant rows against your own user table. Send it alongside email; on its own (no email) it has no identity effect and the session runs anonymously. Max 128 chars.
string
Display name shown in the meeting UI (e.g., on the join screen). Also resolves the {{USER_FIRST_NAME}} and {{USER_NAME}} personalization tokens in the meeting script. Max 80 chars.
string[]
Per-token origin allowlist. Must be a subset of the scenario’s embed_allowed_origins. Use this when one scenario is embedded across multiple sites and you want a token scoped to one of them. Each entry must be a bare HTTPS origin like https://partner.example.
integer
Token lifetime. Range 30–1800. Default 600 (10 min).
string
Which iframe route the snippet points at. One of:
  • button (default) — floating start button that opens the meeting in an overlay
  • inline — full meeting UI inline in the page
  • avatar-assist — floating avatar widget in the corner
  • sidebar — docked chat panel that becomes a call. Ships as a loader <script> rather than an iframe, so it returns snippet and a null iframe_html (see below).
string | number
Iframe width on iframe_html. CSS string ("100%", "640px", "50vw") or a positive px number. Default "100%".
string | number
Iframe height on iframe_html. Same shape as width. Default 640.

Response

string
Signed JWT. Treat as a credential — short-lived but powerful inside its TTL.
integer
Unix seconds at which the token stops validating.
string
Fully formed embed URL with ?t=<token> baked in. Use this if you want to construct your own iframe.
string | null
Copy-paste-ready <iframe> snippet with the URL, sizing, and the WebRTC allow attributes already set. null for sidebar, which mounts itself and owns its own iframe.
string
The copy-paste embed code for whichever variant you asked for — the same iframe HTML for iframe variants, the loader <script> for sidebar. Prefer this over iframe_html; it is always the right thing to paste.

Variants

Sidebar chat

A chat panel docked to the edge of every page, with the persona’s face at the top. The visitor types; when they click the avatar the same session becomes a live call — no reconnect, and the assistant keeps the whole conversation. Best as a website-native assistant on marketing and docs sites.

Button

Floating start button that opens the meeting in a modal overlay. Default. Best when you’re adding embed to an existing page without layout changes.

Inline

Full meeting UI rendered inline. Best for training portals, LMS, or dedicated meeting pages where the embed is the main content.

Avatar Assist

Compact avatar widget in the corner. Best for support bots and onboarding nudges.

Installing the sidebar

The sidebar is the one variant you don’t paste as an iframe. A live WebRTC session can’t be moved between iframes without being torn down, so the chat panel and the popped-out call have to be the same document — which means the loader script owns the iframe and its geometry:
Including the script twice is a no-op, so it’s safe to drop into a tag manager. To drive it from your own UI, use window.WaterrWidget.open(), .close(), .toggle() or .destroy().

Single-page apps

If the assistant belongs on some routes and not others, insert the tag when you enter those routes and call destroy() on the way out — it removes the launcher, closes the panel and gives the page back any width the panel was borrowing. Inserting the tag again afterwards mounts a fresh widget, so the usual mount/unmount pair works:
Two things to know. A conversation does not survive destroy(), so gate on whether the assistant belongs on the page rather than on the page itself — otherwise every click hangs up on a visitor mid-sentence. And handlers registered with Waterr.on go with it; register them again after re-inserting the tag (the window.Waterr = { q: [] } stub covers the case where the script hasn’t landed yet). Route changes themselves need nothing: the loader wraps history.pushState, so page views are reported for you.
Nothing connects when the panel opens. The session — and the billing that comes with it — starts on the visitor’s first message, or when they open the call. An idle chat hangs itself up after a few quiet minutes.

Token refresh

Tokens expire (default 10 min). If the iframe is loaded but the user hasn’t started the session yet when the token expires, the session start will be rejected. Two ways to handle this in practice:
  1. Mint on demand. Generate the token at the moment the page renders, not ahead of time. Server-render the iframe_html. Don’t cache the response.
  2. Mint a fresh token client-side just before opening the modal. If you’re using variant: "button" and the user might sit on the page for a while, refresh the token via an AJAX hit to your own backend (which proxies to this endpoint) right before opening the iframe.
If you need a longer TTL, raise ttl_seconds (max 1800 = 30 min). Going much higher is intentionally not supported — leaked tokens should go stale fast.

Origin lock

The token’s claims include the origin allowlist, and the iframe enforces it at session start. A leaked token presented from an origin not on the list is rejected.
If you omit allowed_origins, the token inherits the scenario’s full embed_allowed_origins list.
The iframe needs allow="camera; microphone; autoplay" to function. The iframe_html field has this set already; if you build your own iframe, copy these attributes verbatim. Your site must also be served over HTTPS — browsers block mic/camera on non-secure origins.

Quick recipes

Node.js / Express

Python / FastAPI

Errors