← Back to API docs

Partner Integration Guide

This guide covers everything a third-party partner needs to integrate with the Profile Health API: provisioning credentials, making the first call, handling errors, and staying within rate limits.

Base URL: https://api.profilehealth.com (production) / https://api-staging.profilehealth.com (staging) Dev: http://localhost:8080 (run pnpm dev in the gateway repo)


1. Getting Credentials

A Profile Health system administrator provisions partner accounts and API keys via the admin UI (/admin/partner-api). You will receive:

The plaintext secret (.<43-chars> suffix) is shown once at key creation and never retrievable again. Store it immediately in your secrets manager.

Test keys (pk_test_…) differ from live keys in exactly one way: they may set skipPersona: true, which creates a patient without a government-ID identity check. A pk_live_ key that sends it is rejected.

A test key is not a separate dataset. On a given deployment both key types read and write the same records — the same FHIR server, documents, synthesis and HIE endpoint. Anything you create with a test key against production is production data. If you need a dataset you can throw away, ask us for access to staging, which is a different deployment with its own upstreams.


2. Authentication

All authenticated requests require:

Authorization: Bearer pk_live_<keyId>.<secret>

The full token (prefix + secret, dot-separated) goes in the Authorization header as a Bearer token.

What the gateway checks on every request

  1. Token format matches pk_(live|test)_<32chars>.<43+chars>.
  2. Key exists in Firestore and its status is active.
  3. Key has not expired (expiresAt > now).
  4. The argon2id hash of the presented secret matches the stored hash.
  5. If ipAllowlist is set on the key, the request IP is in the list.
  6. The partner account is active (not suspended).

Any failure at any step returns 401 UNAUTHENTICATED with the detail "Invalid credentials". The server log records the actual failure reason; the client never learns which check failed (prevents enumeration).


3. Quick Start

Verify connectivity with the health query — it requires no scope:

curl -X POST https://api.profilehealth.com/graphql \
  -H "Authorization: Bearer pk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.yyyyyyy" \
  -H "Content-Type: application/json" \
  -d '{"query":"{ health { status version } }"}'

Expected response:

{
  "data": {
    "health": {
      "status": "ok",
      "version": "1.0.0"
    }
  }
}

4. API Surfaces

POST /graphql with a JSON body {"query": "...", "variables": {...}}.

GraphQL is the canonical surface. Use it if your stack supports it — it lets you request exactly the fields you need, reducing payload size and unnecessary data exposure.

Interactive playground (non-production): GET /graphql → GraphiQL Schema visualizer: GET /v1/docs/graphql → Voyager

REST facade

For partners that cannot use GraphQL, a thin REST facade is available under /v1/. Each REST endpoint internally re-enters the same GraphQL plugin chain, so authentication, rate limits, and audit behave identically.

Method Path Scope required Description
GET /v1/health none Liveness probe
POST /v1/intake/invitations intake:write Issue a patient intake invitation

Full REST reference: GET /v1/openapi.json (or open /v1/docs/rest in a browser for Swagger UI).

4.1 Capabilities by use-case

The table below maps partner-facing tasks to the GraphQL operations and scopes they require. Every scope listed here must be present on your API key at creation time — they cannot be requested at runtime.

Use case GraphQL operations Scope(s) required
Patient & Intake patient, patients, patientDossier; createIntakeInvitation patient:read, intake:write
Labs & Documents labResults, documents; uploadDocument patient:read (labs), document:read, document:write
Clinical Notes (SOAP) composition, compositions; createComposition, updateCompositionSection, signComposition, amendComposition, deleteComposition note:read, note:write, note:sign, note:amend
Encounters encounter, encounters; createEncounter, updateEncounter, deleteEncounter encounter:read, encounter:write
Scratch Notes scratchNote, scratchNotes; createScratchNote, updateScratchNote, deleteScratchNote scratch-note:read, scratch-note:write
AI Synthesis synthesis, synthesisStatus; requestSynthesis synthesis:read, synthesis:run
Health Information Exchange hieQuery; hieDocuments hie:query; hie:query + document:write

Admin-only operations: Partner and API key administration (partner, partners, apiKeys, createPartner, createApiKey, rotateApiKey, revokeApiKey, etc.) and audit event access (auditEvents) require admin:partners:read, admin:partners:write, or admin:audit:read. These scopes are not available to external partners and are never issued to partner-tier keys.


5. Scopes

Your API key carries a fixed set of scopes granted at creation time. You cannot request scopes at runtime. Ask your Profile Health contact to issue a key with the right scopes for your use case.

