Content negotiation & errors
A handful of HTTP mechanisms cut across every openEHR resource: choosing the
wire format (JSON or XML), controlling how much a write returns (the Prefer
header), versioned optimistic concurrency (ETag and If-Match), and the
request headers that enrich a commit (audit metadata and item tags). This
chapter explains them all, plus the shape of error responses, so the examples
in Resource walkthroughs make sense in general.
JSON and XML
FerroEHR speaks canonical JSON and canonical XML for the RM-typed resources. Choose with the standard HTTP headers:
- Request body: set
Content-Type: application/jsonorapplication/xml. - Response: set
Accept: application/jsonorapplication/xml.
JSON is wired end to end for every operation. XML is supported for the
spec-typed RM objects — a single composition, EHR_STATUS, EHR, FOLDER, and
the version family (versioned objects and revision history) — whose canonical
XML shape the openEHR ITS-XML schemas define. Responses that are not a
spec-typed RM value (collections, item tags, and the query and terminology
DTOs) are JSON-only, as is the CONTRIBUTION envelope.
# Commit a composition as XML, ask for XML back
curl -u ferroehr:ferroehr \
-H 'Content-Type: application/xml' \
-H 'Accept: application/xml' \
--data-binary @composition.xml \
http://localhost:8080/ferroehr/rest/openehr/v1/ehr/$EHR_ID/composition
Choosing the XML namespace
openEHR publishes its canonical XML schemas in two lineages that differ only by the namespace the document declares:
| Lineage | Root namespace | Status |
|---|---|---|
| v1 (default) | http://schemas.openehr.org/v1 | The stable schema release most openEHR tooling reads |
| v2 | http://schemas.openehr.org/v2 | The newer schema release, still marked trial by openEHR |
Pick one per request with a version parameter on the XML media type:
# Read a composition in the v2 namespace
curl -u ferroehr:ferroehr \
-H 'Accept: application/xml; version=2' \
"http://localhost:8080/ferroehr/rest/openehr/v1/ehr/$EHR_ID/composition/$UID"
# Commit one whose payload already uses the v2 namespace
curl -u ferroehr:ferroehr \
-H 'Content-Type: application/xml; version=2' \
-H 'Accept: application/xml; version=2' \
--data-binary @composition-v2.xml \
"http://localhost:8080/ferroehr/rest/openehr/v1/ehr/$EHR_ID/composition"
What to expect:
- Leave the parameter off and nothing changes. No parameter (or
version=1) means v1, exactly as before, and the response header staysContent-Type: application/xml. - A v2 response says so:
Content-Type: application/xml; version=2. - On requests the parameter is a courtesy, not a requirement. The server reads both namespaces regardless of what you declare, so you only need it when you want the declaration to be accurate.
- Asking for a namespace the server does not serve (
version=3, say) is 406 Not Acceptable onAcceptand 415 Unsupported Media Type onContent-Type. - Operational templates are always v1.
GET …/definition/template/adl1.4/{template_id}returns OPT XML in the v1 namespace and ignores the parameter — it is a template document, not a canonical RM resource.
Note
The
versionparameter is a FerroEHR extension: the openEHR REST specification predates the two schema lineages and says nothing about selecting one. It never changes the media type itself — responses are alwaysapplication/xml— so a client that ignores it behaves exactly as the specification describes.
Simplified formats (FLAT and STRUCTURED)
Beyond the canonical formats, the server implements the openEHR Simplified
Formats — template-driven JSON representations that use friendly field
identifiers (vital_signs/body_temperature:0/any_event:0/temperature|magnitude)
instead of full RM paths. Select them the same way as JSON/XML, with these
media types:
| Media type | Meaning |
|---|---|
application/openehr.wt.flat+json | FLAT — one flat JSON object of path: value pairs |
application/openehr.wt.structured+json | STRUCTURED — the same data as nested JSON |
application/openehr.wt+json | A template rendered as Web Template JSON (template endpoints only) |
Where they work:
- Compositions — full round-trip: commit with
Content-Type: application/openehr.wt.flat+json(or…structured…) and read back with the matchingAccept. - Template examples —
GET …/definition/template/adl1.4/{id}/example(and the ADL2 form) return the generated example in any of the four formats, chosen viaAccept. - Template definitions —
GET …/definition/template/adl1.4/{id}withAccept: application/openehr.wt+jsonreturns the Web Template document.Accept: application/jsonreturns the same document — it is the only JSON representation of a template — underContent-Type: application/json: the response always carries the media type you asked for. - Contributions — the CONTRIBUTION envelope itself stays canonical JSON;
a simplified media type applies only to each composition payload inside
versions[].data.
Two rules to know when committing a composition in a simplified format:
- A FLAT/STRUCTURED payload cannot carry its own template id, so the
openehr-template-idrequest header is required — the commit is rejected with422without it. - There is no
?format=query parameter: format selection is done exclusively through the standardAcceptandContent-Typeheaders.
Requests naming a media type the endpoint does not support are answered with 415 Unsupported Media Type (request body) or 406 Not Acceptable (response format), with a body naming the formats that endpoint does support. EHR, EHR_STATUS, directory, and demographic resources have no simplified representation (the format is generated from an operational template, which those resources do not have) — they speak canonical JSON/XML only.
The query API is JSON only — it does not accept XML or the simplified media types.
The Prefer header
Write operations (create/update) accept a Prefer header controlling the
response body. Its default is return=minimal:
Prefer value | Effect |
|---|---|
return=minimal (default) | Empty body; the identifier is in ETag/Location. Status 204 on update, 201 on create. |
return=representation | The full created/updated resource in the body, status 200/201. |
return=identifier | Just the resource identifier object — {"uid": "…"} (templates: {"template_id": "…"}). Status 200/201, never 204. |
Use return=representation when you want the server-completed object back
(with its assigned version id and any server-set audit fields); use
return=minimal for throughput when you only need the id.
return=identifier always comes back with a body, so it never uses 204 —
an update that would answer 204 under return=minimal answers 200 with
the identifier object instead.
Every write response names the preference the server actually applied in a
Preference-Applied header (return=representation,
return=identifier, or return=minimal), so a client can tell what it got
without sniffing the body. The header reports what the response did: a
request with no Prefer gets return=minimal (the default behaviour), and
in the rare case where an identifier cannot be produced, the server applies
and reports return=minimal rather than claiming an identifier response it
did not send.
Prefer: resolve_refs
Contribution reads return their versions as OBJECT_REFs by default. Add
resolve_refs to the Prefer header (it combines with the return=…
token, e.g. Prefer: return=representation, resolve_refs) and the response
carries the full ORIGINAL_VERSION objects instead — one round trip instead
of one per version.
ETag and If-Match — optimistic concurrency
openEHR objects are versioned, and updates use HTTP preconditions to prevent lost updates:
-
Every read and successful write returns an
ETagheader carrying the object or version identifier as a weak ETag,W/"...". -
Updating or deleting a versioned object requires an
If-Matchheader set to the current version id. Both the weak form and a bare quoted value are accepted — echoing theETagyou received works either way:If-Match: W/"8849182c-82ad-4088-a07f-48ead4180515::your.system::2" If-Match: "8849182c-82ad-4088-a07f-48ead4180515::your.system::2" -
If the object has moved on since you read it, the write fails with 412 Precondition Failed and the current version id in the response
ETag. Re-read, reconcile, and retry against the new version.
A Location header is emitted only when a resource is created —
reads and deletes identify the version through ETag alone, so do not expect
Location on them; the ETag is the authoritative identifier.
Last-Modified — when the version was committed
Alongside the ETag, versioned responses carry a Last-Modified header
in the standard HTTP-date form (Wed, 22 Jul 2009 19:15:56 GMT). Its value is
the commit time of the version being served — the audit
time_committed of that VERSION — so it changes exactly when the ETag
does.
You get it on:
- every
VERSIONread (…/versioned_composition/{uid}/version[/{version_uid}],…/versioned_ehr_status/version[/{version_uid}]) and revision history read; - every COMPOSITION,
EHR_STATUS, and DIRECTORY read — including the FLAT and STRUCTURED representations, which describe the same version; - every write of those resources (create, update, and the delete
204), and the EHR create201; - every CONTRIBUTION — both the read and the commit
201, where the value is the contribution audit’s commit time. On a contribution commit you get the header under eitherPrefersetting; withreturn=minimalthere is no response body, so the header is the only place the commit time appears.
Resources that are not versioned do not carry it: GET /ehr/{ehr_id} returns
the weak ETag (built from EHR.ehr_id.value) but no Last-Modified,
because the EHR root object has no commit audit of its own.
Template responses (ADL 1.4 and ADL2) carry a weak ETag keyed on the
template identifier; for ADL2 it is the resolved ARCHETYPE_HRID, so
requesting a template by a partial id or a major-version prefix still gives
you an ETag that changes when the served artefact changes.
Note
Version ids normally end in a plain trunk number (
…::2), but openEHR version trees can branch: when a version that was created on another system is modified locally, the server forks a branch and the new version id ends in a three-part tree id (…::2.1.1). Treat the version id as an opaque token — echo it back inIf-Matchexactly as received — and it works the same for trunk and branch versions.ALL_VERSIONSqueries and version reads return branch versions alongside trunk ones; the latest version of an object is always the latest trunk version.
Tip
The round-trip is: read the resource → keep its
ETagvalue → send it back asIf-Matchon the update → get a newETagfor the version you just created. Never fabricate a version id; always echo the one the server gave you.
Commit metadata headers
When you commit through the direct resource endpoints (EHR creation,
composition, EHR_STATUS, directory), the server builds the version’s audit for
you. Two request headers let you set parts of it — openehr-version for the
version’s own attributes and openehr-audit-details for the commit
audit. The value is a comma-separated list of attribute.subkey="value"
pairs (quoted values may contain commas; the header may repeat, and repeats
are merged):
# Commit a composition as a draft (lifecycle state "incomplete", code 553)
openehr-version: lifecycle_state.code_string="553"
# Name the committer, describe the change, and stamp the source system
openehr-audit-details: committer.name="John Doe",description.value="Corrected dosage",system_id="pas.example.org"
The attributes the server merges:
| Header | Attribute | Sub-keys |
|---|---|---|
openehr-version | lifecycle_state | code_string |
openehr-audit-details | change_type | code_string |
openehr-audit-details | description | value |
openehr-audit-details | committer | name, external_ref.id, external_ref.namespace, external_ref.type |
openehr-audit-details | system_id | (bare value) |
A client-supplied system_id is merged into the commit audit — useful when a
gateway commits on behalf of a source system; when absent, the server stamps
its own system id.
Both EHR creates (POST /ehr and PUT /ehr/{ehr_id}) accept the headers too:
creating an EHR commits its EHR_STATUS and EHR_ACCESS in a contribution, so the
supplied description, committer, and system id land on that commit, and
openehr-version sets the new EHR_STATUS version’s lifecycle state. The
change_type on a create is constrained to 249|creation| — a create commits a
first version — so restating 249 is accepted while any other change type is
rejected.
Note
The older dotted spellings —
openEHR-VERSION.lifecycle_state: code_string="553",openEHR-AUDIT_DETAILS.committer: name="John Doe", and so on, with the attribute in the header name — are deprecated but still accepted. If both forms appear, the lowercase value-form header wins.
Item tags via headers
Item tags — small key/value annotations, optionally pointing at a node
inside the data via target_path — can ride the same request as a write, so
tagging does not need a second round trip. Two headers carry them:
openehr-item-tag— tags targeting the versioned object;openehr-version-item-tag— tags targeting the version being committed.
The value is a ;-separated list of tags, each a comma-separated set of
key="…", value="…", and optional target_path="…" pairs:
openehr-version-item-tag: key="diagnosis",value="confirmed",target_path="/content[0]"; key="reviewed",value="true"
They are accepted on the EHR-group change-controlled writes (composition create/update, EHR_STATUS update, directory create/update) and on demographic party writes. Sending the header with an empty value removes all tags.
The two headers address different targets, so the response echo keeps
them apart: openehr-item-tag confirms the tags now stored on the versioned
object, openehr-version-item-tag those stored on the version just
committed, and a header you did not send is not echoed at all. (Demographic
parties store tags against the versioned object only, so both headers carry
the same list there.)
Error responses
Errors use conventional HTTP status codes (see the summary in Resource walkthroughs) with one of two JSON body shapes:
-
Validation errors (a composition that fails its template) use the openEHR error shape:
{ "message": "Composition validation failed", "validationErrors": [ "/content[0]/data/events[0]/data/items[1]/value/magnitude: value out of range", "/content[0]/data/events[0]/data/items[2]/value/defining_code: code not in group" ] }Each entry is
"<path>: <message>", so a client can point the user at the exact offending node. -
All other errors use a simple shape — the status reason plus a message:
{ "error": "Not Found", "message": "No EHR with id ..." }This shape is used consistently — including for
405 Method Not Allowedand501 Not Implemented, which some servers leave bodyless, and for the two refusals that come from the transport layer rather than a handler:408 Request Timeout(the request exceeded the server’s execution limit) and413 Payload Too Large(the request body exceeds the accepted size).
Match on the HTTP status first; read the body for the human-readable detail and, for validation, the per-node list.
405 always names the allowed methods
Every 405 Method Not Allowed carries an Allow header listing the methods
the target resource currently supports, as
RFC 9110 §15.5.6
requires — so a client can discover the right method without guessing:
HTTP/1.1 405 Method Not Allowed
Allow: GET,HEAD,PUT
Content-Type: application/json
{ "error": "Method Not Allowed", "message": "the request method is not allowed on this resource" }
When a resource is switched off by configuration — the
admin API with
FERROEHR__ADMIN__ENABLED=false — the header is present but empty, which
RFC 9110 §10.2.1
defines as “the resource allows no methods”: nothing you can send to that path
will be served until the gate is opened.
A method the server does not recognize at all is answered 405 as well (with
Allow), not 501. The openEHR spec suggests 501 there, but the two rules
it states overlap for any method outside its own list, and a blanket 501
would also mislabel requests to paths that simply do not exist and are owed a
404. 501 Not Implemented remains reserved for a recognized operation this
server does not implement.