Skip to main content

1. What This Guide Covers

EHRbase is the leading open-source openEHR Clinical Data Repository. When openEHR templates constrain coded elements to external terminologies (SNOMED CT, LOINC, ICD-10, RxNorm, and the rest of the OMOP vocabulary set), EHRbase can delegate validation to a remote FHIR terminology server. OMOPHub’s FHIR Terminology Service is one such server. This guide shows the exact configuration that makes that work and walks through one round-trip - template upload, composition commit, code validation - against a real EHRbase instance pointed at OMOPHub. It is not a general openEHR interoperability guide: the promise is narrow and concrete - template terminology validation and ValueSet expansion, nothing else.
Tested, not “drop-in.” The snippets below are verified against ehrbase/ehrbase:next and OMOPHub’s production FHIR Terminology Service. Your EHRbase deployment may have additional security, network, or validation configuration that changes the details - treat this as a reference pattern to adapt, not a turnkey install script.

2. Prerequisites

  • An EHRbase instance - ehrbase/ehrbase:next or v2.x. The integration uses Spring Security OAuth2 and Spring’s RemoteTerminologyServiceValidationSupport, which have been stable since EHRbase 2.0.
  • An OMOPHub API key from your dashboard. Free tier works for testing.
  • Network access from EHRbase to fhir.omophub.com:443 (production) or to your own local OMOPHub dev instance.
  • Familiarity with openEHR templates (OPT, ADL 1.4) and basic YAML configuration.
EHRbase only speaks FHIR R4 for external terminology validation - point it at https://fhir.omophub.com/fhir/r4/. OMOPHub also serves R5 and R6, but EHRbase will not use them.

3. Configuration

EHRbase consumes two config surfaces: one Spring Security OAuth2 client registration (for outbound authentication) and one external terminology provider block (for the validator).
Use an application.yml overlay, not environment variables. EHRbase’s external terminology config is a Map<String, ProviderConfig> - Spring’s relaxed-binding for maps via env vars is fragile and silently drops entries. Mount a YAML file via SPRING_CONFIG_ADDITIONAL_LOCATION to avoid the problem entirely.

3.1 ehrbase.yml overlay

Mount this file into your EHRbase container at /config/ehrbase.yml:
ehrbase.yml
spring:
  security:
    oauth2:
      client:
        registration:
          omophub:
            client-id: ${OMOPHUB_CLIENT_ID}
            client-secret: ${OMOPHUB_CLIENT_SECRET}
            authorization-grant-type: client_credentials
            provider: omophub
        provider:
          omophub:
            token-uri: https://fhir.omophub.com/oauth2/token

validation:
  external-terminology:
    enabled: true
    fail-on-error: true
    provider:
      omophub:
        type: FHIR
        url: https://fhir.omophub.com/fhir/r4/
        oauth2-client: omophub
Key points:
  • client-id is your OMOPHub API key (starts with oh_). client-secret is required by Spring’s config shape but not validated - use any non-empty value.
  • OMOPHub’s token endpoint accepts both RFC 6749 client authentication methods (client_secret_basic / client_secret_post), so you do not need to set client-authentication-method explicitly - Spring’s default works.
  • fail-on-error: true makes EHRbase reject composition commits when terminology validation fails. Flip to false in development while you’re iterating on templates.
  • The trailing slash on url: matters - EHRbase’s validator concatenates path segments without normalizing.

3.2 Docker Compose

A minimal stack for local testing:
docker-compose.yml
services:
  ehrdb:
    image: ehrbase/ehrbase-v2-postgres:16.2
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres
      EHRBASE_USER_ADMIN: ehrbase
      EHRBASE_PASSWORD_ADMIN: ehrbase
      EHRBASE_USER: ehrbase_restricted
      EHRBASE_PASSWORD: ehrbase_restricted
    healthcheck:
      test: ['CMD-SHELL', 'pg_isready -U postgres']
      interval: 5s
      timeout: 5s
      retries: 12

  ehrbase:
    image: ehrbase/ehrbase:next
    depends_on:
      ehrdb:
        condition: service_healthy
    environment:
      DB_URL: jdbc:postgresql://ehrdb:5432/ehrbase
      DB_USER_ADMIN: ehrbase
      DB_PASS_ADMIN: ehrbase
      DB_USER: ehrbase_restricted
      DB_PASS: ehrbase_restricted
      SECURITY_AUTHTYPE: BASIC
      SECURITY_AUTHUSER: ehrbase-user
      SECURITY_AUTHPASSWORD: ChangeMe
      SECURITY_AUTHADMINUSER: ehrbase-admin
      SECURITY_AUTHADMINPASSWORD: ChangeMeToo
      EHRBASE_TEMPLATE_ALLOWOVERWRITE: 'true'
      SPRING_CONFIG_ADDITIONAL_LOCATION: /config/ehrbase.yml
      OMOPHUB_CLIENT_ID: ${OMOPHUB_CLIENT_ID}
      OMOPHUB_CLIENT_SECRET: unused
    volumes:
      - ./ehrbase.yml:/config/ehrbase.yml:ro
    ports:
      - '8080:8080'