Scope What it unlocks
patient:read Read patient demographics, lab results
document:read Read patient documents
document:write Upload patient documents
synthesis:read Read AI synthesis summaries
synthesis:run Submit a synthesis job
intake:write Issue patient intake invitations
hie:query Query Carequality / KNO2 HIE (hieQuery, hieDocuments)
kit:activate Genetic kit activation — patient-facing (identifyKit, recordKitConsent, kitConsentDocuments); first-party patient-app only
kit:manifest Load clinician kit orders + lab manifest rows (importKitOrders) — operations / Dr.Dash / lab feed, never the patient-app key
admin:partners:read Read partner + API key records (admin-only)
admin:partners:write Create / suspend / rotate partners and keys (admin-only)
admin:audit:read Read HIPAA audit events (admin-only)

A request missing a required scope gets 403 FORBIDDEN with detail: "Missing required scopes: <scope>".


6. Clinic Scoping

Patient data is scoped to FHIR Organization ids (clinic ids) that a Profile Health administrator has explicitly granted to your partner account. Queries that touch patient data only return records belonging to your assigned clinics.

If you receive an empty result set when you expect data, check that:

  1. Your key has the patient:read scope.
  2. A system administrator has assigned the relevant clinic to your partner account via assignClinicsToPartner.

An empty clinicIds list means the partner has no FHIR data access yet — this is a safe default after provisioning.


7. Idempotency

All mutations (operations that create or modify data) require an Idempotency-Key header:

Rules:

Example (REST):

curl -X POST https://api.profilehealth.com/v1/intake/invitations \
  -H "Authorization: Bearer $PARTNER_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: invite-$(uuidgen)" \
  -d '{"email":"patient@example.com","name":"Jane Doe","clinicId":"<clinic-id>"}'

8. Error Handling

Errors follow RFC 7807 (application/problem+json) for REST and extensions.code for GraphQL. Both use the same closed set of error codes.

Error code reference

The HTTP status column is what the REST endpoints under /v1/ return. On the GraphQL transport the response is always HTTP 200 and the code lives in errors[0].extensions.code (with the status above mirrored in extensions.httpStatus) — so branch on the code, never on the status, if you call /graphql directly. This includes rejections that never reach a resolver, such as a tripped rate limit or an over-budget query.

Code HTTP status When to retry What to do
UNAUTHENTICATED 401 Never Check Bearer token format, key status, IP allowlist
FORBIDDEN 403 Never Check scopes; check clinicId is in your allowedClinicIds
NOT_FOUND 404 Never Resource does not exist or is not visible to your key
METHOD_NOT_ALLOWED 405 Never Check HTTP method; POST on /graphql, POST on mutation REST routes
CONFLICT 409 Never Duplicate resource or stale precondition
KIT_ALREADY_ACTIVATED 409 Never (different code) The kit code was activated before; do not resubmit it — have the patient contact support with the correlationId
PAYLOAD_TOO_LARGE 413 Never (shrink first) Request body exceeds the size limit; reduce the payload (e.g. a smaller intake Bundle)
VALIDATION_FAILED 422 Never (fix input first) Input failed schema validation; check detail. Kit activation also returns violations: [{ field, rule }] (in extensions on GraphQL, top-level on REST) naming each failing field
IDEMPOTENCY_KEY_REUSED 422 Never (change key) Same key used with a different payload
RATE_LIMITED 429 Yes — honor Retry-After Slow down; see rate limit section
UPSTREAM_TIMEOUT 504 Yes — with backoff Upstream slow; retry after a few seconds
UPSTREAM_UNAVAILABLE 503 Yes — with backoff Upstream down or circuit breaker open
INTERNAL 500 Once, then escalate Unhandled server error; include correlationId in support ticket

Sample error responses

REST — 401:

{
  "type": "https://errors.profilehealth.com/unauthenticated",
  "title": "UNAUTHENTICATED",
  "status": 401,
  "detail": "Invalid credentials",
  "correlationId": "7e8c3a2b-19d4-4f6e-b551-aab1f9c2d3e4"
}

GraphQL — 403:

{
  "data": null,
  "errors": [
    {
      "message": "Missing required scopes: intake:write",
      "extensions": {
        "code": "FORBIDDEN",
        "httpStatus": 403,
        "type": "https://errors.profilehealth.com/forbidden"
      }
    }
  ]
}

Always include the x-correlation-id response header value in support requests. Logs are indexed by it.


9. Rate Limits

Rate limits are enforced per partner tier and per API key. The gateway returns standard RFC 6585 / IETF draft headers on every response:

Header Meaning
RateLimit-Limit Requests allowed in the current window
RateLimit-Remaining Requests left in the current window
RateLimit-Reset Unix timestamp when the window resets
Retry-After Seconds to wait before retrying (present whenever a rate gate rejects you)

Default quotas by tier

