Skip to main content

Insurer webhooks & integrations

Phase 1 shipped (2026-07-17)

The four criticals — gaps 1 (webhook secret decrypt via SecretsCipher), 2 (X-Insurer-Timestamp header + tolerance + HMAC over ${ts}.${body}), 3 (referral transitions route through ReferralsService.transitionFromInsurer), and 4 (claim.received / .settled / .repudiated route through ClaimsService.transitionTo) — landed in release insurer-webhooks-p1-20260717094400.

Transitional flag INSURER_WEBHOOK_LEGACY_NO_TIMESTAMP=true accepts bare HMAC(body) for insurers still on the pre-timestamp scheme; tolerance is INSURER_WEBHOOK_TOLERANCE_SECONDS (default 300 s). Body gained claimId + externalClaimRef to disambiguate multi- claim policies; falls back to the most-recent claim on the policy when neither is supplied.

Phase 2 shipped (2026-07-17)

The operational-hygiene block — gaps 5, 6, 7, 8, 11 — landed in release insurer-webhooks-p2-20260717102500:

  • gap 5: InsurerWebhooksService.onModuleInit refuses to boot in production when any active insurer lacks a webhook_secret InsurerIntegrationSetting. Ops override: INSURER_WEBHOOKS_ALLOW_MISSING_SECRET_IN_PROD=true.
  • gap 6: two-layer throttle — route-level @Throttle({ ttl: 60_000, limit: 60 }) per-source-IP + in-service 120/min sliding-window per-insurer. Excess raises 429 RATE_LIMITED.
  • gap 7: signature-mismatch now returns 200 with { ok: false, status: 'rejected' } and the persisted InsurerWebhookEvent.errorMessage='signature_mismatch' — insurer systems no longer retry-storm against a known-bad payload (matches the Collections Phase 2 pattern).
  • gap 8: bad-sig ingests emit zfa_webhooks_rejected_total{provider='insurer:'+insurerCode,reason='invalid_signature'} and auto-declare a low-severity BreachIncident at ≥10/hour per insurer with metadata.trigger='insurer_webhooks.invalid_signature_auto'. One incident per (insurerCode, hour bucket) at most.
  • gap 11: RetentionPurgeJob gained a pass that hard-deletes InsurerWebhookEvent past data_sharing.insurer_webhook_retention_days (default 730 d).

New insurer-webhooks-phase2.spec locks all 5 contracts. 45/45 suites, 325/325 tests green.

Phase 3 shipped (2026-07-17)

The credential + hygiene block — gaps 9, 10, 12 — landed in release insurer-webhooks-p3-20260717104300:

  • gap 9: InsurerContact.email + .phone ciphered on write via PiiCipher; new emailHash + phoneHash sibling columns (both indexed) + piiEncryptedAt stamp. New InsurerContactPii permission gates decrypt-on-read (finance + compliance + super-admin); everyone else sees ***MASKED***. Legacy plaintext rows round-trip via the PiiCipher.decrypt fallback.
  • gap 10: InsurerWebhooksService.onModuleInit self-registers every active insurer's webhook_secret in the SecretRotation register on boot (code='insurer_webhook.{code}.secret', category='webhook', 90-day SLA). SecretRotationOverdueJob (Collections Phase 3) picks it up automatically and auto-declares a low-severity BreachIncident on overdue codes.
  • gap 12: new OutboundWebhookSigner helper in common/security exports sign(body, secret) + verify(rawBody, signature, timestamp, secret) with typed WebhookSignatureError.reason on each failure mode (missing_timestamp, invalid_timestamp, stale_timestamp, signature_mismatch). Callers that push webhooks to insurers can now use one shared implementation matching the inbound scheme.

Schema migration 20260717110000_insurer_webhooks_phase3 adds InsurerContact.emailHash + phoneHash + piiEncryptedAt (all indexed).

Cross-cutting: pii-permission-scope.spec whitelist gains InsurerContactPii → { compliance_officer, finance_officer }.

New insurer-webhooks-phase3.spec locks all 3 contracts. 46/46 suites, 331/331 tests green.

All 12 findings from the insurer webhooks review are now closed.

