CorebanqCorebanq Developer Docs
KYB

Description

Purpose and use

KYB manages the bank's Know Your Business questionnaire and review flow for legal entities. It captures company facts, ownership, controlling persons, signatories, documents, risk signals, and review state needed before a business customer can be onboarded or updated.

Who uses this. Onboarding analysts, compliance officers, relationship managers, configurator administrators, and auditors use KYB flows when collecting and reviewing business-customer evidence.

How it works. A flow defines steps, questions, conditions, options, document requirements, and navigation. Answers and uploaded evidence build a reviewable record that can trigger risk checks, QES documents, tasks, or remediation.

What users do. Users start or resume a KYB flow, answer questions, upload evidence, navigate conditional steps, review progress, and submit or correct data before approval.

Outcomes and side effects. Completed KYB evidence can unblock customer onboarding, signatory setup, account opening, and product access. Incomplete or high-risk answers can create tasks, request more documents, or hold activation.

Related manuals: Customers, Uploads, QES, Risk Assessment.

Overview

The KYB (Know Your Business) module provides functionality for managing business verification flows, including:

  • Dynamic questionnaire flows
  • Answer management
  • Section-based verification process
  • Status tracking
  • Customizable validation rules
  • Multi-step verification workflows
  • Custom function support for dynamic content generation
  • Restricted country validation
  • Shareholder and signatory management
  • Advanced condition evaluation with case-insensitive operators

Flow Start Step Behavior

  • Each flow has a configured start step (start_step) in KYB flow configuration.
  • When a client starts navigation without explicitly passing question_name, backend flow navigation starts from that configured start_step.
  • In editor workflows, changing the start node in UI updates local draft state first and is only persisted after an explicit save action.

DATA_DIR flow seeds (bootstrap)

