Nirmitee.io

openEHR REST API: The Practical Integration Guide Every Developer Needs

March 12, 202622 min readUpdated Oct 19, 2026
Written by
Dinesh Thorat
Dinesh Thorat

Digital Growth Lead

Writes about healthcare technology, interoperability, and AI-driven transformation across modern care systems.

openEHR REST API: The Practical Integration Guide Every Developer Needs

Every developer who starts working with an openEHR Clinical Data Repository goes through the same two weeks of pain. The specification is comprehensive. The documentation is scattered. The examples assume you already know things you do not know yet. And the gap between "I understand the concepts" and "I can POST a composition that the CDR actually accepts" is wider than anyone warns you about.

This guide bridges that gap. It covers every REST API operation you will use in a real implementation — with actual curl commands, real JSON payloads, real error messages, and the explanations of why things fail that the official docs leave out. Bookmark this. You will come back to it.

The Complete Lifecycle — Seven API Calls That Do Everything

An openEHR integration has exactly seven operations that matter. Everything else is a variation of these. Master these, and you can build any clinical data application.

Step 1 — Create an EHR

Before you can store any clinical data, you need an EHR (Electronic Health Record) container for the patient. Think of it as creating a patient folder — the folder itself has no clinical data, but every composition will go inside it.

curl -X POST 'http://localhost:8080/ehrbase/rest/openehr/v1/ehr' \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json' \
  -H 'Prefer: return=representation' \
  -d '{
    "ehr_status": {
        "subject": {
            "external_ref": {
                "id": {
                    "value": "patient-12345",
                    "scheme": "hospital-mpi"
                },
                "namespace": "hospital.com",
                "type": "PERSON"
            }
        },
        "is_modifiable": true,
        "is_queryable": true
    }
}'

Response (201 Created):

{
  "ehr_id": {
    "value": "7d44b88c-4199-4bad-97dc-d78268e01398"
  },
  "ehr_status": {
    ...
  },
  "system_id": {
    "value": "ehrbase.org"
  },
  "time_created": {
    "value": "2026-03-12T10:00:00.000Z"
  } 
}

What to save: The ehr_id.value — You will need this for every subsequent API call for this patient. Store it in your user/patient table alongside whatever patient identifier your system uses.

The gotcha: If you POST again with the same subject external_ref, most CDRs will return 409 Conflict because the EHR already exists. Use GET /ehr?subject_id=patient-12345&subject_namespace=hospital.com to look up existing EHRs first.

Alternative — create with known ID:

curl -X PUT 'http://localhost:8080/ehrbase/rest/openehr/v1/ehr/7d44b88c-4199-4bad-97dc-d78268e01398' \
  -H 'Content-Type: application/json' \
  -d '{"ehr_status": {...}}'

Using PUT with a specific ehr_id lets you control the identifier — useful when migrating from another system where you want to preserve IDs.

Step 2 — Upload a Template

Templates tell the CDR what kinds of clinical documents it should accept. You must upload at least one template before you can POST compositions. Think of it as registering a form definition before accepting form submissions.

curl -X POST 'http://localhost:8080/ehrbase/rest/openehr/v1/definition/template/adl1.4' \
  -H 'Content-Type: application/xml' \
  -H 'Accept: application/json' \
  -d @vital-signs-template.opt

Response (201 Created):

{
  "template_id": "Vital Signs",
  "concept": "Vital Signs",
  "archetype_id": "openEHR-EHR-COMPOSITION.encounter.v1"
}

Templates are OPT files (Operational Template, XML format). You create these using a template designer tool, not by hand. The CDR parses the template and uses it to validate every composition you submit.

The gotcha: Upload the template once. If you try to upload the same template_id again, some CDRs return 409 Conflict, others silently update. Check your CDR's behavior. Use GET /definition/template/adl1.4 to list all uploaded templates.

Worth noting — get the web template:

curl 'http://localhost:8080/ehrbase/rest/openehr/v1/definition/template/adl1.4/Vital%20Signs' \
  -H 'Accept: application/openehr.wt+json'

The web template response gives you the flat paths and tree structure you need to build composition JSON. This is the single most useful endpoint for front-end developers — it tells you exactly which fields exist, their types, their constraints, and their paths.

Step 3 — Build a Composition

This is where most teams get stuck. The CDR accepts compositions in two formats: structured (canonical) and flat (simplified). Choose wisely — it affects your entire integration architecture.

Structured Format (Canonical)

Mirrors the archetype tree structure exactly. Self-describing — you can interpret the JSON without knowing which template produced it. But it is verbose: a simple blood pressure reading becomes 50+ lines of JSON.

