Sell a new contract
New business is the journey from "a prospect wants cover" to "a contract is in force". It runs through five statuses; most transitions are a call you make, and the last one is not:
POLICY_CREATED → the contract exists, unpriced. POLICY_QUOTED → the risk has been priced.
POLICY_CONFIRMED → the terms are locked and the paperwork can be produced. POLICY_SIGNED → the
policyholder has committed. POLICY_STARTED → cover has begun, which on some products waits for the first
premium as well as the start date.
Statuses carry their entity prefix in every response, and assets and customers use ASSET_* and
CUSTOMER_*.
This page walks the whole journey. It assumes you have credentials and can call the API — see Authentication — and that you have read Read the product configuration, because which fields you must send at each step comes from the product, not from this page.
The journey at a glance
One example run. The product's own configuration decides parts of it — whether a payment method is needed before signature, which fields are required at which step, whether cover starts on signature — so read yours rather than assuming this shape.
Step by step
- Read the product configurationcurl 'https://api.sandbox.korint.io/config/<productId>' \--header 'tenant: <tenant>'
This call is public — no token, just the
tenantheader. Every step after it needs one.Keep the response. Its six field lists tell you what to send in the next four steps, and its
payment,billingandsignaturesections tell you whether this product needs a payment method before signature and how signing is delivered.If you are building a form on top of this, read Should your form be config-driven? first — the short version is that the config drives which fields you ask for and how they behave, while labels, ordering and grouping are yours to write, because the config holds none of them.
- Initialize a customer, and with it a policy
One call creates the policyholder, the policy, and the branch you will work on. The body is optional — send
{}for a direct sale, or name the distributing firm withbrokerageFirmId:curl -X POST 'https://api.sandbox.korint.io/customers?productId=<productId>' \--header 'Authorization: Bearer <access_token>' \--header 'tenant: <tenant>' \--header 'Content-Type: application/json' \--data '{ "brokerageFirmId": "<brokerageFirmId>" }'{"type": "CUSTOMER","id": "<customerId>","initializedWith": { "policy": "<policyId>", "branch": "<branchId>" }}Keep
initializedWith.branch. Confirmation requires abranchIdquery parameter, and a new-business policy carries more than one branch. If you lose it, list the open branches withGET /policies/{policyId}/branches.A branch is a working copy of the contract. New business happens on its own branch; mid-term adjustments and renewals each get theirs. Reads and writes are branch-scoped, which is why querying a policy without the right
branchIdcan look as though your data never landed. - Fill in the customer
Custom fields go in
customFields; the customer's aggregate fields (name,role,siret) are top-level:curl -X PATCH 'https://api.sandbox.korint.io/customers/<customerId>?branchId=<branchId>' \--header 'Authorization: Bearer <access_token>' \--header 'tenant: <tenant>' \--header 'Content-Type: application/json' \--data '{"customFields": [{ "key": "contactFirstName", "value": "Camille" },{ "key": "contactLastName", "value": "Durand" },{ "key": "contactEmail", "value": "camille.durand@example.com" },{ "key": "contactBirthDate", "value": "1985-04-12" }]}'branchIdis marked optional in the reference, but omit it here and any field whoserequiresOnChangeisQUOTE— which on most products is every field you need — is refused:409 FIELDS_DO_NOT_MEET_REQUIREMENTScontactFirstName: "cannot be modified with this context(not configured as requiring NOTHING on change)"The error names the fields, not the missing parameter, which sends you looking in the wrong place. Pass the
branchIdfrom step 2 on every write.Field keys are per-product — read them from the configuration rather than copying these. Note the birth date: a
DATEfield wants1985-04-12, and rejects a full ISO datetime.For a company product, expect the company block you read back to differ from what you sent. Supplying the SIRET triggers a registry lookup that overwrites company name, address, postcode, city, activity code and legal statuses, and nulls legal statuses the registry does not return. This is intended behaviour, explained in Fields the platform owns.
- Create the insured items
startedAtis an aggregate field on most products — a top-level property, not a custom field:curl -X POST 'https://api.sandbox.korint.io/assets?branchId=<branchId>' \--header 'Authorization: Bearer <access_token>' \--header 'tenant: <tenant>' \--header 'Content-Type: application/json' \--data '{"policyId": "<policyId>","startedAt": "2026-08-31T22:00:00.000Z","customFields": [{ "key": "postcode", "value": "75011" },{ "key": "surfaceArea", "value": 120 }]}'That
startedAtis Paris-local midnight, not UTC midnight —22:00:00Zthe previous day in summer,23:00:00Zin winter. UTC midnight silently buys 364.92 days of cover for a full year's premium, with no error anywhere. Why, and how to convert: Dates and times.Check the product's
assetAggregateFieldsrather than assuming: ifstartedAtis declared there withrequiredFor: QUOTE, quoting fails without it. UsePOST /assets/batch/createwhen a contract covers many items. - Set the contract-level terms
The policy's aggregate fields — how often the customer is invoiced, and who pays — are top-level properties of
PATCH /policies/{policyId}. Two different kinds of data live there, and they are not collected at the same moment:invoicingConfigis a term you set. It is required before pricing, so it belongs in a call your code makes as it prepares the quote.- Policy custom fields are usually answers a person gives. They can be sent any time before the
action their
requiredFornames — often confirmation, not quote.
This example sends both in one call because a script can. If you are building an interface, do not copy that shape into the step that opens your pricing screen: the custom fields would be sent before the customer has typed anything, and never sent again. On a product where no policy field is priced or printed, nothing fails — the answers are simply lost.
Take
invoicingConfigstraight from the product'sbilling.defaultConfig— that is the shape it expects, and there is nothing for you to invent:curl -X PATCH 'https://api.sandbox.korint.io/policies/<policyId>?branchId=<branchId>' \--header 'Authorization: Bearer <access_token>' \--header 'tenant: <tenant>' \--header 'Content-Type: application/json' \--data '{"invoicingConfig": {"frequency": "MONTHLY","timing": "IN_ADVANCE","firstPayment": "EARLY_PAYMENT_AT_SIGNATURE","earlyPayment": { "unit": "MONTH", "amount": 2 }},"customFields": [{ "key": "contractAssistanceOption", "value": false }]}'Send every aggregate field the product declares, at the step its
requiredFordemands. On the demo products that meansinvoicingConfig, required at QUOTE — so this call comes before pricing, not after it.Some products additionally declare
billingAssignment({ configId, defaultPayerId }), required at confirmation; none of the demo products do. ReadpolicyAggregateFieldsinstead of assuming either way, because the error names the field rather than the step it was needed for. - Price the risk
Two endpoints price a contract, and the difference matters:
POST /policies/{policyId}/calculate-quotecomputes a premium and persists nothing. Use it to show a price, to compare options, or to try overrides — it acceptscustomFieldsToOverride,brokerageFeesOverride,periodDurationOverrideandassetIdsfor exactly that. It also tolerates assets without astartedAt.POST /policies/{policyId}/quotecomputes the premium and writes it to the policy. This is the call that moves the contract forward; confirmation works from the quote it stored.Both are covered in detail in Quotes.
optionsis an array with one entry per asset, each naming the coverage the customer chose. The permitted values come from the config'squoteobject —quote.availableTiers,quote.availableExcessesandquote.availablePerils. NotpublicQuoteConfig, which is a smaller summary carrying onlyproductId,defaultTierandtiers:curl -X POST 'https://api.sandbox.korint.io/policies/<policyId>/quote?branchId=<branchId>' \--header 'Authorization: Bearer <access_token>' \--header 'tenant: <tenant>' \--header 'Content-Type: application/json' \--data '{"options": [{ "assetId": "<assetId>", "tier": "STANDARD", "excess": "DEFAULT" }]}'Add
perilsto an option to sell optional cover, where the product offers any. Checkquote.availablePerilsand look at each peril'soptionsarray first: a peril with a single permitted option is not a choice, and sending another value is rejected withINVALID_QUOTE_OPTIONS_INVALID_PERIL_OPTION.POST /policies/{policyId}/quotereturns an empty body. It records the quote rather than handing it back, so read the result afterwards — or usecalculate-quote, which does return the full breakdown.Read it from the right place: the premium lands on the policy, but the chosen tier is on the asset (
GET /assets/{assetId}→quote.options.tier), because cover is chosen per insured item. See Read a contract back.Pricing can fail on underwriting grounds rather than on your payload — an ineligible risk, a value outside the carrier's appetite. Those failures are recorded against the policy, so a rejected quote is an outcome to handle, not necessarily a bug in your request.
- Confirm the contract
Confirmation locks the terms:
curl -X POST 'https://api.sandbox.korint.io/policies/<policyId>/confirm?branchId=<branchId>' \--header 'Authorization: Bearer <access_token>' \--header 'tenant: <tenant>'Every field whose
requiredForisCREATION,QUOTEorCONFIRMATIONmust be present, across the customer, the policy and every asset. This is where an integration that treated aggregate fields as custom fields — or never sent them at all — discovers the gap. - Collect a payment method, if the product needs one first
Whether payment comes before signature is a product decision, and it is in the configuration. See Payments for the mechanics, the Stripe hand-off, and the mandate.
- Send it for signaturecurl -X POST 'https://api.sandbox.korint.io/policies/<policyId>/signature?branchId=<branchId>' \--header 'Authorization: Bearer <access_token>' \--header 'tenant: <tenant>' \--header 'Content-Type: application/json' \--data '{ "deliveryMode": "EMAIL" }'
Pass
branchIdhere too, even though the reference marks it optional. The documents offered for signature are the ones on the branch you name, so omitting it looks for the paperwork on the base contract and fails with425 NO_UNIQUE_DOCUMENT_TYPE_READY_TO_BE_SIGNEDanddocumentNumber: 0— whileGET /policies/{policyId}/documents?branchId=<branchId>plainly lists the document.The response lists the signature requests and their signers. Signing happens outside your system — the policyholder receives it by email, or you place them in front of the signer link. Either way, completion is something you observe rather than receive: see Operations that settle after the response.
- Watch it come into force
Activation is not a call you normally make. A signed contract comes into force when its start date arrives and, on products that require it, when the first premium has been collected. Poll
GET /policies/{policyId}and read the status.A contract that is signed but not yet in force is the normal state for a future-dated start or an uncollected first premium — not a failure. There is nothing to call: wait for the start date, or collect the premium.
POST /policies/{policyId}/activateis not the escape hatch its name suggests. It only brings a suspended contract back into force, and rejects anything else with409 CONFLICTING_POLICY_STATUS. On a signed-but-not-yet-in-force contract — the case above — it always fails. Nothing in the new-business journey calls it.
Where integrations get stuck
- Fields rejected as "cannot be modified with this context". You omitted
branchIdon a write. - A required field you believe you sent. Check which of the six configuration lists it came from.
Aggregate fields are top-level properties; custom fields go in
customFields. - Quoting refuses fields you treated as read-only. An
integrationKeydoes not mean the platform fills the field; only the registry family is filled for you. See Fields mapped onto an integration. PAYMENT_CUSTOMER_NOT_FOUNDwhen asking for a setup secret. The payment customer is created when the policy is quoted. Quote first.- The contract is two hours short.
startedAtneeds to be Paris-local midnight. - Confirmation rejected on a field the customer never sees.
invoicingConfigandbillingAssignmentare contract terms you set, not customer input. - A value you sent came back different. A field with an
integrationKeyis owned by its integration; company identity comes from the registry. - Nothing appears to have been saved. You are probably reading a different branch than you wrote.
- The contract is signed but not in force. Check the start date, then the first premium.