Skip to content

Webview

Load our page in a webview inside your iOS or Android app. Members signed in to your app go straight in, and we ship updates.

Your app loads our page in a webview. Your backend mints a member token, and your app hands it over when our page asks. Members already signed in to your app are never asked to sign in again, and we ship updates to the page without any change on your side.

What you build: one endpoint on your backend, and a script your app injects into the webview.

The webview must be a supported version.

To bundle our code inside your app instead, use the SDK and pick “App”. If your webview loads a web page of yours that frames Doshi, that is the iframe integration unchanged.

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.

There is no origin to allow. The webview loads our page as its own page, so no page of yours frames it or calls our API.

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.

Your app calls this endpoint with its own session, then passes the token into the webview in the next step.

Point the webview at our page:

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

It takes the iframe’s parameters: client and env, and optionally edition, start, id and locale. Use env=test while you integrate.

Our page asks for a token by posting doshi:token-request to its parent. With no parent page, its parent is its own window. So inject a script that listens on that window and posts doshi:token back to it, quoting the request’s id. This is that script, the file this page imports and tests on every build:

webview-host.js
// Inject this into the webview at document start. It answers our page's token requests through
// your native bridge, `getTokenFromApp` and `sendToApp`, which you implement.
const DOSHI = "https://surface.doshi.app";
window.addEventListener("message", async (event) => {
if (event.origin !== DOSHI || event.source !== window) return;
const message = event.data;
if (message?.type === "doshi:token-request") {
const token = await getTokenFromApp().catch(() => null); // your native bridge
window.postMessage({ type: "doshi:token", id: message.id, token }, DOSHI);
}
if (message?.type === "doshi:ready" || message?.type === "doshi:error") {
sendToApp(message); // your native bridge
}
});
  • Inject it at document start, before our page runs. Our page asks before its first screen and waits 10 seconds for an answer.
    • iOS: WKUserScript(source:injectionTime: .atDocumentStart, forMainFrameOnly: true).
    • Android: turn on JavaScript first, with webView.settings.javaScriptEnabled = true; it is off by default. Inject with WebViewCompat.addDocumentStartJavaScript, from AndroidX webkit. Before loading our page, check both WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT) and WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER) — they depend on the installed Android System WebView, not on your app. If either is unsupported, the page cannot receive a token: tell the member to update Android System WebView, or use the SDK with “App”, which takes its token through a function call and needs no injected script. Pass allowed origin rules naming only our origin, setOf("https://surface.doshi.app"), never "*", so the script and its token bridge run nowhere else.
  • Answer every ask with a fresh token from your endpoint. Our page asks again about 20 seconds before each token expires. Answer token: null when the member has no session.
  • Handle the two events. doshi:ready fires once, when the first screen has rendered. doshi:error with auth_expired means the token flow ended: sign the member in again and reload the webview. See Events.
  • The native bridge the script above calls (getTokenFromApp, sendToApp) is yours to implement, over WKScriptMessageHandlerWithReply on iOS and WebViewCompat.addWebMessageListener — with the same origin rules — on Android. WKScriptMessageHandlerWithReply has no origin rules of its own, so on iOS answer only a message whose frameInfo.isMainFrame is true and whose frameInfo.securityOrigin has protocol https and our host, surface.doshi.app. Any other page the webview lands on could otherwise ask for a token.

Switch your console to Live in its header, and repeat step 1 there: a Live key, and its four DOSHI_* variables in your backend’s secret store. 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.

Tear down the webview. The member token lives in memory only, so it dies with the page. See Hand over the token.

A webview is not a browser tab, and the framing policy cannot constrain it: a native app can load any URL it likes. We do not pretend otherwise. In a webview, the gate is the token exchange, which is why your token endpoint’s own authentication is load-bearing; see Hand over the token.