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:
- Partner ID — a stable identifier for your organization (e.g.
par_abc123) - API key — a string of the form
pk_live_<32-chars>.<43-chars>(live environment) orpk_test_<32-chars>.<43-chars>(sandbox)
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
- Token format matches
pk_(live|test)_<32chars>.<43+chars>. - Key exists in Firestore and its
statusisactive. - Key has not expired (
expiresAt > now). - The
argon2idhash of the presented secret matches the stored hash. - If
ipAllowlistis set on the key, the request IP is in the list. - 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
GraphQL (recommended)
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) requireadmin:partners:read,admin:partners:write, oradmin: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:
- Your key has the
patient:readscope. - 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:
- GraphQL: pass
idempotencyKeyas a variable, e.g.createIntakeInvitation(input: $input, idempotencyKey: $idk) - REST:
Idempotency-Key: <value>header (max 128 characters)
Rules:
- The key must be unique per logical operation across all retries of that operation.
- Replaying the same key with the same payload returns the original response without a duplicate write.
- Replaying the same key with a different payload returns
422 IDEMPOTENCY_KEY_REUSED. - Use a UUID or
<operation-type>-<business-id>-<timestamp>pattern.
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
}
}
}
firstdefaults to 25, maximum is 100.- Pass
endCursorfrom one response asafterin the next request. pageInfo.hasNextPagetells you whether more results exist.
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
- All traffic requires TLS 1.2 or later. HTTP connections receive a redirect to HTTPS.
- Do not log the full API key string (including the secret suffix). Log the prefix only (
pk_live_<32-chars>). - Key rotation is operated by us, on your request; we recommend at least every 365 days (ADR-0008).
rotateApiKeyandrevokeApiKeyrequireadmin:partners:write, which is never issued to a partner key (see §4.1) — so ask us rather than calling them. On rotation the old key stays valid for 24 hours to allow a safe transition, and we can set a hard expiry on the new one. - If a key is compromised, mail
engineering@profilehealth.comwith the key prefix (pk_live_<32-chars>— never the secret suffix) and say it is a compromise. We revoke it, and the key stops authenticating within seconds on its next request. You cannot revoke it yourself; there is no self-service path, so do not lose time looking for one.
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:
- Per-invitation
returnUrl(Layer 2) — when present on the invitation. - Partner-wide
Partner.intakeConfig.returnUrl(Layer 1) — when no per-invitation value. - Static "Done" screen — when neither is set.
Validation rules (both layers)
- Must be a valid URL parseable by
new URL(). - Scheme MUST be
https://(anti-credential-leak; the wizard refuseshttp://,file:///, etc.). - Maximum length: 2048 characters.
- Malformed values are rejected at request time with
VALIDATION_FAILED— silently dropping the value would surprise you more than the error. - The same rules run on read (defence in depth), so a partial / malformed value cannot reach the wizard even if it landed in storage through some other path.
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.
skipPersona: trueon a test key → wizard bypasses Persona; response echoesskipPersona: true.skipPersona: falseor omitted → Persona runs normally (the default).skipPersona: trueon a live key →VALIDATION_FAILED, no invitation created.
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
- One patient per Bundle. Exactly one
Patiententry; submit patients one at a time. - Type:
transaction. Every entry needs a uniqueurn:uuid:fullUrl; clinical resources reference the patient by thaturn:uuid:(never a server id). You do not need to compute the per-entryrequest(conditional-update) blocks — the gateway derives them from your partner identifier, so you submit just the resources (the example below shows the resulting upsert form for reference). If you do includerequestblocks they are ignored, except thatDELETE/PATCHand conditional headers (ifNoneExist/ifMatch) are rejected. - Medical data only — allowed resource types:
Patient,Condition,MedicationStatement,AllergyIntolerance,Procedure,Observation,FamilyMemberHistory,QuestionnaireResponse,Consent. Any other type (includingAccount/ financial) is rejected. - A
Consentresource is required (your consent attestation). A FHIR-valid Consent needsstatus,scope,category,patient, andpolicy/policyRule. - Coding required on
Observation,Condition,Procedure,AllergyIntolerance(acodingwith acode— LOINC/SNOMED/RxNorm/ICD-10) and onMedicationStatement(medicationCodeableConceptormedicationReference). - Partner identifier on every resource. Each entry must carry an
identifierwhosesystemis the namespace the gateway assigns you —https://partners.profilehealth.com/<your-partnerId>/patient-id— and whosevalueis your own stable id for that record. This(system, value)pair is the dedup key: re-submitting the same patient upserts (updates in place, never duplicates). The system is derived server-side from your API key; you only choose thevalue. - Identifier
valuemust be unique per resource within the Bundle. The value is the per-resource upsert key — treat it like a primary key (e.g.YOUR-PATIENT-1,YOUR-PATIENT-1-obs-1,YOUR-PATIENT-1-obs-2). If several resources of one type share a value they all target the same record: in a single transaction the first commits and the rest are rejected400 invalid, and re-submissions keep overwriting that one record — the data silently collapses to a single resource. Reuse a value across submits only when you intend to update that same resource in place. clinicIdmust be one of your allowed clinics (§6); the gateway forces it onto the Patient'smanagingOrganization(the value inside your Bundle is ignored).- Identity: a patient created this way is never marked identity-verified (no Persona is run); they are tagged
partner-submitted.
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
VALIDATION_FAILED(422) — the Bundle is off-contract (missingConsent, missing coding, a non-allowed type, a missing partner identifier, a cross-patient reference, or non-JSONbundle). If the upstream FHIR server rejected an individual resource (e.g. a malformedConsent),detailnames the failing entry and its diagnostics — the whole submission fails (no partial writes are reported as success).PAYLOAD_TOO_LARGE(413) — the Bundle exceeds the request size limit; submit fewer/smaller resources.FORBIDDEN(403) —clinicIdis not in your allowed clinics, or your key lacksintake:write.UPSTREAM_UNAVAILABLE(503) — ingestion is not configured for this environment, or the upstream is down; retry with backoff.
Example Bundle (Patient + Condition + Consent)
{
"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.
requestSynthesisis metered against a dedicated per-partner daily budget (synthesisPerDay, separate from and lower than your globalRequests/day— see §9). When exhausted it returns429 RATE_LIMITEDwithRetry-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, notsynthesis.synthesisanswersnullfor four different situations — never requested, still running, failed, and artifact-present-but-unreadable — so anullon its own cannot tell you whether to wait or to escalate.synthesisStatus(patientId)always answers and names which one it is:
stateMeaning What to do NOT_AVAILABLENo artifact exists. Either nobody called requestSynthesis, or a run produced nothing — most often because the patient has no clinical records yetSend records (§13, §16, §19), then requestSynthesisIN_PROGRESSThe engine has not finished Keep polling; a run takes minutes READYFinished and readable Call synthesis—summaryis populatedFAILEDTerminal failure Re-issue requestSynthesis; if it recurs, raise it with us
synthesisStatusreads metadata only and never transfers the summary, so it is cheap to poll.generatedAton aREADYstate lets you tell a fresh result from one you have already seen. Ifsynthesisstill returnsnullwhilesynthesisStatussaysREADY, 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.
versionexists on the type for forward compatibility but is currently an empty string; the latest snapshot is always returned regardless of theversionargument.
Errors
FORBIDDEN(403) — your key lackssynthesis:run(trigger) orsynthesis:read(poll), or the patient is outside your allowed clinics.RATE_LIMITED(429) — your daily synthesis budget (synthesisPerDay, §9) is exhausted; honorRetry-After.VALIDATION_FAILED(422) — the synthesis worker rejected the request (e.g. the patient has no evidence to synthesise yet).UPSTREAM_UNAVAILABLE(503) — the synthesis engine is unreachable; retry with backoff.
A long-running synthesis that exceeds the gateway's worker timeout still returns a
PENDINGjob (the engine keeps working server-side) — keep pollingsynthesisrather 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:
- Intake → discover → retrieve — establish the member's patient, then find and pull their outside history onto it.
- Discover → intake → retrieve — screen the network first, then create the patient, then pull.
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.
HieQueryInputacceptsaddressLine,city,statein addition tozipCode. Most Carequality responders will not return a high-confidence match on ZIP alone — supply the complete street address whenever you have it.firstName,lastName,birthDateare the required keys;genderand 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
networkIdin 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 withXDSRegistryError— so you getpatients: []even with a perfect demographic match. Always sendnetworkIdfor 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.
patientIdmust be inside your allowed clinics (a patient you created). An out-of-scope id returnsNOT_FOUND— the HIE is never queried.hiePatientName+hieBirthDateare required (the wrong-patient guard). A candidate whosebirthDatecame backnullfromhieQuerycan't be verified →VALIDATION_FAILED; such a candidate is not retrievable.- Already-present documents are deduplicated (
documentsSkipped); a re-run is safe. - Filter with optional
documentTypes(LOINC codes) anddateRange. - Retrieved documents are parsed into structured clinical data. C-CDA / XML documents you retrieve are automatically converted into FHIR resources (
Observation,Condition,MedicationStatement,AllergyIntolerance,Procedure, …) linked back to the source document, in addition to being stored asDocumentReferences. They therefore surface inpatientDossier(§13) and feedrequestSynthesis(§17) — a patient whose only data is HIE-retrieved documents still produces synthesis evidence. Conversion is idempotent, so a re-run never double-counts.
Errors
FORBIDDEN(403) — your key lackshie:query(both operations) ordocument:write(hieDocuments).NOT_FOUND(404) — the targetpatientIdis not in your clinic scope.VALIDATION_FAILED(422) — missing/blankhiePatientName/hieBirthDate(candidate can't be verified), or a malformeddateRange/document_types.RATE_LIMITED(429) — your dedicated daily HIE budget (hieQueriesPerDay) is exhausted.hieQueryandhieDocumentsshare this counter (separate from and lower than your globalRequests/day, because each HIE call is an external network fan-out); it resets at 00:00 UTC. HonorRetry-After.UPSTREAM_UNAVAILABLE(503) — HIE not enabled for this environment, or the network is unreachable / timed out; retry retrieval with backoff.
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.
matchesNotQueriedchanges how you readdocumentsFound. Those organizations were never queried, so their documents appear in no count —documentsFoundincluded. WhenmatchesNotQueriedis non-zero,documentsFound: 0means "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
hieQueryreturnsUPSTREAM_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):
patientId— the patient the document belongs to. Must be in your allowed clinics (a patient you created), or the call returnsNOT_FOUND.type—DocumentType(LAB_REPORT,CLINICAL_NOTE,IMAGING,CCDA,PRESCRIPTION,INTAKE_FORM,CONSENT,OTHER); drives the LOINC code stored on theDocumentReference, and is whatdocumentsreports back. Pick the value that describes the document — a specialty or functional-medicine lab report is aLAB_REPORT.OTHERis the catch-all for documents that fit none of the above; it stores no LOINC code.mimeType— IANA type of the blob (e.g.application/pdf,image/png,image/jpeg,image/tiff).sizeBytes— exact byte size (the URL is pre-signed with thisContent-Length; a mismatched PUT is rejected by storage).
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:
- PDF lab reports are parsed into FHIR
Observations, each linked to the samedocumentId(no duplicateDocumentReferenceis created). Read them withlabResults(structured labs) ordocuments(the source document). - Images (
image/*) are stored and OCR-text-extracted where possible, but may not yield structuredObservations. - Processing is asynchronous — poll
documentsand readprocessingStatus; results appear shortly after the upload completes, not synchronously with the PUT.
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
FAILEDstate. 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-livedPENDINGis a support question rather than something to retry.
Errors
FORBIDDEN(403) — your key lacksdocument:write.NOT_FOUND(404) —patientIdis not in your clinic scope.VALIDATION_FAILED(422) — unsupportedmimeType,sizeBytesover the 50 MiB cap, or a malformed input.UPSTREAM_UNAVAILABLE(503) — document upload is not enabled for this environment (see Availability).- The PUT itself returns storage errors directly:
403if the URL has expired or theContent-Type/length doesn't match what was signed — request a freshuploadUrland retry.
Availability: Document upload is gated per environment. If
uploadDocumentreturnsUPSTREAM_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.