On API startup, YAML files under $DATA_DIR/kyb/*.yaml are imported into kyb.flows_config / kyb.items (same format as Configurator flow import). There is no incremental *.diff.yaml — one file = one full flow.

TopicDocument
File layout, KYB_FORCE_SEED, checklistseed_authoring.md
Environment variables (all seed types)docs/data-dir-seeds-env.md
Module doc indexREADME.md
Sample.samples/data/kyb/kyb_corebanq.yaml

Loader: common/kybseed. HTTP equivalent: POST /v1/kyb/config/{flow_name}/import — a seed file carries a whole flow, and only the import route writes the header and every item in one transaction. PUT /v1/kyb/config/{flow_name} writes the header row alone.

Flow Step Configuration

Flow steps (questions) are defined in YAML flow files or in kyb.items rows (Configurator / import API). Each step can control navigation UI, warnings, and form behavior.

Back navigation (back)

Controls whether the web client shows a Back button on the current step. The backend exposes this on the navigate/submit response as flow.back (boolean). The client hides the Back control when flow.back is false.

PropertyTypeDefaultDescription
backbooleantrueOn the step root: disable Back when false.
options.backbooleantrue (when options is present)Same effect when set under options.

The server reads back from the step root first, then falls back to options.back. If neither is set, navigate defaults to flow.back: true.

Important: message (warning/info text) is display-only. It does not disable Back. To hide Back, set back: false or options.back: false.

For steps stored in kyb.items (DB / import), prefer options.back — the DB loader maps the options JSON column into the runtime question; step-level back at the root is used in YAML files and is not a separate DB column.

Example — confirm step with warning and no Back:

- name: confirm_company_details
  back: false
  q:
    en: Confirm details
    de: Details bestätigen
  message:
    severity: warning
    text:
      en: There is no way back after you continue, check your input carefully
      de: Es gibt keinen Weg zurück nachdem Sie fortfahren, überprüfen Sie Ihre Eingaben
  section:
    name: company_profile
    status: PENDING
  schema:
    type: object
    properties:
      ZefixName:
        type: string
        readOnly: true
  # afterSubmit, next, formData, ...

Equivalent using options (typical for DB-backed flows):

- name: confirm_company_address
  q:
    en: Confirm address
  options:
    back: false
  section:
    name: company_profile
    status: PENDING

Navigate response (excerpt):

{
  "flow": {
    "CurrentStep": "confirm_company_details",
    "back": false
  },
  "question": {
    "name": "confirm_company_details",
    "q": "Confirm details",
    "message": {
      "severity": "warning",
      "text": "There is no way back after you continue, check your input carefully"
    }
  }
}

Step options

PropertyTypeDefaultDescription
options.backbooleantrueSee Back navigation.
options.saveFormDatabooleantrueWhen false, submitted answers for this step are not persisted to kyb.answers.
options.show_in_progress_railbooleantrue (when omitted)When false, the step is omitted from flow.progress_catalog left-rail milestones (see Progress catalog).
options.presentationstring—Client screen to render instead of the default RJSF form. See Client presentation.
options.wizard_stepstring—Initial sub-step when presentation is signatories_wizard. See Client presentation.

Store these keys under options in flow YAML or in the kyb.items.options JSON column (Configurator / import). They are configuration metadata for integrators and web clients; the server uses show_in_progress_rail when building progress_catalog. Other presentation keys are passed through in flow config and are intended for client routing (and future flow.ui on navigate responses).

Client presentation (options.presentation)

Use options.presentation (and optional options.wizard_step) on a flow item when the web client must render a dedicated screen instead of a JSON Schema form — for example the authorized persons / signatory invite flow (invite list, select signatories by signing rights, enter invitation emails).

This is the DB-driven equivalent of hard-coding section names in the client: configure the item in Configurator or YAML, import to kyb.items, and point next at that step when the user should enter that experience.

presentation values

ValueClient behavior
(omitted)Default: RJSF form when the step has a renderable schema; otherwise section overview (empty schema / SECTION_LIST).
signatories_wizardMulti-step signatory onboarding UI (not RJSF): document preview, invite/add signatories, select who must sign (single vs joint via signatory signature_type and weight rules), collect emails, optional email update. Data comes from signatory and upload APIs, not from kyb.answers for list steps.

Other presentation values (application_submitted, leave_kyb, and computed flow.ui on navigate) may be added later; prefer customers.status (REVIEW, ACTIVE) for post-submit “application under review” and dashboard routing where that already applies.

wizard_step (with presentation: signatories_wizard)

wizard_stepTypical screen
inviteInvite / list signatories, document signing preview (default when omitted).
select_authorisedSelect signatories who must sign; show single vs joint signing from each row’s signature_type.
enter_emailsOne email field per selected signatory; submit via POST .../signatories/invite.
update_emailUpdate a single signatory’s invitation email.

Clients may advance sub-steps locally after the first navigate (same pattern as web-app kybAuthorisedPersonsStore); wizard_step sets the entry screen when landing on the step.

Example — signatory email collection step

Use a dedicated item (not an empty-schema milestone such as section3_complete). Chain it with next after section unlock milestones if needed.

- name: signatory_invite_emails
  q:
    en: Authorized persons
    de: Zeichnungsberechtigte
  section:
    name: authorized_persons
    status: PENDING
  options:
    saveFormData: false
    show_in_progress_rail: true
    presentation: signatories_wizard
    wizard_step: enter_emails
  customFunction:
    name: CustomSignatoryList
    params:
      buttons: []
  next: SECTION_LIST
FieldRole
saveFormData: falseSignatory list / emails are not stored as a kyb.answers row; visit-only for progress rail when applicable.
show_in_progress_rail: trueShow this step as a milestone under authorized_persons in progress_catalog.
presentationSwitch client from RJSF to the signatories wizard shell.
wizard_step: enter_emailsOpen directly on the email-entry screen (skip invite/select if the product allows).
customFunction: CustomSignatoryListOptional; aligns with list-based signatory steps in existing flows. Often combined with invite / select_authorised steps using the same presentation and different wizard_step values.

Do not use an empty schema alone to request this UI — empty schema means section overview, not signatory emails. Milestone nodes such as section3_complete should keep update blocks to unlock sections and use next to reach a presentation-configured item when the signatory flow should start.

Integrators should read options.presentation and options.wizard_step from the active step config (today: flow item loaded from DB; planned: echoed on question or flow.ui in navigate responses). Example target shape:

{
  "flow": {
    "CurrentStep": "signatory_invite_emails",
    "progress_catalog": { "sections": [], "visit_only": ["signatory_invite_emails"] }
  },
  "question": {
    "name": "signatory_invite_emails",
    "q": "Authorized persons",
    "section": { "name": "authorized_persons", "status": "PENDING" },
    "options": {
      "saveFormData": false,
      "show_in_progress_rail": true,
      "presentation": "signatories_wizard",
      "wizard_step": "enter_emails"
    }
  }
}

Progress catalog

Navigate and submit responses include flow.progress_catalog: ordered left-rail milestones per section, rail_parent for button-only sub-flows, and visit_only for steps with options.saveFormData: false.

Steps are ordered by ask order: a depth-first walk of the next graph from the step flow.start resolves to, so each branch runs to its end before the next branch begins (then targets in declaration order, else last, button next links after the question's own chain). kyb.items.sort_order is a presentation column that routinely disagrees with the ask order; it ranks only the steps the walk cannot reach — orphaned entries, and every step when flow.start does not resolve — which sort after all reachable ones.

A step is hidden from the rail when:

  • options.show_in_progress_rail is false, or
  • type is not question (e.g. info, section_list), or
  • the step is a button sub-flow child listed in rail_parent.

Step message

Optional banner shown above the Continue button (client renders by severity: info, warning, error).

message:
  severity: warning
  text:
    en: There is no way back after you continue, check your input carefully
    de: Es gibt keinen Weg zurück nachdem Sie fortfahren, überprüfen Sie Ihre Eingaben

Localized text follows the same locale-key pattern as q (en, de, fr, it, …).

Flow YAML reference (kyb_audax_CAP_CY.yaml)

Field catalog extracted from .runtime/data/kyb/kyb_audax_CAP_CY.yaml (Audax CAP/CY variant). Other brand flows reuse the same shape; step names and form fields differ per product.

Document metricValue
Flow namekyb_flow
Version2.1.2800
Start steppage404
Questions (steps)299
Flow-level Sections5
Named steps in catalog292

Top-level keys

KeyTypePurpose
flowobjectFlow identity, version, start step, last-modified timestamp
SectionsarraySidebar sections (icon, title, initial status)
QuestionsarrayAll steps / nodes in the flow
metadataobjectEditor layout (positions map: step name → { x, y })

flow object

FieldTypeObserved valuesDescription
namestringkyb_flowFlow name passed to navigate/submit APIs
startstringpage404First step when navigation omits question_name
versionstring2.1.2800Flow config version (string, not semver-enforced)
modifiedstring (RFC3339)e.g. 2026-05-01T08:20:49.449ZLast export/edit time from the configurator

Sections entries (flow sidebar)

Each entry defines a section shown in the KYB progress UI.

FieldTypeObserved valuesDescription
namestringcompany_profile, nature_of_business, shareholders, authorized_persons, review_documentsSection id (also used in step section.name)
titlelocale mapen, de, fr, itLocalized section label
iconstringbusiness, work_outline, group, person_outline, descriptionMaterial icon id for the sidebar
statusstringPENDING, NOT_STARTEDInitial section status in this file
showbooleantrue, falseWhether the section appears in the sidebar (review_documents uses false)

Additional section names appear only on steps (account_setup, founders, shareholders_ubo) via section.name / update, not in the top-level Sections list.

metadata.positions

Map of step name → { x, y } (floats). Used by the flow editor (React Flow); ignored at runtime by navigate/submit.


Question (step) — root fields

Keys observed on Questions[] items in this file (camelCase where noted).

FieldTypeUsed in CAP_CYDescription
namestringrequiredUnique step id; referenced in next, templates {{step.field}}
qlocale maprequiredStep title (en, de, fr, it; occasional uk)
descriptionlocale mapoptionalSubtitle under the title
sectionobjectusualActive sidebar section + status for this step
schemaJSON Schema–like objectusualForm fields (properties, required, type, …)
uiSchemaobjectusualRJSF widgets, order, rules (ui:widget, ui:order, …)
formDataobjectusualDefault/prefill values; may contain {{template}} expressions
nextstring or arrayusualUnconditional next step or conditional rules
afterSubmitobjectfrequentPost-continue HTTP actions (actions[])
beforeSubmitobject40 stepsPre-submit HTTP actions (same action shape)
updateobjectoccasionalPatch other sections’ status / show on enter
messageobjectoccasionalBanner (severity + localized text)
optionsobjectoccasionalback, saveFormData, show_in_progress_rail, presentation, wizard_step
backbooleanrareSame as options.back (prefer options for DB import)
customFunctionobjectfrequentServer-driven UI (name + params)
customAPI—not in CAP_CYSupported by runtime; unused in this file

Legacy / invalid keys also appear in exports and should be avoided: decription, root-level de/en/fr/it, formdata (typo for formData).

section (on a step)

FieldTypeObserved values
namestringcompany_profile, nature_of_business, shareholders, shareholders_ubo, authorized_persons, account_setup, founders, review_documents
statusstringPENDING, COMPLETE
showbooleantrue (optional)
titlelocale maprare override

options

FieldTypeObserved in CAP_CY
backbooleanfalse only (when set)
saveFormDatabooleanfalse (when set)
show_in_progress_railbooleanfalse when step should not appear in progress_catalog
presentationstringsignatories_wizard (authorized-persons flows; product-specific)
wizard_stepstringinvite, select_authorised, enter_emails, update_email

message

FieldTypeObserved values
severitystringinfo, warning, error, normal
textlocale mapen, de, fr, it

update

Patches section state when the user lands on the step. Keys are section names; values are partial section objects.

Nested fieldObserved values
statusPENDING, COMPLETE
showtrue

Section names used in update: company_profile, nature_of_business, shareholders, shareholders_ubo, authorized_persons, account_setup, founders, review_documents.

next

ShapeDescription
stringSingle unconditional next step name
array of rulesEach item: when (expression), then (step), optional else on last branch

Rule keys: when, then, else.

Operators used in when (see Condition Evaluation): ==, !=, >, =, 0

Step naming prefixes (292 named steps):

PrefixCountTypical entity path
cy_*50Cyprus-specific onboarding
A_*49Association
F_*50Foundation
T_*50Trust
CAP_*33Capital company
K_*13GmbH / corporate shareholders
(shared)47e.g. enter_company_name, confirm_company_details

afterSubmit / beforeSubmit

Wrapper object with a single key:

KeyTypeDescription
actionsarrayOrdered side effects run after (or before) the user continues. See order groups for how order sequences them.

Action object

Unknown action fields are logged as warnings with the flow, step, phase, action index, and field name, then ignored. This keeps legacy/imported flows visible without making existing bad config fail at load time.

FieldTypeRuntime behaviorDescription
orderinteger1, 2, 3, …Execution order. Actions sharing an order run concurrently — see order groups.
methodstringGET, POST, PUT, PATCH, DELETEHTTP verb (internal intapi); any other value fails the action
urlstring/v1/...Path; may include {{templates}}
bodyobject or string—JSON body for POST, PUT, and PATCH. An object is templated per key; a string must hold a JSON document (templated, then parsed — invalid JSON fails the action). Any other type fails the action.
cacheobjectsee belowMap response paths into form / session
onErrorarray or object{ setValue: { field: value, … } }, { action: flag_for_manual_review, severity: WARN }Makes the action best-effort so the submit continues when the call fails. See onError.
conditionstringexpressionOptional submit-action guard. Supports simple binary expressions using ==, !=, >, =, .actions[order=N][i] or
kyb.submit_action.tolerated..actions[order=N][i]. The validation-only rule and param
members are left empty. The detail message carries only the call's own localized error message;
method, URL template, request body, response body, and resolved placeholder values stay out of the
client response. A group that absorbs every failure returns no error, so its failures are reported
through logs and review markers only.

Every failed call is also logged together in one entry naming the question, submit phase, action index and order, method, the URL template, the status, the error and its cause, and whether a fallback was applied. Like the client details, the log carries the URL template, never the resolved URL — placeholders expand to applicant answers — and request bodies and response bodies are never logged or returned: an action body carries applicant data and base64 document content. When a transport failure wraps a resolved URL, the logged cause keeps only the underlying error text.

onError works in a group exactly as it does for a single action: a failed call with an applicable fallback is absorbed and the rest of the group carries on. An action with no applicable onError fails the group.

cache entry

Each key is a form field name to populate. Value is either:

  • string — JSON path into the response (e.g. list[0].name, [0].code)
  • object with:
    • path — response path
    • default — value if missing
    • store_in_form — boolean, persist on the step form

Common cache keys in CAP_CY: customer_id, ZefixForm, ZefixName, CrifCity, OperCity, existing_signatories, signatory_id, IndustryCode, PepHits, SanctionsScore, form_a_document_id, … (70+ distinct keys in this file).

onError

An action without onError is load-bearing: when its request fails, the whole submit fails and the step rolls back. onError declares how a failure is handled instead, making that action best-effort.

Declare it as a list of operations or as a single operation object — both are accepted.

List form — fallback values for a flaky enrichment lookup:

onError:
  - setValue:
      CrifCity: ""
      existing_signatories: []

Object form — tolerate the failure and flag it for compliance:

onError:
  action: flag_for_manual_review
  severity: WARN
KeyTypeEffect
setValueobjectFallback values, written exactly where a successful cache write would land them: into the answer stock under the question namespace, and into the step form only when the matching cache entry sets store_in_form. Scalars are stringified like a cached response value; objects and arrays are stored as-is. A null value is ignored — use an explicit empty value. A value the answer cache does not store (Redis unavailable) does not count as applied, and an object fallback lands whole or not at all: a write that fails part-way through its members is undone.
actionstringflag_for_manual_review tolerates the failure and reports it for compliance follow-up: it writes no answer value — anything the action's cache would have populated stays absent, and downstream conditions must tolerate that — and records a kyb.review_flags row with the actor, flow, step, failed method/URL, severity, and cause. No task is created.
severitystringLog level for a failure tolerated by action, also stored on the review marker: INFO, WARN (default), or ERROR.

Operations this runtime cannot honour are dropped, and an action whose whole declaration is dropped stays fatal. Unknown action values, entries that are not objects, and objects with neither setValue nor action are logged as warnings and dropped when the action is parsed — that happens while the step is being submitted, not when flow config is loaded or imported. Dropping is per operation: an entry with an unknown action but a valid setValue keeps its fallback values and still makes the action best-effort.

A declaration only tolerates the failure if it applies something. If nothing lands — every setValue is null or was not stored, and no review marker could be persisted — the failure is not swallowed and the submit fails as it would without onError. A tolerated failure is logged once, at the declared severity, with the actor, flow, question, action method and URL template, and a URL-sanitized underlying cause.

onError is not applied when the request context is already cancelled or expired (client disconnect, request timeout). The submit is going away and the writes a fallback depends on would go with it, so that failure stays fatal regardless of what is declared.

kyb.review_flags (review markers)

Each failure tolerated by action: flag_for_manual_review inserts one row: actor_id, flow_name, question_name, action, severity, method, url, reason (localized error plus URL-sanitized underlying cause), and the BaseModel audit columns. There is no API for it yet — compliance queries the table to find affected cases. Rows are written outside the submit transaction, so a case stays flagged even if a later action rolls the step back.


customFunction

FieldTypeDescription
namestringRegistered handler (see below)
paramsobjectHandler-specific configuration
messagelocale mapoptional UI copy from handler

Names used in kyb_audax_CAP_CY.yaml:

nameRole
CheckRestrictedCountriesCountry multi-select with high-risk tags disabled
CheckCountriesForHighRiskHigh-risk country warning
CheckGrantsFundingGrants / funding validation
CheckNameAvailabilityName availability (Zefix / planned name)
ClassifyAMLRiskAML risk classification
CustomSignatoryListDynamic signatory list + add buttons
GetCustomListDynamic shareholder/UBO list + add buttons
GetShareholderStatsHidden shareholder/UBO counters derived from persisted records
IntersectionSet intersection helper for conditions
ValidateCapitalStructureShare capital structure validation

Common params keys (not all used on every function):

KeyDescription
question_nameTarget question for country/check handlers
groupList grouping (e.g. SHAREHOLDER)
buttonsArray of { name, next, icon, label{locales} }
inputs, rules, thresholds, sourcesValidation / AML helpers
legalForm, plannedName, zefixConflictName check parameters
blockMessage, blockOnConflict, readOnlyUX blocking behavior

schema (JSON Schema subset)

Keys appearing on schemas or property definitions in this file:

KeyPurpose
typeobject, string, boolean, integer, number, array
properties, required, itemsStructure
title, descriptionLabels (often locale maps)
readOnly, default, const, enum, enumNamesConstraints / defaults
minLength, maxLength, minimum, maximum, minItems, maxItemsValidation
format, pattern, uniqueItemsFormats
if, then, else, allOf, oneOfConditional subschemas
errorMessageLocalized validation messages

Property type values: string, boolean, integer, number, object, array.

Form field names (schema.properties keys) — 259 unique in CAP_CY

DrcorIncorporationDate, DrcorLegalForm, DrcorName, DrcorRegNumber, DrcorStatus, OperCity, OperCountry, OperHouseNumber, OperStreet, OperZip, OsQuery, RegCity, RegDistrict, RegHouseNumber, RegStreet, RegZip, TIC, TaxResidency, ZefixForm, ZefixName, ZefixSeat, ZefixUID, ZefixUIDFormatted, accountType, acknowledge, acknowledgeFormA, acknowledgeFormK, acknowledgeRisk, additional_documents, address, amlPolicy, amount, amountCurrency, annualRevenue, articles_of_association, articles_of_association_draft, assets, auditedFinancials, auditor, auditorAppointed, auditorName, authorizedRepresentative, bankReference, benefit, boIsContractingParty, branchRegistrationNumber, businessDescription, bylaws, canClaimDistribution, canRevoke, canton, capitalContribution, capitalCurrency, capitalPurpose, certificateOfDirectors, certificateOfGoodStanding, certificateOfIncorporation, certificateOfRegisteredOffice, certificateOfShareholders, city, companyName, confirm, confirmAML, confirmDataProcessing, confirmDataRetention10y, confirmFADP, confirmFormASigned, confirmNoSanctions, confirmTerms, confirmTruthful, confirmation, confirmed, contractors, contributionType, controlDocument, controlType, counterpartie1, counterpartie2, counterpartie3, countries, country, countryOfIncorporation, countryOfResidence, currency, customer1, customer2, customer3, customerCountries, customerTypes, cysecLicenseNumber, dateOf, dateOfBirth, declarationTruthful, defined_beneficiaries, depositary, details, doc_type, email, employeeCount, employees, entityCountry, entityLegalForm, entityName, entityType, entityUID, establishment_type, evidenceDocument, existing_signatories, fleetSize, foundationPurposes, foundation_discretionary, foundation_revocable, founderType, founderTypes, foundersReport, fundManager, fundType, goodsDescription, groupName, hasAppointmentRight, hasControllingPersons, hasIndirectShareholders, hasIntendedAcquisition, hasNotary, hasRevocationRight, hasRightToRevoke, hasVAT, houseNumber, idDocumentsDirectorsUBO, id_documents, if, inCountries, income_sources, incorporationCountry, industries, isCareOf, isCyprusResident, isFromHighRiskJurisdiction, isListed, isNaturalPerson, isOperational, isOperationalEntity, isPEP, isTTSRegistered, isTrustee, is_operational, is_third_person, legalForm, madeBy, members, memorandumArticles, monthlyIn, monthlyOut, municipality, name, nameConfirmed, nameSuffix, nationality, noControllingPerson, noCounterparties, noCriminalProceedings, noCustomers, noWebsite, nominalValuePerShare, nominate_representatives, not_operational_yet, notaryCanton, notaryName, notaryOffice, numberOfShares, operationalEntity, otherBeneficiaries, outCountries, outgoingPaymentsCounterparties, ownershipPercentage, ownership_structure, paidInAmount, parentCompanyName, parentCountry, parentRegistrationNumber, partnerType, partnershipName, partnershipType, partyName, partyType, passportNumber, paymentFrequencyReceive, paymentFrequencySend, paymentMethods, paymentsOut, phone, plannedName, position, postalAddress, power_of_attorney, productDescription, profitShare, proofOfAddress, proof_of_address, providers, purpose, qualification, receiveAmount, register_extracts, registeredOffice, registrationDate, registrationNumber, relationship, representative, requiresSecoLicense, returnTo, revenueRange, revenueSources, revokers_exist, riskCategory, role, salesChannels, sameAsRegistered, same_physical_address, sanctionsScreening, scenario, secretaryType, sendAmount, sendCountries, settlorType, shareType, share_register, shareholderType, shares, sharesSubscribed, shippingActivityType, signatureType, sourceCategory, sourceCountry, sourceDescription, sourceOfWealth, source_of_funds_evidence_pack, status, statutes, street, subtitle1, subtitle2, swissResidencePermit, taxCompliant, taxResidency, taxResidencyCertificate, thirdPartyOwner, totalAUM, totalShareCapital, trustDeedDate, trustName, trustType, type, types, uboDeclaration, uboRegisterExtract, uboType, websiteUrl, zipCode


uiSchema

KeyPurpose
ui:orderArray of property names — field display order
ui:widgetWidget id (see table)
ui:optionsWidget options (see below)
ui:requiredMark field required in UI
ui:readonly, ui:hidden, ui:label, ui:title, ui:help, ui:placeholderPresentation
ui:enumNamesLabels for enum values
ui:fieldCustom field component
ui:rulesConditional show/hide/enable rules

ui:widget values in CAP_CY:

ArrayField, CantonSelectSingle, CountrySelectMulti, CountrySelectMultiple, CountrySelectSingle, FileUpload, ItemSelectMultiple, OpenSanctionsSelectSingle, TextInputWithRecordExistenceCheck, YesNoSwitch, ZefixSelectSingle, checkboxes, date, email, file, hidden, phone, radio, select, text, textarea, updown

ui:options keys:

accept, condition, conditional, dataset, error_message, expandable, format, inline, inputLabel, label, max, min, placeholder, queryFilter, record_field, record_type, rows, schema, searchQuery, step, width

searchQuery.tag is used for country datasets (e.g. operating).


Template placeholders

String values may embed expressions resolved at runtime:

{{actorID}}
{{principalID}}
{{enter_company_name.ZefixUID}}
{{confirm_company_details.customer_id}}

Used in formData, afterSubmit/beforeSubmit url/body, and conditional readOnly expressions in schema.


Locale keys

Observed on q, description, message.text, schema.properties.*.title, and button labels:

en, de, fr, it (primary); occasional uk, name, status on malformed exports — use the four primary locales only.

Condition Evaluation

The KYB module supports powerful condition evaluation for determining flow navigation. All text comparison operators are case-insensitive by default.

Available Operators

Equality Operators

  • == - Case-insensitive equality comparison
  • != - Case-insensitive inequality comparison

Text Pattern Operators

  • contains - Checks if text contains a substring (case-insensitive)
  • starts_with - Checks if text starts with a prefix (case-insensitive)
  • ends_with - Checks if text ends with a suffix (case-insensitive)

Regex Operator

  • =~ - Regular expression pattern matching

Numeric Operators

  • > - Greater than
  • = - Greater than or equal to
  • **` 1000000 then: high_value_processing
    • when: employee_count >= 50 then: large_company_processing
    • when: risk_score 500000 then: large_swiss_corporate
    • when: address.country == "ch" or address.country == "de" then: european_processing
    • when: risk_score > 7 or company.name contains "high risk" then: enhanced_monitoring
    • else: standard_processing

#### Array Operations
```yaml
next:
  - when: "switzerland" in selected_countries
    then: swiss_compliance
  - when: "high_risk" in risk_tags
    then: enhanced_due_diligence
  - else: standard_processing

Tag Operations

next:
  # Check if any selected countries have high-risk tag
  - when: send_countries.sendCountries has_country_tag "high-risk"
    then: restricted_countries_warning
  # Check if any customer countries have sanctions tag
  - when: customer_countries.customerCountries has_country_tag "sanctions"
    then: enhanced_compliance_check
  - else: standard_processing

Advanced Regex Patterns

Address Validation

next:
  # P.O. Box or Postfach detection
  - when: address.line1 =~ "^(?i)(p\\.?o\\.?\\s+box|postfach)"
    then: po_box_processing
  # Street address validation
  - when: address.line1 =~ "^(?i)[0-9]+\\s+[a-z]+\\s+(street|strasse|gasse)"
    then: street_address_processing
  - else: address_validation_required

Company Type Detection

next:
  # Swiss company types
  - when: company.name =~ "^(?i).*\\s+(ag|gmbh)\\s*$"
    then: swiss_corporate
  # International company types
  - when: company.name =~ "^(?i).*\\s+(ltd|inc|corp|llc)\\s*$"
    then: international_corporate
  # German company types
  - when: company.name =~ "^(?i).*\\s+(gmbh|ug|ohg)\\s*$"
    then: german_corporate
  - else: individual_or_other

Contact Information Validation

next:
  # Phone number validation
  - when: contact.phone =~ "^(?i)(\\+?[0-9]{1,3}[\\s-]?)?[0-9]{3,4}[\\s-]?[0-9]{3,4}[\\s-]?[0-9]{3,4}$"
    then: valid_phone
  # Website URL validation
  - when: website.url =~ "^(?i)(https?://)?([\\da-z\\.-]+)\\.([a-z\\.]{2,6})([/\\w \\.-]*)*/?$"
    then: valid_website
  - else: invalid_contact_info

