OpenEMR is one of the most widely deployed open-source EHR systems in the world, but building an AI agent on top of it involves a handful of non-obvious gotchas that will burn you if you go in blind.
This is a practical guide based on real production experience. Not a tutorial — just the things I wish were documented.
Use FHIR for Clinical Reads, REST for Exceptions
OpenEMR exposes two APIs:
-
FHIR R4 at
/apis/default/fhir/ -
Standard REST at
/apis/default/api/
The short answer: use FHIR for everything you can. The Standard API mixes numeric pid with UUID puuid depending on the endpoint (medications and vitals use numeric IDs, everything else uses UUIDs). FHIR uses UUIDs consistently.
The two exceptions where you'll need the REST API:
-
Insurance/coverage — the FHIR Coverage mapper is incomplete (missing
plan_name,group_number,date_end). UseGET /api/patient/:puuid/insuranceinstead. -
Practitioner specialty — FHIR Practitioner doesn't map the
specialtyfield. UseGET /api/practitionerwhich reads directly from theuserstable.
OAuth2: Five Things That Will Break You
1. Clients are disabled by default
After OAuth client registration, your client is created with is_enabled=0. Auth fails silently — no clear error message. Fix with SQL after registration:
UPDATE oauth_clients
SET is_enabled=1, grant_types='authorization_code password refresh_token'
WHERE client_id='<your_client_id>';
2. Consider two separate tokens for FHIR and REST
The official docs show api:fhir and api:oemr being registered together, and it works in many setups. In practice we found that separating them — one token per API type — made scope debugging and token renewal much cleaner. If you're only using FHIR, don't worry about it. If you're hitting both APIs, separate tokens are worth considering.
3. user_role=users is required (and not in the OAuth spec)
The password grant requires a non-standard parameter:
grant_type=password
user_role=users ← required, not standard OAuth2
username=admin
password=...
Omit it and you get a generic auth error.
4. Use user/* scopes, not patient/*
patient/* scopes require a SMART on FHIR launch context. For a backend service agent, always use user/*. And FHIR resource names in scopes are case-sensitive uppercase:
✅ user/Patient.rs
✅ user/MedicationRequest.rs
❌ user/patient.rs (Standard API format, wrong for FHIR)
5. Registration silently drops some REST scopes
Registering with user/practitioner.read in the scope string? It gets silently dropped during registration. After registering, inject missing scopes via SQL:
UPDATE oauth_clients
SET scope = CONCAT(scope, ' user/practitioner.read')
WHERE client_id='<your_client_id>';
FHIR Bugs to Know About
Don't trust bundle.total
The total field in FHIR search responses is unreliable — _summary=count always returns 0. Always use len(bundle["entry"]) instead.
Practitioner?name= returns 500
Searching practitioners by full name crashes. Use family instead and strip title prefixes ("Dr.", "Doctor") before passing them:
GET /fhir/Practitioner?family=Chen ✅
GET /fhir/Practitioner?name=Dr.+Chen ❌ (500)
Appointment?status=free returns nothing
free is a valid FHIR Slot status, but not a valid Appointment status. Querying it returns an empty bundle. To find unbooked appointment slots, fetch all appointments in a date range and detect free slots client-side: a slot with no Patient participant is unbooked.
# A slot is free if it has no patient in participants
is_free = not any(
"Patient" in p.get("actor", {}).get("reference", "")
for p in appointment.get("participant", [])
)
_count breaks Appointment search
Passing _count=N to GET /fhir/Appointment returns empty results regardless of data. Leave it out and accept OpenEMR's default page size.
FHIR Appointment write may not work (version-dependent)
On OpenEMR 7.0.x, POST /fhir/Appointment returned 404 in our setup, and write scopes for Appointment were rejected at the token endpoint. The API README lists Appointment create as supported, so this may be fixed in newer versions or specific to certain configurations. If you hit this, inserting directly via SQL into openemr_postcalendar_events is the fallback.
The REST insurance provider field is a numeric FK
GET /api/patient/:puuid/insurance returns a provider field that looks like a company name but is actually a numeric foreign key to another table. Use plan_name for display instead.
One External API Note
If you're planning to use the RxNorm Drug Interaction API for medication safety checks — don't. The interaction check endpoints were discontinued on January 2, 2024. The RxCUI lookup (/rxcui.json) still works, but /interaction/list.json returns 404. Plan for a local curated dataset or DrugBank (paid) instead.
The Architecture That Works
Given all of the above, here's what a clean integration looks like:
FHIRClient
├── _token → FHIR token (api:fhir scopes, user/Resource.rs)
├── _rest_token → REST token (api:oemr scopes, user/resource.read)
├── get_token() → lazy acquire, re-auth before 1hr expiry (no refresh tokens)
└── Methods
├── All clinical reads → FHIR endpoints
├── get_insurance() → REST primary, FHIR fallback
└── get_providers() → FHIR search + REST specialty enrichment
No refresh tokens are issued with the password grant. Just re-authenticate before the 1-hour expiry.
Quick Reference: Things Not to Do
- Don't use Standard API for clinical reads
- Don't trust
bundle.total— checkentryarray length - Don't assume mixed
api:oemr+api:fhirscope behavior is consistent across versions - Don't use
patient/*scopes (needuser/*for backend services) - Don't use
Practitioner?name=(500 error) — usefamily= - Don't query
Appointment?status=free— detect free slots client-side - Don't pass
_countto Appointment search - Don't rely on the RxNorm Drug Interaction API (discontinued Jan 2024)
- Don't forget
user_role=usersin token requests - Don't assume registered scopes are actually saved — verify via SQL
Built by Faheem Syed while integrating a LangGraph-based clinical AI assistant with OpenEMR 7.x.
Top comments (0)