# Healthie Integration Mapping — VALIDATED **Status:** VALIDATED — Healthie sandbox tested. API is GraphQL (not REST). Webhooks are lightweight pointers requiring follow-up fetch calls. **Sandbox link:** https://help.gethealthie.com/article/1095-sandbox-api-key-generation **API docs:** https://docs.gethealthie.com/guides/intro/ ## Key Findings ### API Architecture - **Protocol:** GraphQL (mutations for writes, queries for reads) — **not REST** - **Authentication:** API key-based (Basic auth header) - **Webhooks:** Lightweight pointers only — contain `resource_id`, `resource_id_type`, `event_type`, `changed_fields` - **Resource fetch:** Requires separate GraphQL query after webhook notification - **Signature verification:** HMAC-SHA256 via `Content-Digest` + `Signature` headers; signing key format `whsec_` ### Authentication Pattern ``` Authorization: Basic YOUR_API_KEY_HERE AuthorizationSource: API ``` - API keys scoped to a user account — inherit that account's permissions - **Server-to-server:** Use a service account API key (single admin user with full permissions) - **Per-user:** Create API keys per-user via `createApiKey` mutation - **Shard deployments:** Also require `AuthorizationShard: YOUR_SHARD_AUTHORIZATION_ID` (obtain from Healthie support) - **Webhook IP allowlist:** Staging `18.206.70.225`, `44.195.8.253`, `44.198.216.125`; Production `52.4.158.130`, `3.216.152.234`, `54.243.233.84`, `50.19.211.21` - **Webhook retry:** Up to 3 days exponential backoff; email notification after ~24h; auto-disable after ~3 days ### Webhook Payload Structure Healthie webhooks deliver **event metadata only** — not the full resource. A follow-up API call is required. | Field | Type | Description | |---|---|---| | `resource_id` | integer | ID of the affected resource in Healthie (converted to string in `parse_webhook` via `str()` for consistent internal handling) | | `resource_id_type` | string | Resource kind: `User`, `Document`, `Patient`, etc. | | `event_type` | string | Event: `patient.created`, `document.created`, `document.updated`, etc. | | `changed_fields` | string[] | Fields modified (empty for creates) | **Example webhooks:** ```json // Patient created { "resource_id": 6263587, "resource_id_type": "User", "event_type": "patient.created", "changed_fields": [] } // Document created { "resource_id": 1087943, "resource_id_type": "Document", "event_type": "document.created", "changed_fields": [] } // Document updated { "resource_id": 1087943, "resource_id_type": "Document", "event_type": "document.updated", "changed_fields": ["rel_user_id"] } ``` ### Webhook Processing Flow (updated) ``` Healthie → Webhook (pointer only) → ALB → SQS → Fargate Worker Worker receives: { resource_id, resource_id_type, event_type, changed_fields } Worker fetches: GraphQL query for full resource (e.g., documents query for that user) Worker stores: raw_ingestion_records (full fetched payload) Worker normalizes: → clinical_documents, daily_checkin_submissions, etc. ``` This is a **two-step ingestion** for Healthie (vs. single-step for Cognito which sends full payloads). ## GraphQL API Examples ### Patient Creation (mutation) ```graphql mutation { signUp( input: { first_name: "Test" last_name: "Patient" email: "testpatient2@example.com" password: "teSt!1234" phone_number: "1112223333" role: "patient" timezone: "America/New_York" dietitian_id: "6263424" } ) { user { id } messages { field message } } } ``` - Returns: `user.id` (Healthie internal user ID) + any validation `messages` - The returned `user.id` is stored as `healthie_id` in `external_system_identifiers` ### Fetch Patient Documents (query) ```graphql query { documents(private_user_id: "6263587") { id display_name expiring_url } } ``` - Returns list of documents with `expiring_url` for download - Documents are downloaded via `expiring_url` → stored in S3 → reference stored in `clinical_documents` ### Upload PDF as Patient Document (mutation) — File Upload Workaround The `createDocument` mutation supports uploading arbitrary file types (PDF, image, etc.) as base64-encoded strings or multipart uploads. This can serve as a file upload workaround in the sandbox where form attachment uploads are image-only. ```graphql mutation { createDocument(input: { file_string: "data:application/pdf;base64,JVBERi0xLjQK..." display_name: "lab_report.pdf" rel_user_id: "6263587" share_with_rel: true }) { document { id display_name file_content_type expiring_url } messages { field message } } } ``` - `file` or `file_string`: Either a multipart `Upload` type or a base64 data URI - `rel_user_id`: Patient user ID to associate the document with - `share_with_rel`: Whether the patient can view the document - Returns `document.id` and `expiring_url` for download - In form answers, reference the document ID as text (e.g., `answer: "document_id:1087943"`) > **Sandbox limitation:** Native form file upload fields (`createCustomModuleFileAttachmentUploadUrl`) only accept images in sandbox. Using `createDocument` as a patient document is the workaround for PDFs. > **Enterprise:** Form attachment API expected to support PDFs natively. ### Create Care Plan (mutation) ```graphql mutation createCarePlan($name: String, $patient_id: ID, $is_hidden: Boolean) { createCarePlan(input: { name: $name patient_id: $patient_id is_hidden: $is_hidden }) { carePlan { id } messages { field message } } } ``` - Requires `name`; optionally scope to `patient_id` or leave as template - Returns `carePlan.id` for tracking in `external_system_identifiers` ### Create Metric Entry (daily check-in data point) ```graphql mutation createEntry($metric_stat: String, $category: String, $type: String, $user_id: String, $created_at: String) { createEntry(input: { category: $category # e.g. "Weight", "Blood Pressure", "A1C" type: $type # "MetricEntry" metric_stat: $metric_stat # e.g. "182" user_id: $user_id created_at: $created_at }) { entry { id } messages { field message } } } ``` - Built-in categories: Weight, A1C, Blood Pressure. Custom categories via FeatureToggle objects or UI. - Can backdate or forward-date (up to 3 months). Default timestamp if `created_at` omitted. ### Query Metric Entries (for reconciliation) ```graphql query entries($category: String, $type: String, $client_id: String, $start_range: String, $end_range: String) { entries( category: $category # e.g. "Blood Pressure" type: $type # "MetricEntry" client_id: $client_id start_range: $start_range # date filter end_range: $end_range ) { id type category metric_stat created_at added_by_user { id is_patient } } } ``` - Paginated, page size 10. Use `entriesCount` query for total count. - `is_org: true` returns entries for all organization clients. `group_id` filters by user group. ### Create Filled-Out Form (structured daily check-in) ```graphql mutation createFormAnswerGroup($finished: Boolean, $custom_module_form_id: String, $user_id: String, $form_answers: [FormAnswerInput!]!) { createFormAnswerGroup(input: { finished: $finished # true to show on patient chart custom_module_form_id: $custom_module_form_id # form template ID user_id: $user_id # patient ID form_answers: $form_answers # [{custom_module_id, answer, user_id}] }) { form_answer_group { id } messages { field message } } } ``` - Triggers `form_answer_group.created` webhook - `finished: true` required for visibility in chart and `formAnswerGroups` query ### Create Recurring Form Request (automated daily check-in prompts) ```graphql mutation createRequestedFormCompletion($recipient_ids: String, $form: String, $is_recurring: Boolean, $frequency: String, $period: String, $minute: String, $hour: String) { createRequestedFormCompletion(input: { recipient_ids: $recipient_ids # array of patient user IDs form: $form # form template ID is_recurring: $is_recurring # true for recurring frequency: $frequency # "Daily", "Weekly", or "Monthly" period: $period # "AM" or "PM" minute: $minute hour: $hour }) { requestedFormCompletion { id } messages { field message } } } ``` ### Query Filled-Out Forms (for check-in data retrieval) ```graphql query formAnswerGroups($custom_module_form_id: ID, $date: String, $user_id: String) { formAnswerGroups( date: $date # date of service custom_module_form_id: $custom_module_form_id # filter by form template user_id: $user_id # patient whose chart the form is in ) { id name created_at form_answers { label displayed_answer id custom_module { required id mod_type label } } } } ``` ## Data Flow Matrix ### Core DB → Healthie (Outbound) | Trigger | Action | Healthie Operation | DB Storage | |---|---|---|---| | Enrollment approved + CMS aligned | Create patient | `signUp` mutation (unauthenticated) or `createClient` mutation (authenticated provider) | Store returned `user.id` in `external_system_identifiers` | | Protocol assigned | Push care plan | `createCarePlan` mutation (with `patient_id`, `name`, `recommendations`); activate via `updateCarePlan` | Protocol version tracked in Core DB; `care_plan.id` stored in `external_system_identifiers` | | Care period dates set | Sync enrollment context | `updateClient` mutation with care period metadata in custom fields | Care period dates in `care_periods` | ### Healthie → Core DB (Inbound, Webhook-Triggered) > **Handler implementation status (as of August 2026):** > - **Fully implemented** (normalizes into domain models): `patient.created`, `patient.updated`, `patient.merged`, `document.created`, `document.updated`, `form_answer_group.created`, `metric_entry.created`, `metric_entry.updated`, `entry.created` (SymptomEntry only), `care_plan.created`, `care_plan.updated`, `care_plan.activated`, `care_plan.deactivated`, `care_plan.deleted` > - **Raw storage only** (stores webhook payload but does NOT create domain models): `applied_tag.created`, `applied_tag.deleted` — handler logs event and stores raw data; no downstream normalization implemented. > - **Partially implemented**: `form_answer_group.signed` — creates `FormSubmissionEvent` record; DailyCheckinSubmission status update not yet wired. | Webhook Event | Follow-up Query | Target Table | Notes | |---|---|---|---| | `patient.created` | `user(id: $resource_id)` | `external_system_identifiers` | Confirm match with Core DB beneficiary; `resource_id_type` = `User` | | `patient.updated` | `user(id: $resource_id)` | `beneficiaries` | Check `changed_fields` for status changes; triggered by `updateClient`, `bulkUpdateClients` | | `patient.merged` | — (no GraphQL fetch) | `intake_records` (via identifiers) | Payload includes `source_user_id` and `destination_user_id`; handler attempts email/phone lookup to match intake records | | `document.created` | `documents(private_user_id: $resource_id)` | `clinical_documents` | Download via `expiring_url` → S3 | | `document.updated` | `documents(private_user_id: $resource_id)` | `clinical_documents` | Update status based on `changed_fields` | | `form_answer_group.created` | `formAnswerGroups` query filtered by ID + `custom_module_form_id` | `daily_checkin_submissions`, `daily_checkin_responses` | Daily check-in form responses; each `FormAnswerGroup` is a submitted form. Query returns `form_answers[]` with `custom_module_id`, `displayed_answer`, `label` | | `form_answer_group.signed` | Same as above | `form_submission_events` | Creates FormSubmissionEvent; DailyCheckinSubmission status update TBD | | `metric_entry.created` | `entry(id: $resource_id)` | `clinical_observations`, `oap_measure_observations` | Individual metric data points (BP, weight, glucose, A1C, etc.). `resource_id_type` = `Entry`. Response includes `type`, `category`, `metric_stat`, `poster`, `added_by_user`. Creates `ClinicalObservation` + `OAPMeasureObservation` when beneficiary is resolved via `ExternalSystemIdentifier`. | | `metric_entry.updated` | `entry(id: $resource_id)` | `clinical_observations`, `oap_measure_observations` | `changed_fields` may include `["value", "category"]`. Creates same records as `metric_entry.created`. | | `entry.created` | `entry(id: $resource_id, type: $type, client_id: $client_id)` | `safety_signal_events` | Generic journal entry — covers `MetricEntry`, `FoodEntry`, `WorkoutEntry`, `SleepEntry`, `NoteEntry`, `WaterIntakeEntry`, `PoopEntry`, `SymptomEntry`. `SymptomEntry` creates `SafetySignalEvent` with severity assessment (red/yellow/green). `MetricEntry` skipped (handled by `metric_entry.created`). | | `entry.updated` | Same as above | `safety_signal_events` | For updated journal entries; `changed_fields` lists modified fields. Same routing as `entry.created`. | | `care_plan.created` | `carePlan(id: $resource_id)` | `monitoring_plans`, `monitoring_plan_items` | Creates `MonitoringPlan` + `MonitoringPlanItem` records from care plan recommendations. Requires beneficiary resolved via `ExternalSystemIdentifier` and active `CarePeriod`. Templates (`is_template=true`) are skipped. | | `care_plan.updated` | `carePlan(id: $resource_id)` | `monitoring_plans`, `monitoring_plan_items` | `changed_fields` lists modifications; updates existing `MonitoringPlan` status and items. | | `care_plan.activated` | `carePlan(id: $resource_id)` | `monitoring_plans` | Sets `MonitoringPlan.status` to `"active"`. | | `care_plan.deactivated` | `carePlan(id: $resource_id)` | `monitoring_plans` | Sets `MonitoringPlan.status` to `"completed"`. | | `care_plan.deleted` | `carePlan(id: $resource_id)` | `monitoring_plans` | Sets `MonitoringPlan.status` to `"archived"`. | ## What Should NOT Be Duplicated - **Full message threads** — too much PHI, not needed for ACCESS reporting - **All Healthie encounters** — only operationally relevant events - **Healthie care plan details** — Core DB owns protocol version; Healthie is display only ## Source of Truth Matrix | Domain | Owner | Healthie Role | |---|---|---| | Beneficiary identity (legal name, DOB, MBI) | Core DB | Patient portal display | | Eligibility & alignment | Core DB + CMS | Not involved | | OAP measure reporting | Core DB | Not involved | | Daily check-ins | Healthie → Core DB | Patient-facing form platform | | Patient messaging | Healthie | Not mirrored (metadata only) | | Document storage | S3 + Core DB references | Upload interface only | | Protocol/care plan | Core DB | Display/sync target | ## Previously Open Items — RESOLVED ### 1. Daily Check-in Form Structure (RESOLVED) Daily check-ins in Healthie can be ingested through **two complementary mechanisms**: **A. Structured Forms (recommended for multi-field daily check-ins)** - **Creation:** `createFormAnswerGroup` mutation — submits all fields at once as a `FormAnswerGroup` - **Query:** `formAnswerGroups(custom_module_form_id: $form_id, date: $date)` — returns filled forms with `form_answers[]` containing `label`, `displayed_answer`, `custom_module_id` - **Webhook:** `form_answer_group.created` — fires when patient submits a form - **Recurring forms:** `createRequestedFormCompletion` mutation supports `is_recurring: true` with `frequency` (Daily/Weekly/Monthly) and scheduling parameters (`hour`, `minute`, `weekday`, `monthday`) - **Onboarding integration:** Daily check-in forms can be associated with `OnboardingFlow` → `OnboardingItem` for new patients **B. Individual Metric Entries (for single-value daily data points)** - **Creation:** `createEntry` mutation with `type: "MetricEntry"`, `category: "Blood Pressure"`, `metric_stat: "120/80"`, `user_id: $patient_id` - **Query:** `entries(type: "MetricEntry", category: "Blood Pressure", client_id: $id)` — paginated (page size 10) - **Webhook:** `metric_entry.created` — fires for each new metric data point - **Built-in categories:** Weight, A1C, Blood Pressure, etc. Custom categories can be added via `FeatureToggle` API objects **C. Generic Journal Entries (catch-all for any entry type)** - **Entry types:** `MetricEntry`, `FoodEntry`, `WorkoutEntry`, `MirrorEntry`, `SleepEntry`, `NoteEntry`, `WaterIntakeEntry`, `PoopEntry`, `SymptomEntry` - **Webhook:** `entry.created` — fires for ALL entry types (broader than `metric_entry.created`) ### 2. Safety Event Ingestion (RESOLVED) Patient-reported safety signals can be captured through Healthie's API via: **A. `SymptomEntry` journal entries** - Create via `createEntry` mutation with `type: "SymptomEntry"`, `description: "patient-reported symptom text"`, `user_id: $patient_id` - Query via `entry(id: $id, type: "SymptomEntry", client_id: $id)` - Triggers `entry.created` webhook (NOT a dedicated `symptom_entry` webhook — use the generic `entry.created`) **B. Out-of-range metric alerts (Healthie pattern)** - Listen to `metric_entry.created` webhook → query `entry(id)` → check `metric_stat` against threshold - If out of range, create `createTask` mutation with `content`, `user_id` (provider), `client_id` (patient) - Healthie documentation explicitly documents this pattern in "Metric Value is out of range" automation example **C. Safety events via custom forms** - Create a custom `CustomModuleForm` (via `createCustomModuleForm`) with safety-relevant questions - Submit as `FormAnswerGroup` via `createFormAnswerGroup` mutation - Trigger `form_answer_group.created` webhook → parse answers for safety flags ### 3. Care Plan Sync — GraphQL Mutations (RESOLVED) | Operation | GraphQL API | Key Inputs | Notes | |---|---|---|---| | Create care plan | `createCarePlan` | `name` (required), `patient_id`, `is_hidden` | Returns `carePlan.id` | | Update care plan | `updateCarePlan` | `id` (required), `name`, `description`, `new_patient_id`, `is_template`, `is_hidden` | Move plan between patients with `new_patient_id` | | Activate care plan | Implicit via `updateCarePlan` | — | `is_active` field on `CarePlan` object | | List care plans | `carePlans(patient_id: $id, sort_by: "newest")` | `patient_id`, `templates_only`, `template_search_keywords` | Sort options: `oldest`, `newest`, `name_asc`, `name_desc`, `status_asc`, `status_desc`, `deactivated_desc,activated_desc` | | Get care plan | `carePlan(id: $id)` | `id` (required) | Returns full `CarePlan` with `recommendations[]` | | Delete care plan | `deleteCarePlan` | `id` (required) | Soft delete by authorized providers/staff | | Create template | `createCarePlan` with `is_template: true` | No `patient_id` required | Templates are reusable care plan blueprints | **Care plan recommendation object:** ```json { "id": "1", "recommendation_body": "

