Two auth tiers — every endpoint uses a
Bearer JWT; the actor guard decides user vs admin/provider. User-facing endpoints (preview, create, get, list, document download) require the token to be the policy’s user (or a trusted backend). Admin/provider endpoints (list all, get one, update, status transition, export, bulk update) require a dashboard-user or benefit-provider token.Overview
An insurance policy ties a primary user to an insurance-type benefit and a set of dependants to be covered. The SELF dependant (the primary user themselves) is always auto-included in the covered members — you do not need to add it manually todependant_ids.
Premium amounts are read directly from the matched plan variant in the benefit’s benefit_details.plans map. The server derives a plan_code (e.g. 1A, 2A, 2A1C, 2A2C) from the resolved member list and looks up the variant’s daily_premium_amount, annual_premium_amount, coverage_amount, currency, and optional grace_period_days — a flat per-variant lookup.
The purchase flow has three steps:
- Preview premium — compute the premium breakdown without creating a record
- Preview enrollment form (optional) — render the prospective enrollment-form PDF for in-app confirmation; read-only
- Create — purchase the policy; status starts as
PENDING
metadata.dependant_mrn_map.
After purchase, an admin reviews the policy (PENDING → APPROVED, or PENDING → REJECTED) and, once the external insurer issues a policy number, records issuance (APPROVED → ISSUED, supplying external_policy_id + start_date + end_date). The live in-force lifecycle (grace, renewal, reissuance, expiry) is then driven by the insurer’s webhooks.
Purchase Flow
Auth Guards by Endpoint
All endpoints authenticate with aBearer JWT. The user vs admin/provider distinction is enforced by the actor guard, not by a separate API key.
Key Concepts
Plan code derivation
The server derives aplan_code for each preview/purchase request from the resolved member list (SELF + selected dependants):
- Adult vs child cutoff: members with
age < 25count as Children,age ≥ 25as Adults. - Code format:
{adult_count}Awhen there are no children, otherwise{adult_count}A{child_count}C. - Examples:
1A(self only),2A(self + spouse),2A1C(self + spouse + 1 child),2A2C(self + spouse + 2 children).
benefit_details.plans map is a free-form dictionary of plan_code → PlanVariant. If the derived code is not a key in that map (e.g. 3A, 1A2C, or any combination the provider has not priced), the request is rejected with 400 IP_1009 PlanVariantNotAvailable. The benefit’s plans map is the single source of truth for which combinations are sellable.
Premium amounts
EachPlanVariant carries:
On the API surface each money field is an AmountResponse ({ value, currency }, major-unit float). They are stored in minor units (MinorUnit) and converted at the boundary.
On every response,
premium_amounts is serialized with each money field as an AmountResponse ({ value, currency }, major-unit float) — { daily, annual, sponsor_contribution }. The sponsor_contribution is resolved at preview/create time (a sponsored subsidy; 0 when there is none). Coverage and grace period are not part of premium_amounts; they are re-derived from the matched variant when needed (e.g. by the document renderer).
Policy Status Lifecycle
PolicyStatus has exactly eight states: PENDING, APPROVED, ISSUED, REQUIRES_CUSTOMER_RENEWAL_IN_GRACE, REQUIRES_POLICY_REISSUANCE, REQUIRES_CUSTOMER_RENEWAL, EXPIRED, REJECTED.
Transitions fall into two independent groups:
-
Human decisions (admin/provider via
PATCH .../status) — a guarded state machine; a rejected pair returnsIP_1021:OnlyAPPROVED → ISSUEDcarries a body payload. Every other transition is a payload-free status flip that preserves the storedexternal_policy_id/dates, so a renewal-state →ISSUEDreactivation keeps the original carrier reference. -
Provider-authoritative (insurer webhooks): once a policy is
ISSUED, the live in-force lifecycle is driven by the insurer —inforced → ISSUED,grace_period → REQUIRES_CUSTOMER_RENEWAL_IN_GRACE, andlapsed → EXPIREDonce the policy’send_datehas passed, otherwiseREQUIRES_POLICY_REISSUANCE(still within term).
/status state machine:
The human
/status machine and the webhook machine are independent. REJECTED, EXPIRED, and REQUIRES_POLICY_REISSUANCE are terminal for the /status endpoint and are also frozen against in-force webhook signals — neither reopens them. Reissuance mints a brand-new policy, so the old record stays terminal (it is also excluded from the bulk-activation flow). REQUIRES_CUSTOMER_RENEWAL[_IN_GRACE] → ISSUED is an in-place reactivation — the driver tops up their balance within the grace window — reusing the stored external_policy_id/dates rather than a fresh carrier issuance. A pre-issue policy (PENDING / APPROVED) ignores every webhook signal. Unknown provider statuses are a webhook no-op and never appear as a PolicyStatus.The insurer webhook (
POST /benefits/{benefit_id}/webhook, insurance branch) responds 200 with the full InsurancePolicyResponse body reflecting the post-transition status — the same shape as GET /insurance_policies/{id}. A no-op signal returns the policy unchanged. The consultation branch of the same endpoint returns an empty 200. (The endpoint’s OpenAPI output stays opaque because the body shape depends on the benefit type.)Pre-purchase confirmation
Before creating a policy, the mobile/SDK frontend can render the enrollment form PDF for a prospective (not-yet-issued) policy and ask the user to verify their selected coverage, members, nominee, and computed premium. The endpoint is read-only and idempotent — it performs zero database writes, makes no Narayana calls, and creates no MRN rows. It is safe to re-call as the user adds or removes dependants, swaps the nominee, or switches plans. The rendered PDF carries aPREVIEW — not yet issued banner, a synthetic policy id of all zeros, and the same applicant / coverage / members / nominee blocks as the post-issuance enrollment form. After the user reviews and confirms in-app, the frontend submits POST /users/{user_id}/insurance_policies with the same payload to create the policy.
Preview Enrollment Form
Authentication:
Authorization: Bearer <access_token>. Auth gate is actor.require_self_or_trusted_backend(&user_id) — same as preview and create.Tenant (
platform_id) is required. Both preview_enrollment_form and create need to know the platform the policy belongs to — an app/platform token carries it automatically; an admin must send it via the x-platform-id header (a UUID), else 400. It decides the fallback address: when the user has no address of their own, the enrollment form prints the platform’s stored address. Since a platform’s address is mandatory, this always resolves — the address is never blank. On create the resolved platform_id is recorded on the benefit-utilization ledger, so document downloads (and the bulk-issuance export) later resolve the same tenant without a header.- Loads the benefit + provider, asserts it is
Activeand of typeInsurancePolicy. - Resolves the caller-supplied dependant ids (ownership-checked) and auto-includes the SELF dependant at the head of the member list.
- Resolves the applicant address with the tenant fallback: the user’s own address if present, else the platform’s stored address (always resolves — the platform address is mandatory).
- Derives the
plan_codefrom the member list (adults/children) and looks up the matchingPlanVariantin the benefit. Unsupported combinations return 400IP_1009 PlanVariantNotAvailable. - Validates the nominee per plan: required when the matched variant’s
nominee_requiredflag istrue(surfaced asnominee_requiredin the premium-preview response — do not infer it from the plan code). Missing returns 400IP_1015 NomineeRequiredForPlan. When a nominee is present — on any plan — it must be the policyholder’s spouse (relationship = SPOUSE), else 400IP_1010. - Reads
daily_premium_amount/annual_premium_amount/coverage_amount/currency/grace_period_daysfrom the matched variant (no on-the-fly bracket calc). - Renders the enrollment-form PDF directly from the resolved data.
- No
INSERT/UPDATEagainstinsurance_policiesormrns. No Narayanafind_or_register_patientscall.
string (uuid)
required
Benefit to preview against. Must be
Active and of type InsurancePolicy.string (uuid)[]
required
Caller-supplied dependants to cover. SELF is auto-included if absent — you may pass an empty array.
object
Nominee details — either
DEPENDANT (existing dependant id, must be a SPOUSE) or EXTERNAL (inline name + DOB + relationship, relationship must be SPOUSE). Optional: omit when the matched plan variant’s nominee_required is false. Required when nominee_required is true; missing → 400 IP_1015.object[]
Optional answers to the benefit’s health questionnaire (
{ question, answer } per item). Validated against the benefit’s questionnaire; disqualifying answers return 400 IP_1016.200 OK with Content-Type: application/pdf. Body is the binary PDF (enrollment_form_preview.pdf).
Endpoints
POST .../preview
Preview the premium breakdown for a set of dependants without creating a policy.
POST .../preview_enrollment_form
Render the enrollment form PDF for a prospective policy. Read-only — safe to re-call as the user adjusts dependants/nominee.
POST .../insurance_policies
Purchase an insurance policy. Status starts as
PENDING.GET .../insurance_policies/{id}
Fetch a single policy by its internal UUID.
GET .../insurance_policies/{id}/details
Fetch a single policy bundled with its linked benefit summary (name, type, provider, benefit_details).
GET .../insurance_policies
List the authenticated user’s policies. Filter by
status, benefit_id, or benefit_provider_id.GET .../documents/{document_type}
Stream a policy PDF (
POLICY_CERTIFICATE, ENROLLMENT_FORM, CUSTOMER_INFORMATION_SHEET, or HEALTH_CARD/{dependant_id}).GET /insurance_policies (admin)
Admin: list all policies with full filtering including statuses, provider, time range and user.
GET /insurance_policies/{id} (admin)
Admin: fetch a single policy by id (no
user_id in the path).PATCH /insurance_policies/{id} (admin)
Admin: set status, external policy ID, start/end dates.
Three further admin/provider endpoints back the bulk onboarding flow and are not yet linked above:
PATCH /insurance_policies/{id}/status (single guarded status transition), GET /insurance_policies/export (joined onboarding rows for one status), and POST /insurance_policies/bulk_update (apply external_policy_id + dates per policy id). GET /users/{id}/insurance_policies/details is the list-with-benefit-details variant of the user list.Request / Response Examples
The SELF dependant is always auto-included in
dependant_ids — you do not need to add it. The members array in the preview response and dependant_ids in the create response will both include SELF automatically.Member fields — primary_member and dependants
Policy responses carry the policyholder identity and full dependant roster as structured objects. Names are resolved at response build time (not snapshotted on the row), so renames flow through automatically — same model as benefit_name. primary_member.id and dependants[].id are the canonical identifiers going forward.
dependants includes the SELF entry (the policyholder represented as a dependant on the policy roster). UIs that want to surface only the additional covered family members should filter by relationship !== "SELF".master_policy_number — group-scheme identifier
master_policy_number is the shared master group-scheme policy number — the same value across every member of the scheme. It is sourced from Superposition (not stored per policy) so it can be updated without a redeploy, and is null when no value is configured. This is distinct from external_policy_id, which is the per-policy certificate number the insurer assigns at issuance.
documents — backend-owned download URLs
Every policy response carries a documents object so clients never construct document paths themselves — render exactly what the backend returns.
All document URLs (
health_cards, enrollment_form, policy_certificate, customer_information_sheet) appear only once the policy is downloadable (status ISSUED and subsequent renewal states). Before then, health_cards is {} and the three URL fields are absent. The pre-purchase enrollment form preview is a separate endpoint (preview_enrollment_form) and is not part of documents.nominee_details — Tagged Union
The nominee_details field is a JSONB tagged union discriminated by nominee_type. Use one of two variants. The nominee must be the policyholder’s spouse on either variant (relationship = SPOUSE).
Preview members Array
The preview response returns a unified members array of MemberDetail objects instead of separate primary_member and dependants fields:
MRN Auto-Creation
When a policy is created, the server automatically creates one MRN record per covered dependant at the benefit’s provider. Themetadata.dependant_mrn_map field maps each dependant ID to its newly created MRN ID.
For each member without an existing MRN at this provider, the server calls the upstream insurance provider (currently Narayana Health) to look up the patient by name + DOB; if not found, it registers a new patient under the primary user’s phone. The returned external_mrn (temp_number and/or mrn) is persisted on the new MRN row. If a member already has an MRN row at this provider it is reused as-is, with no upstream call.