Candid | Canvas Medical
mbiannaccone/candid
Candid Health Integration
Canvas plugin that replaces the built-in candid_integration/ module. Handles the full claim lifecycle with Candid Health: submission, adjudication sync, patient payment reporting and syncing, and claim status banners.
ID Glossary
Three IDs flow through this plugin and can be confused if you're not careful:
| ID | Source | Format | Where it lives |
|---|---|---|---|
| Canvas claim ID | Canvas | UUID (00000000-...) |
claim.id, used for ClaimEffect, metadata lookups, and as external_id sent to Candid (canvas:{claim_id}) |
| Candid encounter ID | Candid returns it from POST /encounters/v4 |
UUID | Stored in claim metadata as candid_encounters[].candid_encounter_id. Used to GET /encounters/v4/{id} |
| Candid claim ID | Nested inside the encounter response at claims[].claim_id |
Candid-generated | Not stored. Discovered at sync time, used ephemerally to query GET /patient-payments/v4?claim_id={id} |
In code, variables are prefixed to avoid ambiguity:
canvas_claim_id— Canvas's internal UUIDcandid_encounter_id— Candid's encounter-level IDcandid_claim_id— Candid's claim-level ID (nested inside an encounter)
The Candid encounter response has this hierarchy:
Encounter (encounter_id) <- we create via POST, fetch via GET
+-- claims[]
+-- claim_id <- Candid creates internally
+-- status <- e.g. "era_received", "finalized_paid"
+-- eras[] <- ERA IDs for dedup
| +-- era_id
| +-- check_number
| +-- check_date
+-- service_lines[] <- payment/adjustment amounts per line
+-- procedure_code
+-- charge_amount_cents
+-- primary_paid_amount_cents
+-- allowed_amount_cents
+-- deductible_cents, coinsurance_cents, copay_cents
+-- service_line_era_data.service_line_adjustments[]
+-- service_line_manual_adjustments[]
Features
Claim Submission
When a claim is moved to the QueuedForSubmission queue, the plugin schedules a submission to Candid's /api/encounters/v4 endpoint via the plugin's own /submit SimpleAPI route after a 60-second grace period (GRACE_PERIOD_SECONDS). When the submit handler fires, it re-checks the claim's queue and skips the submission if the user has moved the claim out of QueuedForSubmission during the grace window.
- Builds the Candid encounter payload from Canvas claim data (patient, providers, diagnoses, service lines, subscribers)
- Splits claims with >12 diagnosis codes into a primary encounter (real service lines) and supplemental encounters (99499 CPT code at $0.01) per CMS-1500 limits
- Diagnoses are split in rank order (first 12, next 12, etc.) without reordering -- the Canvas UI's Claim Review Records (CRR) workflow lets users review and assign diagnoses across splits before submission
- Payload errors are caught per-section (patient, billing provider, etc.) and surfaced as claim comments rather than crashing the handler
- Stores Candid encounter IDs in claim metadata for traceability
- Resubmission handling: If the POST fails with
EncounterExternalIdUniquenessError(the encounter already exists in Candid), the plugin looks up the existing encounter byexternal_idand PATCHes it instead. The PATCH payload excludesdiagnosesandservice_lines(Candid's update schema usesdiagnosis_idsinstead and does not accept service lines) - On success: adds a claim comment with the submission date and Candid encounter IDs, adds a status banner, and moves claim to FiledAwaitingResponse
- On failure: adds an error comment, writes a
candid_submission_errormetadata entry, and moves claim to NeedsCodingReview
Adjudication Sync (Pull-Based)
Candid has no webhooks, so the plugin pulls adjudication data via GET /api/encounters/v4/{encounter_id} and patient payments via GET /api/patient-payments/v4?claim_id={candid_claim_id}.
Triggers:
- Event-driven: When a claim enters the Patient Balance queue, the plugin asynchronously POSTs to its own
/sync-patient-paymentsSimpleAPI route to pull just the patient payments for that claim (full ERA/adjudication sync is left to the nightly cron) - Manual: The "Sync Now" button on the claim timeline application POSTs to
/claim-detail, which runs a full adjudication sync inline - Nightly cron (2 AM): Queries Canvas for all claims in FiledAwaitingResponse, AdjudicatedOpenBalance, and PatientBalance queues that have Candid encounter metadata, then runs full adjudication sync on each
A separate one-time midnight cron migrates legacy SyncLog rows into candid_sync_history metadata.
Insurance adjudication (ERA data):
- Fetches each encounter stored in claim metadata via
GET /encounters/v4/{candid_encounter_id} - Iterates
claims[].eras[]to find new ERA IDs not yet synced - Delta-based posting: Service line amounts (
primary_paid_amount_cents, etc.) are cumulative across all ERAs. The plugin stores previously synced amounts incandid_synced_amountsmetadata and only posts the delta for each payer tier. This correctly handles primary and secondary ERAs arriving at different times without double-posting. - Posts payments via
ClaimEffect.post_payment()for each payer tier where new amounts are detected:- Primary insurance – charged, allowed, payment, contractual CO-45 write-off (charged minus allowed), ERA + manual adjustments
- Secondary/Tertiary insurance – separate
post_paymentper payer, only when that tier's amounts increase
- Contractual adjustment: The difference between
charge_amount_centsandallowed_amount_centsis posted as a CO-45 write-off on the insurance posting - Patient responsibility: Deductible (PR-1), coinsurance (PR-2), and copay (PR-3) amounts from the service lines are posted as transfer adjustments on the insurance posting (not as separate patient postings)
- Balance transfers: Based on the encounter's
next_responsible_party, remaining balance is transferred to the appropriate party:- `