Verification

Questionnaire

Console-built forms inside a verification — every field type and the exact JSON its answer is stored and delivered as, calculated / hidden / read-only fields, conditional logic, conditional tags and field masking.

The QUESTIONNAIRE module renders a form built in Console → Settings → Forms as a step of the hosted flow. The answers are validated on the server against the same form, stored on the module result and delivered in the session API (decision_v3.questionnaire_responses) and every webhook that carries module data.

Answer shapes

Every answer is plain JSON. data.answers maps field id → answer; data.questions carries one row per answer with its label, type, the stored answer and a human-readable display string.

Field typeAnswerExample
text, textarea, email, phone, urlstring"https://example.com"
number, percentage, sliderdecimal string"12.5"
currencyobject{"amount": "1250.00", "currency": "NPR"}
dropdown, searchable_dropdown, radiooption value"salary"
multi_select, checkbox_groupoption values, in option order. checkbox_group is the builder's Checkbox question — one question, many options; shown and left with nothing ticked it is stored as []["salary", "rent"], []
radio_grouprow value → option value{"pep": "no", "us_person": "no"}
date_ad, date_bsYYYY-MM-DD (a BS date in its own calendar)"2080-03-32"
datetimeYYYY-MM-DDTHH:MM, the applicant's wall-clock time (no time zone)"2026-10-01T09:30"
timeHH:MM, 24-hour"14:15"
date_rangeobject{"start": "2020-01-01", "end": "2026-01-31"}
checkbox (legacy single tick — forms built before the Checkbox question), declaration"true" when ticked (absent otherwise)"true"
addressobject of the filled parts — line1, line2, city, district, province, postal_code, country (ISO 3166-1 alpha-2){"line1": "Ward 4", "city": "Lalitpur", "country": "NP"}
locationobject — lat, lng (6 decimals), optional accuracy (metres), address, source (gps | map | manual){"lat": 27.6788, "lng": 85.3137, "accuracy": 8, "source": "gps"}
repeaterlist of entries, one object per entry, each sub-answer in its own type's shape[{"employer": "Acme", "salary": "90000"}]
filethe uploaded document's id (decision_v3 rewrites it to a media URL, with answer_file_id)"7b0c…"
captchanever an answer, so it is left out of data.answers. Its verification result is stored under data.captcha.<field id> and in its questions[] row — see CAPTCHA{"verified": true, "provider": "turnstile", …}

Answers are stored in the form's field order. A field that was not shown to the applicant (see conditional logic) is never stored, and an empty repeater entry is not an entry. The one blank answer that IS stored is a shown Checkbox question with nothing ticked: [] — "none of these", as opposed to a question the applicant never saw (no key at all).

Checkbox questions

A Checkbox question has a label and a list of options; each option has a label per language and a stable value, and the answer is the list of ticked values. Required means at least one tick. A count rule can ask for at least N, at most N or exactly N ticks (stored on the field as validation.minSelected / validation.maxSelected; exactly = both equal). In automations, includes none of matches a stored [].

Bikram Sambat dates

The date_bs picker offers every year from BS 1970 to BS 2100 (13 April 1913 – 13 April 2044 AD) — an elderly applicant's date of birth as well as a future expiry date. The picker and the server share one BS calendar, so every date the picker offers validates and converts to the same Gregorian day on the server (Minor Detection, the date-of-birth checks). Years up to BS 2083 follow the published calendar; later years follow the projected calendar until the official Panchanga for them is published.

Long text limits

A textarea (Long text) field can set a character limit (validation.maxLength) and/or a word limit (validation.maxWords — words are runs of non-space characters). The hosted flow shows a live count, stops typing at the character limit and won't continue past the word limit; the server enforces both.

Answers in automations

Every field of every questionnaire is a variable in Build → Automations — in conditions, in email subjects and bodies, SMS text and webhook payload templates. A voice call passes only the fields picked on its rule ("Questionnaire fields to pass to the call") to the voice provider — none by default:

VariableRenders
{field.<form id>.<field id>}the answer, human-readable: an option's label, ticked options comma-separated, NPR 1,250.00, 24 September 2026 / 8 Asoj 2083 BS, a repeater as one numbered line per entry, an upload as "File uploaded" (never a link)
{field.<form id>.<field id>.value}the answer as stored (option values, 2026-09-24, the amount)
{field.<form id>.<field id>.<part>}one part — an address's city, a radio-group row, a location's lat / lng, a currency's currency, a date range's start / end
{field.<form id>.<repeater id>.count} / .<sub id>how many entries / one sub-field of every entry

