Skip to main content

V1 API Changelog

Track changes, additions, and deprecations to the V1 API.

2026-08-14 (AddressV2 — a new address shape, rolling out field by field)

Breaking for address writers: AddressV2 replaces the legacy Address primitive on the fields that have been migrated

AddressV2 is a new built-in object primitive and the successor to Address. It is not additive: the sub-fields are renamed and re-nested, so a payload written for Address is not a valid AddressV2. Any integration that writes an address field is affected the moment that field is migrated. The migration is per-field and per-company, not a global switch. Fields move from Object: Address to Object: AddressV2 as each company is migrated, so a single company can have some address fields on each shape at the same time. There is no date on which “the API” changes. How to tell which shape a field takes — read the configuration, do not assume. Ask GET /api/v1/companies/{companyId}/entities/{entityType}/configuration and look at the field’s fieldType (or typeInfo.kind):
  • Object: Address → the flat legacy shape, unchanged.
  • Object: AddressV2 → the new shape below.
This is the only reliable signal. The endpoint, the entity type, and the field’s name all stay the same across the migration; only the configured type changes. What moved: Before — a field typed Object: Address:
After — the same field typed Object: AddressV2:
You do not send county, coordinates, or precision. Geocoding runs server-side on save and fills geocode for you, and it never gates the write — a provider miss or outage is recorded as an unmatched or failed geocode, not an error on your request. If you already hold a geocode, send it under geocode with source: "provided" and it is stored as given without a lookup; a geocode sent with any other source is discarded and replaced by the platform’s own result. Reads. Entity reads — fetching one entity, and the entity list endpoints — return a field configured AddressV2 in the AddressV2 shape, including rows that were written before the field was migrated, so you never have to handle both shapes on one field. Reading county moves from the top level to geocode.county, and geocode.granularity tells you how precisely the coordinates (and therefore that county) were located. zipCode is still a string, and still the most common failure. The path is now enteredAddress.zipCode. JSON numbers cannot represent leading zeros, so 02140 parses as 2140; always quote it. Fields that have not been migrated are untouched. Address stays fully supported and is not deprecated — every field still typed Object: Address behaves exactly as before. Full reference: AddressV2 and the Fmv1AddressV2 OpenAPI schema.

2026-08-14 (fullTermPolicyInfo is removed)

Breaking: the fullTermPolicyInfo container is gone from every policy and quote

fullTermPolicyInfo no longer appears on any policy response, in any persisted policy segment we write, or in the framework configuration. The deprecation notice in the 2026-08-13 entry below announced this; the container was a platform-derived mirror, and once every value it carried had a root field there was nothing left for it to say. Who this affects. Any integration still reading a value out of fullTermPolicyInfo. Writers are unaffected — the container has been read-only and platform-derived since the term-bound cutover, so nobody sends it, and a payload that still includes one is simply ignored. Where each member went:
  • fullTermPolicyInfo.policyNumber → root policyNumber
  • fullTermPolicyInfo.policyStartDate → root policyStartDate
  • fullTermPolicyInfo.policyEndDate → root policyEndDate
  • fullTermPolicyInfo.previousPolicyIdthe relationships surface. This is the one member with no root field, and the change the 2026-08-13 entry said would not happen before it had one. It has a better home instead: renewal lineage is the policy’s own previousPolicy link, which you already send on POST /transaction/renew and which the platform writes as the Policy<N:1:previousPolicy>Policy relationship. Read the link back from the policy’s previousPolicy field on any segment, or walk the chain from the expiring side. The container’s copy was a display mirror of exactly that link and could never say anything the link does not.
  • fullTermPolicyInfo.primaryInsuredName / .primaryInsuredJoin → root primaryInsuredName / primaryInsuredId. These left the container on 2026-08-10; see that entry.
Each root field holds exactly the value its container member held, so every move except previousPolicyId is a one-line change per read with no behavioural difference. Historical data is untouched. Policy segments and quotes written before this change still carry the key in their stored JSON, and we neither rewrite nor hide it there. What changed is that nothing on our side reads it and no response carries it. Configuration. fullTermPolicyInfo and the FullTermPolicyInfo custom object are no longer framework-required rows, so a new company is provisioned without them. An existing configuration that still declares them keeps importing cleanly; the rows are ordinary tenant rows now, read by nothing.

2026-08-13 (the whole-term policy facts move to the response root)

Added: root policyNumber, policyStartDate and policyEndDate on every policy response

Every response that carried the policy number and the term bounds only inside fullTermPolicyInfo now also carries them at the root:
  • GET /policies/list, GET /policies/versions, GET /policies/{policyId}/versions — on each item’s summary.
  • GET /policies/{policyId}/versions/{version}.
  • The five policy transactions — new business, renew, endorse, cancel, reinstate.
policyNumber is a string. policyStartDate and policyEndDate are the structured Date object ({day, month, year, timezone}) — the same value the container holds, not the ISO startDate / endDate fields beside them, which report the span the version covers and shrink when a policy is cancelled. All three are null only when the version carries no policy data. Nothing was removed or renamed, so no integration breaks on this change. The values are read from the same cross-segment-validated source the container is, so a root field and its container copy can never disagree on one response.

Deprecated: fullTermPolicyInfo — read the root fields instead

fullTermPolicyInfo is deprecated on every policy response that carries it. It is still returned, unchanged, during the transition window; it will be removed once readers have moved off it (design of record: ADR 0043 — term invariance is a per-field configuration property, and the container is deleted). Who this affects. Any integration that reads a value out of fullTermPolicyInfo. Writers are unaffected: the container has been read-only and platform-derived since the term-bound cutover, so nobody sends it. What you see now. No change — the container is still present and identical. The OpenAPI spec and the generated TypeScript client now mark it deprecated, so a typed client will surface a deprecation hint at the call site. What to do — move each read to its root field:
  • fullTermPolicyInfo.policyNumber → root policyNumber
  • fullTermPolicyInfo.policyStartDate → root policyStartDate
  • fullTermPolicyInfo.policyEndDate → root policyEndDate
  • fullTermPolicyInfo.primaryInsuredName / .primaryInsuredJoin → root primaryInsuredName / primaryInsuredId (already removed from the container; see the 2026-08-10 entry below)
  • fullTermPolicyInfo.previousPolicyIdnot hoisted yet. Keep reading it here; it is the one member without a root field, and the container will not be removed before it has one.
Each root field holds exactly the value its container member holds, so the move is a one-line change per read with no behavioural difference.

2026-08-12 (a member fullTermPolicyInfo no longer declares is discarded, not rejected)

Fixed: an undeclared member of the derived container is no longer a 400

fullTermPolicyInfo is platform-derived — every persisted write replaces it with exactly its four framework members, so nothing you send inside it is ever read. This reference has said as much since the cutover (“anything else you put in it is discarded”), but the write path was rejecting an undeclared member instead of discarding it:
That fell hardest on callers of the entry below. The two retired primary-insured members were deliberately left in already-stored policy segments and quotes, so a client that read a policy, changed one field, and wrote the record back was handed the retired keys by our read and then refused by our write. An undeclared member of fullTermPolicyInfo is now dropped from the payload before validation, on every write path. Nothing else moves: the four declared members are still shape-checked, a term stated only inside the container is still rejected (the term comes from the root policyStartDate / policyEndDate), and the other containers — fullTermPricingInfo, fullTermPolicyRatingResult, crossSegmentRatingOutputs — still reject an undeclared member, because those carry facts you own rather than a mirror of ours.

2026-08-10 (the primary insured left fullTermPolicyInfo)

Recorded on 2026-08-12. This change shipped on 2026-08-10 with no changelog entry; the notice below is the one that should have run with it.

Breaking: fullTermPolicyInfo.primaryInsuredJoin and fullTermPolicyInfo.primaryInsuredName are removed

Both members were dropped from every company’s configuration. Sending either one returned 400 InvalidFieldModelV1DataUnknown field 'fullTermPolicyInfo.primaryInsuredJoin' — from 2026-08-10 until the fix in the entry above; from now on they are accepted and discarded, but they are still gone from the contract and nothing reads them. fullTermPolicyInfo is term-constant, so a primary insured recorded there was frozen for the whole term — and a primary insured genuinely changes mid-term. The container is now closed at four members (policyNumber, policyStartDate, policyEndDate, previousPolicyId), and the primary insured is a per-segment fact you can endorse like any other field. What to do:
  • Remove both keys from your payload. Nothing replaces them on the request.
  • The primary insured is derived, not sent — it comes from the policy’s own exposures, by the rule your configuration defines (typically the exposure your config marks as primary). Set the exposure; the platform resolves the insured.
  • Read it back from the root primaryInsuredName / primaryInsuredId fields on the response — hoisted at the top level and present per segment. They resolve per segment, so a mid-term endorsement of the insured now takes effect from its endorsement date; the flat response fields show the last segment’s insured.
Values already stored under the two removed keys were left in place rather than migrated. Any policy you write again has its container replaced wholesale, which clears the residue on that write.

2026-08-07 (policy invoices can be planned headlessly)

Added: explicit policy invoice plans on transactions and as a standalone batch

All five policy transaction endpoints now accept optional invoicePlan, an explicit keep/void/create plan that commits atomically with the new policy version. Omitting it preserves ordinary transaction behavior and creates no invoices. When a pricing restatement touches a policy with active invoices, the plan is required and must conserve the new pricing contract. The same contract is available directly at POST /api/v1/companies/{companyId}/financials/policies/{policyId}/invoices/batch. Existing invoices omitted from voidInvoices are kept; every void must cite its current headJournalId; dates, payees, and creates are never inferred. Its body is exactly that plan, with no author field, and a company whose separate policy-financials rollout gate is off receives 403 rather than 404. Quote binding also accepts { "generateInvoices": true }. The server resolves policy-invoice presets into that explicit plan before binding; omitting the flag keeps bind behavior unchanged. Presets requiring a broker payee fail closed until a canonical broker party is available. Ad-hoc create or void requests against policy-linked invoice documents continue to return 422 POLICY_INVOICE_BATCH_ONLY, now with the standalone batch route as the recovery path.

2026-08-06 (closing and re-opening an event is its own endpoint)

Added: POST /events/{eventId}/close and POST /events/{eventId}/reopen

An event (a claim or an incident) now closes and re-opens through dedicated endpoints that take the date the action takes effect:
Each moves eventStatus, stamps or clears the event’s close date, and appends an entry to its open/close-history log — in one transaction. Both require company.claim:create and return { eventId, eventStatus }.

Changed: PATCH /entities/event/{entityId} no longer changes eventStatus

A generic update that changes eventStatus is now rejected with 409 (GuardedStatusFieldWrite), and the message names the endpoint to use instead. The lifecycle dates a claim reports — opened on, previously closed on, re-opened on — are derived from the open/close-history log, so a status change that skipped the log would silently misreport them. Two things are deliberately unaffected: setting eventStatus on create still works, so a historical import can load claims that were already closed; and an update that sends eventStatus with the value it already holds is still accepted, so a client that echoes a whole record does not break. If you close events through the API today, move those calls to the new endpoints. The x-computed-default mark on eventStatus is unchanged and remains accurate for creates — the new Flow-written status fields section explains the distinction.

Added: 409 EventLifecycleDateOutOfOrder

Neither action may be dated before the last entry already in the event’s log: a close cannot land before the day the claim was opened, and a re-open cannot precede the close it reverses. The same day is allowed. The error message names the date the request has to clear.

2026-08-05 (the pricing contract replaces the billing container)

Changed: fullTermPolicyBillingInfo is renamed to fullTermPricingInfo, and its shape is new

