Skip to content

Iframe

Frame our page inside yours. Members signed in to your app go straight in, and we ship updates without any change on your side.

The iframe is a page we serve, framed by yours. Your backend mints a member token, and your page hands it over when the frame asks. Members already signed in to your app are never asked to sign in again.

What you build: one endpoint on your backend, and a message listener on your page.

Members need a supported browser.

Your backend vouches for a member by signing a short-lived JWT with a key only it holds. Your Doshi console makes that key for you.

  1. Sign in to your console, and switch it to Test in its header. Getting access has its address.
  2. In Settings, on the Integration tab, press Add key and choose Generate a key.
  3. Copy the four DOSHI_* variables the console shows you into your backend’s secret store. They are shown this once: DOSHI_ISS, the issuer Doshi assigns you; DOSHI_AUD, our exchange endpoint; DOSHI_KID, the key’s kid; and DOSHI_PRIVATE_KEY.

To keep the private key in your HSM or KMS instead, add its public key or a JWKS URL; see Adding a key.

No access yet? See Access and invites.

Allow every origin whose pages frame Doshi. The frame refuses to load anywhere else.

Add each as a bare origin, such as https://app.acme.example, at the console’s setup step Allow the pages that show Doshi, or after your first exchange on its Allowed origins tab. What the console accepts is in Allowed origins.

Add one endpoint to your backend, such as POST /api/doshi-token. For the customer your own session has already authenticated, it signs an assertion and exchanges it for a member token. Copy sign-assertion.ts from Sign an assertion: it is tested against the rules the exchange applies. With it, the endpoint’s core is:

import { configFromEnv, fetchMemberToken } from "./sign-assertion";
// DOSHI_ISS, DOSHI_AUD, DOSHI_KID and DOSHI_PRIVATE_KEY, from your secret store.
const config = configFromEnv(process.env);
// In your "give my page a coach token" endpoint, for the customer that endpoint
// has already authenticated:
const token = await fetchMemberToken(config, customer.id, {
display: { name: customer.name, customer_since: customer.since },
});
// -> { access_token: "...", token_type: "Bearer", expires_in: 900 }

Set the four DOSHI_* variables to the values your console showed you in step 1. Return access_token and expires_in to your page, and nothing else.

The endpoint must authenticate the caller as your own session holder, and sign for that customer only. It is where your session becomes a Doshi session, so nothing else may reach it.

Call it with your Test values. A 400 names its cause in the error table.

Point an <iframe> at:

https://surface.doshi.app/embed.html?client=<slug>&env=<live|test>

It takes these parameters:

Parameter Value
client Your client slug, from your console’s Setup values, never a UUID. Required: a URL without a known slug cannot be framed.
env test while you integrate, live once you go live. Required: the frame loads only on the origins that environment allows, and accepts only its member tokens.
edition The key of the edition to brand the frame as before the token lands. See Parameters.
start Where Doshi opens: learning, course or lesson. See Parameters.
id The course or lesson id, with start=course or start=lesson.
locale A BCP 47 tag such as nl-NL. See Parameters.
https://surface.doshi.app/embed.html?client=acme&env=test&start=lesson&id=0f8e…&locale=nl-NL

The frame asks your page for a token by posting a message to its parent:

{ "type": "doshi:token-request", "id": "5b0c9d1e-…" }

Your page answers with a doshi:token message, posted back to the frame, quoting the request’s id:

{ "type": "doshi:token", "id": "5b0c9d1e-…", "token": "eyJhbGciOiJFUzI1NiIs…" }

This is the whole page side, including the two events of the next step. It is the file this page imports, typechecked and unit-tested on every build against the rules below, so copy it as it is:

