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
| 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
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
| 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
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 }
}
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
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
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 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:
| 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
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:
| 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
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:
- 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
| 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
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..."
}
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 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
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:
- 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
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' }],
},
});