The policy/quote billing container is now the pricing contract, fullTermPricingInfo, in every request, response, saved view, and bordereau field path:
  • Components with kinds. The priced charges live in pricingComponents (the successor of lineItems). Each component is {label, group, kind, value[, earningBasis]}: kind is the new closed classifier (Premium, Taxes, Fees, BrokerCommission, ProgramCommission, Other); group is now only an invoice-grouping name; value is a plain number (the {value, code} Currency wrapper is gone — everything is USD); earningBasis (pro-rata or fully-earned-at-inception) is optional — omitted means pro-rata, and a basis is rejected on kind Other.
  • Server-computed, read-only rollups. The container’s five totals — premium, taxes, fees, brokerCommission, programCommission — are computed by the platform on every write, each the sum of its kind’s components. They are never a contract input: a rollup value you send is ignored and recomputed.
  • policyGrandTotal is retired with no successor. The container carries no grand total; sum the rollups you care about. (The RATING RESULT container, fullTermPolicyRatingResult, is unchanged and keeps its own policyPremium/policyTaxes/policyFees/policyGrandTotal.)
  • The Billing Table display format is renamed Pricing Table in /configuration field locations.

Removed: the old key is no longer accepted in requests

The fullTermPolicyBillingInfo key — accepted (deprecated) during the transition window on NEW_BUSINESS/RENEW payloads and the ENDORSE/CANCEL/REINSTATE channels — is now rejected like any other unknown key/field. Stored data and every tenant configuration were migrated to the new vocabulary, so reads never produce the old name either. What changes for you. Nothing, if you already moved to fullTermPricingInfo during the transition window.
  • Reading. Read the container as fullTermPricingInfo everywhere the old key used to appear (version/transaction responses, the policy list summary, saved views, exports). Read totals from the five rollups; itemized charges from pricingComponents. Bordereau column ids are unchanged (policyPremium, policyPremiumChange) — only configured field-column paths move (e.g. fullTermPricingInfo.taxes).
  • Writing. Send pricingComponents under fullTermPricingInfo (one component per charge, classified by kind) and let the platform compute the rollups. A payload naming fullTermPolicyBillingInfo now fails with a 400 (unknown key on the transaction channels; undeclared field inside fieldModelV1Data.policy).

2026-08-05 (policy field data is no longer nested)

Breaking: policy transactions send and return the field data unwrapped

The fieldModelV1Data.policy wrapper is gone from the policy-transaction request and from every policy response. There is no accept-both window: send and read the new shape. RequestsPOST .../policies/transaction/new-business and POST .../policies/transaction/renew now take the policy field data as data, a plain object, with the command metadata beside it:
Both endpoints accept the same three keys — transactionTimestamp, displayAuthor, data. (displayAuthor is new on renew; it already existed on new-business.) Responses — every policy read and write returns each segment as { startDate, endDate, data }. The old spelling was { startDate, endDate, fieldModelV1Data: { policy: {…} } } on the single, versions, transactions and bordereau reads, while the list read already returned the unwrapped object under the fieldModelV1Data name. Both are now data, so the two read paths agree for the first time. This covers the list, single, versions, transactions and bordereau reads and the MCP policy tools. The three hoisted whole-term containers on a version response — fullTermPricingInfo, fullTermPolicyInfo, fullTermPolicyRatingResult — are unchanged, as are the hoisted primaryInsuredName / primaryInsuredId. Entity CRUD is untouched: a Quote, Exposure or Event still carries fieldModelV1Data. Only the policy surface changed, because only there did the name wrap a second object. Error messages no longer name the wrapper. A payload-shape failure on these two endpoints now returns error code InvalidPolicyData (was InvalidFieldModelV1Data) and names the field without a policy. prefix — for example 'policyStartDate' is required (Date object). The generic InvalidFieldModelV1Data code is unchanged for entity CRUD.

2026-08-05 (forms logic now guards form deletion)

Changed: DELETE /forms/{number} rejects dangling forms-logic references

Deleting a form now returns 409 with error code form-template-referenced-by-form-logic when the live field-model configuration still has a forms-logic rule for that form number. Remove the rule through the configuration import flow, then retry the delete. This guard also applies to deletion from the app’s form library. It prevents a form cleanup from leaving an invalid configuration that blocks a later config import or config-sync reconciliation. Forms with no live forms-logic reference continue to delete as before, and already-generated forms remain readable.

2026-08-05 (platform-generated fields are marked read-only in the schema)

Changed: a generated field publishes readOnly + x-generated

Some fields are generated by the platform: a sequence number minted when a policy binds, an identifier composed from other fields. Originating or changing one has always been rejected with a 400 GeneratedFieldWrite. The /configuration schema did not say so — a generated field could appear with no readOnly mark, and one expressed as a keep-if-supplied calculation was published as a computed default, whose documented meaning is “a value you supply is kept”. Both told you to send a field the API refuses. Such fields now publish readOnly: true + a new x-generated: true mark, with a description saying the value is generated and a supplied one is rejected. The new Generated write tier documents it, and the MCP get_entity_schema tool surfaces it as a generated boolean. No rejection behavior changed — a value the platform did not originate was refused before and after, and resending the exact stored value is still tolerated on an update. If a create or update of yours was failing with GeneratedFieldWrite on a field the schema said was yours to set, this is why. Drop the field from the payload and read the value back from the response. One quiet fix rides along: an explicit null for a generated field inside an embedded exposure used to blank the value on the embedded copy. It is now ignored, so the platform’s value survives. Generated fields are a per-company configuration choice, so which fields carry the mark depends on your configuration; read them from /configuration rather than hard-coding a list. Fields inside an embedded custom object (a sub-field) do not carry write-tier marks at all yet — that surface is unchanged. An embedded exposure does carry them, in both of its modes.

2026-08-05 (bill review is callable over the API)

Added: POST and GET /financials/invoices/{invoiceId}/bill-review

A bill review checks one invoice against the company’s own bill review rules and reports what it thinks is wrong with it. Both halves are now on the API:
  • POST …/bill-review starts one and answers 202 with the runId to poll. Nothing is reviewed inside the request — the review is background work. There is no request body, and no actionId or If-Match: the rules are the company’s own (snapshotted when the run is created) and the invoice version is pinned from its current head, so there is nothing for a caller to send, and a review records a verdict beside the invoice rather than journaling an action against it. Requires company.payment:update.
  • GET …/bill-review returns the latest run — status, the passed verdict, error, the pinned invoiceHeadJournalId, and the findings. Requires company.payment:read.
Polling. status is queued/running until terminal, then succeeded or failed. passed is null until then — and stays null forever on a failed run, so read error there rather than reading an empty findings list as a pass. Re-posting is safe. While a review of the invoice is queued or running, a second POST creates nothing and answers 202 with outcome: in_flight and the id of the run already under way. Once that run is terminal the next call mints a fresh one. A company at its background-work capacity gets a 429 and nothing is queued. Staleness is yours to check. invoiceHeadJournalId is the invoice version the run judged; a review is never re-run because the document changed. Compare it with the invoice’s current headJournalId — if they differ, the findings describe an earlier version. Not offered: writing the rules, dismissing a finding, or reading earlier runs. Rules and dismissals are managed in the app, and only the latest run per invoice is exposed.

2026-08-03 (an embedded exposure no longer carries relationship fields)

Changed: relationship (join) fields are gone from the embedded-exposure object

An embedded exposure is a point-in-time copy of an exposure, taken once when it is embedded and never re-copied. It now carries only the exposure’s own values. Its relationship fields — contacts, exposureAssignee, referencingQuotes, referencingPolicies, referencingSubmissions, referencingEvents, and any other field whose type is a join — are no longer part of that object, in either the reference mode or the create mode, and no longer appear in the published schema. They were never usable there. A copy cannot hold a relationship: the relationship belongs to the exposure record itself, so the platform read these keys back as null whatever you sent. Removing them makes the document match what actually happens instead of advertising a field that silently did nothing. What changes for you. Nothing, if you were not sending these keys.
  • Reading. Read an exposure’s relationships from the exposure itself (GET /exposures/{id}), not from a host’s embedded copy. This is also the value you want: the exposure’s current relationships rather than whoever was linked on the day the copy was taken.
  • Writing. A relationship you send inside an embedded item is ignored rather than stored on the copy. To change a relationship, write the exposure (PATCH /exposures/{id}). When you create an exposure inline inside a host, a relationship you supply is still applied to the new exposure record — that path creates a real exposure, so the value lands on it and reads back from it.
Value fields are unaffected: you can still override any of them on an embedded copy, and the override still never writes back to the exposure record.

2026-08-03 (data validation results are kept for the life of the run)

Removed: 410 Gone on GET /configuration/data-validation-runs/{runId} and its /findings sibling

A data validation run’s status and findings are retained for the life of the run. The 410 Gone response and its DataValidationRunGone error code are removed from both by-id GETs, which now answer either 200 with the run or a 404 for an id that belongs to no run of this company. A run that answered 410 now answers 200, so a branch on 410 — or on the finishedAt / retentionFloorDays fields its body carried — is unreachable and can be deleted.

2026-07-31 (data validation results are retained for at least 30 days)

Added: 410 Gone on GET /configuration/data-validation-runs/{runId} and its /findings sibling

A data validation run’s results — the status counts and the findings — are now documented as retrievable for at least 30 days after the run completes (finishedAt). This is a floor, not an expiry: results may be kept longer, so do not compute an expiry date from it. Once a run has aged past that floor, both GETs answer 410 Gone instead of 404. The distinction is worth handling: a 404 means the run id is wrong (check it), while a 410 means the id is right and the answer is simply no longer available (start a new run) — so a client holding a good run id is not sent off to debug the id. The 410 body carries the run’s finishedAt and the retentionFloorDays it fell outside of, neither of which a 404 ever carries. Three properties to build against. The two endpoints age out together, so a run is never readable through one and gone through the other. A run id belonging to another company is still a 404, never a 410, so this response can never confirm that an id exists elsewhere. And a run that has not finished is never aged out, however long ago it was started.

2026-07-30 (data validation findings — which records would break, and why)

Added: GET /configuration/data-validation-runs/{runId}/findings

The per-record detail behind a data validation run (permission company.configuration:export), completing the three-endpoint set below. It returns the standard { items, totalCount } envelope, one item per stored record the run judged not to adhere: entityType, entityId, the undeclared extraKeys it holds, and the violations whose values would fail a write. Read a violation’s fieldPath — ordered, machine-stable segments — rather than splitting the legacy dotted field; both are [] / null for a record-level failure that names no single field. It is paginated, and separate from the poll, on purpose. A status poll is made roughly once a second, and a run over a broken book can produce tens of thousands of findings, so the poll stays fixed-size and the findings are pulled here with page / pageSize (default 50, maximum 500 — a larger pageSize is clamped, not rejected). totalCount ignores the pagination, but it is only stable once the run has reached a terminal status (succeeded or failed). A run still queued or running appends findings as it scans, so page 1 of a live run can report a smaller totalCount than page 2. Findings only ever append, never reorder, so the ordering of what you have already read stays put. To size a whole pull from the first page, poll GET /configuration/data-validation-runs/{runId} until the status is terminal, then read the findings. entityType is how you group. The response is a flat list — request one entity type at a time to build a per-type view. The filter narrows totalCount as well, so the count always describes the items beside it. An entityType the run found no problem in is a legitimate empty page; an entityType that is not a real entity type is a 400, not an empty page, because ?entityType=Policies answering {"items":[],"totalCount":0} would read as “none of my Policy records have a problem”. One field is worth calling out: adheres is always false on a returned finding — a row exists only for a record that did not adhere.

2026-07-30 (data validation runs — scan stored records against a configuration)

Added: POST /configuration/data-validation-runs and GET /configuration/data-validation-runs/{runId}

