Facial scan
The facial scan connector (ctx.FACIAL_SCAN) is a built-in connector available inside any loop. It handles the full lifecycle of one scan per run: provisioning a session at trigger time, exposing a URL the scanner can open, and returning the scan results to your loop once they’re ready.
This page covers the lifecycle model, why the platform creates a scan session for you up front, and the full connector API.
The scan session
Every facial scan is represented by a scan session — a single record that everything in the lifecycle attaches to: the URL the scanner opens, the scan token, the results, the timeout state.
| Field | Description |
|---|---|
scanSessionId | The session’s identifier. Returned in the trigger response and used internally to thread state across the scan’s lifecycle. |
status | Where the session is in its lifecycle. See the states table below. |
metadata | Whatever you pass to FACIAL_SCAN.create is stored on the session and echoed back when results arrive — use it for per-run context. |
results | The scan output, populated once status is completed. |
Lifecycle states
| Status | When |
|---|---|
pending | The session was created at trigger time. The scanner can open the URL but can’t start scanning yet. |
ready | Your loop called FACIAL_SCAN.create. A scan token is attached and the scanner can begin. |
completed | The scan finished. Results are available; an in-flight step.waitForScan resolves with them. |
failed | The scan failed mid-flow. |
timeout | The step.waitForScan timeout fired before the scan completed. The session is closed and results are empty. |
Why the session is provisioned at trigger time
The trigger response carries a scanUrl for the scanner to open — the consent screen and scan UI:
curl -X POST https://api.ollie.health/loop/$ORG_ID/$LOOP_ID \
-H "Authorization: Bearer $LOOPS_API_TOKEN" \
-d '{ "patientId": "abc" }'
# → {
# "runId": "run_…",
# "scanSessionId": "scan_…",
# "scanUrl": "https://scan.ollie.health/<loopId>/<orgId>/<scanSessionId>/consent"
# }The caller usually wants that URL immediately — to text it to a patient, redirect a kiosk, hand it to a clinician. If the platform waited until the loop’s run() reached FACIAL_SCAN.create before generating a session, the caller would have to poll for the URL after triggering — clunky and slow.
So the platform short-circuits that: at trigger time, it inspects the loop’s deployed code, detects FACIAL_SCAN.create, and provisions a session row in pending state before run() is invoked. The scanSessionId is minted at that point and threaded into the workflow as a parameter, so by the time your loop calls ctx.FACIAL_SCAN.create, the session already exists. That call moves the session from pending to ready and attaches the scan token — it doesn’t create new state.
This is why the trigger response can return both the runId and the scanUrl in the same payload: the session is real, even though the loop hasn’t started running yet.
Connector API
ctx.FACIAL_SCAN exposes three methods. All of them must be called inside a step.do callback — ctx only exists there. See Step context for the surrounding API.
type FacialScanConnector = {
create(input: {
expiresIn?: number;
singleDevice?: boolean;
metadata?: Record<string, unknown>;
}): Promise<void>;
timeout(): Promise<void>;
getResults(): Promise<{
status: "pending" | "ready" | "timeout" | "completed" | "failed";
results: unknown;
}>;
};| Method | Description |
|---|---|
create | Move the pre-provisioned session from pending to ready and attach a scan token. Pass metadata to carry per-run context; it’s echoed back in results. |
timeout | Mark the session as timeout. step.waitForScan calls this automatically when its own timeout fires; you rarely need to call it directly. |
getResults | Read the session’s current state without waiting. Most loops prefer step.waitForScan, which suspends until the scan completes. |
create
| Option | Type | Default | Description |
|---|---|---|---|
expiresIn | number | 3600 | Seconds the scan token is valid for. |
singleDevice | boolean | true | Whether the scan must be completed on the same device that opened the URL. |
metadata | Record<string, unknown> | {} | Arbitrary JSON stored on the session and returned with the results. |
Wait for the scan to finish
After calling create, suspend the run on step.waitForScan to wait for results. While suspended, the loop isn’t consuming compute.
await step.do("Provision scan", async ({ ctx }) => {
await ctx.FACIAL_SCAN.create({
expiresIn: 3600,
metadata: { patientId, phoneNumber },
});
});
const scan = await step.waitForScan({ timeout: "30 minutes" });When the scanner completes, the run resumes with scan set to the payload. If the timeout fires first, waitForScan throws and the session is marked timeout. See Step context for the full waitForScan reference.
Embed the scan in your own app
The scan URL is a normal web page. Drop it into an <iframe> to embed the consent + scan flow inside your own product — useful when you want to keep the scanner on your own domain instead of opening a new tab.
<iframe
src="https://scans.ollie.health/<loopId>/<orgId>/<scanSessionId>/consent"
allow="camera; fullscreen"
style="width: 100%; height: 100svh; border: 0;"
></iframe>A few things matter:
-
allow="camera"is mandatory. Cross-origin iframes don’t inherit camera permission from the parent unless you delegate it explicitly. Without this attribute the SDK can’t open the camera and the scan never starts.fullscreenis optional but useful if you want to support a fullscreen toggle. -
Size the iframe like a viewport. The scan UI is designed to fill its container — the camera preview covers the canvas. Don’t size the iframe smaller than a comfortable scanning area.
-
The iframe runs cross-origin isolated. It sets its own
Cross-Origin-Opener-Policy,Cross-Origin-Embedder-Policy, andCross-Origin-Resource-Policyheaders soSharedArrayBuffer(used by the scan SDK) is available. You can verify from the parent’s DevTools console:document.querySelector("iframe").contentWindow.crossOriginIsolated; // → true -
The iframe is open to any embedder. The scan app sends
Content-Security-Policy: frame-ancestors *, so you can embed it from any domain — including your customers’ apps if you forward the scan URL to them.
When the scan completes, the iframe navigates to its own “all done” page. If your parent app needs to react to completion (close the modal, advance its flow), watch the iframe’s URL or listen for the navigation event.
Examples
Peek at scan state without waiting
For flows that want to short-circuit when a scan has already failed, read state directly with getResults inside a step:
const { status } = await step.do("Check scan", async ({ ctx }) => {
return ctx.FACIAL_SCAN.getResults();
});
if (status === "failed") {
return { error: "scan failed before processing" };
}Carry per-run context through the scan
Anything passed as metadata to create is echoed back on completion, so use it for IDs you’ll need on the other side instead of refetching them:
await step.do("Provision scan", async ({ ctx }) => {
await ctx.FACIAL_SCAN.create({
expiresIn: 3600,
metadata: { patientId, phoneNumber, source: event.body.source },
});
});
const scan = await step.waitForScan<{ metadata: { patientId: string } }>({
timeout: "30 minutes",
});
console.log(scan.metadata.patientId); // same patientId you put in