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

# HAPI FHIR Integration

> Configure HAPI FHIR to use OMOPHub as a remote terminology backend - $validate-code, $lookup, and $translate over the FHIR R4/R5 wire protocol.

## 1. Overview

HAPI FHIR can delegate terminology operations to a remote FHIR terminology server for code validation, concept lookup, and cross-vocabulary translation. Point it at `https://fhir.omophub.com/fhir/r4` and you get all 130+ OMOP vocabularies (SNOMED CT, LOINC, RxNorm, ICD-10, NDC, UCUM, …) without downloading ATHENA and managing a local PostgreSQL vocabulary database.

This guide covers two deployment shapes:

* **HAPI FHIR JPA Starter** (`hapiproject/hapi:latest` Docker image) - the most common "just run HAPI" setup. Needs a small reverse-proxy workaround because the starter's config schema does not expose a Bearer-token field.
* **Custom Spring Boot HAPI build** - when you embed `HapiFhirContext` + `RemoteTerminologyServiceValidationSupport` in your own Spring Boot app, you can attach a `BearerTokenAuthInterceptor` directly and skip the proxy.

Both are covered below, with working snippets verified against HAPI FHIR 8.8.0.

<Note>
  OMOPHub serves **R4, R5, and R6**. This guide uses R4 throughout because it's what HAPI FHIR JPA Starter and most production HAPI deployments run. To target R5 or R6, replace `/fhir/r4` with `/fhir/r5` or `/fhir/r6` in your config - both the terminology endpoints and the `CapabilityStatement` at `/metadata` work identically.
</Note>

## 2. How HAPI Consumes OMOPHub

HAPI FHIR's `RemoteTerminologyServiceValidationSupport` validates a code in three steps:

<Steps>
  <Step title="Discovery - CodeSystem search">
    `GET /fhir/r4/CodeSystem?url=http://loinc.org` - "does this server know LOINC?". HAPI's client expects a FHIR `Bundle` (type `searchset`) with at least one `CodeSystem` entry. OMOPHub returns a lightweight stub with `content: "not-present"`, which tells HAPI the server supports LOINC via operations but doesn't host the full concept list as a resource. If the Bundle is empty, HAPI skips OMOPHub entirely for that vocabulary.
  </Step>

  <Step title="Code validation">
    `GET /fhir/r4/CodeSystem/$validate-code?url=http://loinc.org&code=2951-2` - HAPI asks OMOPHub whether `2951-2` is a valid LOINC code and (optionally) whether the display text matches. OMOPHub's handler dispatches on the FHIR system URI, looks up the concept in the underlying OMOP schema, and returns a FHIR `Parameters` resource with `result: boolean` and the canonical `display` string.
  </Step>

  <Step title="Fallback">
    If OMOPHub returns `result: false` or the HTTP request fails, HAPI's validation chain falls through to whatever other support classes it has configured (in-memory default value sets, local CodeSystems loaded into the JPA database, etc.). This makes OMOPHub safely additive - it never *blocks* validation that HAPI could answer locally.
  </Step>
</Steps>

HAPI's code resolution flow also covers `$lookup` (single-code lookup for display/designations/properties) and `$translate` (cross-vocabulary mapping via `Maps to`). Those are wired through the same config; no extra setup.

## 3. Path A: HAPI FHIR JPA Starter (Docker)

This is the path if you're running the stock `hapiproject/hapi:latest` image. The challenge: **the JPA Starter's YAML schema has no `auth` field on its remote-terminology config**. It accepts `system` and `url` for each provider, but it never calls `addClientInterceptor()` on the resulting validation-support bean, so Bearer tokens can't be attached through configuration alone. We work around this with a tiny reverse proxy that injects the `Authorization` header on outbound requests.

<Warning>
  **The YAML layout used by the JPA Starter is `hapi.fhir.remote_terminology_service.<name>.{system, url}` - not `hapi.fhir.validation.remote_terminology_service_urls`.** Older HAPI FHIR documentation and blog posts reference the `validation.remote_terminology_service_urls` key - that property path is not bound by the stock JPA Starter and silently has no effect. Always verify your config against `src/main/resources/application.yaml` in the `hapifhir/hapi-fhir-jpaserver-starter` repo.
