> ## Documentation Index
> Fetch the complete documentation index at: https://docs.korint.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Read the product configuration

> Derive the fields a product requires, and learn which values the platform owns

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.

<Note>
  **Building a form?** Drive its behaviour from this configuration and write its presentation yourself.
  [Should your form be config-driven?](#should-your-form-be-config-driven) answers where the line falls
  and why.
</Note>

```bash theme={null}
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`.

<Note>
  `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.
</Note>

## The six field lists

The response carries two field lists per entity — one for custom fields, one for aggregate fields:

<ResponseField name="customerFields / customerAggregateFields" type="array">
  Fields of the people and companies on the contract: the policyholder, additional insured parties,
  payers.
</ResponseField>

<ResponseField name="policyFields / policyAggregateFields" type="array">
  Fields of the contract as a whole: billing arrangements, engagement choices, declarations that are
  not specific to one insured item.
</ResponseField>

<ResponseField name="assetFields / assetAggregateFields" type="array">
  Fields of each insured item — a vehicle, a premises, an animal, a person, depending on the product.
</ResponseField>

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:

```json theme={null}
{
  "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:

```json theme={null}
{
  "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`.

<Note>
  `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.
</Note>

## 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.

<ResponseField name="CREATION" type="BusinessAction">
  Required to create the entity.
</ResponseField>

<ResponseField name="QUOTE" type="BusinessAction">
  Required to price the contract. Checked by `POST /policies/{policyId}/quote`, together with the
  `CREATION` fields.
</ResponseField>

<ResponseField name="CONFIRMATION" type="BusinessAction">
  Required to confirm the contract. Checked by `POST /policies/{policyId}/confirm`, together with the
  `CREATION` and `QUOTE` fields.
</ResponseField>

<ResponseField name="SIGNATURE" type="BusinessAction">
  Required to send the contract for signature, on top of everything above.
</ResponseField>

<ResponseField name="NOTHING / NEVER" type="BusinessAction">
  Never required. `NOTHING` fields are optional and accepted; `NEVER` fields are not part of any
  requirement check.
</ResponseField>

<Warning>
  `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](#fields-the-platform-owns) is how to tell the two apart.
</Warning>

### 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](#where-the-quote-options-live).

Two related attributes govern changes rather than creation:

<ResponseField name="requiresOnChange" type="BusinessAction">
  Changing this field invalidates the contract back to that action — a field with
  `requiresOnChange: QUOTE` forces a re-quote after any change.
</ResponseField>

<ResponseField name="mtaModification / renewalModification" type="ModificationBehavior">
  `ALLOWED` or `FORBIDDEN` — whether the field may be changed during a mid-term adjustment or at
  renewal.
</ResponseField>

### Deriving your payloads

Filter each field list by `requiredFor`, and route each field by which list it came from:

```javascript theme={null}
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);
```

<Tip>
  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.
</Tip>

## 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.

<Note>
  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.
</Note>

### A shape that works

<Steps>
  <Step title="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.
  </Step>

  <Step title="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.
  </Step>

  <Step title="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`.
  </Step>

  <Step title="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.
  </Step>

  <Step title="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.
  </Step>
</Steps>

One thing not to do: filter the form down to the required fields, which hides
[whatever optional cover the product sells](#required-is-not-everything-worth-offering).

## 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.

<Warning>
  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.
</Warning>

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.

<Note>
  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.
</Note>

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.

<Warning>
  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.
</Warning>

### 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](/get-started/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.

<Warning>
  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.
</Warning>

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`.

<Warning>
  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
  ```
</Warning>

`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:

<ResponseField name="quote" type="object">
  `availableTiers`, `availableExcesses`, `availablePerils`, `defaultConfig`, `availableConfigs`. **This
  is the one you build quote options from.**
</ResponseField>

<ResponseField name="publicQuoteConfig" type="object">
  `productId`, `defaultTier`, `tiers` — a smaller public summary, not the source of permitted values.
</ResponseField>

### `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:

<ResponseField name="theme" type="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.
</ResponseField>

<ResponseField name="authentication" type="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.
</ResponseField>