Two endpoints (permission company.configuration:export) that check whether the records a company has already stored would still fit a configuration — the data-aware companion to validate, which reads no records at all. The start endpoint’s request body is optional, and that is how you choose what to scan: send a complete configuration body to scan that candidate without importing it, or send no body at all to scan the company’s live configuration. It returns { runId, outcome } immediately; the scan runs in the background and GET .../{runId} reports status (queuedrunningsucceeded / failed) plus running counts of records scanned, adhering, holding undeclared keys, and breaking. There is deliberately no total or percentage, and per-record detail is not part of the response. Two behaviours to build against. Starting a run whose configuration matches one already in flight returns that run’s id with outcome: "duplicate" rather than starting a second scan — a successful 200, not an error, so a retrying client should poll the id it gets back; pass ?force=true to start a new run anyway. And a company may have 3 runs in flight at once: a 4th distinct configuration is refused with a 429 whose body carries inFlightRunIds. ?force=true does not lift that limit.

2026-07-30 (correction: rating and tax output containers are settable again)

Fixed: exposureRatingResponse, crossSegmentRatingOutputs and inscipherTaxPlan are no longer system-owned

The 2026-07-24 embedded-exposures write-tier entry below listed these three Exposure fields as system-owned, and a later change began enforcing that on write: the value was silently dropped from an embedded-exposure item instead of stored. That was wrong, and it is reverted. All three are back to settable, unmarked in the published schema, and stored as sent. The tier was a mistake because the platform does not persist these values itself. Hosted rating is stateless — it computes and returns results without saving them — so the payload that writes them back is their only source. Marking them read-only removed the sole copy and left the exposure’s rating fields null. No action is needed if you never stopped sending them. If you removed them from your payloads after that entry, resume sending them and re-save any quote whose exposure-level rating values are now empty. The documented external-rating flowPATCH the quote with policyRatingResponse and exposureRatingResponse populated — is correct and supported. Whether a future release moves rating persistence onto the platform is an open design question; it will get its own entry, with notice, if the contract changes.

2026-07-28 (read-only smart-tag audit for forms and templates)

Added: GET /forms/template/{number}/smart-tag-audit and GET /forms/generated/{id}/smart-tag-audit

Two read-only endpoints (permission forms:read) that classify every AII… smart-tag identity a stored document carries — live anchors and dormant DOCVARIABLE authoring codes alike — against the company’s current field configuration: resolvable, framework, legacy-renameable, legacy-orphan, dead-hashed, or foreign. Nothing is written. A healthy document reports only resolvable/framework; anything else names a tag that will render unfilled on generated documents and how to interpret it.

2026-07-27 (record payment — explicit allocations or the whole balance)

Breaking: POST /financials/invoices/{invoiceId}/payments replaces amountCents with two modes

The record-payment request no longer takes a document-level amountCents. The field is removed: sending it is now a 400. Every request must instead carry exactly one of two modes — both together, or neither, is a 400:
  • payBalanceDue: true — settle the whole document. The server marks every open line item at its full remaining, in the document’s line-item order, in both directions at once. The invoice comes back paid with balanceDueCents: 0, and the gesture’s net cash equals the balanceDueCents you asked to pay. Only the literal true is accepted; payBalanceDue: false is a 400.
  • allocations — an array of { lineItemId, amountCents }, naming exactly the line items to mark and exactly the cents for each. A line you do not name is untouched; a line you name for less than its remaining stays open.
allocations[].amountCents is in the line’s frame — it settles that line’s remaining toward zero, so it carries that remaining’s sign. This is not the oriented net-cash frame balanceDueCents reads in: a receivable-direction line with 3,000 remaining takes a mark of +3000, even though the cash moves in. Each amount must be a nonzero integer, the array must be non-empty, and a lineItemId may appear at most once per request (each a 400). The 422 guards are unchanged and now apply to the marks you author: UNKNOWN_ID for a line item not on the document, INVALID_AMOUNT for a mark opposing its line’s remaining (or a payBalanceDue against a document with nothing open), and AMOUNT_EXCEEDS_BALANCE_DUE for a mark overshooting its line’s remaining. The bounds are per line, not per document — an allocation well inside the document’s balance due is still rejected if it overshoots the line it names. Everything else on the endpoint is untouched: If-Match, the actionId idempotency key, the auto-approval behaviour, erodeReserves, and the response shape (paymentIds + createdPayments, now in mark order — the allocations order, or line-item order under payBalanceDue). Reusing an actionId with different allocations is 409 ACTION_ID_REUSED. To migrate. A call that settled a whole invoice becomes payBalanceDue: true. A call that paid part of one becomes an allocations array naming the lines — read the invoice’s lineItems and its live payments to see what each line has remaining. There is no transition period: the old field stops being accepted with this change.

2026-07-27 (legacy financials endpoints removed)

Removed: the deprecated legacy financials endpoints

Every company now runs on the current financials surface, so the deprecated legacy endpoints are gone from the API:
  • GET /financials/invoice-drafts/{invoiceId} and POST /financials/invoice-drafts/{invoiceId}/finalize → a draft is an ordinary invoice with stage: "draft"; use GET /financials/invoices/{invoiceId} and POST /financials/invoices/{invoiceId}/finalize.
  • POST /financials/event-transactions/import → use POST /financials/events/{eventId}/import.
  • GET /financials/invoice-types → use GET /financials/config/categories.
  • POST /financials/payees/reassign → use POST /financials/payees/{payeeId}/merge.
Each already answered 404 Not Found for every company, so no working integration changes.

2026-07-27 (file categories — configured per entity type)

Added: entityType on List File Categories

GET /files/categories accepts an optional entityType query parameter (the lowercase kebab owner slug: company, event, exposure, quote, policy, submission, person, organization). With it, the response adds the requested entityType and an entityTypeCategories array of { name, configured } — that entity type’s admin-configured categories in their configured order, followed by the labels in use on its live placements that no configured category covers, and categories carries the same names in the same order. Admins manage these lists in Company Settings → File Categories; each entity type has its own. Without the parameter the response is unchanged: categories alone, the distinct values in use across the whole company. Category writes are still free text — Update File and Update File Placement accept any label up to 255 characters and are not validated against the configured lists.

2026-07-27 (required input — one complete 400)

Changed: a create missing required input is rejected once, naming every field

An entity create that omits required input is now rejected before anything is validated or written, with a single InvalidEntityShape 400 whose userMessages name every missing field — instead of the previous one-field-at-a-time message raised late in the write. The problem code is unchanged, so existing error handling keeps working; only the timing and the completeness change. A payload that is both incomplete and wrong-typed now reports the missing input first — supply it, resubmit, and the remaining checks run as before.

Changed: required in the configuration schema is now the create contract

The required array of GET /entities/{entityType}/configuration is now exactly the set of fields you must supply on a create: the platform’s structural requirements minus everything the write path produces for you (join fields, calculated fields, server-seeded defaults). Previously it echoed the fields your configuration marks required in the UI, which could both demand fields the API fills in for you (e.g. quoteNumber, eventStatus) and omit fields a create genuinely needs. The two now agree by construction: comply with the published required and a create cannot be rejected for missing input. policy is unaffected — it has no generic create endpoint, so its required still reflects UI requiredness.

2026-07-24 (bulk wipes — delete-guarded)

Changed: both bulk wipes now consult the company delete guard

POST /financials/deleteAll and POST /entities/{entityType}/deleteAll are now gated by the company delete guard — the same mechanic that gates the internal admin wipes. A company whose guard is active or onboarding-active (the default posture for a live company) is rejected with 409 DeleteGuardConflict and nothing is deleted, regardless of the key’s permissions. To run either wipe, a non-active delete guard (onboarding-allows-delete, demo, internal-dev, or inactive) must first be set from the Control Plane. The financial guard on the entity wipe (409 DeleteBlockedByFinancials) is unchanged.

2026-07-24 (bulk wipes — clear-financials + financial guard)

Added: Delete All Financial Data (POST /financials/deleteAll)

New SUPER_ADMIN-only endpoint (company.financial-data:deleteAll) that hard-deletes every financial record the company holds — invoices, payments, journal, ledger, and per-entity balances — while keeping financial configuration (transaction categories, line item types, approval config). Returns { deletedRecords }. This is the first rung of the breaking-config reset ladder: clear financials → per-type entity deleteAll → re-import.

Changed: POST /entities/{entityType}/deleteAll is financially guarded

The per-type bulk entity wipe now rejects with 409 DeleteBlockedByFinancials if financials holds a live claim on any record of the type — a live linked invoice (voided invoices included) or a nonzero entity account balance. The rejection is all-or-nothing (nothing is deleted) and carries a structured financialBlockers object: liveInvoiceCount, nonzeroBalanceCount, blockedEntityCount, and up to 10 sampleBlockedEntityIds. Clear the company’s financial records first (POST /financials/deleteAll), then retry. The breaking-config import 409 guidance now names this order.

2026-07-24 (embedded exposures)

Documented: embedded-exposure fields are a two-mode contract

The /entities/{entityType}/configuration schema now renders an embedded-exposure field (an exposure embedded in a Quote, Policy, or Submission) as a oneOf of two modes: reference an existing exposure by id (with optional per-field overrides) or create a new exposure inline (no id). Providing the field restates the complete membership; each item is validated as-if-inserted against the current Exposure configuration, with structured 400s naming the offending item. See Entities → Embedded exposures.

Documented: fields advertise a write tier (x-system-owned joins x-calculated)

Every field in a /entities/{entityType}/configuration schema now advertises its write tier so a caller can tell a settable input from one the platform owns. The full field list is unchanged — the schema stays complete for reading — but a system-owned field is now marked readOnly: true + x-system-owned: true (alongside the existing calculated mark readOnly: true + x-calculated: true; a field may carry both). System-owned covers the values the platform, not the caller, is the source of truth for: the Exposure reverse-listing joins (referencingEvents / referencingQuotes / referencingPolicies / referencingSubmissions), the rating-output containers (crossSegmentRatingOutputs, exposureRatingResponse), and inscipherTaxPlan. The marks appear on the top-level schema and inside both embedded-exposure modes. A fourth tier corrects a false read-only signal: a computed-default (caller-wins) field — one calculated by the self-referential keep-if-supplied idiom IS_PRESENT(<field>) ? <field> : <default> (e.g. quoteNumber, quoteStatus, eventStatus) — is now marked x-calculated: true + x-computed-default: true and is no longer readOnly. The server fills a default only when the field is omitted and keeps a value the caller supplies, so publishing it read-only was wrong. A plain calculated field (any other fallback, or a condition gated on a different field’s presence) keeps readOnly + x-calculated. System ownership wins over computed-default (a system-owned field stays readOnly). The MCP get_entity_schema slim surfaces the tiers as systemOwned and computedDefault booleans. Documentation/annotation only — no wire shape or validation behavior changed. See Entities → Field write tiers.
Since 2026-08-06 this is only half true of eventStatus, which is settable on create but can no longer be changed by an update — see Flow-written status fields.

2026-07-24 (financials — pre-rollout)

Financials V2 is pre-rollout: the published contract updates in place ahead of the enablement flip. No live external consumer exists yet, so semantics changes are legal in this window; it closes at the flip.

Changed: posted line items are immutable — LINE_ITEMS_IMMUTABLE replaces ITEM_BELOW_ALLOCATED

