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
- Embed URL. Set it on your app listing (Control team: superadmin → Apps → your app → Embed URL). The panel opens
/apps/{slug}/openat that URL. - Allow framing. Send a
Content-Security-Policyheader withframe-ancestors https://app.control.cm(and your own origin if you like). Without it the browser refuses to show your app. Don't sendX-Frame-Options: DENY. - 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:
| Check | Expected value |
|---|---|
| Signature | RS256, key from https://api.control.cm/oauth/jwks (match kid) |
Header typ | control-session+jwt |
iss | https://api.control.cm/oauth |
aud | your client_id |
exp / nbf | current (allow a few seconds of clock skew) |
token_use | session |
Claims:
| Claim | Meaning |
|---|---|
sub | {userId}@{companyId}, the same as the sub in your OIDC id_token |
uid | Control user id |
cid | Control company id |
iid | Installation id of your app in that company |
jti | Unique 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,replaceStateandpopstate. Callbridge.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
onNavigateand route withreplaceState; 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
| Member | Description |
|---|---|
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.embedded | true inside the Control panel. |
bridge.ready | Promise of { slug, locale, theme }. Rejects outside the panel or if the panel doesn't answer. |
bridge.context | The 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.