Insurer webhooks & integrations
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.
The operational-hygiene block — gaps 5, 6, 7, 8,
11 — landed in release insurer-webhooks-p2-20260717102500:
- gap 5:
InsurerWebhooksService.onModuleInitrefuses to boot in production when any active insurer lacks awebhook_secretInsurerIntegrationSetting. 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 raises429 RATE_LIMITED. - gap 7: signature-mismatch now returns 200 with
{ ok: false, status: 'rejected' }and the persistedInsurerWebhookEvent.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-severityBreachIncidentat ≥10/hour per insurer withmetadata.trigger='insurer_webhooks.invalid_signature_auto'. One incident per (insurerCode, hour bucket) at most. - gap 11:
RetentionPurgeJobgained a pass that hard-deletesInsurerWebhookEventpastdata_sharing.insurer_webhook_retention_days(default 730 d).
New insurer-webhooks-phase2.spec locks all 5 contracts. 45/45 suites,
325/325 tests green.
The credential + hygiene block — gaps 9, 10, 12 — landed
in release insurer-webhooks-p3-20260717104300:
- gap 9:
InsurerContact.email+.phoneciphered on write viaPiiCipher; newemailHash+phoneHashsibling columns (both indexed) +piiEncryptedAtstamp. NewInsurerContactPiipermission gates decrypt-on-read (finance + compliance + super-admin); everyone else sees***MASKED***. Legacy plaintext rows round-trip via thePiiCipher.decryptfallback. - gap 10:
InsurerWebhooksService.onModuleInitself-registers every active insurer'swebhook_secretin theSecretRotationregister 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-severityBreachIncidenton overdue codes. - gap 12: new
OutboundWebhookSignerhelper incommon/securityexportssign(body, secret)+verify(rawBody, signature, timestamp, secret)with typedWebhookSignatureError.reasonon 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:
- Trustworthy — every inbound message HMAC-verified against a
per-insurer shared secret; replay-attack window bounded; idempotent
on
(insurerId, externalId, eventType); unknown insurers rejected. - 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. - Auditable — every ingest + rejection persists to
InsurerWebhookEvent; every state advance chains throughAuditService.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.valuewherekey='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 endpoint —
POST /v1/insurer-webhooks/:id/replayre-runsprocess()for areceived/rejectedevent 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 byInsurerWebhookRead/InsurerWebhookReplay. InsurerIntegrationSetting.isSecret=trueciphers the stored value viaSecretsCipher(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
- Multiple webhook secrets per insurer for rotation windows?
Today one
webhook_secretkey per insurer. For zero-downtime rotation, we'd want to accept eithersecretORsecret_nextfor a window. Suggest per-insurer secret history withactiveFrom/activeTo— dual-verify during window, promote on window close. - 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.
- 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/:cursorendpoint. 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.