Iframe Embeds
The iframe embed system lets you render REC-CAP assessments, goals, progress, and life domains directly inside your own application. Your users see an RCMS-powered experience that fits visually inside your product.
Why iframes (and when to use the API instead)
Iframes and the JSON API serve different purposes. Use the right one for each task:
| Task | Use |
|---|---|
| Provision an organization, staff member, or client | JSON API |
| Take a REC-CAP assessment, review results, update goals | Iframe embed |
| Sync enrollment status, receive event notifications | JSON API / Webhooks |
| Display a client's assessment history or progress chart | Iframe embed |
Partners get continuous UI improvements for free — when RCMS adds a new chart or refines a workflow, your embed picks it up on the next page load with no work on your side.
The nine embed routes
Production base URL: https://embed.measurerecovery.com. Sandbox base URL: https://embed-sandbox.measurerecovery.com. Both subdomains are isolated from the main RCMS application — their own Content-Security-Policy, cookies scoped to the embed surface, and any non-embed path redirects to a denied page.
Context 1: Staff organization-level
Add these to your staff navigation sidebar. Scoped to an organization.
| Embed | Token context | URL path | Required permission |
|---|---|---|---|
| Assessments library | org_assessments | /embed/org/:orgId/assessments | assessments:read |
| Resources | org_resources | /embed/org/:orgId/resources | resources:read |
| Goal Templates | org_goal_templates | /embed/org/:orgId/goal-templates | goal_templates:read |
Context 2: Staff client-level tabs
Add these as tabs inside your client record view. Scoped to one client.:clientId is the RCMS client_uuid (returned by POST /v1/clients).
| Embed | Token context | URL path | Required permission |
|---|---|---|---|
| Assessments tab | client_assessments | /embed/clients/:clientId/assessments | assessments:read, assessments:write |
| Goals tab | client_goals | /embed/clients/:clientId/goals | goals:read, goals:write |
| Progress tab | client_progress | /embed/clients/:clientId/progress | progress:read |
| Life Domains tab | client_life_domains | /embed/clients/:clientId/life-domains | life_domains:read, life_domains:write |
Context 3: Client portal
Embed these in your client-facing application. The token is scoped to the signed-in client — they see only their own data.
| Embed | Token context | URL path | What the client sees |
|---|---|---|---|
| My Goals | client_portal_goals | /embed/client-portal/goals | Active goals, tasks, progress bars |
| Assessment | client_portal_assessments | /embed/client-portal/assessment | Take or resume their REC-CAP assessment |
| Progress | client_portal_progress | /embed/client-portal/progress | Score trend, domain breakdowns, history |
Client-portal embeds require the client to have a portal account — invite them via POST /v1/clients/:clientId/invite before minting. Minting a client-portal token for a client without a portal account returns 409 precondition_failed.
The embed token flow
Iframes can't use your API key directly — that would expose it in the browser. Instead, your backend mints a short-lived embed token (a signed JWT) that authorizes a specific user to see a specific embed. The iframe URL carries the token; RCMS validates it server-side and enforces permissions via Row Level Security.
Partner backend RCMS API Embed iframe
| | |
| POST /v1/embed-tokens | |
| (API key + context + identity) | |
| ---------------------------------> | |
| | Validate key, mint JWT |
| | (30-min TTL) |
| <--------------------------------- | |
| { token, embed_url, expires_in } | |
| | |
| Render <iframe src={embed_url}> | |
| -----------------------------------------------------------> |
| | |
| | Validate JWT + enforce RLS |
| | <------------------------ |
| | Render REC-CAP UI |
| | -------------------------> |Mint an embed token
curl -X POST https://api-sandbox.measurerecovery.com/v1/embed-tokens \
-H "Authorization: Bearer rcms_sb_sk_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
-H "X-Organization-Id: <org_public_id>" \
-H "Content-Type: application/json" \
-d '{
"context": "client_assessments",
"staff_id": "<staff auth.users UUID>",
"client_id": "<client_uuid>",
"permissions": ["assessments:read", "assessments:write"]
}'Response:
{
"data": {
"token": "eyJhbGciOiJFUzI1NiIs...",
"embed_url": "https://embed-sandbox.measurerecovery.com/embed/clients/<client_uuid>/assessments?token=eyJ...",
"expires_in": 1800
},
"meta": {
"request_id": "...",
"timestamp": "2026-06-09T19:30:00Z"
}
}Request body reference
| Field | Type | Description |
|---|---|---|
| context | string, required | Which embed to render. One of the 12 contexts listed above (e.g. client_assessments). |
| staff_id | UUID, conditional | Required for staff contexts (Contexts 1 & 2). The staff member's auth.users UUID — returned by POST /v1/staff as id. |
| client_id | UUID, conditional | Required for any client-scoped context. The client's client_uuid — returned by POST /v1/clients. |
| permissions | string[], required for staff contexts | Embed permission scopes (see vocabulary below). Validated against the staff member's actual RCMS capabilities at mint time — you can never request more than the staff member has. Client-portal contexts ignore this field (the client is the auth identity). |
| theme | object, optional | Visual overrides. primary_color, logo_url, app_name. The hide_rcms_branding flag requires your key to have the white-label permission — contact support to enable. |
Token TTL is fixed at 1800 seconds (30 minutes). The X-Organization-Id header is required when your API key is network-scoped (typical for partner integrations).
Permission vocabulary
Nine fine-grained read/write codes. Mint with the narrowest set the partner integration needs — minimum-privilege at the token level.
| Code | Grants |
|---|---|
| assessments:read | View completed assessments + scores |
| assessments:write | Take and submit assessments |
| goals:read | View recovery plan + goals |
| goals:write | Create, edit, complete goals & tasks |
| life_domains:read | View life-domain check-ins |
| life_domains:write | Record new life-domain check-ins |
| progress:read | View score trends + history |
| resources:read | Browse org-curated resource library |
| goal_templates:read | Browse org-curated goal templates |
Worked example: assessments tab inside a client record
A staff user opens their client record. You want to embed the RCMS Assessments tab inside your own page.
// Partner backend
const response = await fetch(
"https://api.measurerecovery.com/v1/embed-tokens",
{
method: "POST",
headers: {
Authorization: `Bearer ${process.env.RCMS_API_KEY}`,
"X-Organization-Id": orgPublicId,
"Content-Type": "application/json",
},
body: JSON.stringify({
context: "client_assessments",
staff_id: currentStaff.rcmsAuthUserId,
client_id: rcmsClientUuid,
permissions: ["assessments:read", "assessments:write"],
}),
},
);
const { data } = await response.json();
return { embed_url: data.embed_url };<!-- Partner frontend -->
<iframe
src={embed_url}
title="REC-CAP Assessments"
style={{ width: "100%", height: "800px", border: 0 }}
allow="clipboard-read; clipboard-write"
/>Worked example: client portal
A client logs in to your platform. You want to show their RCMS recovery goals.
// Partner backend (client_portal_* contexts don't take staff_id or permissions)
const response = await fetch(
"https://api.measurerecovery.com/v1/embed-tokens",
{
method: "POST",
headers: {
Authorization: `Bearer ${process.env.RCMS_API_KEY}`,
"X-Organization-Id": orgPublicId,
"Content-Type": "application/json",
},
body: JSON.stringify({
context: "client_portal_goals",
client_id: signedInClient.rcmsClientUuid,
}),
},
);
const { data } = await response.json();postMessage protocol
The embed iframe communicates with your parent app via window.postMessage. Always verify event.origin matches your embed hostname before acting on a message.
Refresh at 80% TTL
Around 24 minutes into the 30-minute token, the iframe posts rcms:token_refresh_needed. Your parent app re-mints via the API and posts the new token back:
window.addEventListener("message", async (event) => {
if (event.origin !== "https://embed.measurerecovery.com") return;
if (event.data?.type === "rcms:token_refresh_needed") {
// 1. Re-mint via your backend (which holds the API key)
const fresh = await fetch("/api/refresh-embed-token", { method: "POST" });
const { token } = await fresh.json();
// 2. Post the new token back to the iframe
document.querySelector("iframe").contentWindow.postMessage(
{ type: "rcms:token_refresh", token },
"https://embed.measurerecovery.com"
);
}
if (event.data?.type === "rcms:token_error") {
// Token validation failed — re-mint immediately
refreshEmbedToken();
}
});Event reference
| Direction | Event | Meaning |
|---|---|---|
| iframe → parent | rcms:token_refresh_needed | Token is 80% through its TTL. Re-mint and post back via rcms:token_refresh. |
| iframe → parent | rcms:token_error | Token is missing, malformed, expired, or rejected by Supabase. Payload includes code and message. |
| parent → iframe | rcms:token_refresh | New token in token field. Iframe swaps the session in-place without reload. |
Responsive sizing
RCMS UI is responsive and adapts to its container. Partners should:
- Use
width: 100%on the iframe so it fills its parent - Set a generous minimum height (we recommend
800px) to avoid scrollbars for typical screens - Do not fix the iframe to a specific pixel height — RCMS UI changes continuously and a fixed height will cause content to be cut off
What's guaranteed — and what isn't
| Contract item | Stable? |
|---|---|
| The 9 embed URL patterns + 12 contexts above | Yes — versioned, changes trigger a new major version |
| Embed token request/response shape | Yes |
| Permission names (assessments:read, etc.) | Yes |
| postMessage event names | Yes |
| The set of features visible in each embed | Yes — we only add, never silently remove |
| Exact pixel layout, chart positions, widget order | No — we iterate the UI |
| Colors, fonts, exact copy | No |
| New widgets or tabs added inside an embed | Added without notice — partners benefit automatically |
Security model
- Short-lived tokens. 30-minute TTL. If permissions change after a token is minted, a staff member retains the old permissions until the token expires or you re-mint.
- Minimum-privilege at the token.The mint function validates every requested permission against the staff member's actual RCMS capabilities — you can never request more than the staff has. Token claims are read-only inside the iframe.
- JWT-claim-based gating. Every embed route validates the JWT's
embed_context, the URL path identity (route:clientId/:orgIdmust equal the matching claim), and the permission set. Three gates per request, all from the signed JWT. - Emergency revoke.If you need to cut off access immediately (offboarded staff member, compromised account), deactivate the user via your backend; RCMS's Row Level Security will deny access at the database layer regardless of outstanding tokens.
- Token stays server-side until the iframe loads. Mint the token in your backend, pass only the
embed_urlto the browser. Never expose API keys in client-side JavaScript. - Subdomain isolation. Embeds run on
embed.measurerecovery.com, separate from the main app domain. Iframe cookies and CSP are scoped to that subdomain. Any non-embed path on the subdomain redirects to a denied page. - RLS is the final boundary.Even if a JWT's claims were wrong or tampered with, RCMS's database RLS policies re-verify authorization on every query. Two layers of defense.