Start it:
export OMOPHUB_CLIENT_ID=oh_your_api_key_here
docker compose up -d

3.3 Confirm the config loaded

EHRbase prints a single, unambiguous log line at startup if the provider registered correctly:
[main] o.e.c.c.v.ValidationConfiguration :
  Initializing 'omophub' external terminology provider (type: FHIR)
  at https://fhir.omophub.com/fhir/r4/  secured by oauth2 client 'omophub'
No line → no provider. Check for YAML parse errors, missing env vars, or a typo in the oauth2-client: reference.

4. Template Terminology Binding

openEHR templates (ADL 1.4 OPT files) bind coded elements to FHIR value sets via a <referenceSetUri> element on the terminology constraint. EHRbase recognizes three authority prefixes as “route this through the configured FHIR provider”:
  • //fhir.hl7.org/...
  • terminology://fhir.hl7.org/...
  • //hl7.org/fhir/...
The body of the URI must contain a FHIR implicit ValueSet URL in a url= query parameter. For OMOP-supported systems the two useful forms are:
<!-- All concepts from a code system -->
<referenceSetUri>terminology://fhir.hl7.org/ValueSet?url=http%3A%2F%2Fsnomed.info%2Fsct%3Ffhir_vs</referenceSetUri>

<!-- Descendants of a concept (is-a subsumption) -->
<referenceSetUri>terminology://fhir.hl7.org/ValueSet?url=http%3A%2F%2Fsnomed.info%2Fsct%3Ffhir_vs%3Disa%2F73211009</referenceSetUri>
The inner url= value is URL-encoded because <referenceSetUri> is parsed as a URI string. The decoded form of the second example is http://snomed.info/sct?fhir_vs=isa/73211009 - the implicit SNOMED ValueSet of all descendants of Diabetes mellitus (73211009). Other FHIR system URIs OMOPHub recognizes:
VocabularyFHIR system URI
SNOMED CThttp://snomed.info/sct
LOINChttp://loinc.org
RxNormhttp://www.nlm.nih.gov/research/umls/rxnorm
ICD-10-CMhttp://hl7.org/fhir/sid/icd-10-cm
NDChttp://hl7.org/fhir/sid/ndc
These five cover most clinical-terms bindings. OMOPHub registers a total of 20 vocabulary-specific CodeSystems plus the unified OMOP omnibus — see Supported Vocabularies for the complete list. Two variants worth naming for EHRbase deployments specifically:
  • ICD-10-GM (http://hl7.org/fhir/sid/icd-10-gm) — the German Modification used for hospital billing in Germany. Typically the right choice for any EHRbase deployment billing against G-DRG.
  • ICD-10-CN (http://hl7.org/fhir/sid/icd-10-cn) — the Chinese Edition maintained by CAMS, used in Chinese hospital systems.
Both are served identically to ICD-10-CM — register them in a template referenceSetUri the same way, and $expand / $validate-code will dispatch to the correct OMOP vocabulary.

5. How Validation Actually Works

When EHRbase commits a composition that contains a DV_CODED_TEXT field constrained by one of these FHIR-routed referenceSetUri values, it walks a three-step flow:
1

OAuth2 token exchange

EHRbase’s Spring Security OAuth2 client obtains a Bearer token from https://fhir.omophub.com/oauth2/token using client_credentials grant type. The client_id you configured (your API key) gets echoed back as the access_token.
2

ValueSet support check

EHRbase calls GET /fhir/r4/ValueSet?url=<implicit-vs-url> to check whether the server knows about the referenced ValueSet. OMOPHub responds with a Bundle (type searchset) containing a stub ValueSet resource if it recognizes the system URI. An empty bundle means “not supported” and EHRbase skips validation for that field.
3

Expansion and code match

If support check passes, EHRbase calls GET /fhir/r4/ValueSet/$expand?url=<implicit-vs-url>. OMOPHub returns a ValueSet resource with an expansion.contains[] list. EHRbase’s JsonPath validator looks for the submitted code in expansion.contains[?(@.code=='<code>')]. If it’s there, the commit proceeds; if not, the commit is rejected.
All three requests carry the Bearer token in the Authorization header. EHRbase caches token + expansion responses in its externalFhirTerminologyCache, so repeated commits against the same value set don’t re-hit OMOPHub.

6. Example Round-Trip

6.1 Upload a template

Any ADL 1.4 OPT that includes at least one <referenceSetUri> binding will do. EHRbase’s own test fixtures provide a good starting point - see the IDCR Allergies List.v0.opt template in the EHRbase repository.
curl -u ehrbase-admin:ChangeMeToo \
  -H "Content-Type: application/xml" \
  -H "Prefer: return=minimal" \
  --data-binary @allergies-fhir.opt \
  http://localhost:8080/ehrbase/rest/openehr/v1/definition/template/adl1.4
Expected: HTTP 201. The template is now registered with whatever FHIR bindings it declared.

6.2 Create an EHR

EHR_ID=$(curl -s -u ehrbase-user:ChangeMe \
  -X POST -H "Prefer: return=representation" -H "Accept: application/json" \
  http://localhost:8080/ehrbase/rest/openehr/v1/ehr \
  | python3 -c 'import json,sys; print(json.load(sys.stdin)["ehr_id"]["value"])')
echo "EHR_ID=$EHR_ID"

6.3 Submit a composition with an invalid code

Build a minimal composition body that includes a DV_CODED_TEXT field bound to one of the template’s FHIR-routed constraints, with a code that is definitely not in the target value set:
composition-invalid.json
{
  "_type": "COMPOSITION",
  "archetype_details": {
    "template_id": { "value": "IDCR Allergies List.v0" },
    "rm_version": "1.0.4"
  },
  "...": "... required RM fields omitted for brevity ...",
  "content": [
    {
      "items": [
        {
          "value": {
            "_type": "DV_CODED_TEXT",
            "value": "Not a real substance",
            "defining_code": {
              "_type": "CODE_PHRASE",
              "terminology_id": {
                "_type": "TERMINOLOGY_ID",
                "value": "http://snomed.info/sct"
              },
              "code_string": "99999999"
            }
          }
        }
      ]
    }
  ]
}
curl -u ehrbase-user:ChangeMe \
  -X POST -H "Content-Type: application/json" \
  --data-binary @composition-invalid.json \
  "http://localhost:8080/ehrbase/rest/openehr/v1/ehr/$EHR_ID/composition"
Expected response - HTTP 422:
{
  "error": "Unprocessable Entity",
  "message": "... Failed to validate DvCodedText{defining_code=http://snomed.info/sct::99999999, value=Not a real substance}, : The value 99999999 does not match any option from value set http://snomed.info/sct?fhir_vs=isa/105590001"
}
This is the canonical “terminology rejected a code” path. EHRbase called OMOPHub’s $expand, scanned the returned expansion.contains[], found no code:'99999999' entry, and refused the commit.

6.4 Submit a composition with a valid code

Replace code_string with a concept that is actually in the value set - any descendant of the constraint’s parent concept. For the SNOMED Substance (105590001) value set, that means any concept with concept_class_id = 'Substance' whose Is a ancestry includes 105590001.
# Query the expansion to pick a valid code:
VS_URL='http%3A%2F%2Fsnomed.info%2Fsct%3Ffhir_vs%3Disa%2F105590001'
curl -sS "https://fhir.omophub.com/fhir/r4/ValueSet/\$expand?url=$VS_URL&count=20" \
  -H "Authorization: Bearer $OMOPHUB_CLIENT_ID" \
  | python3 -c 'import json,sys; [print(c["code"], c["display"]) for c in json.load(sys.stdin)["expansion"]["contains"]]'
Set defining_code.code_string to one of the codes returned, leave terminology_id.value as http://snomed.info/sct (it must match the system field in the expansion), and re-submit - expect HTTP 201.
Terminology ID must match the expansion’s system. EHRbase compares DV_CODED_TEXT.defining_code.terminology_id.value against the system URI returned by $expand. If the template sets the terminology_id to the value-set sentinel URL (as some auto-generated example compositions do), validation will fail with "The terminology {code} must be {system}". Always use the canonical system URI (http://snomed.info/sct, http://loinc.org, etc.) in submitted compositions.

7. Supported Operations

EHRbase uses a subset of OMOPHub’s FHIR Terminology Service during validation:
EHRbase useOMOPHub endpointWhen it fires
Fetch OAuth2 tokenPOST /oauth2/tokenFirst validation call, then cached
ValueSet support checkGET /fhir/r4/ValueSet?url=...Before each $expand for a new VS
ValueSet expansionGET /fhir/r4/ValueSet/$expand?url=...On composition commit, per FHIR-bound field
CodeSystem validation (rare)GET /fhir/r4/CodeSystem/$validate-codeOnly if templates use terminology://fhir.hl7.org/CodeSystem?... bindings
The rest of the Terminology Service ($lookup, $translate, $subsumes, $closure, $find-matches) is not reached through EHRbase’s validator - if you need those, call OMOPHub directly from your application code. See the FHIR Integration guide for the full operation surface.

8. What OMOPHub Adds vs. Generic Terminology Servers

CapabilityOMOPHubOntoserverLocal ATHENA + HAPI
OMOP vocabulary coverage130+ vocabulariesLimitedFull (manual download)
Semantic search ($find-matches)YesNoNo
Phoebe recommendationsYes (opt-in)NoNo
FHIR → OMOP concept resolverYesNoNo
R4 supportYesYesR4 only
Zero infrastructureYes (cloud API)Self-hostPostgreSQL + HAPI server
SDKs (Python, R, MCP)YesNoNo
For hospitals already running EHRbase and planning to publish to OMOP for research, using OMOPHub for both ends (template validation and ETL mapping) keeps vocabulary consistency across the capture and analytics sides of the pipeline.

9. openEHR → OMOP Pipeline

The full loop for a site that uses EHRbase as its clinical repository and OMOP for observational research:
1

Capture

Clinician enters data in an openEHR form. Coded fields are validated against OMOPHub’s $expand at commit time.
2

Store

EHRbase persists the composition with verified SNOMED / LOINC / RxNorm codes.
3

Extract

Your ETL pipeline reads compositions from EHRbase and lifts the DV_CODED_TEXT fields.
4

Resolve

Each Coding goes through OMOPHub’s FHIR Resolver (POST /v1/fhir/resolve) to get the standard OMOP concept + CDM target table.
5

Load

Write the resolved concepts into OMOP CDM tables - measurement, condition_occurrence, drug_exposure, whichever the resolver’s target_table field says.
The same OMOPHub API key serves both ends. Same vocabulary release, same mapping decisions, same concept IDs - no capture/analytics drift.

10. Limitations and Caveats

This integration pattern has been tested against ehrbase/ehrbase:next and OMOPHub’s production FHIR Terminology Service as of 2026-04. EHRbase configurations vary by deployment (security profiles, template sets, validation rules), and production rollouts should run the verification checklist against their own environment before going live.
Specific limitations to be aware of:
  • OMOPHub does not replace openEHR’s built-in terminology. It handles external clinical terminologies only - codes from SNOMED, LOINC, ICD-10, RxNorm, etc. openEHR’s own openehr-terminology (used for things like language, territory, and AOM-level constraint codes) is served by EHRbase internally and does not route through the FHIR provider.
  • OMOP vocabulary content is optimized for observational research. Some source codes are collapsed to a single standard concept in OMOP where a strict clinical coding system would distinguish them. If your templates require sub-concept granularity (e.g. distinguishing two variants of the same SNOMED code), OMOP’s Maps to behavior may treat them as equivalent.
  • Implicit ValueSets only. OMOPHub supports the FHIR implicit ValueSet URL patterns ({system}?fhir_vs, {system}?fhir_vs=isa/{code}) but not user-defined named ValueSets uploaded via PUT /ValueSet/:id. Templates that bind to custom ValueSet IDs must be rewritten to use implicit-VS bindings.
  • OAuth2 is a compatibility shim. OMOPHub’s /oauth2/token endpoint implements just enough of RFC 6749 §2.3.1 to satisfy Spring Security: client_credentials grant, either client_secret_basic or client_secret_post client authentication, no scopes enforced, no refresh tokens, expires_in is nominal. Your API key is echoed back as the access token. Don’t build your own clients against it - use the REST API directly.
  • Template terminology binding only. This guide covers coded-field validation at composition commit time. It does not cover AQL terminology-aware querying, archetype-level binding, or bidirectional FHIR ↔ openEHR data transformation. Those are separate problems outside the scope of a terminology server.
  • EHRbase caches aggressively. The externalFhirTerminologyCache holds expansion results in memory - if you change OMOPHub content, restart EHRbase (or wait for the TTL) before expecting the cache to refresh.

11. Verification Checklist

Run these in order against your own EHRbase + OMOPHub setup:
#TestExpected result
1Start EHRbase with OMOPHub configuredStartup log shows Initializing 'omophub' external terminology provider (type: FHIR) at https://fhir.omophub.com/fhir/r4/
2curl https://fhir.omophub.com/fhir/r4/metadata -H "Authorization: Bearer $KEY"HTTP 200, FHIR CapabilityStatement
3curl -X POST https://fhir.omophub.com/oauth2/token -d "grant_type=client_credentials&client_id=$KEY&client_secret=unused"HTTP 200, {"access_token":"oh_...","token_type":"Bearer",...}
4Upload an OPT template with a terminology://fhir.hl7.org/ValueSet?... bindingHTTP 201 from EHRbase’s template endpoint
5Submit a composition with a SNOMED code in the bound ValueSetHTTP 201
6Submit a composition with a SNOMED code not in the bound ValueSetHTTP 422, error text "does not match any option from value set"
7Check EHRbase logs for OAuth2AuthorizationExceptionShould not appear - if it does, verify client-id matches your OMOPHub API key

12. Troubleshooting

OAuth2AuthorizationException: [invalid_request] client_id is required Your OMOPHub API key is missing or the Spring placeholder didn’t resolve. Confirm OMOPHUB_CLIENT_ID is set in the container’s environment (docker exec ehrbase printenv | grep OMOPHUB) and that ehrbase.yml is mounted at the path SPRING_CONFIG_ADDITIONAL_LOCATION points to. 502 Bad Gateway: 404 Not Found from GET .../fhir/r4/ValueSet EHRbase’s ValueSet support check is hitting the wrong base URL. Most common cause: the url: under validation.external-terminology.provider.omophub is missing the /fhir/r4/ path prefix. The correct value is https://fhir.omophub.com/fhir/r4/ (with trailing slash). The terminology {code} must be {system} The terminology_id.value in your submitted DV_CODED_TEXT does not match the system URI returned by $expand. Use the canonical system URI (http://snomed.info/sct, not the ValueSet sentinel URL) in composition bodies. Failed to validate DvCodedText ... No example for terminology ... available You’re submitting an auto-generated example composition with placeholder values. EHRbase’s template example generator produces code_string: "42" for fields bound to unknown FHIR ValueSets - always a cache-miss. Replace with real codes before testing validation. EHRbase log shows terminology-provider init, but commits never hit OMOPHub Your template is not actually using the FHIR binding prefixes. Check that <referenceSetUri> starts with terminology://fhir.hl7.org/ or //fhir.hl7.org/ - other URI schemes (e.g. terminology:SNOMED-CT?subset=...) are handled by EHRbase’s internal terminology and never route through the FHIR provider.

13. Next Steps

FHIR Integration

The full surface of OMOPHub’s FHIR Terminology Service - $lookup, $translate, $subsumes, $find-matches, batch Bundles, plus the OMOP FHIR Resolver for ETL pipelines.

HAPI FHIR

Point a HAPI FHIR server at OMOPHub as a remote terminology backend - useful if your openEHR stack also fronts a HAPI FHIR facade for external clients.

EHR Integration

Patterns for CDS Hooks, SMART on FHIR apps, and sidecar architectures that resolve vocabularies at the point of care.