</Warning>

### 3.1 `application.yaml` overlay

Mount this file into the container at `/app/config/application.yaml`:

```yaml application.yaml theme={null}
hapi:
  fhir:
    fhir_version: R4
    validation:
      requests_enabled: true
      responses_enabled: true
    remote_terminology_service:
      omophub:
        system: '*'
        url: http://omophub-proxy:8080/fhir/r4/
```

Notes:

* `system: '*'` makes OMOPHub the catch-all terminology authority for every code system. If you want to scope it (e.g. SNOMED via OMOPHub, ICD-9 via a different server), register multiple entries and set each `system` to a specific canonical URI.
* `url:` must point at your reverse proxy, not at OMOPHub directly - the proxy is what adds the `Authorization` header. Trailing slash required.
* `validation.requests_enabled` / `responses_enabled` are off by default in the JPA Starter - flip them on so validation actually runs against incoming and outgoing resources.

### 3.2 Reverse proxy (nginx)

This proxy listens on port 8080, forwards everything to `https://fhir.omophub.com`, and injects `Authorization: Bearer $OMOPHUB_CLIENT_ID` on every request.

```nginx nginx.conf theme={null}
events { worker_connections 64; }
http {
  server {
    listen 8080;
    location / {
      proxy_pass https://fhir.omophub.com;
      proxy_set_header Host fhir.omophub.com;
      proxy_set_header Authorization "Bearer ${OMOPHUB_CLIENT_ID}";
      proxy_http_version 1.1;
      proxy_read_timeout 30s;
    }
  }
}
```

### 3.3 Docker Compose

A minimal stack - HAPI + Postgres + the auth-injecting proxy:

```yaml docker-compose.yml theme={null}
services:
  hapi-db:
    image: postgres:16
    environment:
      POSTGRES_USER: admin
      POSTGRES_PASSWORD: admin
      POSTGRES_DB: hapi
    healthcheck:
      test: ['CMD-SHELL', 'pg_isready -U admin']
      interval: 5s

  omophub-proxy:
    image: nginx:alpine
    environment:
      OMOPHUB_CLIENT_ID: ${OMOPHUB_CLIENT_ID}
    volumes:
      - ./nginx.conf:/etc/nginx/templates/default.conf.template:ro
    command: >
      sh -c 'envsubst "\$$OMOPHUB_CLIENT_ID" < /etc/nginx/templates/default.conf.template > /etc/nginx/nginx.conf && nginx -g "daemon off;"'

  hapi:
    image: hapiproject/hapi:latest
    depends_on:
      hapi-db: { condition: service_healthy }
      omophub-proxy: { condition: service_started }
    environment:
      SPRING_DATASOURCE_URL: jdbc:postgresql://hapi-db:5432/hapi
      SPRING_DATASOURCE_USERNAME: admin
      SPRING_DATASOURCE_PASSWORD: admin
      SPRING_DATASOURCE_DRIVER_CLASS_NAME: org.postgresql.Driver
      SPRING_JPA_PROPERTIES_HIBERNATE_DIALECT: org.hibernate.dialect.PostgreSQLDialect
      SPRING_CONFIG_ADDITIONAL_LOCATION: file:/app/config/application.yaml
    volumes:
      - ./application.yaml:/app/config/application.yaml:ro
    ports: ['8080:8080']
```

Start it:

```bash theme={null}
export OMOPHUB_CLIENT_ID=oh_your_api_key_here
docker compose up -d
```

## 4. Path B: Custom Spring Boot HAPI Build

If you're embedding HAPI FHIR into your own Spring Boot application (as opposed to running the JPA Starter image), you can wire the remote terminology validator programmatically - no proxy needed. The `BearerTokenAuthInterceptor` attaches to the HTTP client before every outbound call.

### 4.1 Static Bearer token

