Chat Widget Embed Guide
The chat widget lets you embed a floating chat launcher on any external site. It renders the full SiteFloatingChat experience inside a fixed-position iframe that auto-resizes between a collapsed launcher button and an expanded chat panel.
The widget is backed by a Site — see Sites in the user guide for how to configure the Site, its workflow, and content.
How It Works
A small async script injected into the host page creates the iframe. On a third-party domain the widget authenticates with a bearer session rather than a cookie: the visitor signs in inline (email/SMS OTP) and the resulting session is held in the iframe's partitioned storage and sent as an Authorization: Bearer token — so no third-party cookies and no COOKIE_SAMESITE deployment flag are required. Same-origin embeds keep using the first-party cookie session automatically.
The only postMessage traffic between parent and iframe is resize events scoped by widgetId. The host page cannot read conversation content.
Two-factor authentication: members with 2FA enrolled complete the challenge inline in the widget — authenticator (TOTP) codes and recovery codes work everywhere; passkeys (WebAuthn) are supported best-effort where the browser permits them in an embedded frame.
Production Embed Snippet
<script
async
src="https://app.gravityrail.com/widgets/chat.js"
data-wid="your-workspace-uuid"
data-locale="en"
data-workflow="your-workflow-slug"
data-title="Need help?"
data-subtitle="Ask our AI assistant"
data-button="Chat now"
></script>
Required Attributes
| Attribute | Description |
|---|---|
data-wid | Workspace UUID |
data-workflow | Workflow slug — determines which workflow starts the chat |
Optional Attributes
| Attribute | Default | Description |
|---|---|---|
data-locale | en | Locale slug for the chat experience |
data-title | — | Headline shown in the launcher popover |
data-subtitle | — | Subtitle text inside the launcher |
data-button | — | CTA button text inside the launcher |
data-voice | — | Set to true to enable microphone/voice mode (also grants the iframe microphone permission) |
data-video | — | Set to true to grant the iframe camera permission for video features |
data-open | — | Set to true to start the widget in expanded state |
data-storage-key | — | Custom localStorage key for persisting chat state |
data-launcher-width | 80 | Collapsed iframe width in pixels |
data-launcher-height | 80 | Collapsed iframe height in pixels |
data-widget-width | 420 | Expanded iframe width in pixels |
data-widget-height | 640 | Expanded iframe height in pixels |
data-bottom | 24px | CSS bottom position of the launcher |
data-right | 24px | CSS right position of the launcher |
data-z-index | 2147483000 | CSS z-index of the widget container |
data-widget-id | auto | Custom identifier scoped to postMessage resize events |
iframe Permissions
The chat widget iframe allow attribute is built from opt-in data attributes. By default it includes only clipboard-read; clipboard-write; autoplay; — no microphone or camera. Add the relevant attributes when your embed uses those features:
| Attributes set | Resulting allow value |
|---|---|
| (none — text-only chat) | clipboard-read; clipboard-write; autoplay; |
data-voice="true" | clipboard-read; clipboard-write; autoplay; microphone; |
data-video="true" | clipboard-read; clipboard-write; autoplay; camera; |
data-voice="true" data-video="true" | clipboard-read; clipboard-write; autoplay; microphone; camera; |
Text-only embeds do not need to disclose microphone/camera permissions in privacy notices, because the iframe never requests them. Add disclosures only for the features you opt into.
The host page must be served over HTTPS or browser microphone/camera access will be blocked even if the permission is granted in the allow attribute.
Server-Side Setup
Authorize the parent domain
Which parent origins may embed a widget is decided by a fail-closed, per-(workspace, widget) verified-domain gate — not an environment variable. Authorizing a domain is a two-step, self-service flow in the app:
- Verify the domain at the org level. Prove ownership of the domain with a DNS-TXT record (the same verification email and site domains use). Until a domain is verified, it cannot be enabled for any widget.
- Enable the widget on that domain. In the embedding workspace, go to Settings → Widgets and enable the chat widget on the verified domain (optionally allowing subdomains and pinning a non-default port).
The edge widget-embed-policy endpoint enforces this at request time: an origin that isn't an enabled, verified widget domain gets a 403, and the Content-Security-Policy: frame-ancestors directive blocks the iframe from rendering at all. The gate is fail-closed — an unverified domain, a disabled widget, or an unparseable request denies every parent origin.
Cookie configuration
Cross-origin embeds do not require SameSite=None; Secure cookies. On a third-party domain the widget uses the bearer session described in How It Works — the visitor signs in inline and the session is sent as an Authorization: Bearer token from partitioned storage — so no cross-domain cookie is involved. The platform keeps its default SameSite=Lax (CSRF-safe); no COOKIE_SAMESITE opt-in is needed for widget deployments.
COOKIE_SAMESITE=none is only relevant if you additionally want a first-party cookie session on a same-site subdomain deployment. In that case, set these in your environment:
COOKIE_SAMESITE=none
COOKIE_DOMAIN=.gravityrail.com # adjust to match your deployment domain
Security Considerations
- Verified-domain gate — The per-(workspace, widget) verified-domain gate is the enforcement boundary. Only org-verified domains that have the widget enabled in Settings → Widgets can embed it; every other origin is blocked fail-closed. Keep the enabled set tight.
- SRI for production — Add
integrity="sha384-..."andcrossorigin="anonymous"to the script tag to prevent tampered loader delivery. - Host page CSP — Allow the loader script origin and iframe origin explicitly:
Content-Security-Policy: script-src https://app.gravityrail.com; frame-src https://app.gravityrail.com; - Data isolation — Conversation data stays in Gravity Rail. The only outbound
postMessageis resize events; no PHI crosses the frame boundary. - Compromised host page — A compromised host can observe the rendered iframe visually. Encourage customers to harden their sites and review their privacy disclosures.
Local Development
Prerequisites
Local widget testing requires HTTPS on both the app server and the test host. See Local HTTPS Development below.
Run the widget test harness
yarn dev:widget-host
Then open:
https://widget-host.test:4444/?wid=<wid>&workflow=<slug>&locale=en
Additional query params: title, subtitle, button, voice=true, launcherWidth, launcherHeight, widgetWidth, widgetHeight, src=<loader-url> (override loader URL for testing local changes to chat.js).
Example:
https://widget-host.test:4444/?wid=00000000-0000-0000-0000-000000000000&workflow=my-workflow&voice=true&title=Support
Add widget-host.test to /etc/hosts:
127.0.0.1 widget-host.test
Local HTTPS Development
Why HTTPS locally?
Browsers require HTTPS ("secure context") for:
- Microphone/camera access — required for voice features
- Partitioned storage for the bearer session — the widget's cross-origin inline-login bearer session lives in the iframe's partitioned storage, which browsers only isolate correctly in a secure context
- Service Workers — required for PWA features
Setup
Install mkcert:
brew install mkcert nss
mkcert -install
Generate certificates:
yarn dev:https:regenerate-cert
Generates certs/dev-local-cert.pem and certs/dev-local-key.pem covering:
localhost*.gravityrail.test*.o.gravityrail.testwidget-host.test127.0.0.1
Add custom domains to /etc/hosts:
127.0.0.1 localhost
127.0.0.1 app.gravityrail.test
127.0.0.1 api.gravityrail.test
127.0.0.1 private-oidc.o.gravityrail.test
127.0.0.1 widget-host.test
Start Next.js with HTTPS:
# Default
yarn dev:local:https
# Custom hostname
NEXT_DEV_HOST=private-oidc.o.gravityrail.test yarn dev:local:https
The server binds to 0.0.0.0 for custom domains so all configured
*.gravityrail.test hostnames resolve to the same server.
Cross-domain widget auth — no cookie flag needed:
Testing the widget across origins exercises the inline-login bearer session: the visitor signs in inside the iframe (email/SMS OTP) and the session is sent as an Authorization: Bearer token from partitioned storage. No COOKIE_SAMESITE flag or .env.local cookie configuration is required for the cross-domain path.
Environment Variables
| Variable | Default | Description |
|---|---|---|
NEXT_DEV_HOST | app.gravityrail.test | Hostname to bind to |
NEXT_DEV_HTTPS_CERT | certs/dev-local-cert.pem | Path to TLS certificate |
NEXT_DEV_HTTPS_KEY | certs/dev-local-key.pem | Path to private key |
AUTH_ALLOWED_ORIGINS | https://app.gravityrail.com | Origins permitted to call /api/auth/complete |
Troubleshooting
Microphone not working
- Must be on HTTPS —
https://private-oidc.o.gravityrail.test:3000, nothttp:// - Check browser mic permissions (lock icon in address bar)
- Clear site data and retry
- Chrome/Edge have better WebRTC support than Safari
Widget auth not working across domains
- The cross-domain widget uses the inline-login bearer session, not cookies — no
COOKIE_SAMESITEflag is needed - Both parent and iframe must be HTTPS
- Confirm you completed the inline sign-in (email/SMS OTP) inside the widget; the session is held in the iframe's partitioned storage and sent as an
Authorization: Bearertoken
Certificate errors ("NET::ERR_CERT_AUTHORITY_INVALID")
- Run
mkcert -installthen restart your browser - Regenerate with
yarn dev:https:regenerate-cert - Verify:
openssl x509 -in certs/dev-local-cert.pem -noout -text | grep -A 5 "Subject Alternative Name"
"Certificate doesn't cover your domain"
- Use
*.gravityrail.testor*.o.gravityrail.testlocal hosts - Regenerate the cert and verify the SAN includes
DNS:*.gravityrail.test
Port 3000 in use
lsof -ti:3000 | xargs kill -9
Mixed content errors ("blocked:mixed-content")
- Ensure
API_HOSTuseshttps:// - WebSocket connections must use
wss://notws://