Skip to main content

Custom API Routes

ChartChat extends the base Medplum server with a set of custom endpoints for EHR sync, document proxying, hospital search, support tickets, and MFA management. This page documents all custom routes not covered by the standard FHIR or Medplum API references.


EHR Sync Routes​

All registered under /fhir/ via fhir/customRoutes.ts. Require a valid patient Bearer token unless noted otherwise.

POST /fhir/referesh/all-ehrs​

Queues a background sync of all active EHR connections for the authenticated patient.

Auth: Patient Bearer token Rate limit: 1 request per 10 minutes per patient

Response

{ "queued": true, "status": "syncing" }

If a sync is already running:

{ "queued": false, "status": "syncing" }

Notes:

  • Uses Redis to track SYNC_STATUS_KEY(patientId) and SYNC_RESULT_KEY(patientId).
  • Fire-and-forget — does not wait for sync completion.

POST /fhir/referesh/single/:connectionId​

Queues a background sync for a single EHR connection by its PatientEHRConnection resource ID.

Auth: Patient Bearer token Rate limit: 3 requests per 5 minutes per patient

Path param: connectionId — the PatientEHRConnection resource ID

Response

{ "queued": true }

GET /fhir/referesh/status​

Returns the current sync state for the authenticated patient.

Auth: Patient Bearer token

Response

{
"status": "idle",
"result": null
}

status is one of "idle" or "syncing". result holds the last sync result object from Redis when available, otherwise null.


GET /fhir/hospitals/search​

Searches for hospitals (FHIR Organization + Endpoint resources) that support SMART on FHIR. Runs as system repo, bypassing patient access policy restrictions on Organization/Endpoint.

Auth: Patient Bearer token

Query params

ParamTypeDescription
namestringPartial name match (CONTAINS)
statestringTwo-letter state code (exact match)
citystringCity name (exact match)
postalstringPostal code (exact match)
_countnumberPage size, max 100, default 50
_offsetnumberPagination offset, default 0

Response

{
"endpoints": [
{
"id": "...",
"resourceType": "Endpoint",
"status": "active",
"connectionType": { "code": "hl7-fhir-rest" },
"address": "https://...",
"_org": {
"name": "General Hospital",
"city": "Houston",
"state": "TX",
"postalCode": "77001"
}
}
],
"hasMore": true,
"nextUrl": "fhir/hospitals/search?_count=50&_offset=50"
}

Each endpoint entry is the raw FHIR Endpoint resource with an added _org field containing the parent organization's address info.

Caching:

  • When any address filter (state, city, postal) is provided, the result is cached in Redis for 1 hour.
  • Cache key format: hospitals:<urlencoded-params> (name + state + city + postal).
  • Paginated (name-only or no-filter) queries are not cached.

Address-filter behavior:

  • Iterates all organization pages (page size 1000) and returns all matching active endpoints in a single response (hasMore: false).
  • Deduplicates endpoints by ID before returning.

Access Policy​

GET /fhir/get-access-policy​

Returns the access policy ID assigned to the authenticated user's current project membership. Used by the frontend to determine what resources and operations the user is permitted to access.

Auth: Bearer token (any authenticated user)

Response

{ "accessPolicyId": "abc-123" }

Returns null for accessPolicyId if no access policy is assigned to the membership.


EHR SMART Discovery​

GET /fhir/ehr-import/smart-discovery​

Probes a FHIR base URL for its SMART on FHIR configuration. Used by the Admin Portal "Test SMART Discovery" button when configuring a Hospital Integration.

Auth: None

Query param: url — FHIR base URL to probe (must be HTTPS)

Response

{
"authorization_endpoint": "https://fhir.example.org/oauth2/authorize",
"token_endpoint": "https://fhir.example.org/oauth2/token"
}

Errors:

StatusCondition
400Missing, non-HTTPS, or invalid url param
502Remote SMART discovery endpoint unreachable or returned an error

Document Proxy Routes​

These routes allow the member portal to display binary documents without exposing S3 credentials or pre-signed URLs to the browser.

GET /fhir/binary-proxy/:binaryId​

Streams a FHIR Binary resource from storage to the browser. The patient's own JWT is used to enforce Medplum access policies — a patient can only reach their own records.

Auth: Patient Bearer token Path param: binaryId — the FHIR Binary resource ID

Response: Streams the binary content with Content-Type from the resource and Content-Disposition: inline.

Errors:

StatusCondition
401Missing or invalid token
404Binary not found or not accessible
500Storage read failure

GET /fhir/url-proxy​

Fetches a pre-signed storage URL server-side and streams it back to the browser. Used when Medplum has already signed an attachment.url and the Binary ID is not directly accessible (e.g. S3 pre-signed URLs returned by Medplum).

Auth: Patient Bearer token

Query param: url — the pre-signed URL to proxy (must be URL-encoded)