```java TerminologyConfig.java theme={null}
@Configuration
public class TerminologyConfig {

  @Value("${omophub.api-key}")
  private String omophubApiKey;

  @Bean
  public IValidationSupport omophubTerminologyService(FhirContext fhirContext) {
    RemoteTerminologyServiceValidationSupport remote =
        new RemoteTerminologyServiceValidationSupport(fhirContext);
    remote.setBaseUrl("https://fhir.omophub.com/fhir/r4");
    remote.addClientInterceptor(new BearerTokenAuthInterceptor(omophubApiKey));
    return remote;
  }
}
```

Register the bean in your `ValidationSupportChain`:

```java theme={null}
@Bean
public ValidationSupportChain validationSupportChain(
    FhirContext fhirContext,
    IValidationSupport omophubTerminologyService
) {
  return new ValidationSupportChain(
      new DefaultProfileValidationSupport(fhirContext),
      omophubTerminologyService,
      new InMemoryTerminologyServerValidationSupport(fhirContext),
      new CommonCodeSystemsTerminologyService(fhirContext)
  );
}
```

Put OMOPHub **before** `InMemoryTerminologyServerValidationSupport` so your code system queries reach OMOPHub first; the in-memory support handles a few HL7-standard value sets that OMOPHub doesn't.

### 4.2 OAuth2 client\_credentials (alternative)

OMOPHub's `/oauth2/token` endpoint accepts RFC 6749 `client_credentials` with either `client_secret_basic` (the Spring Security default) or `client_secret_post`. If your deployment prefers rotating OAuth2 tokens to static bearer keys, wire it via Spring Security:

```yaml application.yml theme={null}
spring:
  security:
    oauth2:
      client:
        registration:
          omophub:
            client-id: ${OMOPHUB_CLIENT_ID}
            client-secret: unused # ignored by OMOPHub - see note below
            authorization-grant-type: client_credentials
            provider: omophub
        provider:
          omophub:
            token-uri: https://fhir.omophub.com/oauth2/token
```

OMOPHub's `/oauth2/token` endpoint is a minimal RFC 6749 `client_credentials` shim: it accepts either `client_secret_basic` (the Spring Security default) or `client_secret_post` client authentication, and it **does not validate `client_secret`** - your OMOPHub API key is the sole credential, passed as `client_id`. Spring's config schema requires `client-secret` to be non-empty, so supply any non-empty placeholder (`unused`, `not-required`, etc.); OMOPHub ignores the value either way.

Then install an interceptor that fetches a token from the authorized-client manager and passes it as a Bearer header:

```java theme={null}
@Bean
public IClientInterceptor omophubOAuth2Interceptor(
    OAuth2AuthorizedClientManager clientManager
) {
  return (theRequest) -> {
    var principal = new AnonymousAuthenticationToken(
        "omophub", "omophub",
        List.of(new SimpleGrantedAuthority("ROLE_SERVICE"))
    );
    var authorizedClient = clientManager.authorize(
        OAuth2AuthorizeRequest.withClientRegistrationId("omophub")
            .principal(principal)
            .build()
    );
    String token = authorizedClient.getAccessToken().getTokenValue();
    theRequest.addHeader("Authorization", "Bearer " + token);
  };
}
```

The token OMOPHub returns is just your API key wrapped in an RFC 6749 envelope (`{access_token, token_type, expires_in}`) - but the round trip does exercise token refresh behavior, which is useful for enterprise deployments that rotate credentials.

## 5. Verify It Works