...

", "recommendation_type": "Nutrition", "sanitized_body": "..." } ``` **Webhook events for care plans:** `care_plan.created`, `care_plan.updated`, `care_plan.activated`, `care_plan.deactivated`, `care_plan.deleted` ### 4. Patient Status Events (RESOLVED) | Status Change | Webhook Event | How It's Triggered | |---|---|---| | New patient created | `patient.created` | `createClient`, `completeCheckout`, or `createNotificationContact` mutation | | Patient record modified | `patient.updated` | `updateClient`, `bulkUpdateClients`, or `toggleCarePlanStatusForSpecificUser`; also fires on form submissions and appointment check-ins | | Patient records merged | `patient.merged` | `mergeClients` mutation — payload includes `source_user_id` and `destination_user_id` | | Patient status (active/archived) | `patient.updated` via `bulkUpdateClients` | `bulkUpdateClients(ids: [...], active_status: true/false)` — sets `active` or `archived` | | Form completion | `form_answer_group.created` | Patient submits a `FormAnswerGroup` | | Form signed by provider | `form_answer_group.signed` | `createFormAnswerGroupSigning` mutation | | Metric added | `metric_entry.created` | `createEntry` with `type: "MetricEntry"` | | Tag applied/removed | `applied_tag.created` / `applied_tag.deleted` | `bulkApply` / `removeAppliedTag` mutation; payload includes `tag_id` and `user_id` | **Patient `active_status` values:** `"active"` or `"archived"` (query via `users(active_status: "active")`) **Note on missed check-ins:** Healthie does not emit a "missed check-in" webhook. This must be inferred server-side by polling `requestedFormCompletions(status: "Incomplete")` or `entries` query to check for missing data on expected dates. ### 5. Authentication Flow (RESOLVED) **API Key Authentication (server-to-server):** ``` Authorization: Basic YOUR_API_KEY_HERE AuthorizationSource: API ``` - API keys are scoped to a user account and inherit that account's permissions - For server-side integrations, use a **service account** API key (tied to one specific user account with admin permissions) - For per-user experiences, create API keys per-user via `createApiKey` mutation **Optional (if data is in a shard):** ``` AuthorizationShard: YOUR_SHARD_AUTHORIZATION_ID ``` - Shard ID must be obtained from Healthie support at `hello@gethealthie.com` **Webhook signature verification:** - Headers: `Content-Digest` (SHA-256 of payload), `Signature-Input`, `Signature` - HMAC-SHA256 with signing key (format: `whsec_`) - Data string to sign: `"{method} {path} {query} {contentDigest} {contentType} {contentLength}"` **Patient self-signup (unauthenticated):** - `signUp` mutation — no API key needed; returns `user.id` **Key generation:** ```graphql mutation createApiKey($name: String, $user_id: ID) { createApiKey(input: { name: $name, user_id: $user_id }) { api_key api_key_object { created_at, displayable_key, id } } } ``` ## Remaining Open Items - [ ] Verify `requestedFormCompletions(status: "Incomplete")` query returns overdue forms for missed check-in detection - [ ] Confirm `FeatureToggle` API for custom metric category management (Healthie docs reference UI setup as alternative) - [ ] Test `bulkUpdateClients` with `active_status` to confirm patient archival behavior - [ ] Determine if Healthie `SymptomEntry` type is available in the sandbox (not all entry types may be enabled) - [ ] Confirm exact `carePlan` query fields for `recommendations[]` structure (full field list at schema reference) - [x] **Implement normalization for `metric_entry.created`/`updated`** — DONE: creates `ClinicalObservation` + `OAPMeasureObservation` records - [x] **Implement normalization for `entry.created`/`updated`** — DONE: `SymptomEntry` creates `SafetySignalEvent` with severity assessment - [x] **Implement normalization for `care_plan.*` events** — DONE: creates/updates `MonitoringPlan` + `MonitoringPlanItem` records