Skip to main content

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

                         ┌─────────────┐
│ 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

StateDescriptionBillable
draftSubmission data being collectedNo
quotedPremium calculated, awaiting acceptanceNo
boundAccepted by producer, pending underwriter reviewYes (pro-rata from effective date)
issuedUnderwriter reviewed and finalizedYes
activeCurrently in forceYes
endorsedMid-term change applied (policy remains active)Yes (adjusted premium)
cancelledTerminated before expirationPartial (by cancellation type)
expiredPolicy period ended without renewalNo
non-renewedCarrier elected not to renewNo
renewedNew term policy createdYes (new term)

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)

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

TypeCodeDescription
Limit changeLIMIT_CHANGEIncrease or decrease the policy limit
Deductible changeDEDUCTIBLE_CHANGEModify the per-occurrence deductible
Additional insuredADD_INSUREDAdd a named additional insured
Location changeLOCATION_CHANGEAdd or remove scheduled locations
Coverage addCOVERAGE_ADDAdd an endorsement form (e.g., EPLI)
Coverage removeCOVERAGE_REMOVERemove a coverage endorsement
Named insured changeNAME_CHANGEUpdate the named insured (e.g., after acquisition)
Driver add/removeDRIVER_ADD/REMOVEAdd or remove a scheduled driver (auto/trucking)
Vehicle add/removeVEHICLE_ADD/REMOVEAdd or remove a scheduled vehicle
CorrectionCORRECTIONAdministrative fix — no premium impact

Endorsement API

POST /v1/policies/:id/endorsements
Authorization: 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 }
}
note

Mid-term endorsements that change the risk materially (limit ≥ 50% increase, new covered locations in excluded states) are automatically flagged for underwriter review before the endorsement is committed.

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 RatingInput snapshot (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):

FunctionSourcePurpose
buildTimeline()timeline.tsBuild full segment timeline from inception + endorsements
insertEndorsement()timeline.tsInsert one endorsement (possibly backdated) and return delta
computeEarnedToDate()timeline.tsEarned premium as of a given date
validateEndorsementDate()timeline.tsValidate effective date against policy bounds
computeCascadeImpact()timeline.tsCompute premium impact on downstream endorsements after OOS insertion

Key functions (@openinsure/policy):

FunctionSourcePurpose
detectOutOfSequence()endorsement-timeline.tsDetect if a new endorsement is out of chronological order
deriveEndorsementChronology()endorsement-timeline.tsDerive chronological ordering of all endorsements
computeScheduleChangePremium()endorsement-timeline.tsPro-rata past/future premium split for schedule changes

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:

  1. Detects the OOS condition via detectOutOfSequence() — compares the candidate's effective date against all issued/approved endorsements
  2. Identifies affected endorsements — all downstream endorsements whose premium may change
  3. Rebuilds the timelineinsertEndorsement() re-derives all segments from the insertion point forward
  4. Computes cascade impactcomputeCascadeImpact() calculates the premium delta on each downstream endorsement
  5. 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)
  6. 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 OOS
const 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 delta
const { 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 endorsements
const cascade = computeCascadeImpact(timelineBefore, timelineAfter, downstreamEndorsements);
// cascade[i].correctedNetDelta → recalculated premium for downstream endorsement
// cascade[i].deltaShift → difference from previously stored value

Database support: The endorsements table tracks OOS state explicitly:

ColumnTypePurpose
sequence_numberintegerTimeline position (1-based, unique per policy)
is_out_of_sequencebooleanWas this endorsement OOS when created?
supersedes_iduuidSelf-referencing FK for cascade-reshuffled endorsements
past_period_adjnumeric(12,2)Retroactive premium adjustment
future_period_adjnumeric(12,2)Going-forward premium adjustment
prior_rating_inputjsonbRating snapshot before the change
new_rating_inputjsonbRating snapshot after the change
rating_resultjsonbFull rating result for the new state

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

Before an endorsement is created, validateEndorsementDate() checks:

CheckResult
Effective date before policy inceptionError (blocked)
Effective date at or after policy expirationError (blocked)
Effective date matches existing endorsementWarning (zero-day segment)
Effective date in the pastWarning (backdated)

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 impact

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 experience
const 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

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

The queue consumer handles endorsement.issued events by:

  1. Creating a notification record
  2. Posting a GL journal entry for the premium delta (if non-zero)
  3. Sending an email to the producer via the configured email provider
  4. Posting to the Slack channel (if configured)
  5. Triggering webhook delivery to external systems

Cancellation Types

TypeCodeReturn Premium Calculation
Flat cancelFLAT100% return — as if policy never incepted
Pro-rataPRO_RATAReturn = full premium × (days remaining / policy days)
Short-rateSHORT_RATEReturn = pro-rata × 90% — penalty for insured-requested cancellations

Cancel API

POST /v1/policies/:id/cancel
Authorization: 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..."
}
caution

Cancellations in states with mandatory notice periods (e.g., NY requires 30 days for non-payment) are validated against the @openinsure/compliance rule engine. The API will reject a cancellation effective date that violates the notice period and return the earliest permissible date.

Cancellation Reasons

Common cancellation reason codes:

  • NON_PAYMENT — Premium invoice unpaid beyond grace period
  • INSURED_REQUEST — Named insured requested cancellation
  • UNDERWRITING — 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

A cancelled policy can be reinstated within 30 days of cancellation (state rules may override this window).

POST /v1/policies/:id/reinstate
Authorization: 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

Renewals are created automatically 90 days before expiration by the nightly renewal scheduler job. The scheduler:

  1. Identifies policies expiring within 90 days.
  2. Creates a draft renewal submission with the current policy's data pre-filled.
  3. Re-rates using the current rate tables (the new rates may differ from the expiring term).
  4. 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

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

bash

# 1. Create submission
SUB=$(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. Quote
QUOTE=$(curl -s -X POST $API/v1/submissions/$SUB/quote \
-H "Authorization: Bearer $TOKEN" | jq -r '.id')

# 3. Bind
POL=$(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"}]}}'

TypeScript

import { OpenInsureClient } from '@openinsure/api-client';

const client = new OpenInsureClient({ token: process.env.OI_TOKEN });

// Create and bind a policy
const 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 endorsement
const endorsement = await client.policies.endorse(policy.id, {
type: 'ADD_INSURED',
effectiveDate: '2025-07-01',
changes: {
additionalInsureds: [{ name: 'Vermont Contractors LLC', relationship: 'contract' }],
},
});