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.
1. Set up in your console
Section titled “1. Set up in your console”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.
- Sign in to your console, and switch it to Test in its header. Getting access has its address.
- In Settings, on the Integration tab, press Add key and choose Generate a key.
- 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’skid; andDOSHI_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.
2. Add a token endpoint
Section titled “2. Add a token endpoint”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.
3. Load Doshi in the webview
Section titled “3. Load Doshi in the webview”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:
// 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 withWebViewCompat.addDocumentStartJavaScript, from AndroidXwebkit. Before loading our page, check bothWebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT)andWebViewFeature.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.
- iOS:
- Answer every ask with a fresh token from your endpoint. Our page asks again about
20 seconds before each token expires. Answer
token: nullwhen the member has no session. - Handle the two events.
doshi:readyfires once, when the first screen has rendered.doshi:errorwithauth_expiredmeans 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, overWKScriptMessageHandlerWithReplyon iOS andWebViewCompat.addWebMessageListener— with the same origin rules — on Android.WKScriptMessageHandlerWithReplyhas no origin rules of its own, so on iOS answer only a message whoseframeInfo.isMainFrameis true and whoseframeInfo.securityOriginhas protocolhttpsand our host,surface.doshi.app. Any other page the webview lands on could otherwise ask for a token.
4. Check it in Test
Section titled “4. Check it in Test”5. Go live
Section titled “5. Go live”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.
Logout
Section titled “Logout”Tear down the webview. The member token lives in memory only, so it dies with the page. See Hand over the token.
What protects the member
Section titled “What protects the member”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.