Tier Requests/second Requests/day Synthesis/day Max query complexity Max concurrent
FREE 5 1,000 10 100 2
PARTNER_STANDARD 50 100,000 500 200 10
PARTNER_PREMIUM 200 1,000,000 5,000 500 50

Synthesis has a separate, lower daily budget (synthesisPerDay) on top of the global Requests/day, because requestSynthesis is the most expensive operation (an LLM run). Exhausting it returns 429 RATE_LIMITED from requestSynthesis specifically; your other calls keep working against the global quota.

Max concurrent is enforced per gateway instance, not fleet-wide. The gateway autoscales, so the effective ceiling is your tier's value times the number of live instances — treat the per-tier number as a floor, not a contract. Exceeding it returns RATE_LIMITED; retry once one of your in-flight requests completes. A fleet-wide cap needs shared state and is a deliberate future step, not an oversight.

Allowed operations narrows a key to a named set of GraphQL operations without re-issuing it with different scopes — useful to tighten a credential already in your hands. When it is set, every request must name its operation: an anonymous operation cannot be checked against the list and is rejected with FORBIDDEN.

Max query complexity is enforced per request. Every field carries a complexity cost (shown per operation in the API reference); the costs of all fields you select are summed and compared against your tier's Max query complexity. A request over the limit is rejected with VALIDATION_FAILED and no partial data — retrying it unchanged will always fail, so split the selection across calls instead. Three request-shape limits apply alongside it, and none of them constrains a normal query (for scale, the deepest document the gateway itself sends is 7 levels and the largest is ~1 kB):

Limit Value
Document size (the query text; variables are not counted) 100,000 bytes
Nesting depth 20
Field selections, counting each alias separately 2,000

Pass large payloads such as an intake Bundle as a GraphQL variable, not inlined into the query text, so the document-size limit never applies to your data.

HIE has its own separate daily budget (hieQueriesPerDay), shared by hieQuery and hieDocuments (each is an external Carequality network fan-out). Exhausting it returns 429 RATE_LIMITED from those operations; it resets at 00:00 UTC and is independent of both Requests/day and synthesisPerDay. See §18.

A system administrator can override any quota per-partner (including synthesisPerDay and hieQueriesPerDay). Contact your Profile Health account team if defaults are insufficient for your use case.

When you receive a 429 RATE_LIMITED response, always honor Retry-After before retrying. Exponential backoff with jitter is recommended for automated clients.


10. Pagination

List queries use Relay cursor-style pagination:

query GetPatients($after: String) {
  patients(page: { first: 25, after: $after }) {
    edges {
      cursor
      node {
        id
        name
        birthDate
      }
    }
    pageInfo {
      hasNextPage
      endCursor
    }
  }
}

11. Correlation IDs

Every response includes x-correlation-id. This ID traces the request across all gateway services, logs, and upstream calls. When reporting an issue, always include this value.

You may also send your own correlation ID by setting x-correlation-id on the request. The gateway adopts your value rather than generating a new one, which is useful for correlating gateway requests with events in your own system.


12. TLS and Security


13. Patient Dossier (end-to-end)

patientDossier is the highest-value operation in the API. One call retrieves the full assembled patient chart — demographics, encounters, documents, observations, conditions, medications, allergies, procedures, family history, questionnaire responses, and consents — without requiring separate requests to each resource type.

Scope required: patient:read

What the response contains

The field returns a union: PatientDossierResult = PatientDossier | DossierPending.

PatientDossier fields:

Field Type Description
patient Patient! Demographics (name, DOB, gender, contact info)
encounters [Encounter!]! All visits for the patient
documents [Document!]! Attached documents (lab reports, imaging, intake forms, …)
observations [Observation!]! Vitals, lab observations, social history, and other FHIR Observations
conditions [Condition!]! Problem list items
medications [MedicationStatement!]! Current and past medications/supplements
allergies [AllergyIntolerance!]! Allergies and intolerances
procedures [Procedure!]! Historical and in-progress procedures
familyHistory [FamilyMemberHistory!]! Family member history entries
questionnaireResponses [QuestionnaireResponse!]! Structured intake answers
consents [Consent!]! Consent forms signed during intake
intakeInvitation IntakeInvitation Populated when the query was keyed by intakeInvitationId
assembledAt String! ISO 8601 timestamp — pass back as since on the next call to fetch only deltas

DossierPending is returned when the partner queried by intakeInvitationId but the patient has not yet completed intake (or the invitation expired). Its status field is one of pending, in-progress, or expired.

Query arguments

Exactly one of patientId or intakeInvitationId must be supplied — passing both or neither returns VALIDATION_FAILED.

