Embedded apps

Run your app inside the Control panel with App Bridge.

Installed apps appear in the Control sidebar under Apps. When your app has an embed URL, clicking it opens your app inside the panel's content area, in an iframe, so users never leave Control.

App Bridge is a small script that connects your page to the panel. With it your app can:

  • keep the panel URL in step with its own routes, so refresh, back/forward and shared links work
  • set the page title shown in the panel
  • show panel toasts, confirmation dialogs and a loading bar
  • send the user to a panel page
  • get a session token for the signed-in Control user, without cookies or redirects

Requirements

  1. Embed URL. Set it on your app listing (Control team: superadmin → Apps → your app → Embed URL). The panel opens /apps/{slug}/open at that URL.
  2. Allow framing. Send a Content-Security-Policy header with frame-ancestors https://app.control.cm (and your own origin if you like). Without it the browser refuses to show your app. Don't send X-Frame-Options: DENY.
  3. OAuth app. Your app is a Control OAuth client (see Authentication). Session tokens are signed for your client_id.

Load App Bridge

Load the script from the panel origin, then create the bridge once per page:

<script src="https://app.control.cm/app-bridge.js"></script>
<script>
  const bridge = ControlAppBridge.create();

  bridge.ready
    .then((context) => {
      // Inside Control: { slug, locale, theme }
    })
    .catch(() => {
      // Opened directly (not inside Control) or an older panel: run standalone.
    });
</script>

Outside the panel bridge.embedded is false. Every method stays safe to call: messages are dropped, confirm falls back to window.confirm and open to window.open.

Signing users in with a session token

bridge.getSessionToken() returns a JWT for the Control user looking at your app. It is valid for 60 seconds; the bridge caches it and asks for a new one when it's close to expiring.

const token = await bridge.getSessionToken();
await fetch("/auth/session-token", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ token }),
});

// Or call your API with it directly:
const res = await bridge.fetch("/api/orders"); // adds Authorization: Bearer <token>

Verify the token on your backend before trusting it:

CheckExpected value
SignatureRS256, key from https://api.control.cm/oauth/jwks (match kid)
Header typcontrol-session+jwt
isshttps://api.control.cm/oauth
audyour client_id
exp / nbfcurrent (allow a few seconds of clock skew)
token_usesession

Claims:

ClaimMeaning
sub{userId}@{companyId}, the same as the sub in your OIDC id_token
uidControl user id
cidControl company id
iidInstallation id of your app in that company
jtiUnique token id: accept each token once
import { createRemoteJWKSet, jwtVerify } from "jose";

const jwks = createRemoteJWKSet(new URL("https://api.control.cm/oauth/jwks"));

export async function verifySessionToken(token: string) {
  const { payload, protectedHeader } = await jwtVerify(token, jwks, {
    issuer: "https://api.control.cm/oauth",
    audience: process.env.CONTROL_CLIENT_ID,
    typ: "control-session+jwt",
    clockTolerance: 5,
  });
  if (payload.token_use !== "session") throw new Error("not a session token");
  return { userId: payload.uid as string, companyId: payload.cid as string, installationId: payload.iid as string };
}

A session token proves who is using your app; it is not a Control API token. To call the Control API on the user's behalf, use the access token from "Log in with Control". A good pattern: on a valid session token, look up the user's stored Control grant; if you don't have one yet, send them through "Log in with Control". For installed apps Control signs them in without a consent prompt, even inside the iframe.

When the user switches company in Control, the next session token carries the new cid. Open a session for that company rather than reusing an old cookie.

Routing and URLs

The panel URL mirrors your app's path: https://app.control.cm/apps/{slug}/open/orders/42?tab=items shows https://your-app.com/orders/42?tab=items, and the bare /open URL shows your embed URL.

  • Your routes → panel. Automatic: the bridge reports every pushState, replaceState and popstate. Call bridge.syncPath() if you change the URL some other way.
  • Panel → your routes. Back/forward and sidebar links send your app a path. Handle it with onNavigate and route with replaceState; the browser already has the history entry:
bridge.onNavigate((path) => {
  router.replace(path); // or history.replaceState(null, "", path) + render
});

Without a handler the frame does a full location.replace(path).

API reference

MemberDescription
ControlAppBridge.create(options?)Returns the page's bridge. Options: panelOrigin (default: the script's origin), timeoutMs for the handshake (default 4000), syncPath: false to report paths yourself.
bridge.embeddedtrue inside the Control panel.
bridge.readyPromise of { slug, locale, theme }. Rejects outside the panel or if the panel doesn't answer.
bridge.contextThe latest context, or null before ready.
bridge.getSessionToken()Promise of a session token (see above).
bridge.fetch(input, init?)fetch with Authorization: Bearer <session token>.
bridge.setTitle(title)Shown as App name › title in the panel header and the browser tab. null clears it.
bridge.toast(message, kind?)Panel toast. kind: "success" (default), "error", "info". At most 5 per 10 seconds.
bridge.loading(on)Shows or hides a loading bar above your app.
bridge.confirm({ title, message?, confirmLabel?, cancelLabel?, destructive? })Panel confirmation dialog. Resolves true when confirmed.
bridge.navigate(path)Opens a panel page, e.g. "/locations". Only panel paths (starting with /).
bridge.open(url)Opens an external http(s) URL in a new tab.
bridge.onNavigate(fn)Called with your app's path when the panel URL changes. Returns an unsubscribe function.
bridge.onContextChange(fn)Called when the panel's locale or theme changes. Returns an unsubscribe function.
bridge.syncPath()Reports the current path now.

Security notes

  • The panel only accepts messages from your iframe on your embed URL's origin, and only replies to that origin.
  • Session tokens are only issued for apps installed in the user's current company, and only for the app open in the panel.
  • Texts from your app (titles, toasts, dialogs) are shown as plain text and truncated; panel navigation accepts only same-site paths.
  • Never accept a session token as a Control API credential, and never put it in URLs or logs.

On this page