Skip to content

Custom forms

The custom-form module renders an author-defined form as a step in the verification journey. The fields, labels, and copy are defined in your workflow template and delivered to the SDK; the module renders one control per field, collects the answers, uploads any files, and submits the step. On success it emits checktiv.custom_form.submitted so your page can advance.

Unlike the IDV module (which runs inside a sandboxed iframe), the custom-form module renders real form controls into your page. That means you must include its stylesheet.

Import the module for its self-registration side effect, and import the required stylesheet so the controls are styled:

import { init } from '@checktiv/sdk-web';
import '@checktiv/sdk-web/custom-form'; // registers the custom_form module
import '@checktiv/sdk-web/custom-form/style.css'; // required: styles the rendered controls

On the CDN script-tag path (window.Checktiv), the module is registered for you, and the stylesheet ships as a sibling file to <link> alongside the bundle.

The default path is mountProvisioned: when the server declares a custom_form step, the SDK renders it and injects the applicant-safe form config and copy for you.

const client = init({ publishableKey: 'ah_pk_us_test_...', getSessionToken });
client.mountProvisioned({
target: document.getElementById('verify-container'),
onEvent: (event) => {
if (event.type === 'checktiv.custom_form.submitted') {
// The step was accepted. Advance to the next step.
window.location.reload();
}
},
});

If you resolve the applicant-safe config yourself (for example, a host that renders the form on a step it already controls), mount the module explicitly and pass the config and copy:

client.mount('custom_form', {
target: document.getElementById('verify-container'),
config, // the applicant-safe form config: { fields, supportedLocales }
copy, // per-field and chrome strings, resolved to the applicant's locale
onEvent: (event) => {
if (event.type === 'checktiv.custom_form.submitted') {
window.location.reload();
}
},
});

The module is locale-agnostic by design. It never reads navigator.language or bundles a translation runtime. Instead:

  • Config ({ fields, supportedLocales }) is the applicant-safe form definition. Author-only fields (rules, notification targets) are stripped before they reach the browser, so nothing sensitive is rendered.
  • Copy is a map of plain strings your host already resolved to the applicant’s locale (for example, field.label[locale]). Absent keys fall back to a built-in English table, which is what a CDN script-tag customer gets. The chrome strings include both labels of the submit control: submit (idle, “Continue”) and submitPending (in flight, “Submitting…”). Supply them together if you localize them, since they are two states of one control.

Every author-supplied string renders as plain text, never as HTML, so author copy cannot inject markup into your page.

The module emits three events, all in the checktiv.custom_form.* namespace:

Event type Meaning
checktiv.custom_form.ready The form config resolved and the fields rendered. The applicant can begin.
checktiv.custom_form.submitted The step submit succeeded. Advance the journey (for example, reload into the next step). This is terminal for the step, not a verdict: rule evaluation happens on the server.
checktiv.custom_form.error A failure (session unresolved, upload failed, submit failed). Carries an actionable error.

Do not exhaustively switch on event.type. New event types may be added without a major version bump. See Versioning.

A multi-step journey reloads between steps

Section titled “A multi-step journey reloads between steps”

There is no in-place advance, on this module or any other. The model is the same everywhere: the module submits, emits its submitted event, and your page reloads or navigates so the substrate re-resolves the session and mounts the module for the next step. The snippets above reload for exactly this reason, and it is the supported way to move a journey forward rather than a placeholder for something better.

What follows from it, if your workflow has more than one SDK-rendered step:

  • Expect one reload per step boundary. Two custom_form screens reload once between them, and so does an id_verification step followed by a credit_history step.
  • Keep anything you need across the boundary in your own storage. Module state does not survive the reload, and it is not meant to. Your own localStorage or your server is the place for it.
  • A collect_user_info step is not one of these boundaries. It is satisfied before the mount rather than rendered by it, either from the inline applicant fields on POST /v1/sessions or through collectUserInfo({ session }). See Embedded-workflow renderability, which a credit_history workflow always has to deal with.

A screen with zero input fields (only content blocks, such as a disclosure to acknowledge) still renders a Continue control. Pressing it submits an empty answer set and advances the journey. This is intentional: without a Continue control, a content-only step would never complete and the workflow would wait forever.

From the moment the applicant presses it until the submit settles, the control is disabled, carries aria-busy, swaps to the submitPending label, and announces itself through a polite live region beside it. The busy state covers the file uploads too, not just the final request, because the uploads are the longest part of the interaction.

It clears on every failure, so the applicant can correct an answer and press again. It deliberately does NOT clear on success: your page navigates away on checktiv.custom_form.submitted, and keeping the control disabled until it does is what prevents a double submit.

When a field accepts a file, the module uploads the file from the browser directly to storage before it submits the answers, so file bytes never pass through your server. If an upload fails, the module emits checktiv.custom_form.error with upload_failed; the applicant can retry.