Argument Type Required Description
patientId ID one-of Medplum patient UUID (from patient or patients queries)
intakeInvitationId ID one-of id returned by createIntakeInvitation
since String no ISO 8601 timestamp — only resources with meta.lastUpdated > since are included

GraphQL query

query GetPatientDossier(
  $patientId: ID
  $intakeInvitationId: ID
  $since: String
) {
  patientDossier(
    patientId: $patientId
    intakeInvitationId: $intakeInvitationId
    since: $since
  ) {
    ... on PatientDossier {
      assembledAt
      patient {
        id
        name
        birthDate
        gender
        email
        phone
      }
      encounters {
        id
        status
        period { start end }
      }
      documents {
        id
        type
        title
        mimeType
        url
        createdAt
      }
      observations {
        id
        category
        code { text }
        value
        unit
        effectiveDateTime
      }
      conditions {
        id
        name
        clinicalStatus
        onsetDate
      }
      medications {
        id
        medication { text }
        status
        dosageText
      }
      allergies {
        id
        substance { text }
        clinicalStatus
        criticality
        manifestations
      }
      procedures {
        id
        code { text }
        status
        performedDate
      }
      familyHistory {
        id
        relationship
        conditions
      }
      questionnaireResponses {
        id
        questionnaire
        status
        authored
        itemsJson
      }
      consents {
        id
        category { text }
        status
        signedAt
      }
      intakeInvitation {
        id
        status
        expiresAt
      }
    }
    ... on DossierPending {
      invitationId
      status
      expiresAt
      invitedEmail
    }
  }
}

curl example

curl -X POST https://api.profilehealth.com/graphql \
  -H "Authorization: Bearer $PARTNER_KEY" \
  -H "Content-Type: application/json" \
  -H "x-correlation-id: $(uuidgen)" \
  -d '{
    "query": "query GetPatientDossier($patientId: ID, $intakeInvitationId: ID, $since: String) { patientDossier(patientId: $patientId, intakeInvitationId: $intakeInvitationId, since: $since) { ... on PatientDossier { assembledAt patient { id name birthDate } encounters { id status } conditions { id name clinicalStatus } medications { id medication { text } status } allergies { id substance { text } criticality } assembledAt } ... on DossierPending { invitationId status expiresAt invitedEmail } } }",
    "variables": {
      "intakeInvitationId": "tok_abc123"
    }
  }'

Async pattern — polling for DossierPending

When the partner queries by intakeInvitationId before the patient completes intake, the gateway returns a DossierPending object instead of PatientDossier. The partner should poll at a reasonable interval (60 seconds is sufficient for most intake flows) until a PatientDossier is returned.

POST /graphql  →  { "data": { "patientDossier": { "invitationId": "...", "status": "in-progress", ... } } }
# patient is still filling in the intake form — try again later

POST /graphql  →  { "data": { "patientDossier": { "patient": { ... }, "assembledAt": "...", ... } } }
# intake complete — dossier is ready

Stop polling when ... on PatientDossier is matched, or when DossierPending.status is expired (the invitation lapsed before the patient submitted; issue a new invitation if appropriate).

REST facade equivalent

The /v1/patients/dossier REST route wraps the same GraphQL field and maps the union to HTTP status codes:

GraphQL result HTTP status Body
PatientDossier 200 OK JSON representation of the dossier
DossierPending 202 Accepted JSON with invitationId, status, expiresAt, invitedEmail
# Query by intakeInvitationId via REST
curl "https://api.profilehealth.com/v1/patients/dossier?intakeInvitationId=tok_abc123" \
  -H "Authorization: Bearer $PARTNER_KEY"

# Query by patientId via REST, with delta filtering
curl "https://api.profilehealth.com/v1/patients/dossier?patientId=patient-uuid&since=2025-01-01T00:00:00Z" \
  -H "Authorization: Bearer $PARTNER_KEY"

Incremental updates (delta polling)

After the first successful PatientDossier fetch, store the assembledAt timestamp and pass it as since on subsequent calls. The gateway filters all resource arrays to entries where meta.lastUpdated > since, keeping response payloads small for high-frequency polling.

# Subsequent call — only resources changed since the last fetch
query {
  patientDossier(patientId: "patient-uuid", since: "2025-05-01T12:00:00Z") {
    ... on PatientDossier {
      assembledAt
      conditions { id name clinicalStatus }
      medications { id medication { text } status }
    }
  }
}

14. Intake completion redirect (returnUrl)

By default the intake wizard shows a static "Done" screen when the patient finishes onboarding — no redirect anywhere. You can give patients a smooth handoff back to your portal by configuring a returnUrl. Two layers, both partner-controlled, both require only your existing intake:write scope (no admin involvement, no waiting on Profile Health support).