Condition Structure

Conditions can be structured in multiple ways:

Simple String Next Step

next: "next_question_name"

Single Condition

next:
  when: field_name == "value"
  then: "next_question_name"

Multiple Conditions with Else

next:
  - when: condition1 == "value1"
    then: "step1"
  - when: condition2 == "value2"
    then: "step2"
  - else: "default_step"

Complex Nested Conditions

next:
  - when: company.type == "ag" and revenue.amount > 1000000
    then: "large_swiss_corporate_processing"
  - when: address.country == "ch" or address.country == "de"
    then: "european_processing"
  - when: risk_score > 7
    then: "enhanced_monitoring"
  - else: "standard_processing"

Endpoints

POST /v1/kyb/navigate

Start or continue a KYB verification flow.

Request Body:

{
  "flow_name": "business_verification",
  "actor_id": "550e8400-e29b-41d4-a716-446655440000",
  "question_name": "company_details"
}

On a flow that has already finished, the two ways of calling this are answered differently, and both keep the rule that a response carrying a question never reports the flow as finished:

  • No question_name (or "start") asks where the applicant is, and a finished questionnaire is answered by the same body the last submit answered with — one function builds both, down to invalidate when the customer is being re-checked — {"message": "KYB flow completed successfully", "flow": {…}}, with no question key at all, so a client that checks whether a question is present does not draw an empty form.
  • A named question_name is an applicant walking back through their answers. That question is returned and the response reports Status SECTION_LIST, so a client keying off the status draws the question rather than the completion screen. The flow row keeps its terminal status — the applicant did finish, and re-submitting the answer that ended the flow is still recognised as a repeat.
  • A flow that finished on a step which leaves its own section open is the exception. That shape is a flow-configuration mistake, not an ending, so resuming returns the step — with back and progress_catalog, which is how the applicant reaches another section from it. The step itself leads nowhere by definition; re-submitting it is either recognised as a repeat or completes the flow again. Reporting "finished" instead would leave nothing to act on short of an administrator repinning the flow.