Once an invoice is posted (a non-draft creation, or finalize for drafts), its line items — ids, types, amounts — and its category are fixed. Update Invoice (PUT /financials/invoices/{invoiceId}) still carries the full document, but the line-item set and categoryId must echo the stored document verbatim; the mutable remainder is incurredDate, dueDate, memo, fieldData, and per-line memos. Any post-posting line-item or category change is rejected 422 LINE_ITEMS_IMMUTABLE, at any payment count — zero included. Corrections are void-and-recreate, payments removed first. Drafts are untouched: a draft’s document (line items and category included) replaces wholesale until finalize. The stable 422 catalog stays at 18 codes: ITEM_BELOW_ALLOCATED (per-line signed cover — unreachable once posted documents cannot change) leaves the enum, LINE_ITEMS_IMMUTABLE joins it. A category change no longer answers LIVE_PAYMENTS (that code remains for re-link, payee change, and void under live payments).

Added: approved and draftAmountPaidCents on the invoice row

The invoice representation documents two fields the wire already serves: approved (boolean — the approval projection: set by approve, cleared only by unapprove; an edit never resets it) and draftAmountPaidCents (integer, nullable — a DRAFT’s annex-derived paid total in the same oriented net-cash frame as amountPaidCents; null on non-draft invoices). Documentation only — no wire change.

2026-07-23 (files)

Added: Company Files version history + re-upload

GET /api/v1/companies/{companyId}/files/{fileId}/versions lists a file’s version history — every finalized (ready) version plus any in-flight pending re-upload, newest first, with the current version flagged. Each row (versionId, fileName, contentType, byteSize, state, isCurrent, createdAt) describes that version’s immutable bytes. Requires company.file:read. POST /api/v1/companies/{companyId}/files/{fileId}/versions mints a re-upload intent — a new version of an existing file. It returns { fileId, versionId, uploadUrl } exactly like a first upload intent; PUT the bytes to uploadUrl, then finalize with the existing POST /files/{fileId}/finalize (there is no new finalize surface). Finalize repoints the file at the new version, which silently becomes current — the file keeps its id, placements, categories, and history. Because it changes an existing file, it is gated by company.file:update, not company.file:create. This closes the gap where a third-party consumer had to delete and re-create a document to update it, losing its placements, categories, and history.

Added: Restore a previous file version

POST /api/v1/companies/{companyId}/files/{fileId}/versions/{versionId}/restore rolls a file back to an earlier version by repointing it at the named version — pure metadata (no bytes move, nothing is deleted, the previously-current version stays in history). It returns { fileId, currentVersionId }. Only a ready version can be restored; restoring a pending version or the version that is already current returns 409, and an unknown version returns 404. This is the rollback counterpart to re-upload — a consumer that pushed a wrong new version now has a way back. Gated by company.file:update.

Added: File category vocabulary

GET /api/v1/companies/{companyId}/files/categories returns { categories } — the distinct category values in use across the company’s live file placements, sorted case-insensitively. Because category is free-text, a consumer writing categories via Update File / Update File Placement can now draw from the existing, company-wide vocabulary instead of fragmenting it. Requires company.file:read.

Added: Batch download URLs

POST /api/v1/companies/{companyId}/files/download-urls mints signed read URLs for up to 100 files in one round trip — the batch counterpart of GET /files/{fileId}/download-url, for fetching an entity’s whole document set without one request per file. It returns { downloadUrls }, each item pairing a fileId with the same per-file fields as the single-file endpoint (url, expiresAt, fileName, contentType, byteSize); an optional disposition (attachment default, or inline) applies to the whole batch. It is all-or-nothing: if any id is unknown, cross-company, or not ready, the whole request is rejected (404/409) and no URLs are returned. An empty fileIds array or more than 100 ids returns 400. Requires company.file:download.

Added: Bulk move files

POST /api/v1/companies/{companyId}/files/bulk-move moves a batch of an owner’s files into one folder (or to the owner’s top level with folderId: null) in a single transactional request — reorganizing an entity’s whole document set without one PATCH per file. The request names the owner (entityType + entityId) whose placements move; only that owner’s placement of each file moves, so a file shared onto other entities keeps its placements there. The target folder must belong to the same owner. It returns { ids } — the ids of the files whose placement moved. It is all-or-nothing: if any id is unknown, cross-company, or not placed under the owner, the whole request is rejected (404) and nothing moves; a target folder owned by someone else is a 400. An empty fileIds array or more than 100 ids returns 400. Requires company.file:update.

2026-07-23 (later still)

Changed: payments are per-line settlement MARKS — record-payment response reshape

POST /api/v1/companies/{companyId}/financials/invoices/{invoiceId}/payments — the request is unchanged in shape (still one scalar amountCents, no per-line input — an explicit per-line array remains a schema-level 400), but its meaning and its response are reshaped. amountCents is now oriented net cash (positive = out, negative = in — the frame balanceDueCents reads in), and the server fans it into per-line settlement marks pro-rata over the open lines of the scalar’s OWN orientation — a net-outflow payment prorates against payable-direction (outflow) lines, a net-inflow payment against receivable-direction (inflow) lines; the non-matching direction never receives partial payments via this API. Worked: 8,000 expense + 3,000 income, pay 2,000 → the 2,000 prorates against the outflow lines only — expense remaining 6,000, income untouched, net balance 3,000. The response’s top-level paymentId is replaced by paymentIds + createdPayments — the created mark rows, each id a removal handle. The actionId anchors the first mark’s journal action; sibling marks mint their own ids in the same transaction, and an identical retry replays the full set. The payment row — in the detail read, invoice write responses, and the record-payment response — is now a MARK: it gains lineItemId and drops allocations (amountCents is signed in the line’s frame and settles that line toward zero). The create endpoint’s draftPayments annex entries likewise now require lineItemId. The payee merge and movePayments re-records copy amount / date / memo / line item / erode flag verbatim. INVALID_AMOUNT and AMOUNT_EXCEEDS_BALANCE_DUE keep their codes with per-line meaning: a zero mark or one whose sign opposes its line’s open remaining (a scalar with no open lines in its direction included), and a mark whose magnitude overshoots its line’s remaining. There is no document-scalar payment bound anymore.

Changed: document scalars are ORIENTED; settlement is per line

On every invoice representation, totalAmountCents, amountPaidCents, and balanceDueCents are now net-cash figures: line items and marks count oriented by their types’ directions (payable +, receivable −), so balanceDueCents is the net cash remaining to move — positive = out, negative = in — and may move non-monotonically as opposite-direction lines settle. Status derives per line (first match): no_charges iff every line’s amount is zero → paid iff every line is settled → owed iff there are no live marks → else partially_paid — a zero-due document with unsettled lines reads owed, and no scalar can make a document paid. paidDate is the payment date of the mark that first made every line settled. Field names, the status enum, and the invoice row’s shape are unchanged — the values’ meaning changed.

Changed: remaining reserves are SIGNED — three 422 codes retired (enum lands at 18)

The stable precondition enum drops TOTAL_BELOW_AMOUNT_PAID (cover is per line — ITEM_BELOW_ALLOCATED, signed cover over a line’s live marks, is the one update-time cover guard), EXPECTED_TOTAL_BELOW_PAID, and ERODES_BELOW_ZERO — and now documents the full stable vocabulary of 18 codes: the draft-lifecycle guards INVOICE_DRAFT / INVOICE_NOT_DRAFT and the approvals guards UNAPPROVE_PAID / NOT_APPROVED (money cannot post against an unapproved invoice while the approvals feature is on; an API caller cannot self-approve) join the published enum they were missing from. A reserve scope’s only law is the identity Reserves + Paid = Expected Total, at every sign: PUT …/events/{eventId}/reserves/{categoryId} now accepts an expected total below the scope’s paid-to-date (200; the remaining reserve reads negative — over-paid against a standing estimate), the import composition’s reserves.set members may land the same state, eroding payments apply to the remaining reserve unbounded (eroding past the estimate takes it below zero, never a 422), and a re-link movePayments with erosion: "preserve" succeeds without destination headroom — the destination scope’s remaining reserve goes negative (send erosion: "none" or set the destination’s estimate first if that reading is not intended; previously a shortfall rejected the whole request). Financials V2 is pre-rollout: the published contract updates in place ahead of the enablement flip, so no live consumer sees a shape or behavior change — semantics changes are legal in this window.

2026-07-23 (later)

Changed: balance account metadata — normalBalance replaced by direction + lineItemTypeId

GET /api/v1/companies/{companyId}/financials/balances and GET …/financials/entities/{entityType}/{entityId}/balances — on every balance account detail row (the accounts[] entries and cash), normalBalance is replaced by direction + lineItemTypeId. direction is the account’s provenance direction (payable | receivable): a line_item leaf’s or payable/receivable twin’s line item type direction, a reserves/unpaid/additional account’s category expectedDirection — and null exactly for the cash account. lineItemTypeId is set on line_item leaves and their payable/receivable balance twins (the per-line-item join handle), null elsewhere. Orientation now derives from role × direction: cash, receivable twins, and receivable-direction reserves/unpaid sit on the assets side; payable-direction leaves and additional on the expenses side (both carry debit); payable twins and payable-direction reserves/unpaid on the liabilities side; receivable-direction leaves and additional on the income side (both carry credit). Amounts are unchanged — still raw signed cents in the ledger convention; only the metadata changed. Financials V2 is pre-rollout: the published contract updates in place ahead of the enablement flip, so no live consumer sees a shape change.

2026-07-23

Changed: Signed payment amounts — credit documents settle toward zero

POST /api/v1/companies/{companyId}/financials/invoices/{invoiceId}/paymentsamountCents is now signed and nonzero, settling the invoice toward zero: it carries the open balance’s sign and its magnitude may not exceed the balance’s (was: positive up to the balance due; on an ordinary all-positive invoice the rule reduces to exactly that). Two 422 codes are re-scoped with no new codes and no enum change: INVALID_AMOUNT now means a zero payment, a payment whose sign opposes the open balance — any nonzero payment on a zero balance included (previously AMOUNT_EXCEEDS_BALANCE_DUE) — or an allocation sign-inconsistent with its line item; AMOUNT_EXCEEDS_BALANCE_DUE is narrowed to pure magnitude overshoot. Credit/reversal invoices are now first-class end-to-end: an invoice may carry a negative total, a negative line amountCents posts opposite the line item type’s expected direction (the posting rule still comes from the type, never the sign), and negative payments settle such documents toward zero. The update-time cover guards (TOTAL_BELOW_AMOUNT_PAID / ITEM_BELOW_ALLOCATED) compare sign and magnitude accordingly. The reserve-eroding guard is now oriented: only a payment whose net effect reduces the remaining reserve is bounded by headroom (422 ERODES_BELOW_ZERO) — a reversal receipt is never blocked. No wire shapes or enum values changed — request/response schemas are byte-identical and this is a description-level contract change only. Financials V2 is pre-rollout: these semantics land in the published contract ahead of the enablement flip, so no live consumer sees a behavior change.

2026-07-22 (later)

POST /api/v1/companies/{companyId}/financials/invoices/{invoiceId}/relink accepts an optional movePayments object. A re-link normally requires zero live payments (422 LIVE_PAYMENTS); opting in composes the paid-invoice flow atomically — every live payment is removed, the invoice re-links, and each payment is re-recorded (amount, date, memo, allocations copied verbatim under fresh paymentIds) against the new link. movePayments.erosion picks how re-records treat reserves: preserve (default — original erode flags kept, destination headroom required, 422 ERODES_BELOW_ZERO on a shortfall) or none (all re-records post non-eroding). journalIds returns every emitted action in execution order.

2026-07-22

Added: optional author display label on the existing financials writes

Every financials write available in this release accepts an optional author field (1–255 characters) — a display label for the source system’s author (see the Financials overview’s Authorship design rule). Where the app shows who recorded a change, a labeled row reads as your label and an unlabeled row reads as the built-in “External API” actor; rows written through the API are always visibly marked as API-written regardless of the label. The reserve-update feed rows now return the label as displayAuthor, and their createdBy is always a real user id (the “External API” actor for API writes) — it is no longer ever null. The policy-invoice batch added on 2026-08-07 is the later exception: its detached plan has no author field.