Layer 1: partner-wide default

Set once per partner. Every invitation you create afterwards uses this URL unless you override it on the individual invitation (see Layer 2).

mutation {
  updateMyIntakeReturnUrl(input: { returnUrl: "https://evermint.example.com/onboarded" }) {
    id
    name
    intakeConfig { returnUrl }
  }
}

Pass returnUrl: null (or "") to clear the default — wizard goes back to the static "Done" screen.

The mutation is self-service: the gateway always uses your API key's partnerId from the principal, so you cannot accidentally edit another partner's config (and a leaked key cannot either).

Layer 2: per-invitation override

Sometimes one partner runs multiple flows (e.g. patient cohorts that should land on different post-completion pages). Override the partner default by passing returnUrl on the individual createIntakeInvitation:

mutation {
  createIntakeInvitation(
    input: {
      email: "patient@example.com"
      firstName: "Ada"
      lastName: "Lovelace"
      clinicId: "..."
      returnUrl: "https://evermint.example.com/onboarded/group-a"
    }
    idempotencyKey: "..."
  ) {
    id
    url
  }
}

Omit returnUrl (or pass empty string) to fall back to the partner-wide default.

Precedence

The intake wizard resolves the redirect target in this order:

  1. Per-invitation returnUrl (Layer 2) — when present on the invitation.
  2. Partner-wide Partner.intakeConfig.returnUrl (Layer 1) — when no per-invitation value.
  3. Static "Done" screen — when neither is set.

Validation rules (both layers)

What the patient sees

When returnUrl is set, the wizard shows a brief "Returning to {your partner name} in a few seconds…" message with a manual "Continue now" button before navigating away. Patients who close the tab early or have JavaScript disabled can still use the fallback button. (This behaviour ships once the downstream Cloud Function and intake-app changes land — track in the api-gateway repo.)

15. Skip identity verification in sandbox (skipPersona)

During integration testing you usually want to drive the intake wizard end-to-end without completing a real Persona identity inquiry (which needs a live government ID). Pass skipPersona: true on createIntakeInvitation and the wizard skips the Persona step for that invitation — the patient is treated as identity-approved and proceeds straight to the rest of onboarding.

mutation {
  createIntakeInvitation(
    input: {
      email: "tester@example.com"
      firstName: "Test" lastName: "Patient"
      clinicId: "<your-clinic-id>"
      skipPersona: true
    }
    idempotencyKey: "..."
  ) {
    id
    url
    skipPersona   # echoed back as `true` when the skip was applied
  }
}

Sandbox only — hard requirement

skipPersona is accepted only on pk_test_ (sandbox) API keys. A pk_live_ (production) key that sends skipPersona: true is rejected with VALIDATION_FAILED — identity verification is mandatory in production and cannot be turned off via the API. This is enforced at the gateway and again at the token service; there is no production opt-out.

Every applied skip is audited server-side (partner, key, invitation), so sandbox identity bypasses are traceable.


16. Headless intake submission (submitIntakeBundle)

If you collect a patient's intake data yourself, you can submit it directly as a FHIR R4 transaction Bundle instead of using the hosted wizard (createIntakeInvitation). The gateway validates and constrains the Bundle, commits it to the clinical record, and returns the materialised patient id — which you then read back via patientDossier(patientId) (§13) or feed to requestSynthesis.

Requires the intake:write scope. Availability: enabled in sandbox (staging) — integrate and verify there first; production enablement is provisioned separately.

mutation Submit($bundle: String!, $key: String!) {
  submitIntakeBundle(
    input: { clinicId: "<your-clinic-id>", bundle: $bundle }
    idempotencyKey: $key
  ) {
    patientId
    resourceCounts { resourceType count }
  }
}

bundle is your FHIR R4 transaction Bundle serialized as a JSON string (the gateway validates its structure, so it is passed as an opaque string rather than typed GraphQL input).

Bundle contract

Result

patientId is the Medplum Patient id (stable across re-submissions of the same patient). resourceCounts lists how many of each type were accepted.

Errors

