VRPlatformVRPlatform
Embed the Hosted UI

Iframe & Token Lifecycle

Mount one-time bootstrap URLs and renew active sessions in place

Your backend issues a bootstrap URL through POST /partner/embed-sessions. The parent receives no bearer token:

{
  "id": "6dbfd424-3c66-450c-b11d-f5099b0e70f7",
  "channelId": "cc0528eb-8a9e-48ac-96fd-743101caac97",
  "parentOrigin": "https://app.hostaway.com",
  "embedUrl": "<embed-origin>/reports/trial-balance?frameAncestorToken=<policy>#code=<code>",
  "teamId": "4f8f94de-2cc0-4ec7-a7f8-f0c7f560e59a",
  "userId": "018f9c2b-7f4b-7d72-bf2e-9ab983f07018",
  "bootstrapExpiresAt": "2026-07-19T12:01:00.000+00:00",
  "sessionTtlSeconds": 900
}

Mounting

Set embedUrl directly as the iframe src. Do not parse either credential, move the fragment code, or call an embedded endpoint from the parent:

<iframe
  src="<embed-origin>/reports/trial-balance?frameAncestorToken=<policy>#code=<code>"
  title="Profit and loss"
></iframe>

Frame-Ancestor Policy

Before the iframe HTML is served, the embedded-application host submits the short-lived query credential to POST /embed-sessions/frame-ancestor-policy over its private API connection. The API returns the session's exact embedOrigin and parentOrigin. The host verifies the embed origin and emits one response header:

Content-Security-Policy: frame-ancestors https://app.hostaway.com

The response is no-store. Missing, invalid, expired, cross-origin, or already-exchanged policies fail closed with frame-ancestors 'none'; the embedded app is not served. The partner page must not call the policy endpoint or synthesize this header itself.

allowedEmbedParentOrigins is the API-token-level upper bound. The exact parentOrigin chosen for this session becomes the browser-enforced CSP, so different approved partner domains can receive different policies without an embedded-application deployment.

Exchange

The iframe calls POST /embed-sessions/exchange from the embedOrigin selected during issuance. One successful exchange:

  • rejects every other browser origin;
  • consumes the single-use code and activates the session;
  • returns a 15-minute bearer with the server-bound channelId and parentOrigin; and
  • ends with the embedded app clearing both bootstrap credentials from its visible URL.

The bearer stays in memory, bound to one session, partner, team, user, audience, and scope set. bootstrapExpiresAt is the 60-second code deadline; sessionTtlSeconds is the bearer lifetime starting at exchange.

A plain iframe reload cannot restore access: the code is spent and the cleared URL holds no session, so a reload needs a new bootstrap URL and mount. A running iframe renews in place through the channel described below.

Removing the iframe only clears its in-memory token. Server-side invalidation is DELETE /partner/embed-sessions/{id} from your backend, on logout, account switching, permission changes, and incident response. The API checks the session record on every bearer request, including GET /me.

Establishing the Channel

Use window.postMessage only to transfer one dedicated MessageChannel port:

  1. After successful exchange, the iframe sends channel.request to the exact returned parentOrigin.
  2. The parent validates the iframe origin, event.source, channelId, protocol, version, and request ID.
  3. The parent creates a MessageChannel and sends channel.connect to the exact iframe origin while transferring one port.
  4. The iframe validates the parent origin, event.source, channel ID, and replyTo before accepting that port.
  5. All lifecycle messages then use the port, not global window messaging.

Never use targetOrigin: "*". Track the expected contentWindow separately for every iframe so two instances cannot claim each other's channel.

The runtime schemas and TypeScript types are exported from @vrplatform/api as embedLifecycle. Every strict envelope includes:

{
  "protocol": "vrplatform.embed.lifecycle",
  "version": 1,
  "channelId": "cc0528eb-8a9e-48ac-96fd-743101caac97",
  "type": "ready"
}

Unknown types, extra fields, malformed identifiers, and incompatible versions must not reach application handlers.

Lifecycle Messages

The iframe sends:

  • ready after authentication and initial view rendering.
  • error with a sanitized code, recoverability, and correlation ID.
  • session.expiring with an expiry and requestId.
  • session.renewed with the replacement expiry and replyTo.
  • resize with a positive integer content height when automatic sizing is enabled.

The parent sends session.renew with a new requestId, sets replyTo to the expiring message's requestId, and includes the complete successor embedUrl. Messages never contain a bearer token, partner key, user data, team data, report data, or filters.

In-Place Renewal

  1. Receive session.expiring from the iframe.
  2. Ask your backend to call POST /partner/embed-sessions/{id}/renewals.
  3. Send the returned complete embedUrl in session.renew.
  4. The iframe validates the URL against its own origin, exchanges the new code once, and keeps the old bearer until exchange succeeds.
  5. A successful exchange activates the successor and revokes the predecessor atomically. The iframe keeps its current route, query filters, and rendered report state, then sends session.renewed.
  6. A rejected renewal produces error and leaves the predecessor usable until its existing expiry.

Initial bootstrap failures happen before the iframe knows the server-approved parent origin and channel ID. They remain visible inside the iframe and are not sent through an unauthenticated pre-exchange channel.

Frontend Flow

  1. Ask your backend for a bootstrap URL.
  2. Mount the iframe before the 60-second bootstrap expiry.
  3. Establish the dedicated channel and wait for ready.
  4. Renew in place before the 15-minute bearer expires.
  5. On access or team change, revoke the old session, discard the iframe, and create a new identity-bound session.

Do not persist the URL, policy token, code, or session token in local storage or telemetry, and never use the partner API key in the browser.

See UI States for shell behavior.

On this page