Added: Financials write surface — the full financials API contract

The financials API is now the complete read/write contract of the financials subsystem (see the reworked Financials overview for the design rules: client-supplied actionId idempotency, the If-Match concurrency watermark, stable 422 precondition codes, cursor pagination, integer-cent amounts). Companies are enabled progressively — until a company’s cutover, these endpoints return 404 for it and the legacy financials endpoints keep serving it. Nine single-invoice writes (company.payment:update):
  • POST /api/v1/companies/{companyId}/financials/invoices — create an invoice; the server mints the id and invoice number; optional links (event or policy, plus payee). Replaces the legacy create contract at this path for enabled companies.
  • PUT /api/v1/companies/{companyId}/financials/invoices/{invoiceId} — full document replacement (links excluded).
  • DELETE /api/v1/companies/{companyId}/financials/invoices/{invoiceId} — terminal delete; live payments swept in the same action.
  • POST …/invoices/{invoiceId}/relink — attach/detach/move the event/policy link (zero ledger rows).
  • POST …/invoices/{invoiceId}/payee — set/clear/change the payee.
  • POST …/invoices/{invoiceId}/payments — record a payment; the server mints paymentId and computes allocations pro-rata.
  • DELETE …/invoices/{invoiceId}/payments/{paymentId} — remove a payment; the horizon rule’s companion reserve unwind composes automatically.
  • POST …/invoices/{invoiceId}/void and POST …/invoices/{invoiceId}/restore — write the charges down to zero and back.
Two reserve writes and two compositions:
  • PUT …/events/{eventId}/reserves/{categoryId} — set the scope’s absolute expected total.
  • POST …/events/{eventId}/reserves/{categoryId}/history-reset — zero the scope’s remaining expectation and mark the reserve feed’s horizon.
  • POST …/events/{eventId}/import — wholesale event refresh in one transaction (sweep → create → set reserves), idempotent by client-authored action ids.
  • POST …/payees/{payeeId}/merge — repoint every linked invoice at another payee, re-recording payments; idempotent by convergence.

Changed: POST /financials/validation/sync contract

For enabled companies the read-state rebuild now takes an optional scope=invoices|balances body (omitted = rebuild everything), runs under an exclusive company-level lock, and returns per-table rebuilt counts (rebuilt.invoices / rebuilt.invoicePayments / rebuilt.entityAccountBalances). The previous offset/limit window-walking shape is retired with the legacy surface, and the endpoint now requires company.payment:update (previously company.financials:sync).

Deprecated: legacy financials endpoints

Deprecated, still serving companies not yet on the new surface; they will be removed after the migration completes:
  • POST /financials/event-transactions/import → use POST /financials/events/{eventId}/import.
  • GET /financials/invoice-types → use GET /financials/config/categories.
  • POST /financials/payees/reassign → use POST /financials/payees/{payeeId}/merge.

2026-07-21

Added: Financials read endpoints

Six new read endpoints expose the company’s financial state (integer-cent amounts, ISO dates):
  • GET /api/v1/companies/{companyId}/financials/invoices — global and entity-scoped invoice lists in one endpoint: filter by status, category, linked event/policy/payee, invoice number, and date ranges (unlinked=true for invoices with no event and no policy link); cursor-paginated. Deleted invoices are hidden. Every row carries headJournalId.
  • GET /api/v1/companies/{companyId}/financials/invoices/{invoiceId} — the whole current invoice document, its live payments, and headJournalId — the concurrency token for subsequent writes. Unlike the listing, returns deleted invoices.
  • GET /api/v1/companies/{companyId}/financials/balances — per-category rollups (reserves / owed / paid / expected total) with per-account detail and account metadata embedded; raw signed cents with normalBalance so clients orient displays.
  • GET /api/v1/companies/{companyId}/financials/entities/{entityType}/{entityId}/balances — one entity’s balance slice (entityType is event | policy | payee) in the same category-grouped shape; zero and absent are the same state.
  • GET /api/v1/companies/{companyId}/financials/events/{eventId}/reserve-updates — an event’s reserve-update feed: user expected-total updates, automatic eroding-payment rows, and history-reset markers, newest first; optional categoryId filter.
  • GET /api/v1/companies/{companyId}/financials/config/categories — read-only discovery of transaction categories and their line item types (the ids write payloads cite); includeDeprecated=true to resolve old references.
All six require company.payment:read.

Changed: GET /financials/validation/check contract

The read-state consistency check now takes scope=invoices|balances plus an optional updatedAfter/updatedBefore window (bounding checked rows by update time) and returns the list of stored-vs-recomputed mismatches — an empty list means the scoped window is consistent. The previous offset/limit transaction-window walk and per-subsystem report shape are retired, and the endpoint now requires company.payment:read (previously company.financials:validate).

Added: displayAuthor on policy transactions

Policy transactions now support an optional user-visible author label, mirroring the existing displayAuthor on notes and file uploads. When an integration or importer supplies it, the policy history displays the label (e.g. “Data Import”) in place of the acting user’s name; audit attribution (createdBy) stays server-set and unchanged.
  • POST /api/v1/companies/{companyId}/policies/transaction/new-business and POST /api/v1/companies/{companyId}/policies/{policyId}/transaction/endorse accept an optional displayAuthor string (trimmed, non-empty, ≤ 255 chars).
  • PATCH /api/v1/companies/{companyId}/policies/{policyId}/transactions/{transactionId} (new endpoint) sets the label on an existing transaction — display metadata only; no other transaction field can be modified. Requires policy:update.
  • Transaction read responses (get/list) now include displayAuthor (null unless a label was supplied).

2026-07-17

Added: Rate a saved quote by id (stateless)

POST /api/v1/companies/{companyId}/quotes/{quoteId}/rate rates an already-saved quote, named by id, and returns the rating results without persisting anything — the by-id sibling of the full-body POST /api/v1/companies/{companyId}/quotes/rate. The quote is never modified (no field write, no rating run recorded; the quote row is byte-identical before and after). The request body carries only ratingWorkflowName; the quote id is a path parameter. There are deliberately no data overrides — to rate what-if values, use the full-body endpoint. The saved quote’s stored field bag runs through the identical create-quote validation, exposure id hydration, and rating pipeline the full-body endpoint uses, and the 200 response returns { data } — the quote’s bag enriched with rating outputs. Any saved quote rates regardless of quoteStatus (bound / cancelled included). ratingWorkflowName is required in practice (missing/unknown → 400 listing the configured names; no workflows configured → 422); an unknown / deleted / other-company / non-quote id returns a 404; and a saved quote whose stored data no longer validates against the current field configuration returns the same structured 400 the full-body endpoint returns. Reuses the company.quote:rate permission.

Clarified: Hosted rating is stateless

The rating overview previously said hosted rating “writes results to the rating response fields” and offered “a single API call to rate and store results.” Hosted rating never persists: both rate endpoints return the enriched field bag and record nothing. Keeping the results is a separate update the caller makes with the entity-update endpoints. The documentation has been corrected to match the endpoints’ actual behavior.

Changed: Legacy smart-tag names are no longer accepted

POST /api/v1/companies/{companyId}/forms/template and PUT /api/v1/companies/{companyId}/forms/template/{number} now reject a DOCX whose anchors carry the retired legacy smart-tag naming (AIIFmv1<ReferenceId>, e.g. AIIFmv1NamedInsured). Field smart tags are identified exclusively by their hashed form (AIIFmv1Fld + 16 hex characters, e.g. AIIFmv1Fld9E5D74B94AC85D40). The rejection is a structured 400 with a per-tag error that names the exact hashed replacement, so a rejected upload is directly actionable. Already-stored templates and generated forms are unaffected — every stored document was converged to hashed identities by the fleet-wide touch migration before this change. To fix a legacy-tagged source document, rename each anchor to the hashed identity the error names, or re-insert the tags from the form editor’s smart-tag sidebar. Fixed non-field tags (e.g. AIIFmv1CurrentDate) keep their readable names and are unaffected.

2026-07-16

Added: Bind Quote — one call turns a quote into a policy

POST /api/v1/companies/{companyId}/quotes/{quoteId}/bind binds a quote into a policy in a single, atomic call — the one-call convenience over hand-rolling the equivalent policy transaction from the quote’s data. There is no request body: the quote is read server-side, and on success the quote is linked to the resulting policy and the policy transaction is committed together, so a quote can never end up bound to a policy that was not created (or vice versa).
  • All five quote types bind through this one endpoint. The endpoint dispatches on the quote’s type: newBusiness and renewal mint a brand-new policy (a renewal’s new policy is linked back to the expiring term), while endorsement, cancellation, and reinstatement transact against their existing source policy in place. The response policyId is the policy the transaction landed on — the new policy for newBusiness / renewal, the existing source policy for the in-place types.
  • Response: 201 { policyId }.
  • Already bound: a quote that is already bound is rejected with 409, and the error body carries referencingPolicy (the id of the policy it is already bound to) so you can recover it without another call.
  • Validation: the quote must satisfy the same policy-create rules a new-business transaction enforces — notably the primary-insured identity on fullTermPolicyInfo (primaryInsuredJoin, referencing an existing Exposure, plus primaryInsuredName). A quote that fails them returns a structured 400.
  • Required permission: company.policy:create — a bind is a policy create.

2026-07-15

Changed: Stateless Quote Rating hydrates exposure id references

POST /api/v1/companies/{companyId}/quotes/rate now looks up and merges the stored data for exposures referenced by id. Because embedded exposures are referenced by id over the API (you cannot inline-create an exposure), each referenced Exposure’s stored fields are fetched and merged under the reference before rating, so the exposure is rated against its real stored data rather than a blank record. Fields supplied inline win over the stored values (the body is a draft-edit over the stored exposure); the lookup is read-only and nothing is persisted. Additionally, a body whose required rating target (e.g. quote.exposures) is empty or missing on the quote after this merge now returns a structured 400 naming the workflow and target path, instead of an opaque 500.

2026-07-14

Added: Stateless Quote Rating

POST /api/v1/companies/{companyId}/quotes/rate rates the quote a create-quote body would create and returns the results — without persisting anything (no quote row, no rating run). The request body carries the exact create-quote payload under data plus a ratingWorkflowName; the data bag is validated with the identical create-quote pipeline (a body create-quote would reject fails with the same structured 400), the named workflow runs, and the response echoes { data } enriched with rating outputs. ratingWorkflowName is required — a missing/unknown name returns a 400 listing the configured names, and a company with no rating workflows returns a 422. Establishes the external quotes/ namespace. Requires the new company.quote:rate permission.

2026-07-13

Added: Touch Form Template and Touch Generated Form

POST /api/v1/companies/{companyId}/forms/template/{number}/touch and POST /api/v1/companies/{companyId}/forms/generated/{id}/touch bring a form up to date in place: legacy-style smart-tag anchors (AIIFmv1<ReferenceId>) are converged to the current hashed identity format (AIIFmv1Fld…), and a generated form’s cached smart-tag metadata is refreshed from the converged document. Content, substituted values, and tag display names are untouched, and a template touch does not create a new version. Both endpoints are idempotent — an already-converged form reports changed: false and writes nothing — and respond with the per-form outcome (changed, renamedTags, and for generated forms metadataRefreshed). Requires the forms:update permission.

Added: List Generated Forms

GET /api/v1/companies/{companyId}/forms/generated lists a company’s generated forms — instances produced from form templates against specific records — which were previously not enumerable over the API (the existing GET …/forms lists the template library). Paginated (?page=/?pageSize=, with a totalCount), optionally filtered by ?category=. Each item carries id, name, category, templateId, bound (whether a finalized copy exists alongside the editable draft), and timestamps. Requires the forms:read permission.

