Skip to main content

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.

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
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.conf
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:
docker-compose.yml
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:
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

TerminologyConfig.java
@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:
@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:
application.yml
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:
@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.

5.1 HAPI itself is up

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:
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:
[
  { "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

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:
[
  { "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

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):
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:
OperationOMOPHub endpointHAPI trigger
CodeSystem discoveryGET /fhir/r4/CodeSystem?url=<uri>Every time HAPI first needs to resolve a system
CodeSystem instance readGET /fhir/r4/CodeSystem/{id}HAPI follow-up after discovery
Code validationGET/POST /fhir/r4/CodeSystem/$validate-code$validate-code operation + resource validation with coded fields
Concept lookupGET/POST /fhir/r4/CodeSystem/$lookup$lookup operation + display-text resolution
Cross-vocabulary translationGET/POST /fhir/r4/ConceptMap/$translate$translate operation
Subsumption testingGET/POST /fhir/r4/CodeSystem/$subsumes$subsumes operation
ValueSet expansionGET/POST /fhir/r4/ValueSet/$expand$expand operation, CQL engine, some validators
ValueSet support checkGET /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 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 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:
{
  "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. 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.