Skip to main content

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

2. How HAPI Consumes OMOPHub

HAPI FHIR’s RemoteTerminologyServiceValidationSupport validates a code in three steps:
1

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

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

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

3.1 application.yaml overlay

Mount this file into the container at /app/config/application.yaml:
application.yaml
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.conf

3.3 Docker Compose

A minimal stack - HAPI + Postgres + the auth-injecting proxy:
docker-compose.yml
Start it:

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

TerminologyConfig.java
Register the bean in your ValidationSupportChain:
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:
application.yml
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:
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.

5.1 HAPI itself is up

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:
Expected:
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

Expected:
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

5.5 Direct check against OMOPHub

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

6. Supported Operations

HAPI’s RemoteTerminologyServiceValidationSupport consumes this subset of OMOPHub’s FHIR Terminology Service: 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 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:
Pin a specific release with the X-Vocab-Release request header or the vocab_release query parameter. See 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:

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. OMOPHub suggests the closest match in its error message - use that.

8. Next Steps

FHIR Integration

The full OMOPHub FHIR Terminology Service surface - $find-matches, $closure, $diff, Batch Bundles, plus the OMOP FHIR Resolver for ETL pipelines.

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.

EHR Integration

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