> ## Documentation Index
> Fetch the complete documentation index at: https://docs.omophub.com/llms.txt
> Use this file to discover all available pages before exploring further.

# EHRbase and openEHR integration with OMOPHub

> Configure EHRbase to use OMOPHub as a remote FHIR R4 terminology server for validating coded openEHR template elements and ValueSet expansion.

## 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.

<Note>
  **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.
</Note>

## 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](https://dashboard.omophub.com/api-keys). 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).

<Tip>
  **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.
</Tip>

### 3.1 `ehrbase.yml` overlay

Mount this file into your EHRbase container at `/config/ehrbase.yml`:

```yaml ehrbase.yml theme={null}
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:

```yaml docker-compose.yml theme={null}
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:

```bash theme={null}
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:

```xml theme={null}
<!-- 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:

| Vocabulary | FHIR system URI                               |
| ---------- | --------------------------------------------- |
| SNOMED CT  | `http://snomed.info/sct`                      |
| LOINC      | `http://loinc.org`                            |
| RxNorm     | `http://www.nlm.nih.gov/research/umls/rxnorm` |
| ICD-10-CM  | `http://hl7.org/fhir/sid/icd-10-cm`           |
| NDC        | `http://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](/api-reference/fhir-terminology/overview#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:

<Steps>
  <Step title="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`.
  </Step>

  <Step title="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.
  </Step>

  <Step title="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.
  </Step>
</Steps>

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.

```bash theme={null}
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

```bash theme={null}
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:

```json composition-invalid.json theme={null}
{
  "_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"
            }
          }
        }
      ]
    }
  ]
}
```

```bash theme={null}
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`:

```json theme={null}
{
  "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.

```bash theme={null}
# 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`.

<Note>
  **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.
</Note>

## 7. Supported Operations

EHRbase uses a subset of OMOPHub's FHIR Terminology Service during validation:

| EHRbase use                  | OMOPHub endpoint                         | When it fires                                                              |
| ---------------------------- | ---------------------------------------- | -------------------------------------------------------------------------- |
| Fetch OAuth2 token           | `POST /oauth2/token`                     | First validation call, then cached                                         |
| ValueSet support check       | `GET /fhir/r4/ValueSet?url=...`          | Before each `$expand` for a new VS                                         |
| ValueSet expansion           | `GET /fhir/r4/ValueSet/$expand?url=...`  | On composition commit, per FHIR-bound field                                |
| CodeSystem validation (rare) | `GET /fhir/r4/CodeSystem/$validate-code` | Only 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](/guides/integration/fhir-integration) for the full operation surface.

## 8. What OMOPHub Adds vs. Generic Terminology Servers

| Capability                        | OMOPHub           | Ontoserver | Local ATHENA + HAPI      |
| --------------------------------- | ----------------- | ---------- | ------------------------ |
| OMOP vocabulary coverage          | 130+ vocabularies | Limited    | Full (manual download)   |
| Semantic search (`$find-matches`) | Yes               | No         | No                       |
| Phoebe recommendations            | Yes (opt-in)      | No         | No                       |
| FHIR → OMOP concept resolver      | Yes               | No         | No                       |
| R4 support                        | Yes               | Yes        | R4 only                  |
| Zero infrastructure               | Yes (cloud API)   | Self-host  | PostgreSQL + HAPI server |
| SDKs (Python, R, MCP)             | Yes               | No         | No                       |

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:

<Steps>
  <Step title="Capture">
    Clinician enters data in an openEHR form. Coded fields are validated against
    OMOPHub's `$expand` at commit time.
  </Step>

  <Step title="Store">
    EHRbase persists the composition with verified SNOMED / LOINC / RxNorm
    codes.
  </Step>

  <Step title="Extract">
    Your ETL pipeline reads compositions from EHRbase and lifts the
    `DV_CODED_TEXT` fields.
  </Step>

  <Step title="Resolve">
    Each Coding goes through OMOPHub's [FHIR
    Resolver](/guides/integration/fhir-integration) (`POST /v1/fhir/resolve`) to
    get the standard OMOP concept + CDM target table.
  </Step>

  <Step title="Load">
    Write the resolved concepts into OMOP CDM tables - `measurement`,
    `condition_occurrence`, `drug_exposure`, whichever the resolver's
    `target_table` field says.
  </Step>
</Steps>

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

<Warning>
  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](#11-verification-checklist) against their own environment before
  going live.
</Warning>

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:

| # | Test                                                                                                                        | Expected result                                                                                                            |
| - | --------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
| 1 | Start EHRbase with OMOPHub configured                                                                                       | Startup log shows `Initializing 'omophub' external terminology provider (type: FHIR) at https://fhir.omophub.com/fhir/r4/` |
| 2 | `curl https://fhir.omophub.com/fhir/r4/metadata -H "Authorization: Bearer $KEY"`                                            | HTTP 200, FHIR `CapabilityStatement`                                                                                       |
| 3 | `curl -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",...}`                                                            |
| 4 | Upload an OPT template with a `terminology://fhir.hl7.org/ValueSet?...` binding                                             | HTTP 201 from EHRbase's template endpoint                                                                                  |
| 5 | Submit a composition with a SNOMED code *in* the bound ValueSet                                                             | HTTP 201                                                                                                                   |
| 6 | Submit a composition with a SNOMED code *not* in the bound ValueSet                                                         | HTTP 422, error text `"does not match any option from value set"`                                                          |
| 7 | Check EHRbase logs for `OAuth2AuthorizationException`                                                                       | Should 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

<CardGroup cols={3}>
  <Card title="FHIR Integration" icon="fire" href="/guides/integration/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.
  </Card>

  <Card title="HAPI FHIR" icon="server" href="/guides/integration/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.
  </Card>

  <Card title="EHR Integration" icon="hospital" href="/guides/integration/ehr-integration">
    Patterns for CDS Hooks, SMART on FHIR apps, and sidecar architectures that
    resolve vocabularies at the point of care.
  </Card>
</CardGroup>