{
  "resourceType": "Bundle",
  "type": "transaction",
  "entry": [
    {
      "fullUrl": "urn:uuid:pat-1",
      "resource": {
        "resourceType": "Patient",
        "identifier": [{ "system": "https://partners.profilehealth.com/<your-partnerId>/patient-id", "value": "YOUR-PATIENT-1" }],
        "name": [{ "family": "Doe", "given": ["Jane"] }]
      },
      "request": { "method": "PUT", "url": "Patient?identifier=https://partners.profilehealth.com/<your-partnerId>/patient-id|YOUR-PATIENT-1" }
    },
    {
      "fullUrl": "urn:uuid:cond-1",
      "resource": {
        "resourceType": "Condition",
        "identifier": [{ "system": "https://partners.profilehealth.com/<your-partnerId>/patient-id", "value": "YOUR-PATIENT-1-cond-1" }],
        "subject": { "reference": "urn:uuid:pat-1" },
        "code": { "coding": [{ "system": "http://snomed.info/sct", "code": "73211009", "display": "Diabetes mellitus" }] }
      },
      "request": { "method": "PUT", "url": "Condition?identifier=https://partners.profilehealth.com/<your-partnerId>/patient-id|YOUR-PATIENT-1-cond-1" }
    },
    {
      "fullUrl": "urn:uuid:cons-1",
      "resource": {
        "resourceType": "Consent",
        "identifier": [{ "system": "https://partners.profilehealth.com/<your-partnerId>/patient-id", "value": "YOUR-PATIENT-1-consent" }],
        "status": "active",
        "scope": { "coding": [{ "system": "http://terminology.hl7.org/CodeSystem/consentscope", "code": "patient-privacy" }] },
        "category": [{ "coding": [{ "system": "http://terminology.hl7.org/CodeSystem/consentcategorycodes", "code": "acd" }] }],
        "patient": { "reference": "urn:uuid:pat-1" },
        "policyRule": { "coding": [{ "system": "http://terminology.hl7.org/CodeSystem/v3-ActCode", "code": "INFA" }] }
      },
      "request": { "method": "PUT", "url": "Consent?identifier=https://partners.profilehealth.com/<your-partnerId>/patient-id|YOUR-PATIENT-1-consent" }
    }
  ]
}

After a successful submit, fetch the record with patientDossier(patientId: "<returned id>") (§13).

17. AI Synthesis (end-to-end)

The synthesis engine reads a patient's clinical record and produces an AI-generated narrative summary (terrain overview, key context, curated labs, medications, notes). It is two calls: trigger, then poll.

Trigger — requestSynthesis

Requires the synthesis:run scope. Returns immediately with a PENDING job handle — synthesis runs asynchronously (tens of seconds for a typical patient); it does not block until done.

mutation Run($id: ID!, $key: String!) {
  requestSynthesis(input: { patientId: $id }, idempotencyKey: $key) {
    jobId
    status        # PENDING immediately
  }
}

Optional input.scope: [String!] limits which domains are synthesised (omit for all).

Daily synthesis budget. requestSynthesis is metered against a dedicated per-partner daily budget (synthesisPerDay, separate from and lower than your global Requests/day — see §9). When exhausted it returns 429 RATE_LIMITED with Retry-After; your other operations are unaffected.

Read / poll — synthesis

Requires synthesis:read. Returns the most recent snapshot, or null when there is nothing to read yet.

Poll synthesisStatus, not synthesis. synthesis answers null for four different situations — never requested, still running, failed, and artifact-present-but-unreadable — so a null on its own cannot tell you whether to wait or to escalate. synthesisStatus(patientId) always answers and names which one it is:

state Meaning What to do
NOT_AVAILABLE No artifact exists. Either nobody called requestSynthesis, or a run produced nothing — most often because the patient has no clinical records yet Send records (§13, §16, §19), then requestSynthesis
IN_PROGRESS The engine has not finished Keep polling; a run takes minutes
READY Finished and readable Call synthesis — summary is populated
FAILED Terminal failure Re-issue requestSynthesis; if it recurs, raise it with us

synthesisStatus reads metadata only and never transfers the summary, so it is cheap to poll. generatedAt on a READY state lets you tell a fresh result from one you have already seen. If synthesis still returns null while synthesisStatus says READY, the artifact is corrupt rather than pending — that is a support question, not something to retry.

query Read($id: ID!) {
  synthesis(patientId: $id, detailLevel: LARGE) {
    status        # PENDING | RUNNING | COMPLETE | FAILED
    summary       # Markdown narrative (only non-empty when COMPLETE)
    generatedAt
  }
}

detailLevel: SynthesisDetailLevel — choose how much summary includes. All levels are produced every run, so this is a free read-time choice (no extra cost, pick per call):

Level Content
SMALL Overview + context + labs + medications + notes (concise prose)
MEDIUM Same sections, richer overview
LARGE (default) Fullest overview/context + labs + medications + notes

summary is Markdown: ### Section headings with bullet items — e.g. an overview, then ### Labs Curated (Ferritin: 950 ng/mL — <interpretation>), ### Medications (Levothyroxine (active)), ### Notes.

version exists on the type for forward compatibility but is currently an empty string; the latest snapshot is always returned regardless of the version argument.

Errors

A long-running synthesis that exceeds the gateway's worker timeout still returns a PENDING job (the engine keeps working server-side) — keep polling synthesis rather than treating the trigger as failed.


