Skip to content

SDK

Install @doshi/sdk and mount it in your web page or app. Nothing loads from us at runtime but your members' data.

The SDK is Doshi’s Learn screens as a package. A page of yours, inside your iOS or Android app or on the web, calls mount with an element and a way to get a token, and Doshi draws inside that element, in a shadow root of its own. The code is the version you installed, and it changes only when you upgrade.

What you build: one endpoint on your backend, and a mount call on your page. Pick where that page runs, and the steps below show only that case.

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.

Our API answers requests only from the origins your console allows.

Allow the origin your app serves the SDK page from. It must be an https:// virtual host, such as Android’s https://appassets.androidplatform.net. The console takes no app scheme, so email hello@doshi.app before you build on one.

Origin: null is never allowed. A page loaded from file: sends Origin: null, and so does every such page on every device, so allowing it would allow all of them. Serve the page from an origin of your own.

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.

The package is @doshi/sdk on npm. Each release comes with the evidence to check it, so verify it, then install that version:

npm install @doshi/sdk@0.3.2

The package is one JavaScript file with its declarations, and it installs no other package.

This is the whole host side. It is the source of our own example host page, imported into this page rather than copied, so it is the code our end-to-end tests drive.

main.ts
// A bank's page with Doshi in it: mount into one element, and unmount on logout.
import { type Mounted, mount } from "@doshi/sdk";
import { getToken } from "./host";
import { LOCAL_STACK } from "./local-stack";
function byId(id: string): HTMLElement {
const element = document.getElementById(id);
if (!element) throw new Error(`the page has no #${id}`);
return element;
}
const slot = byId("doshi");
const status = byId("status");
const session = byId("session");
let mounted: Mounted | null = null;
let readyCount = 0;
function signIn(): void {
status.textContent = "loading";
mounted = mount(slot, {
getToken,
urls: LOCAL_STACK,
onReady: () => {
readyCount++;
status.textContent = "ready";
status.dataset.readyCount = String(readyCount);
},
onError: ({ code }) => {
status.textContent = code === "auth_expired" ? "session ended" : "unavailable";
},
});
session.textContent = "Log out";
}
function logOut(): void {
mounted?.unmount();
mounted = null;
status.textContent = "logged out";
session.textContent = "Log in";
}
session.addEventListener("click", () => (mounted ? logOut() : signIn()));
signIn();

Two lines are there only because the example runs against a local copy of Doshi: the LOCAL_STACK import and urls: LOCAL_STACK. Leave both out. Your page needs urls only to proxy our traffic.

getToken fetches the member’s token from the endpoint of step 3. The example’s backend answers { token }. Yours answers with whatever your page reads, such as the access_token from the token exchange.

host.ts
// The member's token, from the page's own backend. Yours calls your own
// token endpoint: the one that signs an assertion for the signed-in
// customer and exchanges it.
export async function getToken(): Promise<string> {
const res = await fetch("/token", { method: "POST" });
if (!res.ok) throw new Error(`token endpoint: ${res.status} ${await res.text()}`);
const { token } = (await res.json()) as { token: string };
return token;
}
  • Answer every call with a fresh token. Doshi calls getToken before its first screen, then about 20 seconds before each token expires. It caches the token itself, so you should not. See the contract.
  • Reject when the member has no session. Doshi then blanks its content and calls onError with auth_expired, once. See Expiry.
  • Size the element yourself. The SDK fills the element it is mounted in, and your page’s styles do not reach inside the shadow root.

The SDK is JavaScript, so in an app it runs in a webview:

  1. Ship the host page inside your app, and load it in the webview from the origin you allowed in step 2.
  2. Give getToken to your native code: it asks your app for a token from your endpoint, and rejects when the member has no session.
  3. Pass onOpenLink, and open each URL it hands you in the system browser rather than in your webview. Today’s Learn screens have no outside links, so it is not called yet; passing it now means future links reach your native code.
  4. On auth_expired, sign the member in again and mount afresh.

6. Allow our hosts in your Content Security Policy

Section titled “6. Allow our hosts in your Content Security Policy”

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

connect-src https://core.doshi.app https://gateway.doshi.app;
img-src https://core.doshi.app;
font-src data: https://core.doshi.app;

The SDK’s default fonts are inlined as data: URLs in one <style> element it adds to your <head>. Under a style-src without 'unsafe-inline', Doshi draws in fallback fonts. Font files you send us for your brand load from the data host. 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. No host changes: the exchange URL, and with it aud, is the same in both environments.

mount(element, options) draws Doshi inside element and returns { unmount }.

Option Required What it is
getToken yes () => Promise<string>: the member’s token, from your backend. See Hand over the token.
start no Where Doshi opens: { view: "learning" }, { view: "course", id } or { view: "lesson", id }. See Parameters.
locale no A BCP 47 tag such as nl-NL. See Parameters.
urls no { data, api }: base URLs that replace our hosts, for a page that proxies our traffic through its own origin.
onReady no () => void, called once, when the first screen has rendered. See Events.
onError no ({ code, during }) => void, called once per code. See Events.
onOpenLink no (url) => void, handed every outside link the member follows. Learn has none today.

A misspelt option throws. mount throws a TypeError on any key it does not have, and on a start it cannot read. An option that was silently ignored would be a mount that behaves differently from what you wrote.

There is no environment or client option. The token says both: the environment it was minted in, Live or Test, and your client.

Outside links. No screen the SDK ships has an outside link today, so onOpenLink is for links to come. Without onOpenLink, the SDK opens an outside link in a new window with noopener. With it, the SDK opens nothing and hands you the URL instead, which is what a native shell needs. It never navigates your page either way.

One mount at a time. A second mount throws until the first is unmounted.

When getToken rejects, Doshi blanks its content and calls onError with auth_expired, once. See Hand over the token.

Call unmount(). It removes everything the mount drew and forgets the member’s token. There is nothing else to call. See Hand over the token.