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)andSYNC_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.
Hospital Search​
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
| Param | Type | Description |
|---|---|---|
name | string | Partial name match (CONTAINS) |
state | string | Two-letter state code (exact match) |
city | string | City name (exact match) |
postal | string | Postal code (exact match) |
_count | number | Page size, max 100, default 50 |
_offset | number | Pagination 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:
| Status | Condition |
|---|---|
| 400 | Missing, non-HTTPS, or invalid url param |
| 502 | Remote 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:
| Status | Condition |
|---|---|
| 401 | Missing or invalid token |
| 404 | Binary not found or not accessible |
| 500 | Storage 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:
| Status | Condition |
|---|---|
| 400 | Missing or invalid url param |
| 403 | URL origin not in allowed list |
| 5xx | Upstream 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."
}
| Field | Required | Constraints |
|---|---|---|
name | Yes | 1–200 chars |
email | Yes | Valid email, max 200 chars |
phone | No | Max 50 chars |
category | Yes | technical-issue | health-details | general-inquiry | feedback |
description | Yes | 1–5000 chars |
All string fields are HTML-stripped before storage.
Response 201 Created
{ "id": "<ticket-uuid>", "resourceType": "SupportTicket" }
Side effects:
- Creates a
SupportTicketresource withstatus: "unresolved"andpriority: "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."
}
| Field | Values |
|---|---|
status | unresolved | pending | resolved |
priority | low | medium | high |
internalNotes | Max 5000 chars |
Response: Updated SupportTicket resource.
Side effects when status changes:
- Creates a
PatientNotificationresource 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
SupportTicketMessageresource linked to the ticket. - If ticket status was
"unresolved", automatically advances it to"pending". - Creates a
PatientNotificationfor the patient. - Sends a reply email to the patient with the message body.