{
  "_type": "COMPOSITION",
  "archetype_node_id": "openEHR-EHR-COMPOSITION.encounter.v1",
  "name": {
    "value": "Vital Signs"
  },
  "language": {
    "terminology_id": {
      "value": "ISO_639-1"
    },
    "code_string": "en"
  },
  "territory": {
    "terminology_id": {
      "value": "ISO_3166-1"
    },
    "code_string": "US"
  },
  "category": {
    "value": "event",
    "defining_code": {
      "terminology_id": {
        "value": "openehr"
      },
      "code_string": "433"
    }
  },
  "composer": {
    "_type": "PARTY_IDENTIFIED",
    "name": "Dr. Smith"
  },
  "content": [
    {
      "_type": "OBSERVATION",
      "archetype_node_id": "openEHR-EHR-OBSERVATION.blood_pressure.v2",
      "name": {
        "value": "Blood pressure"
      },
      "language": {
        "terminology_id": {
          "value": "ISO_639-1"
        },
        "code_string": "en"
      },
      "encoding": {
        "terminology_id": {
          "value": "IANA_character-sets"
        },
        "code_string": "UTF-8"
      },
      "subject": {},
      "data": {
        "archetype_node_id": "at0001",
        "name": {
          "value": "History"
        },
        "origin": {
          "value": "2026-03-12T10:30:00Z"
        },
        "events": [
          {
            "archetype_node_id": "at0006",
            "name": {
              "value": "Any event"
            },
            "time": {
              "value": "2026-03-12T10:30:00Z"
            },
            "data": {
              "archetype_node_id": "at0003",
              "name": {
                "value": "blood pressure"
              },
              "items": [
                {
                  "archetype_node_id": "at0004",
                  "name": {
                    "value": "Systolic"
                  },
                  "value": {
                    "_type": "DV_QUANTITY",
                    "magnitude": 120.0,
                    "units": "mm[Hg]"
                  }
                },
                {
                  "archetype_node_id": "at0005",
                  "name": {
                    "value": "Diastolic"
                  },
                  "value": {
                    "_type": "DV_QUANTITY",
                    "magnitude": 80.0,
                    "units": "mm[Hg]"
                  }
                }
              ]
            }
          }
        ]
      }
    }
  ]
}

Flat Format (Simplified)

Uses dot-separated paths as keys. Compact, easy to generate from form data, and what most web/mobile apps actually use in practice.

{
  "ctx/language": "en",
  "ctx/territory": "US",
  "ctx/composer_name": "Dr. Smith",
  "vital_signs/blood_pressure/any_event:0/systolic|magnitude": 120.0,
  "vital_signs/blood_pressure/any_event:0/systolic|unit": "mm[Hg]",
  "vital_signs/blood_pressure/any_event:0/diastolic|magnitude": 80.0,
  "vital_signs/blood_pressure/any_event:0/diastolic|unit": "mm[Hg]",
  "vital_signs/blood_pressure/any_event:0/time": "2026-03-12T10:30:00Z"
}

Practical recommendation: Use flat format for write operations (forms → CDR) and structured format for read operations (CDR → analytics). Flat is dramatically easier to build from UI form data. Structured is better for data processing pipelines that need self-describing documents.

Step 4 — POST a Composition

This is the core write operation. You are storing a clinical document for a patient.

Structured format:

curl -X POST 'http://localhost:8080/ehrbase/rest/openehr/v1/ehr/7d44b88c-4199-4bad-97dc-d78268e01398/composition' \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json' \
  -H 'Prefer: return=representation' \
  -d @composition.json

Flat format (note the different content type):

curl -X POST 'http://localhost:8080/ehrbase/rest/ecis/v1/composition?ehrId=7d44b88c-...&templateId=Vital%20Signs&format=FLAT' \
  -H 'Content-Type: application/json' \
  -d @flat-composition.json

Note: The /ecis/v1/ endpoint shown above is EHRbase-specific, not part of the openEHR REST specification. Better Platform and Ocean have their own flat format APIs with different endpoint paths. The flat format concept is the same across CDRs; only the URL differs.

Notably, response

{
  "uid": {
    "value": "8849182c-82ad-4088-a07f-48ead4180515::ehrbase.org::1"
  },
  "archetype_node_id": "openEHR-EHR-COMPOSITION.encounter.v1",
  "name": {
    "value": "Vital Signs"
  },
  ... 
}

What to save: The uid.value — this is the version_uid. It has three parts separated by ::: the object ID, the creating system ID, and the version number. You need the full version_uid for updates.