Success Response (201):

{
  "flow": {
    "ActorID": "550e8400-e29b-41d4-a716-446655440000",
    "FlowName": "business_verification",
    "CurrentStep": "company_details",
    "PreviousStep": "",
    "back": true,
    "Status": "SECTION_LIST",
    "Sections": [
      {
        "name": "company_information",
        "title": "Company Information",
        "icon": "building",
        "status": "PENDING",
        "show": true
      }
    ]
  },
  "question": {
    "name": "company_details",
    "section": {
      "name": "company_information",
      "title": "Company Information",
      "icon": "building",
      "status": "PENDING",
      "show": true
    },
    "q": "Please provide your company details",
    "description": "Enter the basic information about your company",
    "schema": {
      "type": "object",
      "properties": {
        "company_name": {
          "type": "string",
          "title": "Company Name"
        }
      }
    }
  }
}

Repin Actor Flow (Admin)

POST /v1/kyb/admin/flows/repin

Administratively repin an actor's kyb.flows row to a different flows_config snapshot and/or move the current_step. Used to recover actors stuck on inactive snapshots after a flow version rotation, or to forcibly move an actor to a specific step. Requires the Administrator role.

Which of the two you ask for decides what else changes. A repin that moves current_step also resets a terminal status back to SECTION_LIST and clears the recorded completion — the digest that makes a repeat submit idempotent — so the applicant's re-submit of the step they were sent back to is processed normally instead of being skipped as a repeat of the submission that ended their flow. A repin of the snapshot alone moves nobody, and leaves both untouched: a legitimately finished flow stays finished, repeat protection included.