Walk through these curls against your HAPI server after startup. Every command should produce the expected output - if any fails, jump to [Troubleshooting](#7-troubleshooting).

### 5.1 HAPI itself is up

```bash theme={null}
curl -sS http://localhost:8080/fhir/metadata | jq '.software'
# Expected: { "name": "HAPI FHIR Server", "version": "..." }
```

### 5.2 CodeSystem discovery reaches OMOPHub

HAPI's validator calls the FHIR CodeSystem search-type endpoint to check which systems OMOPHub serves. You can trigger the same call HAPI would make:

```bash theme={null}
curl -sS -X POST http://localhost:8080/fhir/CodeSystem/\$validate-code \
  -H "Content-Type: application/fhir+json" \
  -d '{
    "resourceType": "Parameters",
    "parameter": [
      { "name": "url",  "valueUri":  "http://loinc.org" },
      { "name": "code", "valueCode": "2951-2" }
    ]
  }' | jq '.parameter'
```

Expected:

```json theme={null}
[
  { "name": "result",  "valueBoolean": true },
  { "name": "display", "valueString":  "Sodium [Moles/volume] in Serum or Plasma" }
]
```

If you see `result: false` and a message like `"CodeSystem is unknown and can't be validated: http://loinc.org"`, the remote terminology service wasn't consulted - check the config path first, then the proxy's auth header (see troubleshooting).

### 5.3 Bogus code is rejected

```bash theme={null}
curl -sS -X POST http://localhost:8080/fhir/CodeSystem/\$validate-code \
  -H "Content-Type: application/fhir+json" \
  -d '{
    "resourceType": "Parameters",
    "parameter": [
      { "name": "url",  "valueUri":  "http://loinc.org" },
      { "name": "code", "valueCode": "99999-9" }
    ]
  }' | jq '.parameter'
```

Expected:

```json theme={null}
[
  { "name": "result",  "valueBoolean": false },
  { "name": "message", "valueString":  "... Code '99999-9' not found in LOINC" }
]
```

HAPI forwards the exact error message OMOPHub returned, so you'll see "not found in LOINC" rather than a generic HAPI message - a quick visual confirmation that the remote server is in the loop.

### 5.4 SNOMED works the same way

```bash theme={null}
curl -sS -X POST http://localhost:8080/fhir/CodeSystem/\$validate-code \
  -H "Content-Type: application/fhir+json" \
  -d '{
    "resourceType": "Parameters",
    "parameter": [
      { "name": "url",  "valueUri":  "http://snomed.info/sct" },
      { "name": "code", "valueCode": "44054006" }
    ]
  }' | jq '.parameter'
# Expected: result: true, display: "Type 2 diabetes mellitus"
```

### 5.5 Direct check against OMOPHub

As a sanity check independent of HAPI, curl OMOPHub directly (or through the proxy):

```bash theme={null}
curl -sS https://fhir.omophub.com/fhir/r4/CodeSystem?url=http://loinc.org \
  -H "Authorization: Bearer $OMOPHUB_CLIENT_ID" | jq '.entry[0].resource | {id, url, content}'
# Expected: { "id": "loinc", "url": "http://loinc.org", "content": "not-present" }
```

## 6. Supported Operations

HAPI's `RemoteTerminologyServiceValidationSupport` consumes this subset of OMOPHub's FHIR Terminology Service:

| Operation                    | OMOPHub endpoint                              | HAPI trigger                                                       |
| ---------------------------- | --------------------------------------------- | ------------------------------------------------------------------ |
| CodeSystem discovery         | `GET /fhir/r4/CodeSystem?url=<uri>`           | Every time HAPI first needs to resolve a system                    |
| CodeSystem instance read     | `GET /fhir/r4/CodeSystem/{id}`                | HAPI follow-up after discovery                                     |
| Code validation              | `GET/POST /fhir/r4/CodeSystem/$validate-code` | `$validate-code` operation + resource validation with coded fields |
| Concept lookup               | `GET/POST /fhir/r4/CodeSystem/$lookup`        | `$lookup` operation + display-text resolution                      |
| Cross-vocabulary translation | `GET/POST /fhir/r4/ConceptMap/$translate`     | `$translate` operation                                             |
| Subsumption testing          | `GET/POST /fhir/r4/CodeSystem/$subsumes`      | `$subsumes` operation                                              |
| ValueSet expansion           | `GET/POST /fhir/r4/ValueSet/$expand`          | `$expand` operation, CQL engine, some validators                   |
| ValueSet support check       | `GET /fhir/r4/ValueSet?url=<uri>`             | Pre-flight for `$expand` / `$validate-code`                        |

Operations HAPI does **not** proxy automatically (but you can call them directly from application code): `$find-matches`, `$closure`, `$diff`, and FHIR Batch Bundles. See the [FHIR Integration guide](/guides/integration/fhir-integration) for the full operation surface including those.

### Version routing

All the routes above accept an optional version prefix:

* `/fhir/r4/...` - FHIR R4 (what HAPI JPA Starter uses by default)
* `/fhir/r5/...` - FHIR R5 (point HAPI 7.x + `HAPI_FHIR_FHIR_VERSION: R5` here)
* `/fhir/r6/...` - FHIR R6 (draft)

Every response includes the vocabulary release that served it:

```
X-Vocab-Release: 2026.1
X-Vocab-Release-Status: active
```

Pin a specific release with the `X-Vocab-Release` request header or the `vocab_release` query parameter. See [Vocabulary Versions](/vocabulary-versions) for the full release history.

### Error envelope

All 4xx/5xx responses from `/fhir/*` paths return a FHIR `OperationOutcome` resource (not the generic REST JSON envelope), so HAPI's validator parses them cleanly and surfaces the `diagnostics` field in its own logs. For example, a missing API key returns:

```json theme={null}
{
  "resourceType": "OperationOutcome",
  "issue": [{
    "severity": "error",
    "code": "login",
    "details": { "text": "Missing API key in the authorization header" },
    "diagnostics": "missing_api_key"
  }]
}
```

## 7. Troubleshooting

**`CodeSystem is unknown and can't be validated: http://loinc.org`** on every `$validate-code`

HAPI's remote terminology chain never reached OMOPHub. Three likely causes:

1. **Config not loaded.** Check `docker exec <container> env | grep SPRING_CONFIG_ADDITIONAL_LOCATION` and confirm the overlay file is mounted. HAPI silently ignores unknown config keys, so a typo (e.g. `remote_terminology` vs `remote_terminology_service`) yields no startup error - only the "unknown" validator error at query time.
2. **Auth failing at the proxy.** Run `docker logs <proxy>` and look for `401` responses from the upstream. Your `OMOPHUB_CLIENT_ID` env var may not be populated inside the nginx container - `envsubst` only substitutes variables that are actually exported.
3. **HAPI connecting but hitting a parse error.** Check HAPI's logs for `HAPI-1359: Failed to parse response`. If the stack trace mentions Woodstox / StAX / XML, HAPI's client fell into XML-parse mode because the `Accept` header negotiated XML first. OMOPHub prefers JSON on q-value ties, so this shouldn't happen with the stock HAPI client - but a custom `IGenericClient` with `setEncoding(EncodingEnum.XML)` will fail. Remove the XML encoding preference and it works.

**`HAPI-1359: Failed to parse response ... HAPI-1684: Unknown resource name "resourceType"`**

Classic XML-parser-reading-JSON error (the row/col coords refer to Woodstox output). OMOPHub's content negotiation returns JSON whenever the client advertises JSON support at equal-or-higher q-value than XML, which matches HAPI's default. If you're seeing this error, your HAPI client is either:

* Configured to request XML only (`Accept: application/fhir+xml`), or
* Forcing XML via `setEncoding(EncodingEnum.XML)` - switch to `JSON`.

**`No live upstreams` from nginx after network restart**

If the Docker network reshuffles IPs and nginx caches a stale upstream, restart the proxy container: `docker compose restart omophub-proxy`. Optionally add `resolver 127.0.0.11 valid=5s;` and `set` the upstream via a variable to force DNS re-resolution on every request.

**`Unknown code system URI: http://example.com/unknown`**

OMOPHub returned an `OperationOutcome` saying the URI isn't recognized. Check that the system URI in your HAPI request matches one of the [supported FHIR system URIs](/guides/integration/fhir-integration#3-authentication--endpoints). OMOPHub suggests the closest match in its error message - use that.

## 8. Next Steps

<CardGroup cols={3}>
  <Card title="FHIR Integration" icon="fire" href="/guides/integration/fhir-integration">
    The full OMOPHub FHIR Terminology Service surface - `$find-matches`, `$closure`, `$diff`, Batch Bundles, plus the OMOP FHIR Resolver for ETL pipelines.
  </Card>

  <Card title="EHRbase / openEHR" icon="database" href="/guides/integration/ehrbase-openehr">
    Point EHRbase at OMOPHub for template terminology validation. Uses the same FHIR endpoints as HAPI, with OAuth2 client\_credentials as the native auth path.
  </Card>

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