Embed the iframe viewer
Embed Relu Automate's 3D viewer and order UI inside your product using the @relu-bv/iframe-api SDK.
The Relu iframe SDK (@relu-bv/iframe-api) embeds the Automate viewer and order flows in your own web app. Communication between your page and the iframe is typed and event-driven.
Prerequisites
- Iframe access enabled for your organization, contact support@relu.ai.
- Cross-origin isolation on the page that hosts the iframe (see below).
Cross-origin isolation
The viewer uses SharedArrayBuffer, which requires a cross-origin-isolated page. Set these headers on your host page:
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
Permissions-Policy: cross-origin-isolated=(self "https://automate.relu.ai")Replace the viewer origin in Permissions-Policy with the environment you
embed. Add each permitted viewer origin when your integration uses more than
one.
The viewer itself responds with:
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
Cross-Origin-Resource-Policy: cross-originrequire-corp on our side is deliberate. It is the only COEP value Safari
implements, so it is what lets a require-corp host page embed the viewer at
all in Safari, and what makes SharedArrayBuffer available there rather than in
Chromium alone.
Every resource the host page loads (APIs, CDN assets, fonts) must include the corresponding CORS and CORP headers:
Access-Control-Allow-Origin: https://app.client.com
Access-Control-Allow-Credentials: true
Access-Control-Allow-Methods: *
Cross-Origin-Resource-Policy: cross-originToo restrictive for your main app? Create a dedicated page for the iframe where you can enforce the rules.
Can't set these headers? Chrome-only fallback
If your host page can't be made cross-origin isolated (for example you don't control its headers, or other embedded content would break under COEP), the viewer iframe can still isolate itself in Chromium-based browsers.
The viewer already sends a Document-Isolation-Policy header, which makes the iframe cross-origin isolated based on its own response alone — independent of the host page. With it, SharedArrayBuffer works inside the viewer even when the host page sets no isolation headers at all.
There is nothing to configure on your side for this path — just point users at a supported browser:
- Supported: Chrome / Edge and other Chromium browsers, version 137+ — the milestone
Document-Isolation-Policyshipped enabled-by-default in (the origin trial ran 132–136), see the Chrome 137 release notes. - Not supported: Safari and Firefox, and older Chromium versions. There,
SharedArrayBufferis unavailable unless the host page itself is cross-origin isolated with the COOP/COEP headers above.
If most of your users are on Chrome/Edge, pushing them to a Chromium browser is the lowest-effort way to unblock the viewer without touching your host page's headers.
What works without cross-origin isolation
The viewer degrades gracefully — cases still load and render. Capability by browser:
-
Chromium 137+ (Chrome, Edge): full functionality. The viewer self-isolates via
Document-Isolation-Policy, soSharedArrayBufferis available even when the host page is not isolated. -
Safari, Firefox, Chromium < 137: cases load, render, and basic sculpting works, but MeshLib-backed features are disabled (the controls are greyed out with an explanatory tooltip):
- Local recomputation of outputs: crowns, dies, custom trays, splints (everything except the surgical guide).
- Sculpting Patch mode (basic brushes — Brush, Inflate, Smooth, Flatten, Move — stay enabled).
- Lattice / free-form deformation tool.
- The articulator tool.
- Margin-line adaptation, insertion-direction, and molar-ditching recompute.
- Temp-crown recompute, convert-to-temp-crown, and margin-line redetection.
- Crown / IOS heatmaps.
Loading, viewing, navigation, DICOM display, basic sculpting, and ordering all continue to work. To give these users full functionality, make the host page cross-origin isolated with the COOP/COEP headers above.
Install
Download the current version from automate.relu.ai/settings/developer:
npm install ./relu-bv-iframe-api-<version>.tgzConfigure allowed origins
On the Developer settings page, add every origin from which you'll embed the iframe. For local dev, http://localhost:* works.
Without a matching allowed origin, the iframe refuses to communicate.
Initialize
Check the Developer page for the latest config
The SDK evolves quickly. Always reference
automate.relu.ai/settings/developer
for the current recommended initialConfig values, the example below captures
a sensible default at the time of writing, but the page has the authoritative
version.
To load directly into a specific order, append ?orderId=<order_id> to viewerUrl. The viewer reads orderId from the iframe URL on initial load. Alternatively, navigate after onReady with api.navigate({ query: { orderId } }) (see Send commands).
Container must exist before createViewerHost runs
createViewerHost calls container.appendChild(iframe) synchronously, so the
container element must already be in the DOM. If you pass null you'll get
TypeError: Cannot read properties of null (reading 'appendChild'). In React
or Vue, drive createViewerHost from useEffect / onMounted and read the
container from a ref — see the framework tabs below.
Cross-origin isolation is required
Mesh processing requires SharedArrayBuffer. The host page and viewer must
both return compatible Cross-Origin-Opener-Policy and
Cross-Origin-Embedder-Policy headers. When the viewer is hosted on another
site, the host must also delegate the cross-origin-isolated Permissions
Policy to the viewer origin. createViewerHost sets the required iframe
allow attribute automatically; the embedding site's response headers remain
the integrator's responsibility.
Support chat configuration
Use ui.showCustomerSupportChat to control whether the active
customer-support chat widget is shown. The legacy ui.showIntercom field
remains valid for backwards compatibility, but is deprecated and will be
removed in a future API version. If both fields are provided,
showCustomerSupportChat takes precedence.
Draft-order options
initialConfig.orderCreation applies only to the draft-finalization screen
opened with finalize_order_id. These options do not apply to the result viewer
opened with orderId.
See Draft order flow for the recommended configuration, complete option reference, parameter preferences, and precedence rules.
import { createViewerHost } from '@relu-bv/iframe-api';
const orderId =
new URLSearchParams(window.location.search).get('orderId') ?? '';
const host = createViewerHost({
integratorId: 'your-integrator-id',
branding: 'relu',
container: document.getElementById('viewer')!,
viewerUrl: `https://automate.relu.ai?orderId=${orderId}`,
initialConfig: {
localization: {
language: 'en',
},
ui: {
showCustomerSupportChat: false,
toolbar: {
style: 'compact',
showLogo: false,
showTitle: false,
showHome: false,
showStatusFlow: false,
showExport: false,
showShare: false,
showLogout: false,
showShortcuts: true,
showComments: true,
showFeedback: true,
showContactSupport: false,
showSettings: true,
showAccountSettings: false,
showGeneralSettings: true,
userMenuStyle: 'avatar',
},
},
permissions: {
canEdit: false, // read-only viewer
canExport: false, // no export / download
canUndoApproval: false, // lock the decision once approved/rejected
},
clinical: {
toothNumberingSystem: 'fdi',
},
},
auth: {
type: 'apiToken',
token: 'your-auth-token',
},
onReady: (api, appInfo) => console.log('Connected to', appInfo),
onVersionWarning: (result) => console.warn('Version warning', result),
onError: (error) => console.error(error),
onOrderLoaded: (order) => console.log('Order loaded', order),
onSaveStatus: (status) => console.log('Save status', status),
});import { useEffect, useRef } from 'react';
import { useSearchParams } from 'react-router-dom';
import { createViewerHost, type ViewerHost } from '@relu-bv/iframe-api';
export const ReluViewer = () => {
const containerRef = useRef<HTMLDivElement>(null);
const [searchParams] = useSearchParams();
const orderId = searchParams.get('orderId') ?? '';
const token = searchParams.get('token') ?? '';
useEffect(() => {
if (!containerRef.current) return; // guard: ref not attached yet
if (!orderId || !token) {
console.error('[ReluViewer] Missing orderId or token');
return;
}
const host: ViewerHost = createViewerHost({
integratorId: 'your-integrator-id',
container: containerRef.current,
viewerUrl: `https://automate.relu.ai?orderId=${orderId}`,
auth: { type: 'apiToken', token },
onReady: (api, appInfo) => console.log('Connected to', appInfo),
onError: (error) => console.error(error),
// …other callbacks and initialConfig as in the Vanilla tab
});
return () => host.destroy(); // cleanup on unmount / dep change
}, [orderId, token]);
return <div ref={containerRef} style={{ width: '100%', height: '100%' }} />;
};<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue';
import { useRoute } from 'vue-router';
import { createViewerHost, type ViewerHost } from '@relu-bv/iframe-api';
const containerRef = ref<HTMLDivElement | null>(null);
const route = useRoute();
const orderId = (route.query.orderId as string) ?? '';
const token = (route.query.token as string) ?? '';
let host: ViewerHost | null = null;
onMounted(() => {
if (!containerRef.value) return; // guard: ref not attached yet
if (!orderId || !token) {
console.error('[ReluViewer] Missing orderId or token');
return;
}
host = createViewerHost({
integratorId: 'your-integrator-id',
container: containerRef.value,
viewerUrl: `https://automate.relu.ai?orderId=${orderId}`,
auth: { type: 'apiToken', token },
onReady: (api, appInfo) => console.log('Connected to', appInfo),
onError: (error) => console.error(error),
// …other callbacks and initialConfig as in the Vanilla tab
});
});
onBeforeUnmount(() => host?.destroy());
</script>
<template>
<div ref="containerRef" style="width: 100%; height: 100%" />
</template>Options
| Option | Description |
|---|---|
integratorId | Your unique integrator ID from the Developer settings page. |
container | The HTMLElement to mount the iframe into. |
viewerUrl | 'https://automate.relu.ai' for non-implant services. For implant planning: 'https://implant.automate.relu.ai' or 'https://beta.implant.automate.relu.ai', the iframe works best with the beta viewer. Append ?orderId=<order_id> to open a specific order on load. |
initialConfig | What UI is shown inside the iframe. See ViewerConfig for options. |
auth | Either { type: 'apiToken', token } or { type: 'oauth2', token }. See Authentication below. |
branding | Theming hook for grey-label partners. WIP, contact us for custom branding. |
Language config
Set initialConfig.localization.language to choose the interface language for
the iframe session. If it is omitted, the embedded application uses the user's
saved language. This override does not update the user's account preference.
initialConfig: {
localization: {
language: 'en',
},
}Supported language codes are en, nl, fr, de, es, it, ja, zh,
ml, mr, and pt. Use the API received by onReady to change or clear the
session language after connection:
onReady: async (api) => {
await api.setConfig({ localization: { language: 'nl' } });
await api.setConfig({ localization: { language: null } }); // use saved language
};Recommended UI config
The toolbar config above removes Relu's own navigation chrome (logo, title, share button) so the iframe looks native in your product. Keep only what's useful, typically shortcuts and settings.
Help menu and settings toggles
These flags give you granular control over the help menu and the user (account) menu items. All default to true, so the items show unless you opt out.
| Flag | Default | Description |
|---|---|---|
showFeedback | true | "Give feedback" item inside the help menu. |
showContactSupport | true | "Contact support" item inside the help menu. |
showComments | true | The comments button in the toolbar. |
showSettings | true | Master toggle for both settings items. When false, both "Account settings" and "General settings" are hidden regardless of the per-item flags below. |
showAccountSettings | true | "Account settings" item in the user menu (links to the account settings page). Requires showSettings. |
showGeneralSettings | true | "General settings" item in the user menu (opens the in-viewer settings dialog; medical viewer only). Requires showSettings. |
To hide the whole help menu instead of individual items, use showHelp: false. Setting showSettings: false hides both settings items at once — use the per-item flags when you only want to drop one.
Permissions config
initialConfig.permissions controls which actions the embedded user can take. Every can* flag defaults to true (the action is available) — set one to false to take it away. Disabled actions are greyed out with an explanatory tooltip; set hideNonAllowedActions: true to hide them entirely instead.
initialConfig: {
permissions: {
canEdit: false, // read-only viewer
canExport: false, // no export / download
canUndoApproval: false, // lock the decision once approved/rejected
},
}| Flag | Default | Description |
|---|---|---|
canEdit | true | Editing tools (sculpting, advanced edit, and other mutations). |
canSave | true | Saving changes. |
canExport | true | Exporting / downloading output files. |
canShare | true | The share button. |
canApprove | true | Approving or rejecting an order. |
canUndoApproval | true | Changing an order's status after it is Approved or Rejected. Set false to lock the decision — the user can't clear it or switch approve↔reject once made. The first decision is still governed by canApprove. |
canRenameOrder | true | Renaming the order from the viewer's title. Set false to keep the order name visible but read-only, for example when it holds your own case reference. |
hideNonAllowedActions | false | Hide disabled actions entirely instead of greying them out with a tooltip. |
These flags are a UI convenience layered on top of the order's own permissions — they can only remove affordances, never grant access the token doesn't already have. For access control that the backend enforces, use share-link roles and order permissions.
Clinical display config
initialConfig.clinical controls clinical display options inside the viewer.
initialConfig: {
clinical: {
toothNumberingSystem: 'universal', // or 'fdi' (default)
},
}| Option | Type | Description |
|---|---|---|
toothNumberingSystem | 'fdi' | 'universal' | Tooth labels shown in the sidebar, 3D labels, and reports. Defaults to 'fdi'. Session-only override — the user's saved organization setting is unchanged when the iframe closes. |
Use this to align the viewer with your end-user's regional convention without mutating their account preferences. Note that this is a display-layer setting only, the API still requires FDI / ISO 3950 numbers on teeth_* fields (see Implant planning).
Events
onReady(api, appInfo), iframe is ready to receive commands. Don't callhost.getApi()before this.onVersionWarning(result), your SDK version is older than the server expects.resulttells you when the current version will be deprecated.onError(error), runtime errors from the iframe. Always log these.onOrderLoaded(order), fires after an order is loaded inside the viewer.onSaveStatus(status), fires when the viewer's save state changes (dirty, saving, saved).
Other events on the ViewerHostOptions type shipped with the SDK.
Send commands
Once onReady fires, grab a typed API handle:
const api = host.getApi();
const state = await api.getState();
await api.navigate({ query: { orderId: 'abc-123' } });
await api.save();All commands are typed on the ViewerApi interface.
Clean up
When the user navigates away, destroy the host:
host.destroy();Authentication
The host always supplies the credential — the viewer never shows a login screen of its own. Two kinds are accepted.
API tokens
auth: { type: 'apiToken', token: userToken }For end-user flows without a Relu account, mint a short-lived user token via the Draft order flow and pass it here. See User tokens for scope and expiry rules.
OAuth2 access tokens
If your integration signs users into their own Relu accounts via OAuth2, pass the access token directly — no exchange step needed:
auth: {
type: 'oauth2',
token: accessToken,
// Your own backend, not a Relu endpoint: refreshing needs your client
// secret, so it can never happen in the browser.
onTokenRefresh: async () => {
const res = await fetch('https://your-app.example.com/relu/refresh', {
method: 'POST',
});
return (await res.json()).access_token;
},
}The viewer sends an oauth2 token as Authorization: Bearer and an apiToken as X-API-Key; picking the wrong type returns 401 on every call.
Access tokens last 2 hours, so always supply onTokenRefresh for OAuth2 embeds — a viewer session easily outlives one. onTokenRefresh is also available for apiToken; in both cases the SDK calls it when the viewer reports the credential was rejected and pushes the token you return to the viewer. The request that failed is not replayed, so an in-flight save is lost — only later calls use the new token.
insufficient_permissions skips the refresh entirely and goes straight to onAuthRequired, since a new token carries the same scopes.
Requires @relu-bv/iframe-api 0.8.0 or newer on both sides. Embedding a viewer origin that serves an older build fails fast with an AUTH_MODE_UNSUPPORTED error on onError, rather than letting that viewer send the token in the wrong header.
Troubleshooting
- Iframe blank, check the browser console for COOP/COEP errors. Your host page isn't cross-origin isolated.
Refused to display ... in a frame because of Cross-Origin-Embedder-Policy, followed by anINIT_TIMEOUTfrom the SDK. Your host page sets aCross-Origin-Embedder-Policyand the viewer build you are pointing at does not send one the browser recognises. In Safari this happens against viewer versions older than medical-viewer 3.12.1, which sentcredentialless— a value Safari does not implement and therefore reads as no policy at all. Point at a current viewer origin. TheINIT_TIMEOUTis only a symptom: the frame never loaded, so it never signalledready.onErrorwith an origin message, your origin isn't in the allowed list. Add it on the Developer settings page.- Every API call inside the iframe returns
401, theauth.typedoesn't match the credential. An OAuth2 access token needstype: 'oauth2'; a token fromPOST /connections/api-tokenneedstype: 'apiToken'. - Iframe works, then goes blank after a couple of hours, an OAuth2 access token expired and no
onTokenRefreshwas supplied. - Commands time out, you called
getApi()beforeonReady. Wait for the ready callback. - The user sees "This order is not available right now. Contact whoever provided it to you.", the organization that owns the order cannot have it opened or submitted right now. The API answers
403withdetail.codeset toorder_owner_refused. The user cannot fix this; the owner organization has to. See Coded refusals. TypeError: Cannot read properties of null (reading 'appendChild'), thecontainerpassed tocreateViewerHostwasnull. The element wasn't mounted yet, theiddidn't match, or you ran the call before the framework attached the ref. In React, runcreateViewerHostinsideuseEffectand read the container fromuseRef. In Vue, useonMountedwith a templateref. See the Initialize tabs for working patterns.
If something doesn't match the docs, email support@relu.ai, the SDK evolves fast.