Scope

The insurer-webhook surface is the platform's inbound seam from every insurer partner. Bordereau + policy issuance + claim status + commission settlement all flow back to ZFA via a POST /v1/webhooks/ insurers/:insurerCode callback carrying an X-Insurer-Signature HMAC. That surface must be:

  1. Trustworthy — every inbound message HMAC-verified against a per-insurer shared secret; replay-attack window bounded; idempotent on (insurerId, externalId, eventType); unknown insurers rejected.
  2. Stateful — every accepted event drives the correct downstream state machine (ReferralsService, ClaimsService, PoliciesService) so notifications, status history, commission side-effects, and audit trail all fire together.
  3. Auditable — every ingest + rejection persists to InsurerWebhookEvent; every state advance chains through AuditService.record; the compliance dashboard sees inbound event volume + rejection rate per insurer.

Modules in scope: insurer-webhooks, insurer-subresources (specifically InsurerIntegrationSettingsService), insurers. Cross- cuts the Referrals, Claims, and Notifications modules on state transitions.


Current state (2026-07-17)

Module footprint

src/modules/insurer-webhooks/insurer-webhooks.module.ts — service + 2 controllers, 317 LOC
src/modules/insurer-subresources/insurer-subresources.module.ts — Insurer* subresource CRUD
src/modules/insurers/insurers.service.ts — Insurer lifecycle

Prisma models: InsurerWebhookEvent (schema.prisma:534), Insurer (735), InsurerContact (766), InsurerIntegrationSetting (782).

What works today

  • HMAC-SHA256 verification against a per-insurer secret stored in InsurerIntegrationSetting.value where key='webhook_secret'.
  • Timing-safe compare via crypto.timingSafeEqual.
  • Idempotency@@unique([insurerId, externalId, eventType]) short-circuits duplicates on repeat.
  • Rejection persistence — invalid-sig events are persisted with status='rejected', errorMessage='signature_mismatch' for compliance forensics.
  • Replay endpointPOST /v1/insurer-webhooks/:id/replay re-runs process() for a received / rejected event that ops wants to reprocess after a downstream fix.
  • Public + admin controllers separate — the public ingest is @Public(); the admin list / find / replay endpoints are permission-gated by InsurerWebhookRead / InsurerWebhookReplay.
  • InsurerIntegrationSetting.isSecret=true ciphers the stored value via SecretsCipher (in the integration-settings write path) and masks it in the read path.

Gaps

All 12 gaps closed. Insurer-webhooks module is audit-clean against DPPA §21 (webhook-secret cipher + InsurerContact PII encryption), BOU §5.4 (timestamp-verified HMAC + throttle + auto-breach on sustained invalid signatures + boot-gate + secret-rotation ledger), audit-chain integrity on every state transition through ReferralsService.transitionTo + ClaimsService.acknowledge/settle/repudiateFromInsurer, and DPPA §11 storage-limitation via the InsurerWebhookEvent retention sweep. The OutboundWebhookSigner helper unifies inbound + outbound HMAC across the platform.


Open questions

  1. Multiple webhook secrets per insurer for rotation windows? Today one webhook_secret key per insurer. For zero-downtime rotation, we'd want to accept either secret OR secret_next for a window. Suggest per-insurer secret history with activeFrom / activeTo — dual-verify during window, promote on window close.
  2. Failed-event dead-letter? Rejected events persist but there's no dead-letter queue that surfaces the "5 rejects in a row" pattern to ops beyond the invalid-sig auto-breach in gap 8. Suggest promoting non-signature rejections (referral_not_found, invalid_state_transition) to a compliance dashboard row.
  3. Insurer-side dispatch cadence? For claim events, is the insurer polling or pushing? If polling, we should expose a GET /v1/webhooks/insurers/:insurerCode/events/since/:cursor endpoint. Compliance to confirm.

What this surface does not cover

  • Outbound calls to insurer APIs (send-a-referral, request-a-quote) — those live in the per-insurer integration adapters when we add them.
  • Insurer-facing dashboards / portal — future Insurer Portal design doc.
  • Bordereau file delivery — see Bordereau / Warehouse.