The gotcha that wastes the most time: If the CDR returns 400 Bad Request, the response body contains validation errors. Read them carefully. Common causes:

  • Missing mandatory fields defined in the template
  • Wrong data type (sending a string where DV_QUANTITY is expected)
  • Wrong units (sending "mmHg" instead of "mm[Hg]" — the UCUM format matters)
  • Archetype node ID mismatch between your JSON and the template
  • Missing _type field on structured format objects

Step 5 — GET a Composition

Retrieving a specific composition by its version_uid:

curl 'http://localhost:8080/ehrbase/rest/openehr/v1/ehr/7d44b88c-.../composition/8849182c-82ad-4088-a07f-48ead4180515::ehrbase.org::1' \
  -H 'Accept: application/json'

This returns the full structured composition. To get a specific version, use the full version_uid with the version number. To get the latest version, use just the object ID (without ::system::version):

# Latest version curl '.../composition/8849182c-82ad-4088-a07f-48ead4180515'
# Specific version curl '.../composition/8849182c-82ad-4088-a07f-48ead4180515::ehrbase.org::2'

A useful pattern: Add a Accept: application/openehr.wt+json header to get flat format back — much easier to process in front-end applications.

Step 6 — Update a Composition (Versioning)

openEHR never overwrites data. When you "update" a composition, you create a new version. The previous version is permanently preserved. This is legally required in most healthcare jurisdictions and is one of openEHR's strongest features.

curl -X PUT 'http://localhost:8080/ehrbase/rest/openehr/v1/ehr/7d44b88c-.../composition/8849182c-82ad-4088-a07f-48ead4180515' \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json' \
  -H 'If-Match: 8849182c-82ad-4088-a07f-48ead4180515::ehrbase.org::1' \
  -H 'Prefer: return=representation' \
  -d @updated-composition.json

Critical: The If-Match header must contain the current version_uid. This is optimistic concurrency control — if someone else updated the composition between your GET and your PUT, you get 409 Conflict and must re-fetch, merge, and retry.

Response (200 OK):

{
  "uid": {
    "value": "8849182c-82ad-4088-a07f-48ead4180515::ehrbase.org::2"
  }
}

Notice the version number incremented from ::1 to ::2. The old version at ::1 is still retrievable. Nothing was deleted or overwritten.

The version_uid format explained:

  • 8849182c-82ad-4088-a07f-48ead4180515 — the permanent object ID (never changes across versions)
  • ehrbase.org — the system that created this version
  • 2 — the version number (increments with each update)

Deletion works the same way:

curl -X DELETE '.../composition/8849182c-...' \
  -H 'If-Match: 8849182c-...::ehrbase.org::2'

This does not physically remove the data. It creates a "deleted" version marker. The previous versions are still in the CDR and can be retrieved. In clinical data, nothing ever truly disappears — and your integration code should expect this.

Step 7 — Query with AQL

AQL queries let you search across all compositions for all patients. This is how you build dashboards, reports, clinical decision support, and analytics.

curl -X POST 'http://localhost:8080/ehrbase/rest/openehr/v1/query/aql' \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json' \
  -d '{
    "q": "SELECT e/ehr_id/value as patient_id, o/data[at0001]/events[at0006]/data[at0003]/items[at0004]/value/magnitude as systolic, o/data[at0001]/events[at0006]/data[at0003]/items[at0005]/value/magnitude as diastolic, o/data[at0001]/events[at0006]/time/value as measured_at FROM EHR e CONTAINS OBSERVATION o[openEHR-EHR-OBSERVATION.blood_pressure.v2] WHERE o/data[at0001]/events[at0006]/data[at0003]/items[at0004]/value/magnitude > 140 ORDER BY o/data[at0001]/events[at0006]/time/value DESC LIMIT 100"
}'

Response:

{
  "rows": [
    [
      "7d44b88c-...",
      165.0,
      95.0,
      "2026-03-12T10:30:00Z"
    ],
    [
      "a1b2c3d4-...",
      158.0,
      92.0,
      "2026-03-11T14:15:00Z"
    ],
    ...   
  ],
  "columns": [
    {
      "name": "patient_id",
      "path": "/ehr_id/value"
    },
    {
      "name": "systolic",
      "path": "/data[
        at0001
      ]/events[
        at0006
      ]/data[
        at0003
      ]/items[
        at0004
      ]/value/magnitude"
    },
    {
      "name": "diastolic",
      "path": "/data[
        at0001
      ]/events[
        at0006
      ]/data[
        at0003
      ]/items[
        at0005
      ]/value/magnitude"
    },
    {
      "name": "measured_at",
      "path": "/data[
        at0001
      ]/events[
        at0006
      ]/time/value"
    }   
  ] 
}