Request Body:

{
  "actor_id": "550e8400-e29b-41d4-a716-446655440000",
  "flow_name": "kyb_flow",
  "flow_id": "86e827ea-ddc9-4b44-9294-f9d660bc4d9d",
  "current_step": "A_UBO_entities_list"
}

flow_id and current_step are both optional, but at least one must be provided. When flow_id is supplied, the target snapshot must belong to the same flow_name. When current_step is supplied, an active item with that name must exist in the effective snapshot. Both checks run inside the same DB transaction as the update.

Success Response (200):

{
  "id": "1d1f4ba4-2f5e-4f9e-9df8-8b3a3fa1234f",
  "actor_id": "550e8400-e29b-41d4-a716-446655440000",
  "flow_name": "kyb_flow",
  "flow_id": "86e827ea-ddc9-4b44-9294-f9d660bc4d9d",
  "current_step": "A_UBO_entities_list",
  "previous_step": "section3_complete"
}

Errors: 400 invalid actor_id / flow_id / step or missing both targets — including the two checks the transaction runs, a flow_id that names no snapshot or one belonging to another flow_name (kyb_m.invalid_flow_id) and a current_step that is not an active item of the effective snapshot (kyb_m.invalid_step) · 403 non-admin · 404 only when there is no active flow row for actor + flow_name.

Submit Answer

POST /v1/kyb/submit-answer

Submit an answer for a KYB flow question.

Request Body:

{
  "actor_id": "550e8400-e29b-41d4-a716-446655440000",
  "flow_name": "business_verification",
  "question_name": "company_details",
  "record_index": 0,
  "answer": {
    "company_name": "Example Corp GmbH",
    "registration_number": "CHE-123.456.789"
  },
  "metadata": {
    "source": "user_input",
    "version": "1.0"
  }
}

Success Response (200):

{
  "flow": {
    "ActorID": "550e8400-e29b-41d4-a716-446655440000",
    "FlowName": "business_verification",
    "CurrentStep": "address_details",
    "PreviousStep": "company_details",
    "Status": "SECTION_LIST"
  },
  "question": {
    "name": "address_details",
    "section": {
      "name": "company_information",
      "title": "Company Information",
      "icon": "building"
    },
    "q": "Please provide your company address"
  }
}

Success Response (200) — last question of the flow:

A question that declares no next ends the flow, wherever the applicant was standing when they answered it: CurrentStep follows the submitted question, the same way it follows the next step of a question that has one. The answer carries the flow with status COMPLETE and no question key at all. The exception is a step that ends the flow while leaving its own section open — a configuration mistake, described at the end of this section — which is answered with that step instead.

The flow object's keys are the Go field names — ActorID, FlowName, CurrentStep, PreviousStep, Status, Sections — because models.KYBFlow declares no json tags on them, while back, flow_id and progress_catalog do carry tags. The examples below print what the endpoint actually emits. Renaming those keys to snake_case is a breaking change for every client reading flow.Status today, so it needs its own coordinated change, not this one.

{
  "message": "KYB flow completed successfully",
  "flow": {
    "ActorID": "550e8400-e29b-41d4-a716-446655440000",
    "FlowName": "business_verification",
    "CurrentStep": "submission_complete",
    "PreviousStep": "submission_complete",
    "Status": "COMPLETE",
    "back": true,
    "progress_catalog": { "sections": [] }
  }
}

Repeating that request — the same question with the same answer — returns the response above again and does not run the question's beforeSubmit / afterSubmit actions a second time.

Submitting the same question with a different answer is a correction, not a repeat: the applicant can navigate back to the question that ended the flow, and the new answer is stored and its actions run as they would anywhere else. Submitting any other question proceeds normally too, so a flow moved back into the questionnaire keeps working.

"The same answer" means the whole submitted answer, pressed buttons included: a *__submittable button press is a different request even when the data fields match, because the button decides the next step and some buttons delete a shareholder or a signatory.

The completion is recorded on the flow itself, as a digest of the answer that ended it. It is not recomputed from the stored answer, because a question can be configured not to store its answer at all (options.saveFormData: false) — for such a question there would be nothing to compare against, and every retry would run its actions again. The check therefore holds for every question, and depends on no cache.

Overlapping submits are covered too: the flow row is read FOR UPDATE, so a second submit of the same question waits for the first to commit and then sees the completion. A flow that was already complete before this release carries no digest; the first submit that arrives for it is processed normally and records one.

That wait is bounded at 10 seconds, because the lock is held until the transaction commits and the transaction contains the question's outbound calls — screening, for instance, is allowed five seconds of its own. A submit that waits longer than the bound answers 409 with kyb_m.flow_submit_in_progress: nothing is wrong with the request, another submit for the same flow is still running, and repeating it is the right move.

Pressing a delete button on a list — a shareholder or a signatory — takes a completed flow back into the questionnaire: the record leaves the list, status returns to SECTION_LIST, the recorded completion is dropped, and the answer describes the list question again rather than the completion.

A flow ends where a step resolves to no next step, and the engine has no second opinion about it. If a step ends the flow while leaving its own section open, that is a flow-configuration mistake — a question missing its next — and it is logged as a warning naming the actor, the step and the section, because to the applicant it looks like the application finished halfway through.

Such a flow is not reported as finished, and not stored as finished either: status stays SECTION_LIST and the answer hands back that step, both on the submit and on a later resume. The stored status is read outside this module — an applicant's draft company is listed in the company tray only while their flow is not COMPLETE — so a status this module does not believe would take away the way back into an application nobody finished. The submission is still recorded, so repeating it does not run the question's actions again; that record is the digest, not the status.

Get All KYB Answers

GET /v1/kyb/customer/{customer_id}

Retrieve all KYB answers for a specific customer.

Path Parameters:

  • customer_id — despite the name, this is matched against kyb.answers.actor_id.

Record-scoped: a caller without the read-all mask has their permitted answer ids folded into the query, and a caller with no permitted ids receives 200 with an empty array. A refusal is therefore indistinguishable from an actor who has answered nothing.

Success Response (200): a bare array — no envelope, no pagination, no sort parameter.

[
  {
    "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
    "actor_id": "550e8400-e29b-41d4-a716-446655440000",
    "flow_name": "business_verification",
    "question_name": "company_details",
    "previous_step": "",
    "answer": {
      "company_name": "Example Corp GmbH",
      "registration_number": "CHE-123.456.789"
    },
    "created_at": "2026-03-21T10:00:00Z",
    "created_by": "550e8400-e29b-41d4-a716-446655440000",
    "modified_at": "2026-03-21T10:00:00Z",
    "modified_by": "550e8400-e29b-41d4-a716-446655440000",
    "active": true,
    "metadata": null
  }
]

Get KYB Answer

GET /v1/kyb/{kyb_id}

Retrieve a specific KYB answer.

A record-permission refusal on the three /{kyb_id} routes answers 401, not 403. On GET it arrives with code common.invalid_input, which describes the wrong problem entirely; on PUT and DELETE with common.failed_to_check_permission. A client that treats 401 as "token expired" will retry authentication forever against what is really a permissions gap.

