Skip to main content
Korint products are configured, not hardcoded. A motor fleet product, a legal protection product and a livestock mortality product expose different fields, require them at different moments, and enrich some of them from external registries. There is no single list of fields to send. So the first call of any integration is not a write — it is reading the configuration of the product you are selling, and deriving from it what each subsequent call must carry.
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.
No token — this one is public, so you can read a product’s shape before you have credentials. The 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.
The split between the two lists is not cosmetic. It determines where the value goes in your request body, and it is the single most common reason a confirmation is rejected for a field the caller believes it sent.

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 in policyAggregateFields, 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 a requiredFor, 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.
requiredFor means the value must be present by that action, not that you must send it. Registry lookups and document extraction also satisfy it — Fields the platform owns is how to tell the two apart.

Required is not everything worth offering

A funnel built strictly from requiredFor 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 by requiredFor, and route each field by which list it came from:
Do this at runtime rather than transcribing the lists into your code. Products gain fields, and a transcribed list silently stops matching the product you are selling.

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.
One thing not to do: filter the form down to the required fields, which hides whatever optional cover the product sells.

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.
Check the integration key against that registry list before treating a field as read-only. On one demo motor product, nine fields carry an integrationKey and are required for quoting — including contactEmail, whose key is the vocabulary mapping email. Treating all of them as read-only produces FIELDS_DO_NOT_MEET_REQUIREMENTS on every quote attempt.
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 with integrationKey: "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 with INVALID_CUSTOMER_SIRET. Either way every other field in that same PATCH is discarded, so send the SIRET on its own rather than bundled with values you need to keep.
So a customer whose company block you populated from your own records will round-trip with your values replaced. That is deliberate: the registry is authoritative for company identity, and underwriting depends on it. Send the SIRET, read the rest back.
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.
Other lookups behave the same way for their own domains — vehicle fields filled from a registration lookup, telematics fields filled from a connected driving profile.
Company search is not available to public funnels. GET /siren is refused to an anonymous session (403 FORBIDDEN), so a browser-based funnel cannot offer a company picker: the visitor types their SIRET and enrichment happens server-side on the PATCH. Plan the interface around a typed SIRET and a confirmation step showing what came back.

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. null is 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 on EMAIL, DATE, POSTCODE and the other narrow types.
If your form filters out empty values before sending — a reasonable instinct, since "" fails on typed fields — then a field the customer clears keeps its old value. For a consent checkbox or a declaration that can be withdrawn, that is a correctness bug rather than a cosmetic one. Send null for cleared fields instead of dropping them.
Because a rejected 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:
Cover begins at 02:00 on the start date, the contract is two hours short of a year, the premium is for a full year, and every request returns 2xx.
Send startedAt as Paris-local midnight, which is 22:00:00Z the previous day under summer time and 23:00:00Z under winter time. A fixed two-hour offset is wrong for half the year — convert through the Europe/Paris zone rather than subtracting hours.
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 stored startedAt is 22:00:00Z or 23:00:00Z on the previous day, so slicing the ISO string gives you the wrong calendar day:
Convert through 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 are POLICY_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.