The response is tabular — rows and columns, like SQL. You can feed this directly into pandas, a charting library, or any analytics tool.

Parameterized queries (prevent AQL injection):

{
  "q": "SELECT ... FROM EHR e CONTAINS OBSERVATION o[openEHR-EHR-OBSERVATION.blood_pressure.v2] WHERE e/ehr_id/value = $patient_id AND o/data[at0001]/events[at0006]/time/value > $since",
  "query_parameters": {
    "patient_id": "7d44b88c-...",
    "since": "2025-06-01"
  }
}

Always use parameterized queries in production. Never concatenate user input into AQL strings — the same injection risks as SQL apply here.

The Seven Mistakes Every Team Makes in Week One

Mistake 1: Posting compositions without uploading the template first

The CDR validates every composition against its template. No template → 400 Bad Request with an unhelpful error message. Always verify your template is uploaded: GET /definition/template/adl1.4/{template_id}.

Mistake 2: Using the numeric database ID instead of version_uid

Some CDRs expose internal numeric IDs in list responses. Never use these for API calls. The openEHR standard uses the version_uid format: object_id::system_id::version. If your code references a numeric ID, something is wrong.

Mistake 3: Forgetting the If-Match header on PUT

Updates require optimistic locking via the If-Match header containing the current version_uid. Without it, some CDRs return 400, others 409. Pattern: always GET the latest version, extract the version_uid, include it in If-Match on your PUT.

Mistake 4: Wrong archetype version in AQL queries

If your template uses blood_pressure.v2 but your AQL query references blood_pressure.v1, you get zero results with no error. The CDR does not warn you — it just finds no matching data. Always check the archetype version in your template matches your query.

Mistake 5: Wrong unit format

openEHR uses UCUM (Unified Code for Units of Measure) notation. Common traps: mm[Hg] not mmHg. kg/m2 not kg/m^2. Cel not °C. Get the exact unit string from the web template response — do not guess.

Mistake 6: Expecting DELETE to remove data

DELETE creates a deletion marker version. The data is preserved and retrievable. If your application logic depends on data being physically gone after delete, you need to redesign. OpenEHR is an append-only system by design.

Mistake 7: Hardcoding archetype paths instead of discovering them

Fetch the web template (GET /definition/template/adl1.4/{id} with Accept: application/openehr.wt+json) and use the paths it returns. This makes your code resilient to template changes and lets you build dynamic forms from template metadata.

Production Integration Pattern

In production, you do not call the CDR directly from your front-end. Here is the architecture pattern that works.

Write Path

Front-end form → your backend API → validation layer → CDR. The validation layer converts form data to flat-format composition JSON, validates business rules that the CDR does not know about (e.g., "nurses cannot submit medication orders"), and handles authentication. Never expose the CDR REST API directly to client applications.

Read Path

CDR → AQL Engine → your backend API → consumer applications. For dashboards and analytics, pre-build common AQL queries as named stored queries: POST /query/{query_name}. This avoids AQL construction in front-end code and lets you optimize queries centrally.

Event Path

Many CDRs support webhooks or event triggers when compositions are created or updated. Use these for real-time notifications (new lab result → alert the ordering physician), audit logging, and downstream synchronization (new diagnosis → update the problem list in your FHIR server).

Key Architectural Decisions

  • Validate before the CDR, not after. CDR validation errors are correct but not user-friendly. Validate in your backend and return human-readable errors to the UI.
  • Cache web templates. Fetch once at startup, refresh on template change. Do not call GET /definition/template on every form render.
  • Use flat format for writes, structured for reads. Your front-end team will thank you.
  • Index your AQL queries. The CDR's AQL engine performs best with archetype-specific queries. Avoid broad CONTAINS COMPOSITION queries without archetype filters in production.
  • Handle version conflicts gracefully. In clinical workflows, two nurses can record vital signs for the same patient simultaneously. Your UI should detect 409 Conflict and merge or prompt, not crash.

Quick Reference — Every Endpoint You Need

OperationMethodEndpointKey Headers
Create EHRPOST/ehrPrefer: return=representation
Get EHR by subjectGET/ehr?subject_id=X&subject_namespace=Y—
Upload templatePOST/definition/template/adl1.4Content-Type: application/xml
Get web templateGET/definition/template/adl1.4/{id}Accept: application/openehr.wt+json
Create compositionPOST/ehr/{ehr_id}/compositionPrefer: return=representation
Get compositionGET/ehr/{ehr_id}/composition/{version_uid}—
Update compositionPUT/ehr/{ehr_id}/composition/{object_id}If-Match: {current_version_uid}
Delete compositionDELETE/ehr/{ehr_id}/composition/{object_id}If-Match: {current_version_uid}
Execute AQL queryPOST/query/aqlContent-Type: application/json
List stored queriesGET/query—
Store named queryPUT/query/{query_name}Content-Type: application/json

