@captello/ulc-webview-sdk
v1.1.0
Published
Typed SDK for embedding the Captello capture webview: message protocol, host client, and embed-URL builder.
Maintainers
Readme
@captello/ulc-webview-sdk
Typed SDK for embedding the Captello capture webview in a host application
(React, plain JS, or any framework). It encodes the exact postMessage contract
the webview speaks, so integrators don't have to reverse-engineer message strings.
It provides:
- The message protocol — enums and discriminated-union types for every message the webview emits and accepts.
CaptelloWebview— a host-side client that wraps an<iframe>and handles the send/receive wire details (JSON-string encoding, origin/source filtering).buildEmbedUrl— a typed builder for the webview's query-string contract.- Typed submission data —
VisibleSubmissionDataItemand friends type a submission'svisible_submissions_dataas a discriminated union you narrow onelement_typeto render however you like. - Promise helpers (
@captello/ulc-webview-sdk/promises) — one-shotawait-style utilities likesubmitForm(iframe)andwaitForFormLoad(iframe). - React adapter (
@captello/ulc-webview-sdk/react) — auseCaptelloWebviewhook that manages the client lifecycle and returns an iframe ref plus typed senders.
The core package is framework-agnostic with no runtime dependencies; React is an
optional peer dependency used only by the /react entry point.
Install
pnpm add @captello/ulc-webview-sdkQuick start
import {
CaptelloWebview,
buildEmbedUrl,
FormMode,
Language,
LauncherType,
OutboundMessageType,
} from "@captello/ulc-webview-sdk";
// 1. Build the embed URL. Pass just the capture origin — the SDK appends the
// capture path for you (passing the full ".../capture/submission" URL also works).
const src = buildEmbedUrl("https://capture.captello.com", {
formId: 1234,
mode: FormMode.Submit,
launcher: LauncherType.EventGenWeb,
language: Language.English,
});
// 2. Point an iframe at it.
const iframe = document.createElement("iframe");
iframe.src = src;
document.body.appendChild(iframe);
// 3. Wire up the client (scope messages to the webview's origin).
const webview = new CaptelloWebview(iframe, {
targetOrigin: new URL(src).origin,
});
webview.on(OutboundMessageType.FormLoadComplete, () => {
console.log("form is ready");
});
webview.on(OutboundMessageType.SubmissionBody, (msg) => {
// Embedded forms hand the submission to the host instead of submitting directly.
persist(msg.data);
});
webview.on(OutboundMessageType.FormErrorMessage, (msg) => {
showToast(msg.data); // already translated & display-ready
});
// 4. Drive the form programmatically.
submitButton.onclick = () => webview.submit();
// 5. Tear down when the iframe is removed.
webview.destroy();Submit and await the result
For the common "click submit, then act on the outcome" flow, use submitAndWait()
instead of wiring submit() to separate listeners. It resolves with the submission
body on submission_body, rejects with a SubmissionError on form_error_message,
and rejects with a SubmissionTimeoutError if neither arrives in time — cleaning up
its listeners in every case.
import { SubmissionError } from "@captello/ulc-webview-sdk";
try {
const body = await webview.submitAndWait(); // default 60s timeout
await persistSubmission(body);
closeDialog();
} catch (err) {
if (err instanceof SubmissionError) {
showToast(err.message); // translated, display-ready
} else {
// SubmissionTimeoutError or a send failure
}
}React — @captello/ulc-webview-sdk/react
The React adapter is the smoothest way to integrate, in two flavors:
<CaptelloForm>— a turnkey component. Drop it in with anembedUrland message callbacks; it renders the<iframe>, shows your loading / error overlays, and forwards the senders on aref. The shortest path.useCaptelloWebview— the underlying hook, for when you'd rather own the markup.
Both own one CaptelloWebview for the iframe's lifetime: they build the embed URL, create
the client when the iframe mounts, wire outbound messages to typed callbacks, track
readiness, and destroy the client on unmount.
react is an optional peer dependency (React 18+).
<CaptelloForm> — the turnkey component
import { useRef } from "react";
import { FormMode, LauncherType, ActionButtonPosition, type SubmissionBody } from "@captello/ulc-webview-sdk";
import { CaptelloForm, type CaptelloFormHandle } from "@captello/ulc-webview-sdk/react";
function UlcForm({
eventWebAccessToken,
onSubmitted,
}: {
eventWebAccessToken: string;
onSubmitted: (body: SubmissionBody) => void;
}) {
const form = useRef<CaptelloFormHandle>(null);
return (
<CaptelloForm
ref={form}
style={{ height: 600 }} // an iframe has no intrinsic height — size the form here
embedUrl={{
baseUrl: "https://capture.captello.com",
eventWebAccessToken,
actionButtonPosition: ActionButtonPosition.Hidden, // b=2
mode: FormMode.Submit,
launcher: LauncherType.EventGenWeb,
}}
defaultFormValues={{
info: [
{ ll_field_unique_identifier: "FirstName", value: "Ada" },
{ ll_field_unique_identifier: "Email", value: "[email protected]" },
],
}}
onSubmissionBody={onSubmitted}
loading={<Spinner />}
error={(message) => <ErrorBanner>{message}</ErrorBanner>}
>
{({ isReady }) => (
<button disabled={!isReady} onClick={() => form.current?.submit()}>
Submit
</button>
)}
</CaptelloForm>
);
}Props are the hook options (the required embedUrl, defaultFormValues, every message
callback, and the client options) plus a few rendering conveniences:
className/style/id— applied to the wrapper element. Size the form here; the iframe fills it.iframeProps— attributes spread onto the<iframe>(title,allow,sandbox,name, …). Defaults:title="Captello form",allow="camera; microphone; geolocation".srcis ignored — the URL comes fromembedUrl.loading— a node shown, centered over the iframe, untilform_load_complete. The iframe stays mounted underneath so it keeps loading.error— a node (or(message) => node) shown when the form reportsform_error_message; the function form receives the translated, display-ready text.children— inline controls rendered after the form. A function receives the live api (status + senders), so a submit button needs no separateref.ref— aCaptelloFormHandle: the senders (submit,reset,updateDraft,triggerValidation,prefill,submitAndWait) plusstatus/isReady,getIframe(), andgetClient(). Use it to drive the form from a parent without lifting state.
useCaptelloWebview — the hook
Prefer to own the markup? The hook returns iframeProps to spread, an isReady flag, and
stable senders — so a typical form is just the hook plus an <iframe>.
import { FormMode, LauncherType, ActionButtonPosition, type SubmissionBody } from "@captello/ulc-webview-sdk";
import { useCaptelloWebview } from "@captello/ulc-webview-sdk/react";
function UlcForm({
eventWebAccessToken,
onSubmitted,
}: {
eventWebAccessToken: string;
onSubmitted: (body: SubmissionBody) => void;
}) {
const { iframeProps, isReady, submit, prefill } = useCaptelloWebview({
embedUrl: {
baseUrl: "https://capture.captello.com",
eventWebAccessToken,
actionButtonPosition: ActionButtonPosition.Hidden, // b=2
mode: FormMode.Submit,
launcher: LauncherType.EventGenWeb,
},
onSubmissionBody: onSubmitted,
onFormErrorMessage: showToast,
});
return (
<>
{!isReady && <Spinner />}
<iframe {...iframeProps} title="UlcForm" allow="camera; microphone" />
<button onClick={submit}>Submit</button>
</>
);
}What the hook (and the component built on it) handle for you:
- URL + origin.
embedUrl: { baseUrl, ...EmbedUrlOptions }is required; the hook builds the URL, derivestargetOrigin, and returns it asiframeProps.src— no separatebuildEmbedUrl/ manualsrcwiring to keep in sync. (Need to own both the URL and the origin? Use theCaptelloWebviewclass directly.) - Readiness.
isReadyandstatus("loading" | "ready" | "error") — no manualuseState+onFormLoadCompletefor a spinner. - Prefill timing. Sends made before the form loads are queued and flushed on
form_load_complete, so you canprefill(...)as soon as you have data — no gating on readiness, and no silently-dropped messages. - Default values.
defaultFormValues: { submission?, info? }populates the form as soon as it's ready, so seeding a known email or a previous submission needs noref, no effect, and no readiness check. See below. - No memoization. Callbacks are read fresh via a ref, so inline arrow functions
won't re-subscribe or re-create the client. The client is recreated only when the
embedUrl-derived origin /hostWindow/matchSource/queueUntilReadychange. - Stable senders (
submit,reset,updateDraft,triggerValidation,prefill,submitAndWait) — safe in deps or passed to children.getClient()returns the live client for escape hatches.
defaultFormValues — populate the form on load
Both <CaptelloForm> and useCaptelloWebview take a defaultFormValues option that seeds
the form the moment it reports form_load_complete:
<CaptelloForm
embedUrl={{ baseUrl: "https://capture.captello.com", eventWebAccessToken }}
defaultFormValues={{
// matched by ll_field_unique_identifier
info: [{ ll_field_unique_identifier: "Email", value: user.email }],
// and/or keyed by element id, same shape prefill() takes
submission: { data: { "1042": "Acme Inc." } },
}}
/>It's the declarative form of prefill(...) — same wire message, same shapes — so you don't
need a ref, an effect, or a readiness check just to seed a form. Semantics:
- Sent first. It goes out ahead of everything else, so an explicit
prefill(...)you make later overwrites it. - Read once, at mount. These are defaults, not controlled values: changing the prop
afterwards does not re-populate the form. Call
prefill(...)for that. - No memoization needed. An inline object literal won't re-fire it or re-create the client.
- Omit or leave empty (
{}) and no message is sent at all.
submission accepts a partial, and a whole SubmissionBody handed to you by
onSubmissionBody also fits — handy for re-opening a captured lead.
Callback props
useCaptelloWebview accepts one optional callback per outbound message. Each receives the
message's payload, not the { type, … } envelope — the callback name already tells you
the type, so there's nothing to discriminate on:
| Callback | Receives |
| ----------------------------- | --------------------------- |
| onFormLoadComplete | — (no payload) |
| onFormErrorMessage | string (translated text) |
| onSubmissionBody | SubmissionBody |
| onFormSubmitSuccess | "create" \| "update" |
| onConnexionsProfileRedirect | — (no payload) |
| onConnexionsDownloadVcard | — (no payload) |
| onAnyMessage | the whole OutboundMessage |
onAnyMessage is the exception: it fires for every type (after the specific handler), so it
gets the full message including type. So does the low-level CaptelloWebview.on(type, …),
which is unchanged — envelopes there, payloads here.
embedUrl (required), defaultFormValues, and the client options (hostWindow,
matchSource, queueUntilReady) go in the same object.
Without the adapter
If you don't want the hook, create a CaptelloWebview yourself in an effect against an
iframe ref, subscribe with .on(...), and call .destroy() on unmount. Hold the client
in a ref (or context) so sibling components can drive it without querying the DOM.
The message contract
Wire format: every message is a JSON string with a type discriminator.
The SDK handles this for you — CaptelloWebview JSON.stringifys outgoing messages
(the webview parses inbound data with JSON.parse, so a raw object would be silently
ignored) and parses + validates incoming ones. Direction is named from the webview's
point of view.
Outbound — webview → host (you listen)
| type | Constant | Payload | Meaning |
| ----------------------------- | ----------------------------------------------- | ---------------------- | --------------------------------------------- |
| form_load_complete | OutboundMessageType.FormLoadComplete | — | Form finished loading/rendering. |
| form_error_message | OutboundMessageType.FormErrorMessage | data: string | Translated, display-ready error message. |
| submission_body | OutboundMessageType.SubmissionBody | data: SubmissionBody | Full submission for the host to persist. |
| form_submit_success | OutboundMessageType.FormSubmitSuccess | — | Submission succeeded (kiosk / quick-capture). |
| connexions_profile_redirect | OutboundMessageType.ConnexionsProfileRedirect | — | Host should perform the profile redirect. |
| connexions_download_vcard | OutboundMessageType.ConnexionsDownloadVcard | — | Host should trigger the vCard download. |
Inbound — host → webview (you send)
| type | Method | Notes |
| -------------------- | ----------------------------------------- | -------------------------------------------------- |
| submit_form | webview.submit() | Submit as if the user pressed the button. |
| reset_form | webview.reset() | Clear all entered values. |
| update_draft | webview.updateDraft() | Switch to draft-update mode. |
| trigger_validation | webview.triggerValidation(target) | target: "email" \| "invitation_code" \| "all". |
| form_prefill | webview.prefill({ submission?, info? }) | Submission body, transcription items, or both. |
Prefill (form_prefill)
A single prefill({ submission?, info? }) carries either or both payloads:
submission— aSubmissionPrefill(a partial{ data?, ... }you assemble). Itsdataaccepts either shape you might be holding, and the webview normalizes whichever it gets:- a
SubmissionPrefillDataItem[]— the array returned by the submissions API, so asubmission.datafetched from there passes through as-is; - a
DraftSubmissionData— values flat-keyed by element / sub-element id. This is the shape a receivedSubmissionBodycarries and the shape a draft is stored in, so anonSubmissionBodypayload round-trips directly.
- a
info— a list ofPrefillInfoItem. The webview matches each item byll_field_unique_identifier(e.g."FirstName","Email");ll_field_idis optional metadata (number or string) andvaluemay be a string or boolean.
Both ride the same wire data_type (ulc_submission_and_info); pass just the key(s) you
have.
Rendering a submission
A submission_body payload includes visible_submissions_data — one entry per filled,
visible element, typed as VisibleSubmissionDataItem[] and discriminated by
element_type. Narrow on element_type and element_value is precisely typed (string,
string array, name/address objects, order quantities, etc.), so you render it exactly
how your UI needs — no flattening helper to fight:
import { FormElementType, OutboundMessageType } from "@captello/ulc-webview-sdk";
webview.on(OutboundMessageType.SubmissionBody, (msg) => {
for (const item of msg.data.visible_submissions_data ?? []) {
switch (item.element_type) {
case FormElementType.email:
addRow(item.element_title, item.element_value); // element_value: string
break;
case FormElementType.checkbox:
// element_value: string[] | OrderCheckboxSubmissionData
addRow(item.element_title, renderChoices(item.element_value));
break;
case FormElementType.simple_name:
// element_value: NameSubmissionValue ({ FirstName?, LastName? })
addRow(
item.element_title,
[item.element_value.FirstName, item.element_value.LastName].filter(Boolean).join(" "),
);
break;
case FormElementType.address:
// element_value: AddressSubmissionValue ({ StreetAddress?, City?, State?, Zipcode?, Country?, … })
addRow(
item.element_title,
[item.element_value.StreetAddress, item.element_value.City].filter(Boolean).join(", "),
);
break;
// …other element types
}
}
});Because visible_submissions_data is already typed, there's no parse/validate step in
the SDK — read it straight off the message. The submission_body data is unknown-safe
at the boundary (SubmissionBody.data is Record<string, unknown>), so cast or validate
to taste if you consume untrusted sources.
buildEmbedUrl(baseUrl, options)
Maps friendly option names onto the webview's short query keys. Existing params on
baseUrl are preserved; options override matching keys.
You only pass the capture origin (e.g. https://capture.captello.com) — the SDK owns
the capture path. The origin, the origin with a trailing slash, and the full
…/capture/submission URL all normalize to the same result, so there's nothing to get
wrong. An existing …/capture/activation route is preserved rather than rewritten.
buildEmbedUrl("https://capture.captello.com", { formId: 1234 });
buildEmbedUrl("https://capture.captello.com/", { formId: 1234 });
buildEmbedUrl("https://capture.captello.com/capture/submission", { formId: 1234 });
// → all produce "https://capture.captello.com/capture/submission?f=1234"| Option | Query key | Notes |
| --------------------------- | ----------- | --------------------------------------------------------------------------------- |
| formId | f | |
| submissionToken | s | |
| stationId | st | |
| mode | m | FormMode enum. |
| eventWebAccessToken | e | |
| activationId | a | |
| language | l | |
| actionButtonPosition | b | ActionButtonPosition enum: Fixed ("0"), Bottom ("1"), Hidden ("2"). |
| formType | form_type | "template" \| "device". |
| launcher | launcher | LauncherType enum. |
| submissionType | t | "normal" \| "drafted". |
| submitButtonBottomPadding | sbbp | |
| useIn | useIn | "outbound" \| "inbound" \| "notes". |
| platform | platform | "web" \| "mobile". |
| hideEmail | he | Boolean → "1" when true, omitted when false. |
| connexionsEmbedMode | cem | Boolean → "1". |
| emro | emro | Boolean → "1". Edit mode read-only. |
| extraParams | (verbatim) | Appended as-is; undefined/null skipped. |
CaptelloWebview API
new CaptelloWebview(iframe, {
targetOrigin?: string; // recommend the webview origin; defaults to "*"
hostWindow?: Window; // defaults to global window
matchSource?: boolean; // default true: only accept messages from this iframe
queueUntilReady?: boolean; // default true: buffer sends until form_load_complete
});isReady—trueonce the form has reportedform_load_complete.on(type, listener) => unsubscribe— subscribe to one outbound type.once(type, listener) => unsubscribe— fire at most once.onAny(listener) => unsubscribe— every outbound message.submit(),reset(),updateDraft(),triggerValidation(target)— inbound helpers.submitAndWait(timeoutMs?)— submit and awaitsubmission_body/form_error_message(see above).prefill({ submission?, info? })— pre-fill from a submission body, transcription items, or both.send(message)— low-level escape hatch for anyInboundMessage.destroy()— detach the listener and drop subscriptions (idempotent).
Send queueing. With queueUntilReady (default true), any send before the webview
reports form_load_complete is buffered and flushed, in order, on load — so calling
prefill(...) right after mount won't be silently dropped. A client that attaches
after the form already loaded won't observe form_load_complete; either create the
client with the iframe, or pass queueUntilReady: false to send immediately. The
one-shot /promises helpers set queueUntilReady: false automatically.
Security note
Always set targetOrigin to the webview's origin in production. With the default
"*", the client accepts messages from any origin and posts without an origin check —
acceptable only for trusted/local development. Derive it from the URL you built with
new URL(src).origin.
Promise helpers — @captello/ulc-webview-sdk/promises
A separate entry point with one-shot, await-style helpers for imperative flows.
Where CaptelloWebview is a long-lived client you subscribe to, these take an iframe
directly, create a short-lived client internally, wait for the relevant message, and
tear it down — convenient when you just want to "submit and get the body" without
managing a client instance.
import { submitForm, waitForFormLoad, waitForMessage, SubmissionError } from "@captello/ulc-webview-sdk/promises";
import { OutboundMessageType } from "@captello/ulc-webview-sdk";
await waitForFormLoad(iframe, { targetOrigin }); // resolves on form_load_complete
try {
const body = await submitForm(iframe, { targetOrigin }); // submit + await result
await persist(body);
} catch (err) {
if (err instanceof SubmissionError) showToast(err.message);
}
// generic: await the next message of any outbound type
const msg = await waitForMessage(iframe, OutboundMessageType.SubmissionBody, { targetOrigin });| Helper | Resolves / rejects |
| ------------------------------------ | ---------------------------------------------------------------------------------------------------------- |
| submitForm(frame, opts?) | resolves SubmissionBody; rejects SubmissionError (on form_error_message) or SubmissionTimeoutError |
| waitForFormLoad(frame, opts?) | resolves void on form_load_complete; rejects MessageTimeoutError |
| waitForMessage(frame, type, opts?) | resolves the typed message; rejects MessageTimeoutError |
opts is { targetOrigin?, hostWindow?, matchSource?, timeoutMs? } (timeoutMs
defaults to 60_000; 0/Infinity waits indefinitely). All helpers remove their
internal listener before settling, including on timeout.
Note: these catch a future message. If the form may already have loaded before you call
waitForFormLoad(e.g. you attach late), create a long-livedCaptelloWebviewbefore the iframe navigates instead.
Migrating an existing integration
Host apps that integrated before this SDK typically hand-rolled the same three pieces:
their own copy of the message-type strings, a manual URLSearchParams builder with the
short keys, and ad-hoc window.addEventListener("message") / iframe.contentWindow.postMessage
calls. Replace them as follows.
1. Message-type constants → SDK enums. Delete local copies (e.g. UlcFormActionTypeSent,
UlcFormActionTypeReceived, UlcFormDataType) and import InboundMessageType /
OutboundMessageType (the form_prefill data_type is set for you by prefill(...)).
2. Manual URL building → buildEmbedUrl.
// before
const src = `${base}/capture/submission?e=${token}&b=2&m=submit&launcher=event_gen_web` + (code ? `&l=${code}` : "");
// after — pass the origin; the SDK appends the capture path
const src = buildEmbedUrl(base, {
eventWebAccessToken: token,
actionButtonPosition: ActionButtonPosition.Hidden,
mode: FormMode.Submit,
launcher: LauncherType.EventGenWeb,
language: code as Language | undefined,
});3. Manual listeners → client.on(...). Replace the messageListener +
safeJsonParse(e.data) + if (type === ...) chain with typed subscriptions; the SDK
parses, validates origin/source, and cleans up on destroy().
4. Hand-rolled submit promise → submitAndWait(). A common pattern is a custom
promise that posts submit_form, listens for submission_body/form_error_message,
dedupes listeners, and times out. That whole helper collapses to:
const body = await client.submitAndWait(); // throws SubmissionError on form_error_message5. document.querySelector("#ulcForm").contentWindow.postMessage(...) → client methods.
Hold the CaptelloWebview instance in a ref/context and call submit() / reset() /
prefill() instead of re-querying the DOM and stringifying messages by hand.
Prefill notes for migrators. prefill({ info }) items are matched by
ll_field_unique_identifier; ll_field_id is optional (number or string) and value
may be a string or boolean — so existing payloads with numeric ids and boolean values
type-check as-is. prefill({ submission }) accepts a loose SubmissionPrefill, so a
partial { email?, data?, ... } you assemble type-checks as-is, and data may be either a
SubmissionPrefillDataItem[] (the submissions API array) or a flat DraftSubmissionData
record (the shape a received SubmissionBody carries) — a previously-received body can be
passed back directly.
Out of scope. If your app is itself embedded inside the webview shell and talks to
its parent via window.parent.postMessage (e.g. relaying email/clientId, or custom
NAVIGATE_BACK / scanner messages), that is a separate channel from the capture-form
contract — keep that code; this SDK only models host ↔ capture-webview messaging.
Development
pnpm install # from the repo root (pnpm workspace) or this package
pnpm build # bundle ESM + .d.ts into dist/
pnpm typecheckLicense
MIT © Lead Liaison, LLC. See LICENSE.