Path Parameters:

  • kyb_id: KYB answer UUID

Success Response (200):

{
  "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
  "actor_id": "550e8400-e29b-41d4-a716-446655440000",
  "flow_name": "business_verification",
  "question_name": "company_details",
  "previous_step": "",
  "answer": {
    "company_name": "Example Corp GmbH",
    "registration_number": "CHE-123.456.789"
  },
  "created_at": "2026-03-21T10:00:00Z",
  "created_by": "550e8400-e29b-41d4-a716-446655440000",
  "modified_at": "2026-03-21T10:00:00Z",
  "modified_by": "550e8400-e29b-41d4-a716-446655440000",
  "active": true,
  "metadata": null
}

Update KYB Answer

PUT /v1/kyb/{kyb_id}

Update a specific KYB answer.

Path Parameters:

  • kyb_id: KYB answer UUID

Request Body:

{
  "answer": {
    "company_name": "Updated Corp GmbH",
    "registration_number": "CHE-123.456.789"
  }
}

Success Response (200): the shared apireply.StdResponse envelope — there is no "Answer updated successfully" body, and no answer payload is echoed.

{
  "status": 200,
  "message": "OK"
}

The update is issued as a GORM Updates over the decoded struct, and the Go type embeds BaseModel. Every non-zero field in the body is written, so active, metadata, created_by, modified_by, created_at and id are all writable through this endpoint even though only answer is meaningful to send — id because BaseModel carries it into the decoded update struct too.

Delete KYB Answer

DELETE /v1/kyb/{kyb_id}

Delete a specific KYB answer.

Path Parameters:

  • kyb_id: KYB answer UUID

This is a hard delete. models.KYBAnswer embeds BaseModel, which declares no gorm.DeletedAt, so GORM issues a real DELETE: the row is gone, with no undo and no audit trail. It is the only destructive delete in the module — the flow and item config routes deactivate instead.

Success Response (200): the shared apireply.StdResponse envelope, not a "Answer deleted successfully" body.

{
  "status": 200,
  "message": "OK"
}

Flow configuration API

Eleven routes under /v1/kyb/config administer the DB-backed flow definitions. They are what the Configurator's flow editor calls, and what a seed import goes through.

Who may call them. The three reads — GET /v1/kyb/config, GET /v1/kyb/config/{flow_name} and GET /v1/kyb/config/flows/{flow_id}/items — are not admin-guarded: any authenticated caller can enumerate and read the flow catalogue. Every mutating route and the cache invalidate call requireKYBConfigAdmin, which checks Storage.IsAdminAccount and answers 403 common.forbidden otherwise. Note the ordering: the path segment is validated before the admin check, so a non-admin who sends a malformed flow_id receives 400, not 403.

flow_name is only validated on one route. GET /v1/kyb/config/{flow_name} resolves through loadFlowConfigForFlow, which calls validateKYBFlowName: non-empty after trimming, at most 128 characters, no .., / or \, and matching ^[a-zA-Z0-9][a-zA-Z0-9_-]{0,127}(\.yaml)?$ — the pattern exists because the same value can address a YAML fallback file. The PUT, both DELETEs and the import check only that the segment is non-empty. There is no length cap and no character check on those four, so a name with dots, slashes or 128+ characters is written straight through.

Snapshots. One flow_name may own several flows_config rows. Import archives the previous rows and inserts a new id, so flow_id addresses a version, not a flow. The item routes are keyed by flow_id, never by name.

MethodPathAdminSuccessNotes
GET/v1/kyb/configno200 arrayBare array of headers, active and inactive. No pagination.
GET/v1/kyb/config/{flow_name}no200 objectThe assembled config map (header + items), same shape as the legacy YAML. Falls back to an inactive snapshot so a disabled flow stays inspectable. Cached in Redis.
PUT/v1/kyb/config/{flow_name}yes200 headerHeader row only. Server forces flow_name, active: true, created_by, modified_by. Echoes the amended input, not a fresh read — and the id it echoes is not the stored row's id when the flow already existed. See below.
DELETE/v1/kyb/config/{flow_name}yes204 no bodySoft-deletes the header and every item. The only KYB delete that is not 200. 404 when there is neither a header row nor an active item for that name — orphan items alone are enough to make it succeed.
DELETE/v1/kyb/config/{flow_name}/cacheyes200 StdResponseDrops the Redis entry. Touches no rows.
POST/v1/kyb/config/{flow_name}/importyes200 objectWhole flow in one transaction. See below.
PATCH/v1/kyb/config/flows/{flow_id}/activeyes200 headerThe live-version switch, not a single-row write: active: true first deactivates every other active snapshot of the same flow_name. Leaves items alone. See below.
GET/v1/kyb/config/flows/{flow_id}/itemsno200 arrayActive items only, sorted by sort_order. A deactivated item cannot be found through this list.
PUT/v1/kyb/config/flows/{flow_id}/items/{item_name}yes200 itemServer forces flow_id, name, active: true, created_by, modified_by — but not system, which is taken from the body — and written only when the row is created, since the upsert's DO UPDATE SET omits that column. 404 when {flow_id} names no snapshot.
PATCH/v1/kyb/config/flows/{flow_id}/items/{item_name}/positionyes200 StdResponseCanvas x/y. No echo of the stored value. An unknown item_name also answers 200 — see below.
DELETE/v1/kyb/config/flows/{flow_id}/items/{item_name}yes200 StdResponseSoft-deletes and writes a blame row. An unknown item_name also answers 200 — see below.