2026-07-10

Added: Back-datable displayDate on Notes and Files

Historical imports and integrations can now supply an optional displayDate (ISO 8601 timestamp) when creating records, so imported history displays and sorts under its original date instead of the import time. Audit timestamps (createdAt/updatedAt) and creator/uploader attribution remain server-set and cannot be overridden.
  • POST /api/v1/companies/{companyId}/notes accepts an optional displayDate; note responses expose it (null when unset). GET …/notes now orders by the display date (displayDate when set, else createdAt), so backdated notes interleave correctly.
  • POST /api/v1/companies/{companyId}/files (upload intent) accepts an optional displayDate; file list items and file metadata expose it. GET …/files now orders by it the same way.

2026-07-09

Added: Explicit modules selection and replace guard on Seed Configuration

POST /api/v1/companies/{companyId}/configuration/seed gains two request fields, and its discovery endpoint now advertises the starter-module catalog.
  • modules — an explicit starter-module selection (checkbox granularity). Mutually exclusive with starterSheet: modules is authoritative for every axis and feature (the server unions it with the always-on base core + default but appends no default feature modules), whereas starterSheet expands to a complete out-of-box config. Supplying both is a 400 (InvalidModuleSelection); an unknown module id is a 400 (UnknownStarterModule); selecting two modules from the same pick-one axis — e.g. two raters or two exposure schemes — is a 400 (InvalidModuleSelection).
  • replace — an overwrite guard. Seeding a company that already has a non-empty configuration is now refused with a 409 (ConfigAlreadyExists) unless replace: true; a never-configured company still seeds without it.
  • GET /api/v1/companies/{companyId}/configuration/seed/options now returns a modules array — the full starter-module catalog, each entry carrying id, label, description, defaultSelected, its axis group (always-on / exposure-scheme / rating / policy-number / feature), and alwaysOn — alongside the existing starterSheet options.
POST /api/v1/companies/{companyId}/configuration/seed/generate is unchanged: it still accepts starterSheet only (no modules, no replace), since it never mutates the company.

2026-07-07

Breaking: Bordereau additionalColumns removed — columns is the one column-selection mechanism

The legacy additionalColumns parameter is removed from all three bordereau endpoints (GET /api/v1/companies/{companyId}/policies/bordereau, GET …/bordereau/download, POST …/bordereau/export). Field columns are now requested exclusively via columns {"kind":"field"} entries (each a path into the policy’s field data plus a display header); the per-row resolution semantics are unchanged.
  • Migration: replace each {"path":"…","columnHeader":"…"} entry with a columns entry {"kind":"field","path":"…","header":"…"} (on the rendered surfaces, list the fixed columns you want alongside them — columns is the complete ordered set there). A request still sending the removed parameter is not rejected: unknown parameters are ignored, so its extra columns silently no longer appear in the output.
  • The JSON list endpoint now accepts columns, restricted to {"kind":"field"} entries. Column order/omission is meaningless in its typed JSON rows (every fixed property is always present), but field selection is not — the selected fields resolve into each row’s map. A {"kind":"fixed"} entry there is rejected with HTTP 400 and a clear message. (This supersedes the initial columns design note that the list endpoint took no columns at all.)
  • Response rename: the per-row output map on the list endpoint is renamed additionalColumnsfieldColumns (same shape: resolved values keyed by the requested headers; empty object when no field columns are requested).
  • The columns-vs-legacy mutual-exclusion 400 is gone — with one mechanism left there is nothing to be exclusive with.
This ships without a deprecation window: the API has no live third-party consumers yet, so the surface was unified before first external adoption.

Added: Bordereau full column selection via columns (download + export)

The bordereau rendered surfaces — GET /api/v1/companies/{companyId}/policies/bordereau/download (CSV) and POST …/policies/bordereau/export (Google Sheets) — accept a new optional columns parameter: an ordered array of column specs that completely describes the output. The 12 fixed columns become selectable, omittable, and reorderable ({"kind":"fixed","key":"policyNumber"}), and field columns ({"kind":"field","path":"policyStatus","header":"Status"}) interleave anywhere. On the CSV download columns is a JSON-encoded query param; on the export it is a JSON array in the POST body.
  • Omitting columns preserves the default output — the 12 fixed columns in canonical order.
  • The JSON list endpoint’s columns support (field-only entries) is described in the entry above.

Changed: Bordereau field columns accept any field path, resolved at each transaction’s effective date

The bordereau endpoints (GET /api/v1/companies/{companyId}/policies/bordereau, GET …/bordereau/download, POST …/bordereau/export) now accept any dot-path in a field column — previously a path had to begin with one of the policy-root FullTerm containers (fullTermPolicyInfo., fullTermPolicyBillingInfo., fullTermPolicyRatingResult.) and anything else was rejected with HTTP 400.
  • Per-row resolution moved to the transaction’s effective date. Each row now resolves its field-column paths against the policy data as of that transaction’s effective date, so per-segment fields (e.g. policyStatus, or a mid-term endorsement’s changed values) report the value the transaction put in force. Existing FullTerm and fixed columns are unchanged — their values are identical across the policy term by construction.
  • Malformed dot-paths (empty, or leading/trailing/doubled dots) are still rejected with HTTP 400; a well-formed path that doesn’t exist in the policy data yields an empty value instead of an error.
  • Object-typed leaves render as values, not [object Object] — Date, Address, and Currency fields are formatted; other objects serialize as JSON.

2026-07-02

Added: List Forms and Download Form (read endpoints for the Forms API)

Two new read endpoints complete CRUD on the Forms API, both requiring the forms:read permission:
  • GET /api/v1/companies/{companyId}/forms — list a company’s form library (one summary per form’s current version), paginated (page/pageSize, with a totalCount) and optionally filtered by category and/or kind. Each item includes number, name, kind (template vs static), category, templateKey, version, and timestamps.
  • GET /api/v1/companies/{companyId}/forms/{number} — download a form’s current-version original file (the DOCX for templates, the PDF for static forms) by its FM-XXXX number. Returns { downloadUrl, fileName, contentType, expiresAt }, where downloadUrl is a 15-minute signed URL the consumer GETs the bytes from directly.
Together these enable round-tripping (export a form, edit it, re-upload via the replace endpoints) and config-as-code backup of a company’s form library. The change is purely additive — no existing endpoint changes.

2026-07-01

Changed (additive): List Parse Runs now reports logical runs with per-stage detail

GET /api/v1/companies/{companyId}/files/parse-runs now reports one record per logical parse run — the extract and create_<flow>_v<N> stages of a single trigger, grouped — instead of one flat record per pipeline task. All existing fields keep their types and documented semantics; the change is additive:
  • target is now populated with the { kind, id } file or folder the run parsed (previously always null). Runs that predate grouping still report target: null and are tagged legacy: true.
  • New optional fields per record: stages (per-stage status — including the distinct retrying and pending — plus attempts, error, finishedAt), createdEntities (entityType / entityId / deleted for every entity the run created), extractionReady, rerunOfRunId, reusedExtractOfRunId, and legacy.
  • status is unchanged: still exactly running / succeeded / failed, with every internal retry state reading as failed (per-stage retrying detail now lives in stages).

Breaking: Upload Form Template moved to /forms/template

Upload Form Template has moved from POST /api/v1/companies/{companyId}/form-templates to POST /api/v1/companies/{companyId}/forms/template. The request/response contract, forms:create permission, and DOCX-only smart-tag validation are unchanged — only the path changed, grouping form uploads under a forms/ namespace alongside the new static-form endpoint. Update any integration that posts to the old path.

Added: Upload Static Form (PDF)

  • Added: Upload Static FormPOST /api/v1/companies/{companyId}/forms/static uploads a .pdf static form (a certificate, handout, or any finished PDF) into a company instance under a chosen category (event, quote-flow, or quote-bind-flow). PDFs carry no smart tags, so nothing is validated — the file is committed as-is and forms generated from it are the PDF unchanged. PDF only; the file is sent as base64. Complements Upload Form Template (DOCX + smart tags). Required permission: forms:create.

Added: Replace a form in place (type-specific)

  • Added: Replace Form TemplatePUT /api/v1/companies/{companyId}/forms/template/{number} replaces a DOCX template’s content in place, and Replace Static FormPUT /api/v1/companies/{companyId}/forms/static/{number} replaces a static PDF form’s content. The uploaded file becomes a new version under the SAME identity — same number, templateKey, display name, and category — so forms-logic rules and existing form bindings that reference the number keep working (no need to keep minting new forms). The template endpoint validates embedded smart tags against the template’s existing category (DOCX-only); the static endpoint commits the PDF as-is (no tags). Both are same-type only — a replace can never flip a form’s file type (a DOCX↔PDF mismatch returns 400 form-template-type-mismatch / form-static-type-mismatch), and category is not part of the request (a replace never re-scopes). An unknown number returns 404. Required permission: forms:update.

Added: Delete Form

  • Added: Delete FormDELETE /api/v1/companies/{companyId}/forms/{number} soft-deletes a form (DOCX template or static PDF) addressed by its stable FM-XXXX number (the value returned by the upload endpoints). Every version is removed from the library, but forms already generated from it keep working (they resolve the version they were bound to). Forms-logic rules are configuration and are left untouched. An unknown or already-deleted number returns 404 (form-template-not-found) rather than a silent success. Required permission: forms:delete.

2026-06-30

Breaking: Finalize Upload no longer accepts flow / parseVersionOverride

The opt-in parse trigger has been removed from Finalize Upload (POST /api/v1/companies/{companyId}/files/{fileId}/finalize). The endpoint is now a pure pending → ready flip and accepts only versionId; the flow and parseVersionOverride body fields are gone.
  • Parsing is now reachable only through Trigger Parse (POST /api/v1/companies/{companyId}/files/trigger-parse). To parse on upload, finalize the file and then POST it to trigger-parse.

Added: List Parse Runs

  • Added: List Parse RunsGET /api/v1/companies/{companyId}/files/parse-runs lists a company’s parse runs, newest-first and paginated (1-based page / pageSize), so you can poll the outcome of a parse you kicked with Trigger Parse. Each pipeline task (extract, create_<flow>_v<N>) is its own flat run record with a runId, flow (null for the flow-agnostic extract stage), status (running / succeeded / failed — the internal failed_* retry states collapse to failed), attempts, optional error, and timestamps. target is currently always null. Required permission: company.file:read.

2026-06-29

