Skip to Content
ConnectorsFacial scan

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.

FieldDescription
scanSessionIdThe session’s identifier. Returned in the trigger response and used internally to thread state across the scan’s lifecycle.
statusWhere the session is in its lifecycle. See the states table below.
metadataWhatever you pass to FACIAL_SCAN.create is stored on the session and echoed back when results arrive — use it for per-run context.
resultsThe scan output, populated once status is completed.

Lifecycle states

StatusWhen
pendingThe session was created at trigger time. The scanner can open the URL but can’t start scanning yet.
readyYour loop called FACIAL_SCAN.create. A scan token is attached and the scanner can begin.
completedThe scan finished. Results are available; an in-flight step.waitForScan resolves with them.
failedThe scan failed mid-flow.
timeoutThe 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; }>; };
MethodDescription
createMove 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.
timeoutMark the session as timeout. step.waitForScan calls this automatically when its own timeout fires; you rarely need to call it directly.
getResultsRead the session’s current state without waiting. Most loops prefer step.waitForScan, which suspends until the scan completes.

create

OptionTypeDefaultDescription
expiresInnumber3600Seconds the scan token is valid for.
singleDevicebooleantrueWhether the scan must be completed on the same device that opened the URL.
metadataRecord<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. fullscreen is 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, and Cross-Origin-Resource-Policy headers so SharedArrayBuffer (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
Last updated on