Seven behaviours are easy to get wrong:

  • PUT cannot deactivate. Both PUT routes force active: true. Use PATCH …/flows/{flow_id}/active for a flow, or DELETE …/items/{item_name} for an item.
  • PATCH …/active with an empty body deactivates. active is a plain bool with no pointer and no validation, so {} decodes to false.
  • PATCH …/position with a missing coordinate moves the node to the origin. The body is read with a plain json.NewDecoder; an absent x or y decodes to 0 rather than being rejected. A decode failure answers 400 with no detail attached at all.
  • system on an item is caller-settable — but only when the row is created. UpsertItemHandler overwrites flow_id, name, active, created_by and modified_by, while system is bound straight from the decoded body. Nothing validates it, so anyone with the config-admin grant can mark a new item as platform-owned. On an existing active item the value is silently dropped: sqlKYBItemUpsert lists system in the INSERT columns but not in its ON CONFLICT … DO UPDATE SET clause. And because the 200 echoes the amended input rather than a fresh read, it confirms a change that did not happen.
  • Neither item route can tell you the item was not there. DELETE …/items/{item_name} and PATCH …/items/{item_name}/position both answer 200 {"status":200,"message":"OK"} for a name that does not exist, having changed nothing: GetItemByName returns (nil, nil) on no rows, so the lookup succeeds with nothing and the handler falls through, and DeactivateItem and UpdateItemPosition are bare ExecContext calls that never inspect RowsAffected. There is no 404 on either route. Do not treat the 200 as confirmation that a node was removed or moved — read GET …/flows/{flow_id}/items if you need to know.
  • PATCH …/active with true is the version switch, and it deactivates the siblings. In the same transaction it first runs UPDATE kyb.flows_config SET active = false WHERE flow_name = (the named row's flow_name) AND id <> the named id AND active = true, enforcing at most one active snapshot per flow_name. Activating snapshot B to look at it takes snapshot A out of service and every actor not pinned to a snapshot immediately reads B. There is no compare mode. active: false writes only the named row, which can leave the flow_name with no active snapshot at all.
  • The id that PUT /v1/kyb/config/{flow_name} echoes is not the stored row's id. UpsertFlowConfig mints a UUID into the input struct when the body carried none, but the statement conflicts on flow_name and its DO UPDATE SET clause never touches id — so an existing flow keeps its original id and the echoed one matches no row. It is the real id only on a create. Read the authoritative snapshot id from GET /v1/kyb/config before using it on any /flows/{flow_id}/… route; keying the item routes off the echoed id writes against a non-existent snapshot and GET …/items then answers 200 null — not []: ListItemsByFlow leaves its var out []models.KYBItem nil and apireply.WithJSON encodes a nil slice as null. GET /v1/kyb/config behaves the same way on an empty table. The customer answers route does not — it reads through GORM, which replaces the destination slice, so that one really does answer [].

Import a flow

POST /v1/kyb/config/{flow_name}/import

Archives the existing active header row for this flow_name, then inserts the new header and every item — one transaction, so a failure leaves the previous version live. Records a blame snapshot of what was there before, warns about unsupported submit-action fields, and busts the cache.

It does not deactivate the previous snapshot's items. importFlowArchiveExistingByFlowName runs the header statement alone, and importFlowDeactivateMissingItems scans by the new cfg.ID, so it can never reach rows that belong to the old one. Those keep active = true, and GET /v1/kyb/config/flows/{old_flow_id}/items keeps returning them. Only DELETE /v1/kyb/config/{flow_name} deactivates a flow's items.

Request Body:

{
  "config": {
    "start_step": "company_details",
    "version": "3",
    "sections": []
  },
  "items": [
    {
      "name": "company_details",
      "type": "text",
      "sort_order": 0
    }
  ],
  "source_filename": "kyb_audax_CAP_CY.yaml"
}

config.flow_name, config.active, config.created_by, config.modified_by and the same four fields on every element of items are overwritten by the server. source_filename — or the X-KYB-Import-Source-Filename header, which it takes precedence over — has its basename stored in flows_config.comment.

Pass target_flow_id to update an existing snapshot in place instead of minting a new one. The id must exist (404 otherwise) and belong to the same flow_name as the path segment (400 otherwise). The nil UUID counts as absent.

Success Response (200):

{
  "flow_name": "business_verification",
  "flow_id": "6f1c0f6e-2b6a-4b1f-9d5a-1e2c3d4e5f60",
  "items_count": 1
}

flow_id is the real id of the snapshot that was written, including when target_flow_id was omitted: ImportFlow takes the config by pointer and importFlowInsertNewConfig assigns cfg.ID = uuid.New() on that same struct before the insert, so the handler reads the minted value back out. Use it — there is no need to re-read GET /v1/kyb/config to learn the id.

Custom Functions

The KYB module supports custom functions that can be used in question configurations to generate dynamic content, validate data, and provide enhanced user experiences.

Available Custom Functions

CheckRestrictedCountries

Validates selected countries against a restricted/high-risk list and returns a disabled CountrySelectMultiple widget with restricted countries.

Usage in YAML:

customFunction:
  name: "CheckRestrictedCountries"
  params:
    question_name: "selected_countries"

Response:

  • Returns a CountrySelectMultiple widget with restricted countries marked as disabled
  • Automatically filters countries tagged as "high-risk"
  • Provides both country codes and names for restricted countries

GetCustomList

Generates dynamic lists of shareholders with progress tracking and management capabilities.

Usage in YAML:

customFunction:
  name: "GetCustomList"
  params:
    group: "shareholders"
    total_shares: "{{share_capital.numberOfShares}}"
    buttons:
      - name: "add_shareholder"
        label:
          en: "Add Shareholder"
        icon: "plus"
        next: "add_shareholder_form"

Features:

  • Displays shareholder information with stake percentages
  • Shows progress bar with total shares held
  • Provides delete buttons for individual shareholders
  • Supports custom action buttons
  • Tracks total shares and remaining shares; total_shares can normalize absolute share-count stakes into percent progress
  • Leaves founder rows editable when founder country or capital metadata cannot be resolved, while marking the hidden derived value unresolved so downstream numeric/risk routing remains fail-closed

GetShareholderStats

Returns hidden form data derived from persisted shareholder records without rendering a shareholder list.

Usage in YAML:

customFunction:
  name: "GetShareholderStats"
  params:
    group: "FOUNDER"
    fields:
      - "corporateFounderCount"

Features:

  • Tracks total shares, remaining shares, and shareholder count
  • For founder lists, derives founder capital, corporate-founder count, and high-risk founder count
  • Marks derived founder values unresolved when founder country or capital metadata cannot be resolved
  • Supports an optional fields list when a step only needs selected hidden values

CustomSignatoryList

Generates dynamic lists of signatories with management capabilities.

Usage in YAML:

customFunction:
  name: "CustomSignatoryList"
  params:
    buttons:
      - name: "add_signatory"
        label:
          en: "Add Signatory"
        icon: "user-plus"
        next: "add_signatory_form"

Features:

  • Displays signatory information with signature types
  • Provides delete buttons for individual signatories
  • Supports custom action buttons
  • Shows secondary text with signature type descriptions

Custom Function Configuration

Custom functions can be configured in question YAML files with the following structure:

name: "example_question"
q: "Please select countries"
customFunction:
  name: "CheckRestrictedCountries"
  params:
    question_name: "selected_countries"
schema:
  type: "object"
  properties:
    selected_countries:
      type: "array"
      items:
        type: "string"
uiSchema:
  selected_countries:
    ui:widget: "CountrySelectMultiple"

Widget Types

CountrySelectMultiple

A specialized widget for country selection that supports:

  • Multiple country selection
  • Disabled state for restricted countries
  • Automatic filtering of high-risk countries
  • Visual indicators for restricted countries

Shareholder Widget

A custom widget for displaying shareholder information:

  • Shows shareholder name and stake percentage
  • Displays chip with stake information
  • Includes delete functionality
  • Supports secondary text for additional information

Button Widget

A customizable button widget that supports:

  • Custom labels with internationalization
  • Icon support
  • Color customization
  • Action handling

Error Responses

Every error body is the shared apireply.StdResponse envelope — status, message and code at the top level, with class always present on an error body and retryable and details optional. There is no nested error object.

{
  "status": 400,
  "message": "Invalid actor ID: 550e8400-e29b-41d4-a716-446655440000",
  "code": "kyb_m.invalid_actor_id",
  "class": "validation"
}

class is always present on an error body — and absent from a success one. writeAppError stamps code, class, retryable and details only outside 2xx, and all four carry omitempty, so an Ok200 success really is just {"status": 200, "message": "OK"}. On a non-2xx, ClassForStatus has no empty branch: 400 and 422 are validation; 408, 429, 502, 503 and 504 are temporary and additionally carry retryable: true; everything else — 5xx included — is business. Two of the temporary set are reachable here: the 429, and the auth-cache 503 — which therefore also carries retryable: true.

message is the i18n template, not the context the handler attached. Only {placeholder} tokens the template itself contains are substituted. No common.* template contains one, so a common.invalid_input body always reads exactly "Invalid input" regardless of what went wrong. The kyb_m.* templates do carry placeholders — and a call site that omits the parameter leaves the token literal in the response. kyb_m.invalid_actor_id is Invalid actor ID: {actorID}: navigate passes actorID, so it renders as above, while submit-answer does not, so its body reads "Invalid actor ID: {actorID}" verbatim.

StatusMeaning on these routes
400Malformed input. Also what a failed permission lookup on GET /v1/kyb/customer/{customer_id} answers (common.invalid_input), and what a database error on that route's final query answers (common.database_error) — neither is a 500.
401Token rejected by the auth middleware or a record-permission refusal on the three /{kyb_id} routes. See the note above.
403common.forbidden — either the KYB customer-scope check on navigate/submit, or requireKYBConfigAdmin on a config route. Also common.rbac_no_rec_access or a license_m.* code from the auth middleware, on any route.
404Flow, snapshot or answer row not found. Not a question: an unknown question_name is 400 on submit and 500 on navigate, see below. The shared key is common.record_not_found — note there is no common.not_found. Not for a missing item name: DELETE …/items/{item_name} and PATCH …/items/{item_name}/position answer 200 for a name that does not exist. A missing snapshot id is a different matter — PUT …/items/{item_name} does answer 404 for a {flow_id} that no longer exists, because it resolves flow_name from that id before writing.
409kyb_m.flow_submit_in_progress — the wait for another submit's row lock ran past ten seconds. A timeout, not a refusal; see above.
429Rate limit exceeded. Not common.too_many_requests — see the note below. Reachable only when the AppConfig flag rate_limits.rate_limits_switcher is true.
500Server or flow-engine failure — and two cases that read like client errors: a step the resolved config does not contain, on navigate (kyb_m.failed_to_get_question), and a config whose flow section has no start (kyb_m.start_field_not_found). Neither call site sets WithCode, and CodeToInt defaults an unset code to 500. Submit re-stamps the same lookup failure as 400.
503Two shapes. auth_m.internal_server_error in the envelope when the auth cache is unhealthy; a bare {"overall_status": "unhealthy", …} map with no envelope fields at all while the server is draining. See below.

The kyb_m.* code space is large — roughly seventy constants — and which one arrives from navigate or submit depends on the stage that failed (config read/parse, section initialisation, question lookup, condition evaluation, answer persistence, flow save). Branch on the HTTP status and on the code prefix; do not enumerate the individual keys.

Refusals the middleware writes before the handler runs

These apply to every route in this module and are produced by the root router's middleware chain — auth.RateLimitMiddleware, then health.LifecycleMiddleware, then auth.Middleware — not by module code, which is why they are easy to overlook.

StatusCodeCause
401common.unauthorizedNo bearer token, one that does not parse, a blacklisted token, or a cache error during the blacklist lookup
403common.rbac_no_rec_access → No access to the recordrbac.CanCallAPIv0 denied the endpoint grant. Forbidden403 is called with no AppError, so the body carries the helper's default code
403license_m.license_invalid, license_m.license_expired, license_m.module_not_licensedThe licence branch. Only these three are reachable. module_not_licensed means the tenant's licence does not cover this module: its routes exist in the binary and are refused per request — unless the module is one of the six the loader gates (crif, crp, kyc, kyt, noga, zefix), where the routes are never registered at all and an unlicensed tenant gets chi's plain 404 instead. Two further codes the middleware matches cannot reach a client: license_m.license_service_unavailable is written to the licence-error context by no code path, and license_m.license_key_missing needs licenseService == nil, a state a running process cannot be in — rbac.Init calls InitLicenseService unconditionally and routes a failure through logger.Fatalf, so a bad key stops the process at startup instead of serving 403s
503{"overall_status": "unhealthy", …} — not the envelopeGraceful shutdown. health.LifecycleMiddleware is mounted with r.Use on the root router, so it precedes authentication and every handler — though not everything: auth.RateLimitMiddleware is registered two lines earlier, so a caller over its limit gets a 429 even while the server drains
503auth_m.internal_server_error → Internal server errorThe auth cache is unhealthy — and only when the rate limiter is off, see below

The 503 is two different bodies, and a client has to branch on the shape rather than assume the envelope. While the server is draining, health.LifecycleMiddleware writes a bare map — {"overall_status": "unhealthy", "message": "Service is shutting down", "timestamp": "…"} — with no status, code, class or retryable field on it at all, and whose message is a fixed English string, not an i18n key, so Accept-Language does not translate it. The auth-cache 503 is the envelope, but its code is auth_m.internal_server_error, not common.server_error: ensureCacheAvailable calls errs.New(MsgInternalServerError) unqualified from inside package auth, so the constant that resolves is auth.MsgInternalServerError. A client branching on common.server_error to detect a dead cache never matches.

And that second 503 is only observable with the rate limiter off. auth.RateLimitMiddleware is registered before auth.Middleware and touches the same Redis/valkey, so with rate_limits.rate_limits_switcher on a dead cache is answered by the limiter first — 500 for an authenticated caller, 429 for an anonymous one — and ensureCacheAvailable is never reached. That 500 is generic as well: handleRateLimitError's default branch calls apireply.InternalServerError500(w, r) without the AppError, so auth_m.failed_to_cache_user_limits, auth_m.failed_to_fetch_user_roles and rate_limits_m.failed_to_increment_ip_limit are discarded and the body reads common.server_error. Do not use the 503 as your cache-down signal in a deployment that rate limits.

created_by and modified_by are stripped for most callers

Unless app-config auth.audit_fields_internal is explicitly false, auth.WrapWithMiddlewares also runs RemoveAuditFieldsHandler. It triggers on the Content-Type: application/json that apireply.WithJSON always sets, unmarshals the whole body, deletes every created_by and modified_by key at every nesting depth, replaces every comply_advantage_meta with null — set to nil, not deleted, so the key survives with its value destroyed — and re-marshals.

That matters here beyond the audit columns of a flow or an answer: metadata, Sections and SectionStatus are free-form, so a caller without the internal role loses those keys from data this module never inspected. Two further consequences of the round trip: every number passes through float64, so an int64 beyond 2^53 comes back altered; and the rewrite is skipped entirely when rbac.HasInternalRole returns an error, so the same request is answered with or without the subtraction depending on database health. Key order is not preserved either.

Rate limiting

auth.RateLimitMiddleware is mounted on the root router in , above every route in the process — but only when the AppConfig flag rate_limits.rate_limits_switcher is true. With the switch off no 429 is reachable at all. It exempts only OPTIONS and the health-check path.

The code is not common.too_many_requests. That value is only the fallback apireply.TooManyRequests429 uses when no AppError is supplied, and handleRateLimitError always supplies one — so it never reaches a client. Five codes are reachable, and two of them are free text rather than an errs.MsgCode, because the call site hands a plain string to errs.New and MachineCode() returns Key verbatim:

Conditioncodemessage
Per-user, per-endpoint, per-window limit trippedrate_limits_m.exceededRate limit for to exceeded.
Global per-IP limit trippedrate_limits_m.global_exceededGlobal rate limit for exceeded.
The counter increment itself failed, authenticated callerrate_limits_m.failed_to_increment_ip_limitFailed to increment IP limit.
RBAC per-endpoint checkrate limit exceededrate limit exceeded
Anonymous caller, global IP limit tripped or its increment failedGlobal rate limit exceededGlobal rate limit exceeded

The last is reachable on these routes even though every one of them requires a token, because the ordering runs the other way round: auth.RateLimitMiddleware is r.Used on the root router in , while auth.Middleware is applied per route by auth.WrapWithMiddlewares. A caller without a valid token that is over the global per-IP ceiling is answered 429 before authentication ever runs, and never sees the 401. On that anonymous branch the two conditions are also indistinguishable: a failing IncrementIPLimit is reported with the same free-text error, so rate_limits_m.failed_to_increment_ip_limit reaches a client only from the authenticated path.

For an authenticated caller the ceilings come from the RBAC endpoint configuration in the database, evaluated per minute, hour, day, week and month — not from constants. The one hard-coded value, 1000 per IP per endpoint, is the global limit.

On this page

Purpose and useOverviewFlow Start Step BehaviorDATA_DIR flow seeds (bootstrap)Flow Step ConfigurationBack navigation (back)Step optionsClient presentation (options.presentation)presentation valueswizard_step (with presentation: signatories_wizard)Example — signatory email collection stepNavigate / submit (client contract)Progress catalogStep messageFlow YAML reference (kyb_audax_CAP_CY.yaml)Top-level keysflow objectSections entries (flow sidebar)metadata.positionsQuestion (step) — root fieldssection (on a step)optionsmessageupdatenextafterSubmit / beforeSubmitAction objectcache entryonErrorkyb.review_flags (review markers)customFunctionschema (JSON Schema subset)uiSchemaTemplate placeholdersLocale keysCondition EvaluationAvailable OperatorsEquality OperatorsText Pattern OperatorsRegex OperatorNumeric OperatorsTag OperationsAdvanced Regex PatternsAddress ValidationCompany Type DetectionContact Information ValidationCondition StructureSimple String Next StepSingle ConditionMultiple Conditions with ElseComplex Nested ConditionsEndpointsNavigate FlowRepin Actor Flow (Admin)Submit AnswerGet All KYB AnswersGet KYB AnswerUpdate KYB AnswerDelete KYB AnswerFlow configuration APIImport a flowCustom FunctionsAvailable Custom FunctionsCheckRestrictedCountriesGetCustomListGetShareholderStatsCustomSignatoryListCustom Function ConfigurationWidget TypesCountrySelectMultipleShareholder WidgetButton WidgetError ResponsesRefusals the middleware writes before the handler runscreated_by and modified_by are stripped for most callersRate limiting