Building a form? Drive its behaviour from this configuration and write its presentation yourself.
Should your form be config-driven? answers where the line falls
and why.
tenant header is required, and omitting it returns 400 MISSING_TENANT.
GET /config returns the tenant-level configuration and the list of products available to you.
Start there if you do not yet know your productId. It is public on the same terms.The six field lists
The response carries two field lists per entity — one for custom fields, one for aggregate fields:array
Fields of the people and companies on the contract: the policyholder, additional insured parties,
payers.
array
Fields of the contract as a whole: billing arrangements, engagement choices, declarations that are
not specific to one insured item.
array
Fields of each insured item — a vehicle, a premises, an animal, a person, depending on the product.
Custom fields travel in customFields
A field declared in customerFields, policyFields or assetFields is sent as an entry of the
customFields array, keyed by the key from the configuration:
Aggregate fields are top-level properties
A field declared inpolicyAggregateFields, assetAggregateFields or customerAggregateFields is
not an entry of customFields. It is a property of the entity itself, sent at the top level of
the body, on the endpoint that owns that entity:
Read the lists rather than assuming their contents; which properties a product declares varies, and a list
is often empty. Across the demo products,
policyAggregateFields is usually just invoicingConfig,
assetAggregateFields just startedAt, and customerAggregateFields is empty on all of them — their
customer data is entirely custom fields, including company identity, which one product declares as a
custom field companySiret carrying integrationKey: "siret".
Putting one inside customFields instead is rejected with INVALID_CUSTOM_FIELDS and the detail
Could not find field config.
invoicingConfig has no documented value of its own to invent. Take it from the product’s
billing.defaultConfig, which is the shape the product expects; it appears in the config response
alongside the field lists.When each field is required
Every field carries arequiredFor, naming the first business action that cannot proceed without it.
Requirements accumulate: reaching a later action means satisfying every earlier one too.
BusinessAction
Required to create the entity.
BusinessAction
Required to price the contract. Checked by
POST /policies/{policyId}/quote, together with the
CREATION fields.BusinessAction
Required to confirm the contract. Checked by
POST /policies/{policyId}/confirm, together with the
CREATION and QUOTE fields.BusinessAction
Required to send the contract for signature, on top of everything above.
BusinessAction
Never required.
NOTHING fields are optional and accepted; NEVER fields are not part of any
requirement check.Required is not everything worth offering
A funnel built strictly fromrequiredFor offers only what the product forces you to ask.
Where a product sells optional cover it appears in quote.availablePerils — but check each peril’s
options array first. A peril whose options holds a single value offers no choice, and sending anything
else is rejected with INVALID_QUOTE_OPTIONS_INVALID_PERIL_OPTION, which names the permitted options back
to you. Optional cover is worth finding where it exists; it does not exist on every product. The tiers and
excesses you can offer come from the same place — see
Where the quote options live.
Two related attributes govern changes rather than creation:
BusinessAction
Changing this field invalidates the contract back to that action — a field with
requiresOnChange: QUOTE forces a re-quote after any change.ModificationBehavior
ALLOWED or FORBIDDEN — whether the field may be changed during a mid-term adjustment or at
renewal.Deriving your payloads
Filter each field list byrequiredFor, and route each field by which list it came from:
Should your form be config-driven?
Yes for behaviour, no for presentation. That split is not a matter of taste: the configuration contains everything needed for the first and nothing at all for the second. Take from the configuration: which fields exist, which are required and at which step (requiredFor), input type and format (type), constraints and enum values (validationRules), which
are read-only (isCalculated and the registry family), and default values.
Author yourself: the label for each field, the order they are asked in, grouping into pages or steps,
help text, and conditional display between fields.
The second list is not in the configuration and cannot be derived from it. A field carries a key, not a
label — there is no label, order, group, placeholder or description property to read. So a form
cannot be generated end-to-end from the config alone, and any integration that tries ends up showing raw
field keys to customers.
The first list is where hardcoding hurts. Products gain fields, a field’s requiredFor moves from
NOTHING to QUOTE, a validation rule tightens — and a transcribed list keeps passing your tests while
quoting starts failing in production, because the requirement check runs against the config, not against
your copy of it.
This is how Korint’s own funnels are built: shared components render fields from the product’s field
lists and take their required, disabled and read-only behaviour from the config, while a per-product
layer supplies the step order, the labels and the conditional display.
A shape that works
1
Fetch the config at runtime, per product
Not at build time, and not copied into your source. Cache it for as long as a funnel session lasts.
2
Map field type to input component, once
One
type → component map covers every product: STRING, EMAIL, PHONE_NUMBER, DATE, BOOLEAN,
ENUM, SIRET, POSTCODE, IBAN, and the rest. Adding a product then needs no new input code.3
Drive validation and state from the field, not from your form
Required comes from
requiredFor against the step you are on. Disabled comes from isCalculated, and
from the registry family for company identity. Constraints come from validationRules. Re-quote
triggers come from requiresOnChange.4
Keep presentation in a per-product layer keyed by field key
Labels, order, grouping, help text. This is the part you write once per product and translate.
5
Test that every required field has a label
A product can gain a required field at any time, and nothing tells your form. Write one test that reads
the live config and fails if a field whose
requiredFor is not NOTHING or NEVER has no label in your
code. Without it you find out when quoting starts failing in production; with it, on your next build.Fields the platform owns
Some fields are yours to set. Others the platform computes, enriches, or overwrites — whatever you send. The configuration tells you which, and sending a value for one of them is not an error you will be warned about; the value is simply replaced.Fields mapped onto an integration (integrationKey)
An integrationKey maps a product’s field key onto a name some integration uses. That mapping alone
does not mean the platform owns the value — and getting this wrong is the fastest way to make
quoting fail.
Two kinds of mapping share the attribute:
Vocabulary mappings — you send these. Keys like firstName, lastName, email, phoneNumber and
birthDate exist so integrations can find the field. Nothing fills them for you. They are frequently
requiredFor: QUOTE, and a funnel that skips them because they “have an integration key” cannot quote at
all.
Registry-owned — the lookup fills these. The company-registry family: siren, siret, nafCode,
nafLabel, name, creationDate, location.address, location.postCode, location.city,
location.country, and legalStatus.1 through legalStatus.3. Send the SIRET; the rest arrive from the
registry.
Note also that the registry list holds integration keys, not field keys. The field key is
product-specific and you read it from the configuration: a product may call its address field
companyHeadquarterAddress with integrationKey: "location.address". There is no field named
location.address to look for.
How the registry lookup behaves
When the configuration declares a field withintegrationKey: "siret", supplying that field triggers a
lookup in the French company registry, and every other field on the same customer whose
integrationKey belongs to the registry family above is overwritten with the registry’s values.
The consequences are worth stating plainly:
- The SIRET you send is preserved. The company name, address, postcode, city, activity code and legal statuses you send are not — they come back as the registry holds them.
- Legal status fields the registry does not return are set to
null, even if you supplied them. - A SIRET the registry does not know fails the whole request with
COMPANY_NOT_FOUND, and a malformed one withINVALID_CUSTOMER_SIRET. Either way every other field in that samePATCHis discarded, so send the SIRET on its own rather than bundled with values you need to keep.
Products that sell to companies not yet registered usually declare a boolean such as
isCompanyBeingCreated. When it is true, no registry lookup applies and the company fields are
yours to set.Calculated fields (isCalculated)
A field with isCalculated: true is derived by the platform — typically a pricing parameter computed
from other declarations rather than collected from the customer.
Sending one is rejected with INVALID_CUSTOM_FIELDS and the detail
Field <key> is not editable, it is calculated. So do not collect it and do not send it: read it back
after quoting. It appears in the configuration so that you can recognise it, skip it when building
your payloads, and display it read-only if you build an interface.
Document-extracted fields (autoFill, extractionSources)
A field with autoFill.enabled and extractionSources can be populated from a document the customer
uploads — extractionSources: ["kbis"] means the value can come from a company registration
extract. extractionFieldKey names the value inside the extraction result.
These fields are not read-only: you may set them yourself. But if a matching document is uploaded and
its extraction succeeds, the extracted value is applied. Extraction is asynchronous — see
Asynchronous operations.
Validation rules
validationRules carries the constraints the platform enforces on a field, alongside type. Applying
the same rules in your own interface turns a rejected request into an inline message, but the platform
enforces them regardless of what you check.
Updating and clearing values
customFields on a PATCH merges into what is stored; it does not replace it. Three consequences:
- Omitting a key leaves it unchanged. You can send one field at a time, and you never need to re-send the whole set.
{ "key": "x", "value": null }clears the field.nullis accepted for any type, skips validation, and removes the value from the stored set.""is not how you clear a value. An empty string is validated against the field’s type, so it is rejected onEMAIL,DATE,POSTCODEand the other narrow types.
PATCH is discarded in full, keep a value that might fail validation — a SIRET
above all — in its own request rather than bundled with fields you need to keep.
Conventions that are easy to get wrong
These are not per-product. They apply everywhere, and none of them fails loudly.Dates and times
Contracts run on Europe/Paris days. A policy carries that timezone internally, and it is not configurable. The asymmetry that costs you cover:startedAt is stored exactly as you send it, while endedAt is
derived — the start date converted to Paris, plus the contract period, snapped to the start of the
Paris day. Send UTC midnight and the two disagree:
2xx.
endedAt and nextRenewalAt are outputs. Never send them; read them back to check you got the start
date right.
Reading dates back
The same convention applies in reverse, and it is the easier half to forget. A storedstartedAt is
22:00:00Z or 23:00:00Z on the previous day, so slicing the ISO string gives you the wrong
calendar day:
Europe/Paris before displaying a date or loading one into a date input. Rehydrating an
input by slicing is worse than a display bug: the customer continues, the shifted day is saved back, and
the contract moves a day earlier on every resume.
Field types differ too: a DATE custom field wants a calendar day, 2026-09-01, and rejects an ISO
datetime with Date value doesn't match format YYYY-MM-DD. A DATETIME field, and the startedAt
aggregate field, want a full instant.
Amounts are integer cents
Every monetary amount in the API is an integer number of cents.40611 is €406.11. There is no decimal
form and no currency conversion.
Statuses carry their entity prefix
The values arePOLICY_CREATED, POLICY_QUOTED, POLICY_CONFIRMED, POLICY_SIGNED, POLICY_STARTED
— and likewise ASSET_* and CUSTOMER_*. Comparing against a bare QUOTED never matches.
Where the quote options live
Two objects in the config look interchangeable and are not:object
availableTiers, availableExcesses, availablePerils, defaultConfig, availableConfigs. This
is the one you build quote options from.object
productId, defaultTier, tiers — a smaller public summary, not the source of permitted values.GET /config returns product ids, not products
The tenant config carries availableProductIds, a list of strings — fetch each
GET /config/{productId} separately for the product itself.
Two other blocks on that response are worth knowing about, because they save you configuration you would
otherwise be handed by hand:
object
primaryColor as a ten-shade ramp, plus colorScheme, logo and favicon. Enough to theme a funnel
to the tenant without hardcoding anything. logo and favicon are paths relative to the tenant’s own
front end, not to the API, so they will not resolve from your host — treat them as names, and ask for
the assets.object
cognitoUserPoolId, cognitoUserPoolClientId and cognitoDomain for the tenant. If you are building
a sign-in step, read them here rather than accepting them as environment variables.
