Appearance
Embedding the collection flow
The applicant fills in a collection submission in the collection terminal (apps/collection-terminal), through the hosted link:
<COLLECTION_FLOW_URL>/flow/<submissionId>#token=<jwt>COLLECTION_FLOW_URL is where the terminal is served: the collect. host of a deployment, http://localhost:5177 on the development stack. The token is a signed, expiring applicant token scoped to that one submission. It sits in the fragment, which browsers never send to a server, so no proxy, CDN or access log in front of the terminal sees it; a ?token= query works too. The terminal keeps it in sessionStorage for the tab and strips it from the address on first render, so a reload works and the address bar never shows it.
The same link can be embedded in a merchant's page. Embedding is an iframe: the merchant's page and the flow stay isolated (CSS, scripts, cookies; camera and file inputs work natively). When the link carries embed=1 and the page is actually framed, the terminal drops its own header and footer (the merchant's page owns the chrome), checks who embeds it and talks to the embedding page with postMessage. A Web Component is deferred (see docs/plans/deferred.md).
The terminal reaches the API through its own origin: it requests /api/* on its own host and the server that serves it (nginx in the web image, Vite in development) forwards those requests to the API. The merchant's page never talks to the API, and neither origin belongs in API_CORS_ORIGINS.
Obtaining a link
A merchant backend creates the submission for its own flow and gets the link back in one call:
http
POST /collection-submissions
Authorization: Bearer <integration-role token>
Content-Type: application/json
{ "flowKey": "kyc-basic", "subjectId": "…" }json
{ "submission": { "id": "…", "status": "in_progress", … }, "link": "https://collect.example/flow/…#token=…" }The API builds link from COLLECTION_FLOW_URL and falls back to http://localhost:5177 when it is not set, so set it in every deployment. The link embeds the applicant token: pass it to the browser once (render it into the page or return it from an authenticated endpoint); never log it. Submissions started by a workflow's wait_for_collection step get their link the same way, through the workflow's output.
Link TTL. The token expires after linkTtlSeconds of the flow definition, or for a flow that sets none the tenant's collection.defaultLinkTtlSeconds, else 7 days (maximum 30 days = 2 592 000 s), counted from when the link was created. An applicant who keeps answering keeps the link alive: once less than half of its lifetime is left, a save asks for a fresh token (POST /collection-submissions/{id}/refresh), which lives the same lifetime from then (capped at a request for information's expiry), and so does "Keep going" in the warning the terminal shows as the end approaches. An idle link expires on time. An expired link opens on "This link has expired" with Ask … for a new link: POST /collection-submissions/{id}/resume-link raises the webhook event submission.link_requested (ids only), on which the tenant's backend sends a fresh link (POST /collection-submissions/{id}/link); a tenant with no endpoint taking the event answers 409 link_requests_unavailable and the terminal offers the support contact instead. Inside a frame it posts aletheia:error with reason: 'token_expired' to an allowed parent (see Events), and nothing once it expired more than 30 days ago.
Unfinished forms. A tenant can let forms nobody saved for some days expire (Settings › Tenant, collection.abandonAfterDays, 1 to 365): the worker looks every hour, expires them (collection.submission.expired with reason abandoned) and cancels a run that waits for one; a request for information keeps its own expiry. The link then opens on "This request has expired".
Two devices, one link. The terminal saves answers as they change, each save only over the version it last read (PATCH /collection-submissions/{id} with If-Match: <updatedAt>). When the same link saved answers in another tab or on another device in between, the API answers 412 stale_submission and the terminal stops saving and asks the applicant to load the newer answers or keep the ones on screen.
The loader: aletheia-collect.js
The terminal's build includes a dependency-free loader at /embed/aletheia-collect.js, with an ES-module twin at /embed/aletheia-collect.esm.js.
html
<div id="kyc"></div>
<script src="https://collect.example/embed/aletheia-collect.js"></script>
<script>
var handle = AletheiaCollect.mount({
container: '#kyc', // element or CSS selector
link: '<the hosted link>',
theme: 'light', // or 'dark'; optional
accent: '#0f766e', // #rgb or #rrggbb; optional
minHeight: 480, // px, default 480
title: 'Verify your business', // the iframe's accessible name; optional
skipWelcome: false, // true opens on the first question; optional
locale: 'fr', // the language to open in, when the tenant offers it; optional
onReady: function () {},
onResize: function (height) {},
onStep: function (detail) {
// detail.step of detail.total
},
onComplete: function (detail) {
// detail.submissionId; detail.outcome once the decision is known
},
onError: function (detail) {
// detail.code, detail.message
},
});
// later: handle.iframe, handle.destroy()
</script>With a bundler:
js
import { AletheiaCollect } from 'https://collect.example/embed/aletheia-collect.esm.js';
const handle = AletheiaCollect.mount({ container: '#kyc', link });| Option | Type | Default | Effect |
|---|---|---|---|
container | element or CSS selector | required | Where the iframe is appended; throws AletheiaCollect: container not found. |
link | string | required | The hosted link; its existing query is kept. |
theme | 'light' or 'dark' | none | Added as theme=. |
accent | #rgb or #rrggbb | none | Added as accent=. |
minHeight | number (px) | 480 | Initial height and lower bound. |
title | string | "Aletheia collection flow" | The iframe's accessible name. |
skipWelcome | boolean | false | Added as welcome=0: the flow opens on its first question. |
locale | language tag (fr, pt-BR) | none | Added as locale=: see Languages. |
onReady | () => void | none | On aletheia:ready. |
onResize | (height: number) => void | none | After the loader resized the frame, with the applied height. |
onStep | ({ step, total }) => void | none | On aletheia:step. |
onComplete | ({ submissionId, outcome? }) => void | none | On aletheia:complete (called twice after a submit). |
onError | ({ code, message }) => void | none | On aletheia:error. |
mount returns { iframe, destroy() }. It builds the iframe src by appending embed=1 (and theme, accent, welcome=0 and locale when given) to the link's query, and sets allow="camera; microphone", the title, style="width:100%;border:0;height:<minHeight>px" and referrerpolicy="strict-origin-when-cross-origin". The referrer policy matters: the terminal learns who embeds it from location.ancestorOrigins (Chromium and WebKit) or else from the referrer's origin, and only responds when that origin is on the tenant's allow-list. A stricter policy (no-referrer) leaves the flow refusing to render. destroy() removes the listener and the iframe.
The loader only accepts messages whose event.origin is the link's origin and whose event.source is the iframe it created, and ignores message types it does not know, so a page keeps working when the terminal adds events.
Pinning a version
/embed/aletheia-collect.js is always the latest loader (revalidated on every load). A page that pins the loader with subresource integrity loads a frozen version instead: /embed/v1/ holds the same two files, never changed and cached for a year, with their hashes in /embed/v1/integrity.json:
| File | integrity |
|---|---|
aletheia-collect.js | sha384-WiIm1CB5jaXBsOp2nB9G4GlkEBEE0upJgexgdTY64v4tL8Iib26ZZ0JDS+C0ZgRF |
aletheia-collect.esm.js | sha384-IFDi2pEW0nYvQ1pvDZ+jU0Vckd0h0fyzOG1bJyw0a6+h4t/QHPL/crQw4NTn7wKb |
html
<script
src="https://collect.example/embed/v1/aletheia-collect.js"
integrity="sha384-WiIm1CB5jaXBsOp2nB9G4GlkEBEE0upJgexgdTY64v4tL8Iib26ZZ0JDS+C0ZgRF"
crossorigin="anonymous"
></script>v1 takes every option and event on this page. A loader that changes is frozen as a new version (v2, …) beside the earlier ones (node scripts/freeze-loader.mjs v2 in apps/collection-terminal), so a pinned page keeps working until it moves on.
Plain iframe fallback
Without the loader:
html
<iframe
src="<the hosted link>&embed=1&theme=light"
allow="camera; microphone"
referrerpolicy="strict-origin-when-cross-origin"
style="width:100%;border:0;height:600px"
title="Aletheia collection flow"
></iframe>and, if you want resizing and completion, your own message listener with the same origin and source checks as above. Add &welcome=0 to skip the welcome screen and &locale=fr to ask for a language.
Events
The terminal posts these messages to its parent (window.parent.postMessage(message, <parent origin>), never '*') and listens to none:
type | Payload | When |
|---|---|---|
aletheia:ready | none | Once, when the origin check passed and the submission loaded. |
aletheia:resize | height (px, the content's height) | After the origin check, then, throttled to one per 100 ms, whenever the content's height changes: a step change, a question shown or hidden, the outcome screen. The height goes down as well as up. |
aletheia:step | step, total | When the first question shows (after the welcome screen, or at once with skipWelcome or a resumed draft), on every step change, and when an answer changes how many steps lie on the path. step counts from 1; total follows the answers. |
aletheia:complete | submissionId, outcome? | When the submission is sent (no outcome), and again when the run settles, with outcome. A link reopened after the submit posts only the second. |
aletheia:error | code, message, reason?, requestId? | When the submission fails to load (load_failed or the API error code), or saving or sending fails (save_failed, submit_failed or the API code, such as unauthenticated once the link has expired mid-form). Upload errors are shown in the frame only. |
outcome is one of:
approvedorrejected: the run completed with that decision;review: the run waits for a reviewer, or completed withmanual_review;failed: the run failed or was cancelled;follow_up: the run had not settled after 10 minutes (the terminal polls every 2 seconds);processing: the submission belongs to no run.
The outcome is posted whether or not the applicant sees the decision: by default the terminal shows a neutral "Thank you" instead of an approval or a rejection (see Theming and branding).
When the run asks the applicant for more (a reviewer's request for information, or a later collection step), the terminal hands over instead of settling: it shows the request and a Continue button to the next form, and posts no aletheia:complete for it; the next form posts its own events once opened. A withdrawn or expired form opens on a "no longer needed" or "expired" screen.
On aletheia:error, reason names a finer cause when one is known (offline: the request never reached the API; token_expired: the link has expired) and requestId is the API's request id for support. Treat both as optional and unknown values as opaque. An expired link still reports to the embedding page: GET /collection-submissions/{id}/embed answers a link that expired in the last 30 days, with expired: true, so the frame can check its parent before it posts.
The loader maps the events to onReady, onResize(height), onStep({ step, total }), onComplete({ submissionId, outcome? }) and onError({ code, message }), and sets the iframe height to max(minHeight, height). reason and requestId are on the message only, so read them with your own listener when you need them.
Origin allow-list
Which pages may embed a tenant's flows is a tenant setting, embedOrigins (at most 20 origins such as https://shop.example, with no path or trailing slash):
http
PUT /tenants/me/settings
Authorization: Bearer <admin token with tenants:settings>
{ "embedOrigins": ["https://shop.example", "http://localhost:3000"] }The admin console edits the same list under Settings › Tenant (Embedding). The terminal reads it through GET /collection-submissions/:id/embed (applicant token) → { embedOrigins, flowName, expired }, determines the parent origin as described above, and:
- if the parent is not on the list (or cannot be determined), it shows "This form can't be shown here" with an "Open in a new tab" button for the hosted link, and posts nothing;
- otherwise it posts only to that origin.
The terminal's own origin is always allowed. The browser enforces the same list through the page's frame-ancestors below; this in-app check stands behind it. A framed link without embed=1 behaves as a hosted one (no origin check, no messages), and embed=1 on a page that is not framed is ignored.
Content-Security-Policy: frame-ancestors
Browsers decide whether a page may be framed from the response headers of the framed page, so the terminal's host also sends
Content-Security-Policy: frame-ancestors 'self' https://shop.example http://localhost:3000The web image builds it per form link: for /flow/<id> nginx asks the API who may frame that submission (GET /collection-flow-frame-ancestors with X-Submission-Id, an auth_request subrequest), and the answer, the embedOrigins of the submission's tenant, follows the deployment's own EMBED_ORIGINS (origins every tenant may embed from; see Deployment). So one host serves many tenants, each framed only by its own pages. Should the API not answer, the page still loads, framed by EMBED_ORIGINS alone. The Vite dev and preview servers send VITE_EMBED_ORIGINS (comma-separated origins in the repository's .env; empty → 'self' only), so a local merchant page can be tested.
A tenant can also have its own address of the terminal (collection.flowUrl in its settings, links then point there): its host is routed to the web image like the deployment's, in the chart with ingress.hosts.collect.extraHosts and their certificates under ingress.tls.
Uploads
File fields upload straight from the frame to object storage with a presigned URL, the one request that does not go through the terminal's origin. STORAGE_CORS_ORIGINS must therefore list the terminal's origin (Documents); the merchant's origin is not needed there either.
Theming and branding
The hosted link accepts theme=light|dark and accent=<hex> (#rgb or #rrggbb, URL-encoded %23…) whether embedded or not; invalid values are ignored.
themewins over the tenant's or the deployment's color scheme; with none, the terminal follows the device's light or dark setting.accentsets the primary color (buttons, links, focus, progress) when neither the tenant nor the deployment has a brand color.
Each tenant's branding (display name, logo, brand color, color scheme, shape, font, support, privacy and terms links; Settings › Branding in the console, branding in PUT /tenants/me/settings) applies over the deployment's defaults, the API's COLLECTION_TERMINAL_* variables (Environment). The API merges them: a name or logo set by the tenant replaces the default name and logo together, and either support contact replaces both default ones; every other value replaces its own. The terminal reads the result with GET /collection-submissions/{id}/appearance, which also answers a link that expired in the last 30 days, so the screen that says so wears the tenant's brand. Until it arrives the page shows nothing but a loader on a light background (the spinner only after a moment), so neither a default brand nor a dark scheme the tenant does not use shows first; without an answer the page goes neutral. Tenants' logos load from their own https hosts (COLLECTION_TERMINAL_IMAGE_ORIGINS narrows which), and a font must be one the device has or the deployment serves.
What applicants see after the submit is the flow's own choice (outcome.visibility: a thank-you only, where the application stands, or the decision too), else the deployment's COLLECTION_TERMINAL_SHOW_DECISION (the API's, answered with the appearance). The welcome and thank-you screens take the flow's own copy (presentation) when it has some.
Identity checks after the submit
A workflow may wait, after the submit, for a vendor check the applicant takes part in: a liveness check, or documents the vendor collects itself (the sumsub app on such a level). While it does, GET /collection-submissions/{id}/status answers handoff: true, and the terminal shows "Verify your identity" with the vendor's SDK (Sumsub's WebSDK) in place of the progress screen, hosted or framed and whatever the flow's outcome.visibility. It starts the SDK with a short-lived token from GET /collection-submissions/{id}/handoff, renews it the same way and passes on the applicant's language. Once the applicant has sent everything (or the vendor has what it needs: 409 handoff_unavailable), the progress screens take over and the run waits for the vendor's review as before. An applicant who leaves midway finds the check again on the same link.
Besides the app's webhook (App catalogue), this needs:
COLLECTION_TERMINAL_HANDOFF_SDKS=sumsubon the web image, which adds Sumsub's hosts to the terminal's Content-Security-Policy and lets their frames use the camera and microphone (Deployment);- the terminal's host among the allowed domains of the WebSDK in the Sumsub dashboard;
- inside a frame, the camera and microphone delegated to the terminal: the loader's iframe has
allow="camera; microphone"(add it to a plain iframe too), and the embedding page's own Permissions-Policy must not turn them off.
Languages
A tenant's forms come in the languages Settings › Tenant lists (collection.locales in PUT /tenants/me/settings, up to 20 language tags in their canonical case such as fr or pt-BR; unset, English). The first is the language its flows are written in; each flow adds the others it is translated into (translations, edited with Edit translations in the flow editor): names, step titles and descriptions, field labels, help, placeholders, units, option labels and messages, and the welcome, thank-you and outcome copy. Whatever a translation leaves out keeps the flow's own words.
The terminal reads the tenant's languages with the appearance and takes the first of these the flow comes in:
- the applicant's own pick, from the language menu in the page header when the flow comes in more than one language (hosted links only; a frame has no header);
- the link's
localeparameter (the loader'slocaleoption); - the first of the browser's languages the flow comes in, matching
fr-CAtofr; - the flow's own language.
A pick is kept in the address (?locale=), so a reload keeps it. The page's lang follows the language shown and its dir turns right to left for scripts written so (Arabic, Hebrew, Persian, Urdu and others); the layout mirrors with it. A flow version opened with Preview in the console offers the same menu. The terminal's own words (buttons, notices, step counts, dates) are in English whatever the language.
What the terminal reports
So a tenant can see where applicants stop, the terminal reports each step it shows and each file that does not make it (POST /collection-submissions/{id}/events), in batches every few seconds and when the page is hidden: the step's id, or the field's key and a code (too_large, unsupported_type, network, …). Never an answer, a file name or anything typed. The API keeps what names the flow version's steps and fields while the submission is in progress and answers 202 whatever arrives, so a form never waits on it. The admin console shows the result per flow (Drop-off in the flow editor). Previews report nothing.
Trying it locally
There is no example merchant page in production. apps/collection-terminal/mock-merchant.html is a development-only page, never part of the build, that mounts the link from its ?link= parameter with the loader's source and logs every event:
pnpm --filter @aletheia-dev/collection-terminal dev:mockserves the terminal on http://localhost:5177 with a fake API and sample links; open http://localhost:5177/mock for the scenarios, whose "embedded" links open in the mock merchant page. The languages scenario offers English, French and Arabic; the merchant page passes its?locale=to the loader.- Under
pnpm dev, with the real API, openhttp://localhost:5177/mock-merchant.html?link=<the URL-encoded link>.
The page shares the terminal's origin, which is always allowed. To test a merchant page on another origin (say http://localhost:3000), add that origin to the tenant's embed origins and to VITE_EMBED_ORIGINS.