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 to dependant_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:
  1. Preview premium — compute the premium breakdown without creating a record
  2. Preview enrollment form (optional) — render the prospective enrollment-form PDF for in-app confirmation; read-only
  3. Create — purchase the policy; status starts as PENDING
At creation time, the server also auto-creates MRN records for each covered dependant at the benefit’s provider. The resulting mapping is stored in the policy’s 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 a Bearer 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 a plan_code for each preview/purchase request from the resolved member list (SELF + selected dependants):
  • Adult vs child cutoff: members with age < 25 count as Children, age ≥ 25 as Adults.
  • Code format: {adult_count}A when 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).
The benefit’s 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

Each PlanVariant 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 returns IP_1021: Only APPROVED → ISSUED carries a body payload. Every other transition is a payload-free status flip that preserves the stored external_policy_id/dates, so a renewal-state → ISSUED reactivation 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, and lapsed → EXPIRED once the policy’s end_date has passed, otherwise REQUIRES_POLICY_REISSUANCE (still within term).
The diagram below shows the human /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 a PREVIEW — 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.
What happens server-side
  • Loads the benefit + provider, asserts it is Active and of type InsurancePolicy.
  • 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_code from the member list (adults/children) and looks up the matching PlanVariant in the benefit. Unsupported combinations return 400 IP_1009 PlanVariantNotAvailable.
  • Validates the nominee per plan: required when the matched variant’s nominee_required flag is true (surfaced as nominee_required in the premium-preview response — do not infer it from the plan code). Missing returns 400 IP_1015 NomineeRequiredForPlan. When a nominee is present — on any plan — it must be the policyholder’s spouse (relationship = SPOUSE), else 400 IP_1010.
  • Reads daily_premium_amount / annual_premium_amount / coverage_amount / currency / grace_period_days from the matched variant (no on-the-fly bracket calc).
  • Renders the enrollment-form PDF directly from the resolved data.
  • No INSERT / UPDATE against insurance_policies or mrns. No Narayana find_or_register_patients call.
Request body
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.
Example
Response 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.
The legacy fields primary_user_id and dependant_ids are still returned on every response for backwards compatibility with existing SDK/mobile clients, but are deprecated and will be removed in a future release. New integrations should rely on primary_member and dependants exclusively. The deprecated fields exactly mirror their structured counterparts (same id, same order).
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. The metadata.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.
Narayana only accepts MALE / FEMALE for patient gender. Members with gender = OTHER cause policy creation to fail with 400 IP_1013 — update the dependant’s gender before retrying. Upstream registration failures (timeout, 5xx) surface as 502 IP_1014 and the entire policy creation is rolled back.

Error Codes