The form id keeps the same field id on two questionnaires apart; two steps on the same questionnaire join their answers. An unanswered field renders blank, and a masked field only ever its masked form. An automation can also be sent to a questionnaire field — the email or phone the applicant typed (a grievance form's "Your email"); an empty, invalid or masked answer is skipped and listed with the reason in the run history.

What the server refuses

A submission that breaks a rule is refused with 400 {"detail": "validation_failed", "feature": "QUESTIONNAIRE", "errors": {"<field id>": "<code>"}} and nothing is stored — the applicant corrects the answer and submits again. On top of each field's own settings (required, options, minimum / maximum, length, pattern):

  • Any text an answer holds — a text field, an address line, a repeater entry, a free multi-select value — is refused with invalid_characters when it contains markup (< directly followed by a letter, /, ! or ?) or an invisible control character. A textarea keeps its line breaks and tabs; in a single-line answer they are folded to a space. Option values you authored are exempt.
  • A field without a maximum length of its own is capped at 500 characters — 5,000 for textarea, 254 for email, 40 for phone, 2,048 for url — and refused past it with too_long.
  • A configured limit is refused with max_length (characters) or max_words (words); a Checkbox / multi-select count rule with min_selected, max_selected or exact_selected, and a value that isn't one of the options with invalid_option.
  • date_ad and date_bs answers must be real calendar dates (invalid_date).
  • A date of birth can't lie in the future (future_date) or before 1900 (date_out_of_range; 1957 for a date_bs field). A date field counts as one when its id, label or prefill binding names a date of birth — dob, date_of_birth, Date of birth, जन्म मिति, ocr:date_of_birth — or when a Lens read fills it from one. Other date fields may lie ahead.

Calculated, hidden and read-only fields

Any field can be marked:

  • Calculated — worked out from other answers with a formula, e.g. {quantity} * {unit_price}, sum({employers.salary}) or age({date_of_birth}). The applicant sees the result update live but cannot edit it.
  • Hidden — part of the form and its answers, never shown. Filled by its default value, a prefill, a calculation or a form action.
  • Read-only — shown but not editable.

The server keeps its own value for all three and ignores whatever the browser submits for them, so a calculated total or a hidden reference can't be tampered with. Their rows in data.questions carry "calculated": true, "hidden": true or "read_only": true.

Formulas are a small closed language — field references in braces, arithmetic, comparisons, and / or / not, quoted text and a fixed set of functions (round, floor, ceil, abs, min, max, sum, avg, count, if, coalesce, concat, upper, lower, trim, len, number, text, today, age, days_between, years_between, add_days). A blank answer counts as 0; division by zero leaves the field blank.

Conditional logic

A field can be shown only when, or hidden when, other answers match — any number of conditions, all or any of them. It can also be required only when conditions hold. Conditions compare by the answer's kind: numbers and currency amounts (greater than, between…), dates (before, after…), options (is one of), multi-selects (includes any of), checkboxes (is ticked), a part of an address, or any entry of a repeater. The server evaluates the same rules on submit.

Conditional tags

A field's tag rules add tags when its answer matches — for example income above 500,000 → high_income, PEP box ticked → pep_declared, any dependent under 18 → has_minor_dependent. They are evaluated on the server when the form is submitted and applied to the verification (and its application) and to the applicant's identity. Resubmitting never duplicates a tag, and a tag a rule no longer earns is removed again (only the automatic one — a tag an operator added by hand stays). The awarded tags are listed in data.tags.

Field masking

A field with field masking on (text, number, phone, email and long-text fields) is masked card-number style: at most the first four and the last four characters stay visible, everything between is replaced with * — 4111111111111111 is stored as 4111********1111. Shorter values show less:

Characters in the valueVisibleExample
9 or morefirst 4 + last 44111111111111111 → 4111********1111, 9841234567 → 9841**4567
5–8first 2 + last 212345678 → 12****78
exactly 4first 1 + last 11234 → 1**4
3 or fewernone123 → ***

Only letters and digits count; separators (spaces, dashes, dots) stay where they were, so 4111 1111 1111 1111 is stored as 4111 **** **** 1111. Rows for masked answers carry "masked": true, and data.masked_fields lists them. Values stored before this rule (first six / last four) are returned in the current form.

The real value is used once, in memory, while the submission is processed:

  1. it is sent unmasked to the form's own dynamic form actions — the partner-API calls that validate or process it in real time — and
  2. it is sent unmasked in the first delivery attempt of the submission webhook below.

It is never written to the database. The stored module result, logs, form-action audit records, later webhooks (including the decision webhook), retries and resends, API responses, the console and the applicant's own screen after typing all see only the masked value. If the first delivery attempt fails, its retries carry the masked value — plan your receiver for that.

The submission webhook

When a submission contains a masked field's value, your webhook URL receives identity.kyc.questionnaire.submitted before any other webhook for that submission:

{
  "event_type": "identity.kyc.questionnaire.submitted",
  "idempotency_key": "b3f0…",
  "session_id": "6a1f6f7e-…",
  "workflow_id": "1f2e3d4c-…",
  "vendor_data": "user-42",
  "node_id": "QUESTIONNAIRE",
  "form_id": "card-application",
  "answers": {"holder": "Jane Doe", "card_number": "4111111111111111"},
  "questions": [{"id": "card_number", "type": "text", "answer": "4111111111111111",
                 "display": "4111111111111111", "masked": true}],
  "masked_fields": ["card_number"],
  "unmasked": true,
  "tags": [],
  "version": 1,
  "edited": false,
  "submitted_at": "2026-09-24T10:04:12+00:00"
}

unmasked is true only on that first attempt; the stored delivery (what a retry or a console resend sends) has the masked values and "unmasked": false. Forms without masked fields don't send this event to your webhook URL.

version is 1 for the first submission. If the applicant goes Back and changes their answers, the changed submission is sent again with the next version and "edited": true — the masked field must be typed again to save a change, so that delivery's first attempt carries the newly typed value. Continuing with nothing changed sends nothing.

With the Connect layer, endpoints can subscribe to questionnaire.submitted for every submission (answers under data.questionnaire, same shape); for a masked submission it is fired before module.completed and only its first attempt is unmasked.