frame-host.ts
// Frame Doshi in your page and answer its token requests. Browser only: your page runs this,
// and `getToken` asks your backend, which holds the private key, for a member token.
//
// const doshi = frameDoshi(document.querySelector("#doshi")!, {
// client: "acme-bank",
// env: "test",
// getToken: () => fetch("/doshi/token").then((r) => r.json()).then((t) => t.access_token),
// onError: ({ code }) => console.log(code),
// });
//
// doshi.unmount(); // on logout
//
// `getToken` rejects when the member has no session; the frame then hears `token: null`.
/** Where the frame is served from, and the only origin this page talks to. */
export const DOSHI_ORIGIN = "https://surface.doshi.app";
/** The frame asks for a token: before its first screen, then before each one expires. */
export type DoshiTokenRequest = { type: "doshi:token-request"; id: string };
/** Your answer, quoting the request's `id`. `null` means the member has no session. */
export type DoshiToken = { type: "doshi:token"; id: string; token: string | null };
/** The first screen has rendered. Posted once. */
export type DoshiReady = { type: "doshi:ready" };
/** The member cannot use Doshi now. Posted once per `code`. */
export type DoshiError = {
type: "doshi:error";
code: "unavailable" | "auth_expired";
during: "load" | "session";
};
export type FrameOptions = {
/** Your client slug, from your console's Setup values. */
client: string;
/** `test` while you integrate, `live` once you go live. */
env: "live" | "test";
/** A fresh member token from your backend. Reject when the member has no session. */
getToken: () => Promise<string>;
/** Where Doshi opens, with `id` naming the course or lesson. */
start?: "learning" | "course" | "lesson";
id?: string;
/** A BCP 47 tag such as `nl-NL`. */
locale?: string;
onReady?: () => void;
onError?: (error: Pick<DoshiError, "code" | "during">) => void;
/** Our origin. Change it only when we tell you to. */
origin?: string;
};
const isMessage = <T extends { type: string }>(data: unknown, type: T["type"]): data is T =>
typeof data === "object" && data !== null && "type" in data && data.type === type;
/** Draw the frame inside `element`. `unmount` removes it, and the member's session with it. */
export function frameDoshi(element: HTMLElement, options: FrameOptions): { unmount: () => void } {
const origin = options.origin ?? DOSHI_ORIGIN;
const query = new URLSearchParams({ client: options.client, env: options.env });
if (options.start) query.set("start", options.start);
if (options.id) query.set("id", options.id);
if (options.locale) query.set("locale", options.locale);
const frame = document.createElement("iframe");
frame.src = `${origin}/embed.html?${query}`;
frame.title = "Doshi";
const errored = new Set<string>();
const onMessage = async (event: MessageEvent) => {
// Only our frame. The token is a secret, so check who asked before you answer.
if (event.origin !== origin || event.source !== frame.contentWindow) return;
const message: unknown = event.data;
if (isMessage<DoshiTokenRequest>(message, "doshi:token-request")) {
// A fresh token for every ask, and none kept here: the frame holds its own.
const token = await options.getToken().catch(() => null);
const answer: DoshiToken = { type: "doshi:token", id: message.id, token };
// To our origin, never "*": the browser then delivers it only if the frame still holds
// our page.
frame.contentWindow?.postMessage(answer, origin);
} else if (isMessage<DoshiReady>(message, "doshi:ready")) {
options.onReady?.();
} else if (isMessage<DoshiError>(message, "doshi:error") && !errored.has(message.code)) {
errored.add(message.code);
options.onError?.({ code: message.code, during: message.during });
}
};
window.addEventListener("message", onMessage);
element.append(frame);
return {
unmount: () => {
window.removeEventListener("message", onMessage);
frame.remove();
},
};
}

Its first lines show the call: the element to draw in, your slug and environment, and getToken. It takes the parameters of step 4 as options too, start, id and locale, and unmount is your logout. Your console’s Setup values tab shows this file with your slug and environment filled in.

To write your own instead, keep its check before every answer: the message’s origin is ours and its source is the frame’s window. The rules below say the rest.

getToken calls the endpoint from step 3:

async function getToken() {
const res = await fetch("/api/doshi-token", { method: "POST" });
if (!res.ok) throw new Error(`doshi-token: ${res.status}`);
const { access_token } = await res.json();
return access_token;
}
  • Post to our origin, never "*". The browser then delivers the token only if the frame still holds our page.
  • Answer every ask with a fresh token. The frame asks before its first screen, then about 20 seconds before each token expires. It caches the token itself, so you should not. See the contract.
  • Answer token: null when the member has no session. Silence means the same: the frame waits 10 seconds for an answer.

The frame ignores any answer that does not come from its parent window or that quotes another id.

The frame posts doshi:ready once, when its first screen has rendered, and doshi:error once per code:

{ "type": "doshi:error", "code": "auth_expired", "during": "session" }
code What to do
unavailable Offer “try again later”.
auth_expired Your page stopped handing over tokens. Sign the member in again and reload the frame, or remove it.

during is load if the member never saw a screen, and session if they had been using Doshi. See Events.

7. Allow the frame in your Content Security Policy

Section titled “7. Allow the frame in your Content Security Policy”

If your page sends a Content-Security-Policy, merge these sources into it, as Content Security Policy explains:

frame-src https://surface.doshi.app;

The frame’s own requests are its page’s business, so nothing else is needed. See Content Security Policy.

Switch your console to Live in its header, and repeat steps 1 and 2 there: a Live key, and its four DOSHI_* variables in your backend’s secret store, then the origins of your live pages. Live has its own issuer, and should have its own key; see Yours. For Live we recommend your own public key over a generated one.

Then deploy your backend with the Live variables, and load the embed URL with env=live. No host changes: the exchange URL, and with it aud, is the same in both environments.

Remove the <iframe> element. The frame holds the member token in memory only, so it dies with the frame. There is no logout endpoint to call. See Hand over the token.