Domain restriction: Only URLs originating from the configured Medplum baseUrl or storageBaseUrl are allowed. All other origins return 403.

Response: Streams the content with Content-Type from the upstream response and Content-Disposition: inline.

Errors:

StatusCondition
400Missing or invalid url param
403URL origin not in allowed list
5xxUpstream returned an error

Admin-only FHIR Routes​

These routes require admin or superAdmin membership.

DELETE /fhir/deletePatient/:id​

Deletes a patient and all associated FHIR resources.

Auth: Admin or superAdmin token Path param: id — the FHIR Patient resource ID

Response: FHIR OperationOutcome


POST /fhir/import/bulk-patient​

Imports patient data in bulk from NDJSON export URLs.

Auth: Admin Bearer token

Request body

{
"ndjsonUrls": { "Patient": "https://...", "Observation": "https://..." },
"patientIds": ["patient-id-1"],
"selectedResources": ["Patient", "Observation"]
}

Response: Import result summary.


POST /fhir/epic/exchange-token​

Exchanges an Epic authorization code for an access token. Used during Epic OAuth flows.

Auth: None (uses configured Epic credentials server-side)

Request body

{ "code": "<authorization_code>" }

Response

{
"access_token": "...",
"refresh_token": "...",
"expires_in": 3600,
"scope": "...",
"patient": "epic-patient-id-or-null"
}

MFA Routes​

Registered under /auth/mfa/ via auth/mfa.ts.

GET /auth/mfa/status​

Returns the current MFA enrollment state for the authenticated user.

Auth: Bearer token (any authenticated user)

Response — not enrolled

{
"enrolled": false,
"enrollUri": "otpauth://totp/...",
"enrollQrCode": "data:image/png;base64,..."
}

A new TOTP secret is generated and stored if one does not already exist.

Response — already enrolled

{ "enrolled": true }

POST /auth/mfa/enroll​

Confirms TOTP enrollment by verifying a code from the authenticator app. Sets mfaEnrolled: true on the user.

Auth: Bearer token

Request body

{ "token": "123456" }

Response: FHIR OperationOutcome (allOk on success)


POST /auth/mfa/verify​

Verifies a TOTP code during the login flow (used when mfaEnrolled: true).

Auth: None (pre-login step)

Request body

{
"login": "<login-resource-id>",
"token": "123456"
}

Response: Login result (same shape as /auth/login)


POST /auth/mfa/disable​

Disables MFA for the authenticated user. Requires a valid current TOTP code to confirm. Generates a new secret so the old authenticator entry cannot be re-used.

Auth: Bearer token

Request body

{ "token": "123456" }

Response: FHIR OperationOutcome (allOk on success)


Support Routes​

Registered under /support/v1/ via support/routes.ts. All routes require a valid Bearer token.

POST /support/v1/create-ticket​

Creates a new support ticket and sends a confirmation email to the submitter.

Auth: Patient Bearer token Rate limit: 10 requests per 15 minutes per IP

Request body

{
"name": "Jane Smith",
"email": "jane@example.com",
"phone": "+1-555-0100",
"category": "technical-issue",
"description": "I cannot see my lab results."
}
FieldRequiredConstraints
nameYes1–200 chars
emailYesValid email, max 200 chars
phoneNoMax 50 chars
categoryYestechnical-issue | health-details | general-inquiry | feedback
descriptionYes1–5000 chars

All string fields are HTML-stripped before storage.

Response 201 Created

{ "id": "<ticket-uuid>", "resourceType": "SupportTicket" }

Side effects:

  • Creates a SupportTicket resource with status: "unresolved" and priority: "medium".
  • Sends a confirmation email to the submitter with the ticket ID.

PATCH /support/v1/admin/tickets/:id​

Updates a ticket's status, priority, or internal notes.

Auth: Admin Bearer token Path param: id — the SupportTicket resource ID

Request body (all fields optional)

{
"status": "pending",
"priority": "high",
"internalNotes": "Escalated to engineering."
}
FieldValues
statusunresolved | pending | resolved
prioritylow | medium | high
internalNotesMax 5000 chars

Response: Updated SupportTicket resource.

Side effects when status changes:

  • Creates a PatientNotification resource for the patient (in-app notification).
  • Sends a status-change email to the patient.

POST /support/v1/admin/tickets/:id/reply​

Sends a reply to the patient for a given ticket.

Auth: Admin Bearer token Path param: id — the SupportTicket resource ID

Request body

{ "body": "We have investigated the issue and..." }

body is required, max 5000 chars, HTML-stripped.

Response: FHIR OperationOutcome (allOk)

Side effects:

  • Creates a SupportTicketMessage resource linked to the ticket.
  • If ticket status was "unresolved", automatically advances it to "pending".
  • Creates a PatientNotification for the patient.
  • Sends a reply email to the patient with the message body.