All endpoints are relative to the base URL of your CDR's openEHR REST API (e.g., http://localhost:8080/ehrbase/rest/openehr/v1). The base path varies by CDR vendor — check your CDR's documentation for the correct prefix.

From Here — What to Build First

If you are starting a new openEHR integration, build in this order:

  1. Template upload and web template fetch. Automate this in your CI/CD pipeline. Templates should be version-controlled and deployed like database migrations.
  2. Single composition write. Get one vital signs composition through the CDR with flat format. This proves your end-to-end path works.
  3. Composition read-back. Verify what you wrote matches what you read. This catches serialization bugs early.
  4. AQL query for the data you wrote. Confirm the data is queryable and the archetype paths match.
  5. Version update cycle. Create → update → verify both versions exist. This proves your version_uid handling works.
  6. Error handling. Intentionally submit invalid compositions and verify your code handles 400 and 409 responses gracefully.

Do not try to build the full clinical application first. Get the API integration solid with one template, one composition type, and one AQL query. Then expand. The API surface is the same for every clinical concept — once blood pressure works, adding lab results, medications, and diagnoses is the same pattern with different templates and paths.

The teams that ship fastest are the ones that master the seven operations above before they write a single line of UI code. The API is your foundation. Build it right and everything above it moves faster. For teams also working with FHIR, see how we built a FHIR R4 server from scratch and our HL7v2-to-FHIR migration guide.

Building interoperable healthcare systems is complex. Our Healthcare Interoperability Solutions team has deep experience shipping production integrations. We also offer specialized Agentic AI for Healthcare services. Talk to our team to get started.

Ready to scale?

Talk to our healthcare engineering team about building, integrating, and shipping faster.

Frequently Asked Questions

What is the openEHR REST API?

The openEHR REST API is the standard interface for working with an openEHR Clinical Data Repository (CDR) such as EHRbase. A complete integration boils down to seven core operations — everything else is a variation of them: creating an EHR container for the patient, uploading templates, building and posting compositions, and querying the stored clinical data. Mastering these seven calls is enough to build any clinical data application on top of an openEHR CDR.

How do you create an EHR in an openEHR CDR like EHRbase?

You POST to the /ehr endpoint with an ehr_status containing the patient's external reference (identifier, scheme, and namespace), and the CDR returns a 201 with an ehr_id. Save that ehr_id.value in your patient table — every subsequent API call for that patient needs it. Watch the gotcha: posting again with the same subject external_ref typically returns 409 Conflict, so look up existing EHRs first with GET /ehr?subject_id=...&subject_namespace=.... PUT with a specific ehr_id lets you preserve identifiers during migrations.

Why do you need to upload a template before posting compositions in openEHR?

Templates tell the CDR what kinds of clinical documents to accept — like registering a form definition before accepting submissions — so at least one template must exist before any composition will be accepted. Templates are OPT (Operational Template) XML files built with a template designer tool, not by hand, and the CDR validates every submitted composition against them. Upload behavior varies: re-uploading the same template_id returns 409 Conflict on some CDRs and silently updates on others.

What is the difference between flat and structured composition formats in openEHR?

Structured (canonical) format mirrors the archetype tree exactly and is self-describing — interpretable without knowing the source template — but verbose: a simple blood pressure reading becomes 50+ lines of JSON. Flat (simplified) format uses dot-separated paths as keys, is compact, and is easy to generate from form data, which is why most web and mobile apps use it in practice. The choice affects your entire integration architecture, so decide deliberately before building.

What is the most useful openEHR endpoint for front-end developers?

The web template endpoint — GET /definition/template/adl1.4/{templateId} with the application/openehr.wt+json Accept header. It returns the flat paths and tree structure you need to build composition JSON, telling you exactly which fields exist, their types, constraints, and paths. Developers integrating openEHR for the first time, a process Nirmitee's healthcare engineering team describes as two weeks of pain without guidance, lean on this endpoint to bridge the gap between templates and working compositions.

How does Nirmitee.io approach healthcare software development?

Nirmitee.io follows a healthcare-first engineering approach — every solution is built with HIPAA compliance, HL7/FHIR interoperability standards, and clinical workflow requirements at the core. We combine domain expertise with modern engineering practices to deliver production-ready systems.
Share