18. Health Information Exchange (Carequality / KNO2)

Pull a member's existing records from the national exchange networks in two steps: discovery (hieQuery — find candidate matches by demographics) then retrieval (hieDocuments — fetch a candidate's documents onto a patient you own).

Ordering — which comes first?

Operation Needs a patient first? Why
hieQuery (discovery) No Demographics-only network search; no patientId. Can run standalone, even before intake (e.g. screening).
hieDocuments (retrieval) Yes Retrieved documents are persisted as FHIR DocumentReferences onto a Medplum patient, so it needs a patientId in your clinic scope.

So the only hard dependency is on hieDocuments: it needs both a candidate (from hieQuery) and a patientId (from intake). The two common flows:

End-to-end runbook

1. submitIntakeBundle(...)            → patientId            (scope: intake:write)
     (or createIntakeInvitation → poll patientDossier → patient.id)
2. hieQuery({ firstName, lastName, birthDate, ... })         (scope: hie:query)
     → pick a candidate: { id, name, birthDate, organizationId, homeCommunityId }
3. hieDocuments({ patientId, hiePatientId: candidate.id,
                  hiePatientName: candidate.name, hieBirthDate: candidate.birthDate,
                  organizationId, homeCommunityId })         (scope: hie:query + document:write)
     → documents persisted; each carries its documentReferenceId
4. patientDossier(patientId) / documents(patientId) / requestSynthesis
     → read the newly-attached records

Step 2 — discover (hieQuery)

query Discover($input: HieQueryInput!) {
  hieQuery(input: $input) {
    patients { id name birthDate organization documentCount }
    queryId networkQueried queriedAt
  }
}

HiePatient.id is the HIE-side id you pass to hieDocuments as hiePatientId. Pass the candidate's name and birthDate too (as hiePatientName / hieBirthDate) — hieDocuments uses them as a wrong-patient guard (retrieval is rejected unless they match the target patient). birthDate is network-confirmed or null (never echoed from your query); documentCount is best-effort (the real count comes from retrieval).

Match quality — send the full address, not just the ZIP. HieQueryInput accepts addressLine, city, state in addition to zipCode. Most Carequality responders will not return a high-confidence match on ZIP alone — supply the complete street address whenever you have it. firstName, lastName, birthDate are the required keys; gender and the full address sharply raise the match rate. A query with only name + DOB + ZIP frequently returns zero candidates even when the responder holds the record.

Sandbox test patient (dev / staging)

On our dev and staging deployments the HIE targets the KNO2 sandbox network. Which network is queried is a property of the deployment you are pointed at, not of your key — a test key against production queries the live network. Use this synthetic patient — the sandbox responder returns a 100 %-confidence match only when the complete address is sent: addressLine, city, state, AND zipCode together. Dropping any one of them — including zipCode — yields patients: []:

Field Value
firstName / lastName Myra / Jones
birthDate 1947-05-01
gender female
addressLine 1357 Amber Dr
city / state / zipCode Beaverton / OR / 97006
networkId ceq:urn:oid:2.16.840.1.113883.3.3126.2.4.40429.5

Pin networkId in the sandbox. The value above targets the KNO2 sandbox responder that holds Myra. Without it, discovery falls back to a directory search by postal code, which in the sandbox returns unrelated test organizations that fail with XDSRegistryError — so you get patients: [] even with a perfect demographic match. Always send networkId for sandbox rehearsals. (In production you normally omit it and let discovery search the live network.)

Omitting any address component — addressLine, city, state, or zipCode — returns patients: []. This is deterministic responder-matching strictness, not a fault or a rate limit: the empty result carries no error signal, so an incomplete address is indistinguishable from a genuine no-match. Send all four address fields on every sandbox query.

Step 3 — retrieve (hieDocuments)

hieDocuments is a mutation (it persists documents) — send it as a mutation, not a query:

mutation Retrieve($input: HieDocumentsInput!) {
  hieDocuments(input: $input) {
    documentsFound documentsRetrieved documentsSkipped
    incomplete notAttempted matchesNotQueried
    documents { documentId documentReferenceId title typeName mimeType creationTime }
  }
}

Retrieval is idempotent (deduplicated by source document id — re-runs are safe), so no idempotencyKey argument is needed; for transport-level replay you may still send an Idempotency-Key header.

Errors

Large charts — incomplete means "ask again", not "failed"

A retrieval has a wall-clock budget. Charts of 200+ documents are common on the national networks, and a single call may run out before reaching every one. When that happens you get a normal 200 with incomplete: true — not an error:

