Frame the hosted case-collection UI in your own product with a case-pinned, short-lived embed token
Embeddable case flow
The embeddable case flow lets you collect documents for a 9thSense case inside your own product, without building an upload UI yourself. The shape is deliberately narrow:
- Your backend creates a case and mints a short-lived, case-pinned embed token using your API key — server-to-server, never in a browser.
- Your backend hands that token to your frontend, which frames our upload UI in an
<iframe>. - The end customer uploads documents inside the iframe. Your page listens for a handful of
postMessageevents to know when to move on.
The token travels in the iframe URL's fragment (#token=...), which is never sent to a server — it stays out of access logs, Referer headers and intervening proxies. It is still readable by the end customer via devtools, though, so treat everything the iframe can reach as something the end customer can see: every embed-reachable response is projected server-side to strip extracted fields, confidence scores and verdicts (see What the embed identity can't see below).
The embed token authenticates the end customer's browser, not your backend. Never send your API key to a browser, and never accept an embed token as a substitute for your own API key on your server.
Step 1: Create a case
From your backend, using your API key:
curl -X POST https://api.9thsense.ai/v1/cases \
-H "X-Api-Key: $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"goal": "kyc_basic",
"client_reference_id": "loan-app-48213"
}'
{
"session_id": "8f14e45f-ceea-467e-9de1-13d0e2f4c123",
"goal": "kyc_basic",
"status": "collecting",
"...": "..."
}
Requires the write scope. Keep the returned session_id — you'll pass it as {session_id} to the mint call next.
Step 2: Mint an embed token
Still server-to-server, using the same API key:
curl -X POST https://api.9thsense.ai/v1/cases/8f14e45f-ceea-467e-9de1-13d0e2f4c123/embed-token \
-H "X-Api-Key: $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"parent_origin": "https://app.your-product.example.com",
"result_visibility": "neutral"
}'
{
"token": "eyJhbGciOiJIUzI1NiIs...",
"expires_in": 900,
"absolute_expires_at": 1788782400,
"embed_url": "https://app.9thsense.ai/embed/3f9c2e10-8b7a-4d2f-9e15-6a2c9b7f1e00/cases/8f14e45f-ceea-467e-9de1-13d0e2f4c123#token=eyJhbGciOiJIUzI1NiIs..."
}
| Field | Type | Description |
|---|---|---|
parent_origin | string, required | The exact origin (scheme + host + port) your frontend will frame the embed from. Must already be registered for your API key's environment — see Managing your embed origins. |
result_visibility | "neutral" | "verdict", optional | What the iframe is allowed to disclose about the outcome. Defaults to your tenant's configured visibility if omitted. neutral never reveals whether the case was approved or denied — see What the embed identity can't see. |
parent_origin is matched exactly against your tenant's allow-list (after stripping a trailing slash on both sides). A mismatch returns 400 parent_origin is not an allowed embed origin for this tenant — this fails closed: a tenant with no configured origins rejects every parent_origin, rather than defaulting to permissive.
Minting requires the write scope and is not reachable using an embed token itself — only your API key (or a first-party tenant session) can call it.
Step 3: Frame it
Hand embed_url to your frontend and drop it straight into an iframe:
<iframe
src="https://app.9thsense.ai/embed/3f9c2e10-8b7a-4d2f-9e15-6a2c9b7f1e00/cases/8f14e45f-ceea-467e-9de1-13d0e2f4c123#token=eyJhbGciOiJIUzI1NiIs..."
style="border: 0; width: 100%;"
allow="camera"
></iframe>
allow="camera" is only needed if your agent's goal captures a selfie or live photo inside the iframe.
The postMessage contract
The iframe posts messages to window.parent at the exact parent_origin you minted the token with — never "*". There is no inbound listener: nothing your page sends to the iframe can influence the flow.
window.addEventListener("message", (event) => {
if (event.origin !== "https://app.9thsense.ai") return; // ignore anything else
switch (event.data.type) {
case "ninthsense:ready":
// { case_id, height } — the iframe has mounted and rendered its first frame.
break;
case "ninthsense:resize":
// { height } — content height changed; resize the iframe element.
document.getElementById("embed").style.height = `${event.data.height}px`;
break;
case "ninthsense:submitted":
// { case_id } — the end customer pressed Submit; the case moved out of collecting.
break;
case "ninthsense:complete":
// { case_id, status } — UI HINT ONLY. See the warning below.
// Under the default "neutral" visibility, status is always "submitted".
break;
case "ninthsense:expired":
// { case_id } — the embed token hit its absolute cap and could not be refreshed.
break;
case "ninthsense:error":
// { code, message } — something went wrong inside the iframe.
break;
}
});
| Type | Payload | When |
|---|---|---|
ninthsense:ready | { case_id, height } | The embed has mounted and rendered. |
ninthsense:resize | { height } | Content height changed. Iframes don't self-size — resize your element in response to this. |
ninthsense:submitted | { case_id } | The end customer submitted the case. |
ninthsense:complete | { case_id, status } | The case reached a terminal state. status is the projected status, so under the default result_visibility: "neutral" it is always the literal "submitted" — that is the only value a neutral client will ever observe here. Under result_visibility: "verdict" it is the real one: "completed" | "review" | "denied" | "failed". |
ninthsense:expired | { case_id } | The embed session's absolute cap was reached; the customer must reopen the page. |
ninthsense:error | { code, message } | An error occurred inside the iframe. |
ninthsense:complete is a UI hint only. A browser can forge or replay a postMessage, so it must never be the signal your backend acts on to release funds, approve an account, or make any other consequential decision. The only authoritative channel is the signed case.completed webhook delivered to your backend — see the Webhooks guide. Use ninthsense:complete purely to update your own frontend (e.g. close the iframe, show a "we'll be in touch" message), and confirm the outcome server-side against the webhook (or a direct GET /v1/cases/{id} call with your API key) before doing anything that matters.
Handling resize
The iframe has no way to size itself from outside, so it tells you its content height instead:
const iframe = document.getElementById("embed");
window.addEventListener("message", (event) => {
if (event.origin !== "https://app.9thsense.ai") return;
if (event.data?.type === "ninthsense:resize" || event.data?.type === "ninthsense:ready") {
iframe.style.height = `${event.data.height}px`;
}
});
Token lifetime
Embed tokens are intentionally short-lived:
- 15-minute sliding window. The iframe automatically calls
POST /v1/cases/{session_id}/embed-token/refresh(authenticated with the current token itself, viaAuthorization: Embed <token>) before it expires, extending the window without any action from your page. - 2-hour absolute cap, which refresh can never extend. Once a token crosses this cap, refresh stops working and the iframe posts
ninthsense:expired. At that point the end customer must reopen the page — which means your frontend should mint a fresh embed token (repeat Steps 1–2, reusing the samesession_id) and re-render the iframe with the newembed_url.
Your frontend never needs to call refresh itself; the iframe owns that entirely.
What the embed identity can't see
Because the embed token is held by the end customer's browser, every response reachable with it is projected server-side — stripping in the frontend alone would not be enough, since the token can be read out of devtools and used directly against the API. Concretely, an embed-reachable response never includes, in either visibility mode:
- Per-document
extracted,coordinates,confidence,object_key, orfile_url. - Top-level
resultorextractions. progress.rule_evaluationsorprogress.cross_match.
Under result_visibility: "neutral" specifically, the response additionally masks:
progress.guidance,progress.blocked, andprogress.denied.- The case
status: every terminal status (completed,denied,review,failed,expired) is reported as the literal string"submitted". Non-terminal statuses (collecting,processing,remediation) pass through unchanged, since the iframe's upload checklist needs to tell them apart.
If you need the real outcome, read it from your own backend — via the case.completed webhook or a direct GET /v1/cases/{id} call using your API key, which is never subject to this projection.
Onboarding prerequisites
Before framing works for a tenant, three things have to be in place:
Register your origin
Your frontend's exact origin (scheme + host + port) must be registered before you mint any token. You do this yourself — see Managing your embed origins. Until an origin is registered, minting returns 400 and the page cannot be framed.
EMBED_JWT_SECRET provisioned in core's environment
Embed tokens are signed with a dedicated secret, EMBED_JWT_SECRET, which must differ from the platform's own JWT_SECRET — this is enforced at service startup. This is an operational prerequisite on the 9thSense side; it doesn't require anything from you, but framing will not work until it's set.
Managing your embed origins
An origin must be registered before it can frame your case flow or be passed as
parent_origin. Registration is per environment: the environment is taken
from the API key you call with, never from the request, so a test key cannot
register or remove a live origin.
Reading the list needs the read scope. Adding and removing need admin —
changing who may frame your case data is deliberately a narrower credential
than creating a case, so you may need a key minted with that scope.
# What is registered for this key's environment
curl https://api.9thsense.ai/v1/embed/origins \
-H "X-Api-Key: $NINTHSENSE_API_KEY"
# -> {"environment": "live", "origins": ["https://app.client.com"]}
# Register one
curl -X POST https://api.9thsense.ai/v1/embed/origins \
-H "X-Api-Key: $NINTHSENSE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"origin": "https://app.client.com"}'
# -> 201 {"environment": "live", "origin": "https://app.client.com", "created": true}
# Remove one
curl -X DELETE "https://api.9thsense.ai/v1/embed/origins?origin=https%3A%2F%2Fapp.client.com" \
-H "X-Api-Key: $NINTHSENSE_API_KEY"
# -> 204
Re-registering an origin you already have returns 200 with "created": false
rather than an error, so a provisioning script can run repeatedly.
An origin is scheme + host + optional port and nothing else. These are
rejected with a 400 naming the value and the problem:
| Rejected | Why |
|---|---|
app.client.com | no scheme |
http://app.client.com | http:// is allowed only for localhost |
https://app.client.com/embed | paths, queries and fragments are not part of an origin |
https://*.client.com | wildcards are not supported — register each origin |
https://user:pw@app.client.com | credentials are not part of an origin |
Surrounding whitespace, one trailing slash, and the case of the scheme and host
are normalised for you, so https://APP.client.com/ registers and matches as
https://app.client.com. A port is significant and is preserved.
The response echoes the environment your key belongs to. If an origin you
registered is missing from the list, check that first — an origin registered
with a test key is invisible to a live one.
Test against your staging origin first. Once framing is confirmed there, ask for your production origin to be added before going live.
Full example
import httpx
API_KEY = "zk_a1b2c3d4e5f6_0123456789abcdef0123456789abcdef"
BASE_URL = "https://api.9thsense.ai"
def create_embed_session(client_reference_id: str, parent_origin: str) -> str:
"""Server-to-server only. Returns the embed_url to hand to your frontend."""
with httpx.Client(base_url=BASE_URL, headers={"X-Api-Key": API_KEY}) as client:
case = client.post("/v1/cases", json={
"goal": "kyc_basic",
"client_reference_id": client_reference_id,
}).raise_for_status().json()
token = client.post(
f"/v1/cases/{case['session_id']}/embed-token",
json={"parent_origin": parent_origin, "result_visibility": "neutral"},
).raise_for_status().json()
return token["embed_url"]
Never return the raw API key, or anything derived from it, to your frontend. Only embed_url (and, if you want it, the bare session_id for your own bookkeeping) should ever reach the browser.