ReluAutomate
Guides

Embed the iframe viewer

Embed Relu Automate's 3D viewer and order UI inside your product using the @relu-bv/iframe-api SDK.

Iframe + draft orders

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

  1. Iframe access enabled for your organization, contact support@relu.ai.
  2. 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-origin

require-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-origin

Too 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-Policy shipped 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, SharedArrayBuffer is 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, so SharedArrayBuffer is 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>.tgz

Configure 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

OptionDescription
integratorIdYour unique integrator ID from the Developer settings page.
containerThe 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.
initialConfigWhat UI is shown inside the iframe. See ViewerConfig for options.
authEither { type: 'apiToken', token } or { type: 'oauth2', token }. See Authentication below.
brandingTheming 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
};

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.

FlagDefaultDescription
showFeedbacktrue"Give feedback" item inside the help menu.
showContactSupporttrue"Contact support" item inside the help menu.
showCommentstrueThe comments button in the toolbar.
showSettingstrueMaster toggle for both settings items. When false, both "Account settings" and "General settings" are hidden regardless of the per-item flags below.
showAccountSettingstrue"Account settings" item in the user menu (links to the account settings page). Requires showSettings.
showGeneralSettingstrue"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
  },
}
FlagDefaultDescription
canEdittrueEditing tools (sculpting, advanced edit, and other mutations).
canSavetrueSaving changes.
canExporttrueExporting / downloading output files.
canSharetrueThe share button.
canApprovetrueApproving or rejecting an order.
canUndoApprovaltrueChanging 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.
canRenameOrdertrueRenaming 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.
hideNonAllowedActionsfalseHide 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)
  },
}
OptionTypeDescription
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 call host.getApi() before this.
  • onVersionWarning(result), your SDK version is older than the server expects. result tells 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 an INIT_TIMEOUT from the SDK. Your host page sets a Cross-Origin-Embedder-Policy and 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 sent credentialless — a value Safari does not implement and therefore reads as no policy at all. Point at a current viewer origin. The INIT_TIMEOUT is only a symptom: the frame never loaded, so it never signalled ready.
  • onError with 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, the auth.type doesn't match the credential. An OAuth2 access token needs type: 'oauth2'; a token from POST /connections/api-token needs type: 'apiToken'.
  • Iframe works, then goes blank after a couple of hours, an OAuth2 access token expired and no onTokenRefresh was supplied.
  • Commands time out, you called getApi() before onReady. 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 403 with detail.code set to order_owner_refused. The user cannot fix this; the owner organization has to. See Coded refusals.
  • TypeError: Cannot read properties of null (reading 'appendChild'), the container passed to createViewerHost was null. The element wasn't mounted yet, the id didn't match, or you ran the call before the framework attached the ref. In React, run createViewerHost inside useEffect and read the container from useRef. In Vue, use onMounted with a template ref. See the Initialize tabs for working patterns.

If something doesn't match the docs, email support@relu.ai, the SDK evolves fast.

On this page