Field Meaning
incomplete The budget ran out before every document was reached.
notAttempted Documents that were enumerated but not fetched.
matchesNotQueried Source organizations never asked at all — the budget ran out during the query phase.

Everything already returned is persisted, and re-issuing the same hieDocuments call continues rather than restarts — documents already stored are skipped, so the follow-up call is cheap and safe. Repeat until incomplete is false.

matchesNotQueried changes how you read documentsFound. Those organizations were never queried, so their documents appear in no count — documentsFound included. When matchesNotQueried is non-zero, documentsFound: 0 means "we did not finish asking", not "the patient has no records". Do not present that to a clinician as an empty chart.

Both counters are 0 and incomplete is false on a retrieval that finished, so you never have to distinguish "complete" from "not reported".

Availability: HIE is gated per environment and requires a signed BAA plus a treatment-based purpose of use. If hieQuery returns UPSTREAM_UNAVAILABLE, HIE is not yet enabled for your key — contact your Profile Health representative.

19. Document Upload & Auto-OCR (end-to-end)

For partners who hold documents (PDF lab reports, scans) rather than structured FHIR. You upload the file directly to storage via a short-lived signed URL; the platform then finalizes it and auto-OCRs it — PDF lab reports are parsed into FHIR Observations linked to the created DocumentReference, which you can then read back via labResults / documents. No completion callback is required — finalization and OCR run automatically once the blob lands.

Scope required: document:write.

Step 1 — request a signed upload URL (uploadDocument)

mutation Upload($input: DocumentUploadInput!, $key: String!) {
  uploadDocument(input: $input, idempotencyKey: $key) {
    uploadUrl documentId expiresAt
  }
}

input (DocumentUploadInput):

idempotencyKey is required — reusing it returns the same pending document instead of creating a duplicate.

Returns uploadUrl (a signed PUT URL, valid ≤ 15 minutes), documentId (the DocumentReference created in a pending state), and expiresAt.

Step 2 — PUT the blob

Upload the raw bytes directly to uploadUrl before expiresAt, with the same Content-Type you declared as mimeType:

curl -X PUT --upload-file report.pdf \
  -H "Content-Type: application/pdf" \
  "<uploadUrl>"

The gateway never sees the bytes — the PUT goes straight to storage. Max blob size is 50 MiB.

Step 3 — automatic finalize + OCR (no action needed)

Once the blob lands, the platform automatically flips the DocumentReference from pending to final and runs OCR:

Step 4 — poll processingStatus (how to know when it's done)

Document carries two fields that tell you where a file is in the pipeline, so you never have to infer progress from an empty labResults:

query DocStatus($patientId: ID!) {
  documents(patientId: $patientId) {
    edges { node { id title processingStatus extractedValueCount } }
  }
}
processingStatus Meaning What to do
PENDING Stored but not finalized: the blob may not have landed yet, extraction may be in flight, or it may have failed Keep polling. Still PENDING after a few minutes → tell us, don't re-upload
PROCESSED Extraction completed and produced values Read them via labResults; extractedValueCount says how many
NO_DATA_EXTRACTED Extraction completed and produced nothing — not a lab report, an unreadable scan, or an image with no values Terminal. Stop polling; this file will never yield values

extractedValueCount is what separates "processed, nothing to extract" from "processed, 12 values". It counts Observations linked to the document by the OCR pipeline; documents converted from C-CDA link through a different field and report 0.

There is deliberately no FAILED state. A document whose processing died is indistinguishable from one still in flight, because failures are not recorded on the document today. That is why a long-lived PENDING is a support question rather than something to retry.

Errors

Availability: Document upload is gated per environment. If uploadDocument returns UPSTREAM_UNAVAILABLE, it is not yet enabled for your key — contact your Profile Health representative.

Step 5 — read the values that came out of the file

Once processingStatus is PROCESSED, narrow labResults to this one document:

query FromOneFile($patientId: ID!, $documentId: ID!) {
  labResults(patientId: $patientId, documentId: $documentId) {
    edges { node { name value unit referenceRange effectiveDate documentId } }
  }
}

It is the field you already use, narrowed to one file. It stays scoped to the patient and to laboratory results, so what you get back is a subset of the patient-wide page — never something you could not otherwise read. Each row now also carries documentId, so a row read from the patient-wide page still says which file produced it.

This filter applies to documents we extracted, not to every document id. It selects on Observation.derivedFrom, which the OCR pipeline sets on the values it extracts from an uploaded file. Documents converted from C-CDA — the ones hieDocuments retrieves — link their Observations through a different field, so passing one of those ids returns an empty page rather than an error. That is the same limitation Document.extractedValueCount already documents, seen from the other side. For an HIE-retrieved chart, read labResults(patientId:) without the filter.

Scope: patient:read, unchanged.