Breaking: dropped the external segment from every API path (/api/v1/external/*/api/v1/*)

Every endpoint has moved off the /api/v1/external/ prefix onto /api/v1/ — the external URL segment is gone, and the v1 version segment is unchanged. There are no backwards-compatible aliases: requests to the old /api/v1/external/* paths now return 404. Consumers must update their base path.
  • The base origin is unchanged (https://go.aiinsurance.io). Only the path prefix changed.
  • Example: POST /api/v1/external/companies/{companyId}/filesPOST /api/v1/companies/{companyId}/files.
  • Likewise GET /api/v1/external/me/companiesGET /api/v1/me/companies, and so on for every route.
  • Request/response shapes, operationIds, permissions, and behavior are otherwise unchanged — only the path prefix moved. Update any saved URLs, base-path configuration, and regenerate SDKs.

Added: upload a form template with smart tags

  • Added: Upload Form TemplatePOST /api/v1/companies/{companyId}/form-templates uploads a .docx form template into a company instance under a chosen category (event, quote-flow, or quote-bind-flow). The DOCX’s embedded smart tags are validated against the catalog for that category before the template is committed: any unknown, not-enabled, wrong-category, or unsupported tag rejects the whole upload with a 400 whose details name each offending tag. DOCX only; the file is sent as base64. Built to support migrating forms between instances with their smart-tag links intact. Required permission: forms:create.

2026-06-26

Breaking: dropped the -json suffix from the configuration endpoints

Now that the spreadsheet configuration endpoints are gone and JSON is the only machine format, the -json suffix is redundant. The four endpoints have been renamed to their clean paths, and their operationIds renamed to match. There are no backwards-compatible aliases — the old -json paths now return 404. Update any saved URLs and regenerate SDKs.
  • POST /configuration/import-jsonPOST /configuration/import (operationId importFmv1ConfigurationJsonimportFmv1Configuration)
  • POST /configuration/export-jsonPOST /configuration/export (operationId exportFmv1ConfigurationJsonexportFmv1Configuration)
  • POST /configuration/compare-jsonPOST /configuration/compare (operationId compareFmv1ConfigurationJsoncompareFmv1Configuration)
  • POST /configuration/validate-jsonPOST /configuration/validate (operationId validateFmv1ConfigurationJsonvalidateFmv1Configuration)
The request/response shapes, permissions, and behavior are unchanged — only the paths and operationIds changed.

Breaking: removed the spreadsheet Import and Export endpoints

The two Google-Spreadsheet configuration endpoints have been removed from the external API. Their JSON equivalents are the canonical machine surface and fully cover this functionality.
  • Breaking: removed POST /api/v1/external/companies/{companyId}/configuration/import — applied changes from a Google Spreadsheet to the database. Use POST /configuration/import-json (apply a structured JSON configuration body) for the Google-free, JSON-native equivalent.
  • Breaking: removed POST /api/v1/external/companies/{companyId}/configuration/export — wrote current config to an existing Google Spreadsheet. Use POST /configuration/export-json (returns current config as a structured JSON body) instead; its output is the exact shape import-json accepts, so export-json → edit → import-json is a lossless round-trip.
  • Unaffected: the in-app onboarding spreadsheet import/export UI. The company.configuration:import / :export permissions are unchanged (still used by the JSON endpoints and the seed surface).

Restructured the seed surface; removed the spreadsheet Generate endpoint

The seed configuration surface is now machine-discoverable and Google-free, and the old Google-Spreadsheet Generate endpoint has been removed.
  • Added: List Seed OptionsGET /api/v1/external/companies/{companyId}/configuration/seed/options returns the valid starterSheet variants, each with a description and a default marker, plus the defaultStarterSheet applied when starterSheet is omitted. Sourced from a single in-code registry, so it cannot drift from the names the seed endpoints accept. Required permission: company.configuration:export.
  • Added: Generate Seed Configuration (JSON)POST /api/v1/external/companies/{companyId}/configuration/seed/generate returns the exact JSON configuration a seed would apply for the selected starterSheetwithout seeding, without mutating the company, and without Google. The body is the shape import-json accepts, so you can review/edit it and POST it to import-json. Optional { starterSheet?: string }; no googleOAuthToken. Required permission: company.configuration:export.
  • Breaking: removed POST /api/v1/external/companies/{companyId}/configuration/generate — the endpoint that created a blank Google Spreadsheet template has been removed from the external API. Use Generate Seed Configuration (JSON) for the Google-free, JSON-native equivalent. (The in-app spreadsheet generation UI is unaffected.)
  • POST /configuration/seed is unchanged — it still applies starter content directly to the company. Its starterSheet field now also documents an enum of the valid variant names.

Documentation: object-primitive corrections (no API change)

Several object-primitive docs were corrected to match the implementation. No runtime behavior changed — these are documentation fixes.
  • CoverageLimit was never a built-in object primitive. The docs (and the Fmv1CoverageLimit OpenAPI schema) described a CoverageLimit primitive with required { coverageLimitName, coverageLimitAmount }. No such primitive exists at runtime. The thing tenants actually model is a custom object named NumberLimit ({ numberLimitName, numberLimitAmount }), defined in the (default) tenant configuration — Coverage.coverageLimits is a list of it. The schema was renamed Fmv1CoverageLimitFmv1NumberLimit with the real keys and is now documented as a custom-object example, not a built-in primitive.
  • The built-in object primitives are exactly Address, Currency, Date, and StringOrNumber. Earlier entries listed CoverageLimit (never real) and QuoteBindError (since removed), and omitted StringOrNumber. The Object Primitives reference is now accurate.
  • Object-primitive requiredness is per-primitive, not blanket. The previous “every sub-field is required” rule was only ever true for Currency (value), Date (day/month/year), and StringOrNumber (kind). Address sub-fields are all optional — a partial address is valid and is not rejected. timezone on a Date is optional in the DMY shape.
  • Date input shapes documented. A generic Date field accepts either { day, month, year, timezone } (timezone optional) or the ISO envelope { date: "YYYY-MM-DD", timezone } (timezone required) on every write path, entity CRUD and policy transactions alike. Policy term dates (fullTermPolicyInfo.policyStartDate/policyEndDate) are the exception: they accept a full ISO 8601 string with a UTC offset, or a structured { year, month, day, timezone } object — but not the { date, timezone } envelope.
  • New-business example clarified. The policy new-business example mixes framework fields (primaryInsuredJoin, primaryInsuredName, fullTermPolicyInfo dates) with default-tenant-config-specific fields (annualPremium, primaryInsured, bedCount, …). The example now flags which fields are config-specific.

2026-06-25

Added: Seed Configuration endpoint (Google-free config setup)

New endpoint POST /api/v1/external/companies/{companyId}/configuration/seed puts a company into a usable FMV1 configuration state directly from the framework’s code-defined starter content — with no Google Spreadsheet and no Google OAuth token. It is the Google-free counterpart to POST /configuration/import: where import reads a spreadsheet, seed materializes the equivalent starter content in-memory and runs it through the same validate → compare → apply pipeline.
  • Request: optional { starterSheet?: string } — which starter module variant to seed (omitted → the product default). A bodyless POST seeds the product default.
  • Response: { success: true, message? } on success; a 400 with an error (no changes applied) if the starter content fails validation.
  • Required permission: company.configuration:import (same as Import; FMV1_CONFIGURATION_MANAGER role).

2026-06-17

Breaking: by-id update endpoints are PATCH-only (PUT removed)

The by-id update endpoints accepted both PUT and PATCH, both performing a partial merge. Because a partial merge is PATCH semantics — and PUT implies full replacement, which these endpoints do not do — accepting PUT was misleading. PUT has been removed: a PUT to any of these routes now returns 405 Method Not Allowed with an Allow: PATCH response header. PATCH behaviour is unchanged (only provided fields change, an explicit null clears, omitted keys are untouched). Affected endpoints (all under /api/v1/external/companies/{companyId}):
  • PATCH /entities/{entityType}/{entityId}
  • PATCH /notes/{noteId}
  • PATCH /tasks/{taskId}
  • PATCH /files/{fileId}
  • PATCH /folders/{folderId}
  • PATCH /files/{fileId}/placements/{placementId}
Migration: if you were sending PUT to any of these, switch the verb to PATCH — the request body and semantics are identical (they were always a partial merge). PUT was previously documented as a 200 alias of PATCH on the file, folder, task, note, and placement routes; that alias is removed.

2026-06-16

Documentation: accuracy reset across the API reference

The API reference was audited end-to-end against the live route surface and corrected so every documented endpoint, schema, status code, and permission matches what the API does today. No runtime behaviour changed — these are documentation corrections only.
  • Removed phantom endpoint groups. Three capability groups that had never shipped as endpoints (resolve-address / Address Tools, Event Financials, and quote send) were removed from the spec; they were never callable.
  • Corrected every endpoint’s schema, examples, status codes, and permissions to match the implementation, including the bare (un-prefixed) permission strings that the authorization guard actually checks: insured:update / insured:delete for the Exposure update/delete, policy:update for the policy cancel/endorse/reinstate transactions, and policy:delete for transaction delete. Create/read operations keep their company.-prefixed permissions. The Event configuration endpoint requires company.event:export.
  • Raw Authorization header. External API authentication takes your API key as the raw Authorization header value with no scheme prefix — not Bearer and not ApiKey , and never X-API-Key. See Generating API Keys.
  • Narrative pages reconciled. The overview, roadmap, getting-started, object-primitives, and data-models pages were corrected: Submissions, Persons/Organizations, Notes, Tasks, and Company Files are documented as available today (not “planned”); the unified entity envelope (fieldModelV1Data with epoch-second createdAt/updatedAt, no top-level companyId) and its { items, hasMore, totalCount } / zero-based pageNumber list shape are documented accurately; and broken internal links were repaired.

2026-06-11

Added: per-placement organize — move/categorize a shared file under one entity

folderId and category are per-placement attributes, so the owner-less PATCH /files/{fileId} cannot address them once a file is shared (its 409 Conflict below). The new placement update lifts that limitation:
  • PATCH /api/v1/external/companies/{companyId}/files/{fileId}/placements/{placementId} — update ONE placement’s folderId (a folder of the placement’s owner, or null for the owner’s top level) and/or category (free-text ≤255 chars, or null to clear). Absent fields are untouched; at least one must be present. Returns 200 { placementId, fileId, entityType, entityId, folderId, category }; the file’s other placements are never affected. Placement ids come from GET /files/{fileId}/placements or the share response. Requires company.file:update.
  • The 409 Conflict body returned by PATCH /files/{fileId} for folderId/category on a shared file now points at the placement endpoint: File has multiple placements: {fileId}. Update one placement instead: PATCH /files/{fileId}/placements/{placementId} (enumerate them with GET /files/{fileId}/placements). Single-placement files are unaffected — either endpoint works there.

Added: file placements — share one file across entities

A file can now be placed on more than one entity at a time. Sharing adds a placement — never a copy: the bytes are stored once and every placement sees the same current version and history. Folder location and category are per placement; displayName stays on the file. Sharing is same-company only.
  • GET /api/v1/external/companies/{companyId}/files/{fileId}/placements — list everywhere a file appears ([{ placementId, entityType, entityId, entityDisplayName, folderId, category, createdAt }]). Requires company.file:read.
  • POST /api/v1/external/companies/{companyId}/files/{fileId}/placements — share the file to another owner (entityType, entityId?, optional folderId of the target owner). Returns 201 { placementId, fileId, entityType, entityId, folderId }; a duplicate share to the same owner is 409 Conflict. Requires company.file:create.
  • DELETE /api/v1/external/companies/{companyId}/files/{fileId}/placements/{placementId} — remove the file from ONE owner. While other placements remain, the file and its content are untouched; removing the last placement deletes the file and reclaims storage (fileDeleted: true). Requires company.file:delete.
  • The file responses now carry placement information: list items gained placementId and placementCount; GET /files/{fileId} gained placementCount, and its owner fields (entityType/entityId/folderId/category) now describe the file’s primary (oldest) placement.
  • PATCH /files/{fileId} on a shared file can only change displayName; an owner-less folderId/category update is ambiguous across placements and returns 409 Conflict. Single-placement files behave exactly as before.
  • DELETE /files/{fileId} removes the file everywhere (all placements) — unchanged for single-placement files; use the placements endpoint for per-entity removal.

2026-06-10

Added: file categories

Files can now carry a free-text category label (max 255 characters). There is no configured category list — any non-blank string is a valid category.
  • PATCH /api/v1/external/companies/{companyId}/files/{fileId} accepts an optional category field alongside displayName/folderId: a string sets the label, an explicit null clears it, and an absent field leaves it untouched.
  • The file responses (GET /files/{fileId} metadata and the GET /files list items) already include category (null when unset).

Breaking: Company Files API rebuilt on signed URLs

The Company Files API was rebuilt end-to-end. File bytes no longer travel through the API — uploads and downloads now go straight to cloud storage via short-lived signed URLs, and every request/response shape changed. There is no compatibility mode for the old contract. See the Company Files overview for the new workflow.
  • Upload is now a two-phase handshake. POST /api/v1/external/companies/{companyId}/files no longer accepts multipart/form-data; it takes a JSON upload intent (entityType, entityId?, folderId?, fileName, contentType, byteSize) and returns 201 { fileId, versionId, uploadUrl }. PUT the bytes to uploadUrl (pinned to the declared content type and byte size, 15-minute expiry), then POST /files/{fileId}/finalize with the versionId to make the file visible.
  • Files and folders are now owner-scoped. Every file/folder belongs to a configured entity (entityType + entityId) or the company level (entityType: "company"). The folder endpoints keep their five paths but now require the owner on create/tree and return new shapes; GET /folders returns the owner’s whole tree as { folders: [{ id, parentFolderId, name }] } (no pagination), and GET /folders/{folderId} returns { folder, folders, files, totalCount } instead of the mixed contentType-discriminated item list.
  • GET /files/{fileId} returns the new metadata shapedisplayName/fileName/contentType/byteSize/status/folderId/category/createdAt/updatedAt/uploadedAt/uploadedBy replace the legacy name/mimeType/entityName/userDate fields.
  • PATCH /files/{fileId} renames with displayName (was name) and/or moves with folderId; it returns { id } (was the full file). PATCH /folders/{folderId} keeps name/parentFolderId but returns { id }; reparenting is cycle-checked. DELETE /folders/{folderId} now reports the cascade: { id, deleted, deletedFolders, deletedFiles }.

Removed: binary download and multipart upload

  • GET /api/v1/external/companies/{companyId}/files/{fileId}/content (binary stream) is removed — use the new GET /files/{fileId}/download-url, which returns { url, expiresAt, fileName, contentType, byteSize } (requires the new company.file:download permission), and fetch the bytes from the signed url.
  • The multipart/form-data upload body is removed — see the upload handshake above.

Added: list files

  • GET /api/v1/external/companies/{companyId}/files — list an owner’s files (entityType, entityId?, optional folderId placement filter, 1-based page/pageSize). Previously files were only discoverable through folder contents. Requires company.file:read.
  • POST /api/v1/external/companies/{companyId}/files/{fileId}/finalize and GET /api/v1/external/companies/{companyId}/files/{fileId}/download-url — the new halves of the signed-URL upload/download workflow.

2026-06-07

Removed: QuoteBindError object primitive

The Object: QuoteBindError object primitive (and its Fmv1QuoteBindError OpenAPI schema) has been removed. It was never produced at runtime — no endpoint ever returned or accepted a quoteBindErrors value — so this removal is not expected to affect any integration. The remaining built-in object primitives (Address, Currency, Date, StringOrNumber) are unchanged. See the Object Primitives reference. (This entry originally listed a CoverageLimit primitive; it was never a built-in primitive — see the 2026-06-26 correction below.)

2026-06-01

Added: Notes endpoints

Notes can now be managed via the external API. Notes are simple text records attached to a top-level Field Model V1 entity (Event, Exposure, Quote, Submission, Person, Organization, Policy). The parent entity type travels as the entityType query parameter on every verb; permission is gated by the parent entity (read for GET, update for write).
  • GET /api/v1/external/companies/{companyId}/notes — List notes for a parent entity (1-based page/pageSize)
  • POST /api/v1/external/companies/{companyId}/notes — Create a note (returns { id }, HTTP 201)
  • GET /api/v1/external/companies/{companyId}/notes/{noteId} — Get a note
  • PATCH /api/v1/external/companies/{companyId}/notes/{noteId} — Update a note’s body
  • DELETE /api/v1/external/companies/{companyId}/notes/{noteId} — Soft-delete a note

New: Tasks API

Added 5 endpoints for managing company tasks at /api/v1/external/companies/{companyId}/tasks:
  • GET /tasks — List tasks (paginated, filter by status, assigneeId, entityType, entityId). Requires company.task:read.
  • POST /tasks — Create a task. Returns { id }. Requires company.task:create.
  • GET /tasks/{taskId} — Get a single task. Requires company.task:read.
  • PATCH /tasks/{taskId} — Partial update (only changed fields; unknown fields rejected). Requires company.task:update.
  • DELETE /tasks/{taskId} — Soft delete. Returns { id, deleted: true }. Requires company.task:delete.
A task has a name, description, status (Not Complete / Complete), ISO 8601 deadline, optional linked entity snapshot ({ type, id, name }), and assignees (company user IDs — non-members are rejected with 400). See the Tasks overview.

Breaking: Full-term policy transaction reshape

The segmented Policy Transaction API moved to the full-term (“Model-B”) design. Affects new-business, endorse, cancel, reinstate, and renew.
  • Term bounds come solely from fullTermPolicyInfo. policyStartDate / policyEndDate are read from fieldModelV1Data.policy.fullTermPolicyInfo on NEW_BUSINESS / RENEW — the top-level policyStartDate / policyEndDate (and RENEW’s top-level previousPolicyId / newPolicyStartDate / newPolicyEndDate) parameters are removed. previousPolicyId now lives in fullTermPolicyInfo.
  • fullTermPolicyBilling renamed to fullTermPolicyBillingInfo in every request, response, and bordereau column path.
  • policyStatus is a segment-scoped policy field with lowercase values "active" / "cancelled" — no longer inside fullTermPolicyInfo.
  • ENDORSE now has five channels: deltas XOR fullTermDeltas (the latter restricted to policy.fullTermPolicyInfo, no dates), plus additive fullTermPolicyBillingInfo, fullTermPolicyRatingResult, and crossSegmentRatingOutputs. List elements are addressed by predicate — exposures[id = '…'].
  • CANCEL / REINSTATE take the date plus optional whole-object fullTermPolicyBillingInfo / fullTermPolicyRatingResult. CANCEL flips per-segment policyStatus to "cancelled" and records a single cancellationEffectiveOnDate (uniform across the term); REINSTATE flips policyStatus back to "active" and clears cancellationEffectiveOnDate (no reinstatement date field). A reinstate that would leave a coverage gap is rejected — that scenario is a new policy term. policyEarlyTerminationDate is removed.
  • Rating output split into fullTermPolicyRatingResult (policy-root, hoisted) and crossSegmentRatingOutputs (element-level, inline). Responses hoist fullTermPolicyInfo, fullTermPolicyBillingInfo, and fullTermPolicyRatingResult.

2026-05-15

New: Company Files API (folders + files)

Added 10 endpoints for managing company-level folders and files: Folders: POST, GET, GET /{folderId}, PATCH /{folderId}, DELETE /{folderId} under /api/v1/external/companies/{companyId}/folders Files: POST (multipart upload), GET /{fileId}, GET /{fileId}/content (binary download), PATCH /{fileId}, DELETE /{fileId} under /api/v1/external/companies/{companyId}/files Key capabilities:
  • Create nested folder hierarchies with parentFolderId
  • Upload files via multipart/form-data, optionally placing them in a folder
  • Stream binary file content with correct Content-Type and Content-Disposition headers
  • Rename and move files/folders (including moving to root by setting parent to null)
  • Recursive soft-delete of folders (deletes all contents)
All endpoints use company.file:{action} permissions. See the Company Files overview for details.
These are company-level file endpoints only. Entity-scoped file endpoints (attached to exposures, policies, events) are planned for a future release.

2026-05-01

Event→Policy relationship migrated to eventPolicy Join field (additive)

Event responses now surface the associated policy in two places: the existing top-level policyId and the new eventPolicy key inside fieldModelV1Data. Both reflect the same value — policyId stays at the top level for backwards compatibility, while eventPolicy is the underlying Join: Policy field where the value is stored. What changed:
  • GET /api/v1/external/companies/{companyId}/events and /events/{eventId} responses include eventPolicy inside fieldModelV1Data alongside the pre-existing top-level policyId. No fields were removed.
  • POST and PUT event endpoints continue to accept policyId as a top-level request param. The server maps it to fieldModelV1Data.eventPolicy internally; clients that already submit policyId need no changes.
  • policyId is no longer stored on a dedicated events.policy_id column on the FMV1 read/write paths — it now lives in fieldModelV1Data.eventPolicy, consistent with how eventInsureds was migrated previously. The external API contract is unchanged for existing integrators.
This is a non-breaking change. Integrators can ignore eventPolicy and continue using policyId, or migrate to reading the value from fieldModelV1Data.eventPolicy to align with the rest of the field model.

Address.zipCode must be a JSON string (breaking)

Address object-primitive writes that send zipCode as a JSON number are now rejected with 400 and problemCode: "InvalidAddressZipCode". Previously, numeric ZIPs were accepted by the API but silently lost their leading zeros — 02140 parses as 2140, corrupting the stored address. Affected endpoints: every FMV1 create/update endpoint that can carry an Address value (top-level field or nested inside a custom object), including all exposure, event, quote, policy-transaction, and custom-object writes. What to send: quote ZIP codes in your payload — "zipCode": "02140", never "zipCode": 02140. The Address sub-field reference and the Fmv1Address OpenAPI schema both call this out explicitly. Why this is most likely to bite:
  • Spreadsheets (Excel / Google Sheets) auto-coerce ZIP-shaped cells to numbers — export as text or wrap in =TEXT(...) before serializing.
  • OpenAPI clients / codegen that infer the zipCode JSON type from a sample value rather than the schema (which has always been type: string).
  • LLM-generated requests that “helpfully” unquote numeric-looking strings.

2026-04-29

Documentation: Object Primitives

  • New Object Primitives reference page documenting the FMV1 built-in object shapes — sub-field tables, JSON examples, the completeness rule, and List cardinality.
  • OpenAPI spec now exports reusable object-primitive schemas under components.schemas for client-codegen consumers.
This entry originally listed CoverageLimit and QuoteBindError as built-in object primitives and claimed the schemas “mark every sub-field as required”. Both were inaccurate: QuoteBindError was later removed (2026-06-07) and CoverageLimit was never a built-in primitive (it is a tenant-config custom object — see the 2026-06-26 correction). The actual built-in primitives are Address, Currency, Date, and StringOrNumber, and requiredness is per-primitive, not blanket.

2026-04-28

Strict object-primitive sub-field validation (breaking)

When a request body for an FMV1 create/update endpoint includes an object-primitive value (Address, Currency, Date, StringOrNumber), its required sub-fields must now be present and non-empty. Affected endpoints:
  • POST/PATCH /api/v1/external/companies/{companyId}/exposures and /exposures/{id}
  • POST/PATCH /api/v1/external/companies/{companyId}/events and /events/{id}
  • POST/PATCH /api/v1/external/companies/{companyId}/quotes and /quotes/{id}
  • All segmented policy transaction endpoints (new-business, endorse, renew)
  • POST/PATCH /api/v1/external/companies/{companyId}/custom-objects/{objectType} and /{objectId}
What changed:
  • A null, undefined, or empty-string ("") value on a required sub-field of a present object-primitive value now returns 400 with Field '<parentField>' is missing required sub-field '<refId>'. Previously, partial object primitives were silently accepted and surfaced as confusing rating errors downstream.
  • Numeric 0 (e.g. Currency.value: 0) and Boolean false are still valid — the rule only treats null / undefined / "" as missing.
  • Whitespace-only strings (e.g. " ") are NOT treated as missing by this validator. A consumer sending county: " " will pass this check; downstream rating may still reject it. Trim/normalize sub-field strings client-side before submitting.
  • Custom-object sub-fields keep their existing requiredCondition rules unchanged.
  • Omitting the parent object-primitive field entirely is unchanged — the strict rule only fires when the parent value is provided.