npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@captello/ulc-webview-sdk

v1.1.0

Published

Typed SDK for embedding the Captello capture webview: message protocol, host client, and embed-URL builder.

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:

  1. The message protocol — enums and discriminated-union types for every message the webview emits and accepts.
  2. CaptelloWebview — a host-side client that wraps an <iframe> and handles the send/receive wire details (JSON-string encoding, origin/source filtering).
  3. buildEmbedUrl — a typed builder for the webview's query-string contract.
  4. Typed submission dataVisibleSubmissionDataItem and friends type a submission's visible_submissions_data as a discriminated union you narrow on element_type to render however you like.
  5. Promise helpers (@captello/ulc-webview-sdk/promises) — one-shot await-style utilities like submitForm(iframe) and waitForFormLoad(iframe).
  6. React adapter (@captello/ulc-webview-sdk/react) — a useCaptelloWebview hook 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-sdk

Quick 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 an embedUrl and message callbacks; it renders the <iframe>, shows your loading / error overlays, and forwards the senders on a ref. 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". src is ignored — the URL comes from embedUrl.
  • loading — a node shown, centered over the iframe, until form_load_complete. The iframe stays mounted underneath so it keeps loading.
  • error — a node (or (message) => node) shown when the form reports form_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 separate ref.
  • ref — a CaptelloFormHandle: the senders (submit, reset, updateDraft, triggerValidation, prefill, submitAndWait) plus status / isReady, getIframe(), and getClient(). 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, derives targetOrigin, and returns it as iframeProps.src — no separate buildEmbedUrl / manual src wiring to keep in sync. (Need to own both the URL and the origin? Use the CaptelloWebview class directly.)
  • Readiness. isReady and status ("loading" | "ready" | "error") — no manual useState + onFormLoadComplete for a spinner.
  • Prefill timing. Sends made before the form loads are queued and flushed on form_load_complete, so you can prefill(...) 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 no ref, 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 / queueUntilReady change.
  • 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 — a SubmissionPrefill (a partial { data?, ... } you assemble). Its data accepts either shape you might be holding, and the webview normalizes whichever it gets:
    • a SubmissionPrefillDataItem[] — the array returned by the submissions API, so a submission.data fetched from there passes through as-is;
    • a DraftSubmissionData — values flat-keyed by element / sub-element id. This is the shape a received SubmissionBody carries and the shape a draft is stored in, so an onSubmissionBody payload round-trips directly.
  • info — a list of PrefillInfoItem. The webview matches each item by ll_field_unique_identifier (e.g. "FirstName", "Email"); ll_field_id is optional metadata (number or string) and value may 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
});
  • isReadytrue once the form has reported form_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 await submission_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 any InboundMessage.
  • 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-lived CaptelloWebview before 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_message

5. 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 typecheck

License

MIT © Lead Liaison, LLC. See LICENSE.