Read the product configuration
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.
curl 'https://api.sandbox.korint.io/config/<productId>' \
--header 'tenant: <tenant>'
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:
customerFields / customerAggregateFieldsarrayFields of the people and companies on the contract: the policyholder, additional insured parties, payers.
policyFields / policyAggregateFieldsarrayFields of the contract as a whole: billing arrangements, engagement choices, declarations that are not specific to one insured item.
assetFields / assetAggregateFieldsarrayFields 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:
{
"customFields": [
{ "key": "companySiret", "value": "<siret>" },
{ "key": "companyRevenue", "value": 480000 },
{ "key": "isCompanyBeingCreated", "value": false }
]
}
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:
{
"invoicingConfig": {
"frequency": "MONTHLY",
"timing": "IN_ADVANCE",
"firstPayment": "EARLY_PAYMENT_AT_SIGNATURE",
"earlyPayment": { "unit": "MONTH", "amount": 2 }
}
}
| Declared in | Sent on |
|---|---|
policyAggregateFields | PATCH /policies/{policyId} |
assetAggregateFields | POST /assets or PATCH /assets/{assetId} |
customerAggregateFields | PATCH /customers/{customerId} |
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.
CREATIONBusinessActionRequired to create the entity.
QUOTEBusinessActionRequired to price the contract. Checked by
POST /policies/{policyId}/quote, together with theCREATIONfields.
CONFIRMATIONBusinessActionRequired to confirm the contract. Checked by
POST /policies/{policyId}/confirm, together with theCREATIONandQUOTEfields.
SIGNATUREBusinessActionRequired to send the contract for signature, on top of everything above.
NOTHING / NEVERBusinessActionNever required.
NOTHINGfields are optional and accepted;NEVERfields 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:
requiresOnChangeBusinessActionChanging this field invalidates the contract back to that action — a field with
requiresOnChange: QUOTEforces a re-quote after any change.
mtaModification / renewalModificationModificationBehaviorALLOWEDorFORBIDDEN— 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:
const config = await getProductConfig(productId);
const requiredBy = (action, fields) =>
fields.filter(field => field.requiredFor === action).map(field => field.key);
// goes in the customFields array of the asset call
const assetCustomFieldsForQuote = requiredBy('QUOTE', config.assetFields);
// goes at the top level of the asset call
const assetPropertiesForQuote = requiredBy('QUOTE', config.assetAggregateFields);
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
- 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.
- 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. - Drive validation and state from the field, not from your form
Required comes from
requiredForagainst the step you are on. Disabled comes fromisCalculated, and from the registry family for company identity. Constraints come fromvalidationRules. Re-quote triggers come fromrequiresOnChange. - 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.
- 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
requiredForis notNOTHINGorNEVERhas 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 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.
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 Operations that settle after the response.
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.
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:
sent startedAt 2026-09-01T00:00:00.000Z (= 02:00 in Paris, summer)
→ endedAt 2027-08-31T22:00:00.000Z = 364.92 days of cover
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.
sent startedAt 2026-08-31T22:00:00.000Z (= midnight 1 Sept in Paris)
→ endedAt 2027-08-31T22:00:00.000Z = 365.00 days
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:
stored 2026-08-31T22:00:00.000Z (cover starts 1 September)
.slice(0, 10) "2026-08-31" ← wrong day, shown to the customer
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:
quoteobjectavailableTiers,availableExcesses,availablePerils,defaultConfig,availableConfigs. This is the one you build quote options from.
publicQuoteConfigobjectproductId,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.
A few blocks on that response are worth knowing about, because they save you configuration you would otherwise be handed by hand:
themeobjectprimaryColoras a ten-shade ramp, pluscolorScheme,logoandfavicon. Enough to theme a funnel to the tenant without hardcoding anything.logoandfaviconare 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.
authenticationobjectcognitoUserPoolId,cognitoUserPoolClientIdandcognitoDomainfor the tenant. If you are building a sign-in step, read them here rather than accepting them as environment variables.
claimsPortalobjectClaims-portal capabilities for the tenant, all optional:
previousPlatformUrl(a platform used before the tenant migrated, worth linking for claims filed there),whatsappEntryPoint, and acontactblock ofphone/email/postalAddressfor the claims-management platform. Absence means the tenant has not enabled that piece, so read each one rather than assuming a tenant has it. Replaces the former top-levelpreviousClaimsPlatformUrl.
Whether claimants can open a claim themselves is a property of the product, not the tenant, so it sits on the product config instead:
isSelfServiceClaimIntakeEnabledbooleanOn
GET /config/{productId}.truewhen claimants can declare a claim on this product through a self-service funnel;false(the default) otherwise. Read it across every id inavailableProductIdsto find which product backs the funnel, rather than hardcoding a product id.