Policy Lifecycle
The @openinsure/policy package implements a deterministic finite state machine for every insurance policy. All transitions are server-enforced — the API returns a 422 Unprocessable Entity for any invalid transition attempt.
State Machine
Section titled “State Machine” ┌─────────────┐ │ draft │ ◄── Submission created └──────┬──────┘ │ POST /submissions/:id/quote ┌──────▼──────┐ │ quoted │ ◄── Premium calculated, expires in 30 days └──────┬──────┘ │ POST /submissions/:id/bind ┌──────▼──────┐ │ bound │ ◄── DA checks passed, policy number assigned └──────┬──────┘ │ POST /policies/:id/issue (underwriter review) ┌──────▼──────┐ ┌────► issued │ ◄── Dec page finalized │ └──────┬──────┘ │ │ (effective date reached) │ ┌──────▼──────┐ reinstate ───────┤ │ active │ │ └──────┬──────┘ │ │ ┌────┘ ┌──────┴──────────┬───────────────┐ │ │ │ │ ┌──────▼──┐ ┌─────▼──────┐ ┌──────▼──────┐ ┌────▼────────┐ │cancelled│ │ endorsed │ │ non-renewed │ │ expired │ └─────────┘ └────────────┘ └─────────────┘ └──────┬──────┘ │ ┌──────▼──────┐ │ renewed │ ◄── New policy term created └─────────────┘Lifecycle States
Section titled “Lifecycle States”| State | Description | Billable |
|---|---|---|
draft | Submission data being collected | No |
quoted | Premium calculated, awaiting acceptance | No |
bound | Accepted by producer, pending underwriter review | Yes (pro-rata from effective date) |
issued | Underwriter reviewed and finalized | Yes |
active | Currently in force | Yes |
endorsed | Mid-term change applied (policy remains active) | Yes (adjusted premium) |
cancelled | Terminated before expiration | Partial (by cancellation type) |
expired | Policy period ended without renewal | No |
non-renewed | Carrier elected not to renew | No |
renewed | New term policy created | Yes (new term) |
Transition Rules
Section titled “Transition Rules”All transitions are enforced in packages/policy/src/index.ts. The valid transitions are:
const VALID_TRANSITIONS: Record<PolicyStatus, PolicyStatus[]> = { draft: ['quoted'], quoted: ['bound', 'draft'], // draft = re-rate bound: ['issued', 'cancelled'], issued: ['active', 'cancelled'], active: ['cancelled', 'expired', 'non-renewed'], endorsed: ['active', 'cancelled'], // endorsement resolves back to active cancelled: ['active'], // via reinstatement expired: ['renewed'], non-renewed: [], renewed: [],};Any call that attempts an unlisted transition returns:
{ "error": "invalid_transition", "message": "Cannot transition from 'draft' to 'active'. Valid next states: ['quoted']", "currentStatus": "draft", "requestedStatus": "active"}Endorsement Flow (Mid-Term Changes)
Section titled “Endorsement Flow (Mid-Term Changes)”Endorsements apply changes to an active policy and produce a new premium calculation for the remaining policy term. OpenInsure models the policy as a timeline of segments — each endorsement creates a new segment with its own annual premium rate, pro-rated to the days it was in effect.
Endorsement Types
Section titled “Endorsement Types”| Type | Code | Description |
|---|---|---|
| Limit change | LIMIT_CHANGE | Increase or decrease the policy limit |
| Deductible change | DEDUCTIBLE_CHANGE | Modify the per-occurrence deductible |
| Additional insured | ADD_INSURED | Add a named additional insured |
| Location change | LOCATION_CHANGE | Add or remove scheduled locations |
| Coverage add | COVERAGE_ADD | Add an endorsement form (e.g., EPLI) |
| Coverage remove | COVERAGE_REMOVE | Remove a coverage endorsement |
| Named insured change | NAME_CHANGE | Update the named insured (e.g., after acquisition) |
| Driver add/remove | DRIVER_ADD/REMOVE | Add or remove a scheduled driver (auto/trucking) |
| Vehicle add/remove | VEHICLE_ADD/REMOVE | Add or remove a scheduled vehicle |
| Correction | CORRECTION | Administrative fix — no premium impact |
Endorsement API
Section titled “Endorsement API”POST /v1/policies/:id/endorsementsAuthorization: Bearer <token>Content-Type: application/json
{ "type": "LIMIT_CHANGE", "effectiveDate": "2025-04-15", "description": "Increase per-occurrence limit for new GC contract", "changes": { "occurrenceLimit": 2000000, "aggregateLimit": 4000000 }, "requestedBy": "usr_01J8..."}The response includes the pro-rata premium adjustment, timeline segment data, and out-of-sequence detection:
{ "endorsement": { "id": "end_01J8...", "policyId": "pol_01J8...", "endorsementNumber": "ENT-003", "type": "LIMIT_CHANGE", "status": "pending", "effectiveDate": "2025-04-15", "sequenceNumber": 3, "isOutOfSequence": false, "priorAnnualPremium": 12500, "newAnnualPremium": 15200, "pastPeriodAdj": 0, "futurePeriodAdj": 1842.0, "netPremiumAdjustment": 1842.0 }, "timeline": { "segments": [...], "totalEarnedPremium": 14342 }}Timeline Engine Architecture
Section titled “Timeline Engine Architecture”A policy is a function of time. The timeline engine in @openinsure/rating (timeline.ts) models this as an ordered sequence of segments:
Policy inception ──→ Endorsement 1 ──→ Endorsement 2 ──→ Expiration│ Segment 0 │ Segment 1 │ Segment 2 ││ $10,000/yr annual│ $12,000/yr │ $15,200/yr ││ 120 days │ 90 days │ 155 days │Each segment has:
- An effective date (= endorsement effective date, or policy inception for segment 0)
- An expiration date (= next segment’s effective date, or policy expiration)
- A
RatingInputsnapshot (the rated state for that segment) - An annual premium (from
rateSubmission()) - A pro-rated segment premium:
annualPremium × segmentDays / 365
Total earned premium = sum of all segment premiums. Penny-perfect allocation uses the largest-remainder method to prevent cent drift across segments.
Key functions (@openinsure/rating):
| Function | Source | Purpose |
|---|---|---|
buildTimeline() | timeline.ts | Build full segment timeline from inception + endorsements |
insertEndorsement() | timeline.ts | Insert one endorsement (possibly backdated) and return delta |
computeEarnedToDate() | timeline.ts | Earned premium as of a given date |
validateEndorsementDate() | timeline.ts | Validate effective date against policy bounds |
computeCascadeImpact() | timeline.ts | Compute premium impact on downstream endorsements after OOS insertion |
Key functions (@openinsure/policy):
| Function | Source | Purpose |
|---|---|---|
detectOutOfSequence() | endorsement-timeline.ts | Detect if a new endorsement is out of chronological order |
deriveEndorsementChronology() | endorsement-timeline.ts | Derive chronological ordering of all endorsements |
computeScheduleChangePremium() | endorsement-timeline.ts | Pro-rata past/future premium split for schedule changes |
Out-of-Sequence Endorsements
Section titled “Out-of-Sequence Endorsements”An out-of-sequence (OOS) endorsement has an effective date earlier than a previously issued endorsement. This is common in insurance — a backdated driver addition, a retroactive limit increase, or a correction with an earlier effective date.
Example: Policy has ENT-001 (effective June 1) and ENT-002 (effective September 1) already issued. A new endorsement arrives with effective date March 1 — this is out-of-sequence because it predates both existing endorsements.
When an OOS endorsement is created, OpenInsure:
- Detects the OOS condition via
detectOutOfSequence()— compares the candidate’s effective date against all issued/approved endorsements - Identifies affected endorsements — all downstream endorsements whose premium may change
- Rebuilds the timeline —
insertEndorsement()re-derives all segments from the insertion point forward - Computes cascade impact —
computeCascadeImpact()calculates the premium delta on each downstream endorsement - Splits past/future adjustments — for backdated endorsements, the premium adjustment is split:
- Past period: endorsement effective date → processing date (retroactive earned premium delta)
- Future period: processing date → policy expiration (going-forward billing delta)
- Row-locks the policy during the transaction to prevent concurrent endorsement conflicts
import { detectOutOfSequence, computeScheduleChangePremium } from '@openinsure/policy';import { buildTimeline, insertEndorsement, computeCascadeImpact } from '@openinsure/rating';
// 1. Detect OOSconst oosReport = detectOutOfSequence(existingEndorsements, new Date('2025-03-01'));// oosReport.isOutOfSequence → true// oosReport.affectedEndorsements → [ENT-001, ENT-002]// oosReport.assignedSequenceNumber → 1
// 2. Insert into timeline and get deltaconst { updatedTimeline, delta } = insertEndorsement(currentTimeline, newEndorsement, plan);// delta.pastPeriodAdjustment → retroactive earned premium change// delta.futurePeriodAdjustment → going-forward billing change// delta.netAdjustment → total premium impact// delta.affectedSegments → per-segment breakdown
// 3. Cascade impact on downstream endorsementsconst cascade = computeCascadeImpact(timelineBefore, timelineAfter, downstreamEndorsements);// cascade[i].correctedNetDelta → recalculated premium for downstream endorsement// cascade[i].deltaShift → difference from previously stored valueDatabase support: The endorsements table tracks OOS state explicitly:
| Column | Type | Purpose |
|---|---|---|
sequence_number | integer | Timeline position (1-based, unique per policy) |
is_out_of_sequence | boolean | Was this endorsement OOS when created? |
supersedes_id | uuid | Self-referencing FK for cascade-reshuffled endorsements |
past_period_adj | numeric(12,2) | Retroactive premium adjustment |
future_period_adj | numeric(12,2) | Going-forward premium adjustment |
prior_rating_input | jsonb | Rating snapshot before the change |
new_rating_input | jsonb | Rating snapshot after the change |
rating_result | jsonb | Full rating result for the new state |
Same-Day Endorsements
Section titled “Same-Day Endorsements”Multiple endorsements with the same effective date are allowed. They are ordered by sequence number (issue order) and do not trigger OOS detection — only endorsements with a later effective date are considered “downstream.”
Same-day endorsements generate a warning (creates a zero-day segment) but are not blocked, because legitimate scenarios exist (e.g., adding a driver and changing limits on the same day).
Endorsement Validation
Section titled “Endorsement Validation”Before an endorsement is created, validateEndorsementDate() checks:
| Check | Result |
|---|---|
| Effective date before policy inception | Error (blocked) |
| Effective date at or after policy expiration | Error (blocked) |
| Effective date matches existing endorsement | Warning (zero-day segment) |
| Effective date in the past | Warning (backdated) |
Batch Endorsements
Section titled “Batch Endorsements”Portfolio-wide rate changes (carrier-mandated rate increases, surcharges, factor overrides) are processed via the batch endorsement engine in @openinsure/rating (batch-endorsement.ts):
import { processBatchEndorsement } from '@openinsure/rating';
const results = processBatchEndorsement({ batchId: 'rate-increase-2025-q3', changes: [ { id: 'gl-sc-5pct', description: '5% GL rate increase — SC filing effective 2025-07-01', changeType: 'percentage', value: 5, filters: { lob: 'GL', state: 'SC' }, effectiveDate: '2025-07-01', }, ], policies: affectedPolicies,});// results.applied → policies with new premium// results.skipped → policies filtered out or below minimum impactDriver & Vehicle Endorsement Changes
Section titled “Driver & Vehicle Endorsement Changes”For commercial auto and trucking lines, the @openinsure/rating package (endorsement-changes.ts) provides structured change builders that translate driver/vehicle roster mutations into RatingInput deltas:
import { buildDriverAddChanges, buildDriverRemoveChanges } from '@openinsure/rating';
// Adding a new driver recalculates the fleet's average MVR score and experienceconst changes = buildDriverAddChanges( currentRoster, // { totalDrivers: 12, averageMVRScore: 3.2, averageYearsExperience: 8.5 } { mvrScore: 5, yearsExperience: 2 } // new driver with poor MVR);// changes → { driverCount: 13, averageMVRScore: 3.34, averageYearsExperience: 8.0 }Endorsement Documents
Section titled “Endorsement Documents”When an endorsement is issued, @openinsure/documents generates an Endorsement Certificate — a PDF showing the policy number, endorsement number, effective date, change description, and premium impact. The certificate is stored in R2 and linked to the endorsement record.
Event Processing
Section titled “Event Processing”The queue consumer handles endorsement.issued events by:
- Creating a notification record
- Posting a GL journal entry for the premium delta (if non-zero)
- Sending an email to the producer via the configured email provider
- Posting to the Slack channel (if configured)
- Triggering webhook delivery to external systems
Cancellation Types
Section titled “Cancellation Types”| Type | Code | Return Premium Calculation |
|---|---|---|
| Flat cancel | FLAT | 100% return — as if policy never incepted |
| Pro-rata | PRO_RATA | Return = full premium × (days remaining / policy days) |
| Short-rate | SHORT_RATE | Return = pro-rata × 90% — penalty for insured-requested cancellations |
Cancel API
Section titled “Cancel API”POST /v1/policies/:id/cancelAuthorization: Bearer <token>Content-Type: application/json
{ "cancellationType": "PRO_RATA", "effectiveDate": "2025-07-01", "reason": "INSURED_REQUEST", "reasonDetail": "Business sold"}Response:
{ "policyId": "pol_01J8...", "status": "cancelled", "cancellationDate": "2025-07-01", "returnPremium": 4127.5, "returnPremiumInvoiceId": "inv_01J8..."}Cancellation Reasons
Section titled “Cancellation Reasons”Common cancellation reason codes:
NON_PAYMENT— Premium invoice unpaid beyond grace periodINSURED_REQUEST— Named insured requested cancellationUNDERWRITING— Risk no longer acceptable (requires advance notice)FRAUD— Material misrepresentation (immediate, state-specific rules apply)REWRITE— Policy being rewritten to another carrier on same terms
Reinstatement
Section titled “Reinstatement”A cancelled policy can be reinstated within 30 days of cancellation (state rules may override this window).
POST /v1/policies/:id/reinstateAuthorization: Bearer <token>Content-Type: application/json
{ "effectiveDate": "2025-07-15", "paymentConfirmation": "pi_3NkX...", "lapseCoverage": false}If lapseCoverage: false, the reinstated policy covers the gap period (subject to underwriter approval). If true, the policy is reinstated with a coverage gap — common for non-payment reinstatements.
Renewal Automation
Section titled “Renewal Automation”Renewals are created automatically 90 days before expiration by the nightly renewal scheduler job. The scheduler:
- Identifies policies expiring within 90 days.
- Creates a
draftrenewal submission with the current policy’s data pre-filled. - Re-rates using the current rate tables (the new rates may differ from the expiring term).
- Sends renewal offer to the producer via the notification system.
The producer has until 30 days before expiration to accept, modify, or decline the renewal. If no action is taken, the policy is auto-renewed on the same terms (if configured in the program settings).
Renewal vs. Rewrite
Section titled “Renewal vs. Rewrite”A renewal maintains the same policy number with a new term suffix (e.g., GL-2025-000001-R1). A rewrite creates a new policy number — used when the carrier, program, or coverage terms change materially.
Example: Full Lifecycle via API
Section titled “Example: Full Lifecycle via API”# 1. Create submissionSUB=$(curl -s -X POST $API/v1/submissions \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"insuredName":"Acme Roofing","naicsCode":"238160","annualRevenue":2500000,"requestedLimit":1000000,"effectiveDate":"2025-06-01","state":"VT","lineOfBusiness":"GL"}' \ | jq -r '.id')
# 2. QuoteQUOTE=$(curl -s -X POST $API/v1/submissions/$SUB/quote \ -H "Authorization: Bearer $TOKEN" | jq -r '.id')
# 3. BindPOL=$(curl -s -X POST $API/v1/submissions/$SUB/bind \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"installmentPlan":"monthly"}' | jq -r '.id')
# 4. Endorse (add additional insured)curl -s -X POST $API/v1/policies/$POL/endorse \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{"type":"ADD_INSURED","effectiveDate":"2025-07-01","changes":{"additionalInsureds":[{"name":"Vermont Contractors LLC","relationship":"contract"}]}}'import { OpenInsureClient } from '@openinsure/api-client';
const client = new OpenInsureClient({ token: process.env.OI_TOKEN });
// Create and bind a policyconst submission = await client.submissions.create({ insuredName: 'Acme Roofing', naicsCode: '238160', annualRevenue: 2_500_000, requestedLimit: 1_000_000, effectiveDate: '2025-06-01', state: 'VT', lineOfBusiness: 'GL',});
const quote = await client.submissions.quote(submission.id);const policy = await client.submissions.bind(submission.id, { installmentPlan: 'monthly',});
console.log(`Policy ${policy.policyNumber} bound — premium: $${quote.grossPremium}`);
// Add an endorsementconst endorsement = await client.policies.endorse(policy.id, { type: 'ADD_INSURED', effectiveDate: '2025-07-01', changes: { additionalInsureds: [{ name: 'Vermont Contractors LLC', relationship: 'contract' }], },});