Skip to content

sct serve

Run a FHIR R4 terminology server over a SNOMED CT SQLite database - a lightweight, local, drop-in alternative to hosted services (Ontoserver, Snowstorm, the NHS FHIR Terminology Server) for development, testing, and small-scale production.

Included by default

sct serve is compiled in by default (the serve Cargo feature is part of default), so it is present in the released binaries, cargo install sct-rs, and s/install. For a minimal build without the async HTTP stack, use --no-default-features.

When to use: a FHIR client (EHR, HL7 validator, SMART app, integration engine) needs $lookup / $validate-code / $subsumes / $expand and you want sub-millisecond, offline, single-binary terminology with no Elasticsearch, JVM, or Docker. The entire server is backed by one inspectable snomed.db file.


Usage

sct serve [--db <FILE>] [--port <PORT>] [--host <HOST>] [--fhir-base <PATH>] [--codelists <DIR>]
Flag Default Description
--db <FILE> discovered (see Path resolution) SQLite database produced by sct sqlite.
--port <PORT> 8080 TCP port to listen on.
--host <HOST> 127.0.0.1 Address to bind. Use 0.0.0.0 to accept remote connections. The server has no authentication, so binding beyond a loopback address (127.0.0.0/8, ::1, localhost) prints a startup warning - put your own network or auth controls in front before doing so.
--fhir-base <PATH> / Base path for all routes. Set to /fhir for Ontoserver-compatible URLs.
--codelists <DIR> ./codelists (or $SCT_CODELISTS / [codelists] dir) Directory of .codelist files to serve as named FHIR ValueSets.
--fst <FILE> snomed.fst beside the database, if present FST index (from sct fst build) powering the GET /autocomplete endpoint.
--read-only on The server never writes; the flag documents that intent.
# Local dev server
sct serve --db snomed.db

# Ontoserver-compatible base path, reachable on the network
sct serve --db snomed.db --host 0.0.0.0 --port 8080 --fhir-base /fhir

Responses are application/fhir+json. An Accept header that requests XML exclusively gets a 406 (XML is not supported).

Search-as-you-type endpoint

With an FST index available (--fst, or a snomed.fst beside the database), the server also exposes a non-FHIR GET /autocomplete?q=<partial>&count=<n> endpoint - sub-millisecond, typo-tolerant autocomplete backed by the FST index, for a web front-end to hit per keystroke:

sct serve --db snomed.db --fst snomed.fst
curl 'http://localhost:8080/autocomplete?q=myocard&count=5'
# {"query":"myocard","hits":[{"id":"22298006","display":"Myocardial infarction","score":0.77,"tag":"disorder"}, ...]}

id is a JSON string (SCTIDs exceed 2^53). Without an FST index the endpoint returns 501. It shares its engine with sct sayt, which also offers an interactive TUI and a stdio line protocol over the same index.

Docker Compose

For a full self-host walkthrough - a caddy reverse proxy in front of sct for automatic HTTPS, optional basic auth, CORS, and the bootstrap/config reference - see Get your own terminology server.


Operations (Phase 1)

Endpoint What it does
GET /metadata CapabilityStatement declaring the supported operations (add ?mode=terminology for a TerminologyCapabilities statement)
CodeSystem/$lookup Concept details: display, designations (FSN + synonyms), parents, children, ancestors, inactive, moduleId, effectiveTime
CodeSystem/$validate-code Whether a code exists (and an optional display matches)
CodeSystem/$subsumes Subsumption between two codes (subsumes / subsumed-by / equivalent / not-subsumed)
GET /CodeSystem Searchset Bundle wrapping the single SNOMED CT CodeSystem resource this server serves
GET /CodeSystem/{id} The SNOMED CT CodeSystem resource metadata (content: not-present - concepts are reached via $lookup/$expand, not embedded here)
ValueSet/$expand Expand by free-text filter (FTS5), ECL, or a stored .codelist (by canonical URL)
ValueSet/$validate-code Whether a code is a member of a ValueSet (stored .codelist or implicit ECL)
GET /ValueSet Searchset Bundle of the stored .codelist ValueSets
GET /ValueSet/{id} The stored ValueSet resource (with compose)
GET /ValueSet/{id}/$expand Expand a stored ValueSet by id
ConceptMap/$translate Map a code across terminologies when the loaded database has crossmaps data (SNOMED CT ↔ ICD-10 / OPCS-4 / CTV3 / Read v2)
POST / (batch) A FHIR batch Bundle of $lookup, $validate-code, $subsumes, $expand, or $translate operation calls, executed in one request

FHIR operation endpoints shown without an explicit method accept both GET and POST. The resource routes explicitly marked GET are GET-only. Operation parameters are always read from the query string, including on POST - this server does not accept a Parameters resource as a request body. ValueSet/$expand rejects a request body with HTTP 400 rather than ignoring it, because a discarded body previously left the operation with no value set to expand and it fell back to returning the entire code system.

For the same reason, $expand refuses valueSet (inline definitions), valueSetVersion, context, date, exclude-system, and force-system-version with HTTP 400 instead of ignoring them: each one narrows or redirects an expansion, so silently dropping it widens the result. R4 sanctions this directly for date, which says the server should honour it "or return an error if this is not possible". system-version is treated as equivalent to check-system-version, since an implicit SNOMED ValueSet never specifies its own version.

Transitive closure fallback

At startup, sct serve checks whether the database has a usable transitive closure table: a transactionally completed table with the expected schema, indexes, and source/closure invalidation triggers. Ancestor lookup, subsumption, and hierarchy ECL expansion use indexed TCT queries when usable and correct recursive-CTE fallbacks otherwise. A missing, legacy, stale, or damaged TCT produces the shared build-or-repair instruction on server stderr, never inside a FHIR response; run sct tct --db <db> to remove the warning and accelerate those operations.

Batch requests

POST a FHIR batch (or transaction) Bundle to the base path to run many operations in one round trip - handy for a client that would otherwise fire dozens of sequential $lookup / $validate-code / $translate calls. Each entry's request.url is a GET operation URL; the response is a batch-response Bundle with one entry per request (in order), each carrying an HTTP response.status and the result resource (or an OperationOutcome for that entry). Entries succeed or fail independently. The server is read-only, so entries must use GET. Bundles are limited to 100 entries, and every HTTP request has a 30-second response timeout.

curl -X POST 'http://localhost:8080/fhir' -H 'Content-Type: application/fhir+json' -d '{
  "resourceType": "Bundle", "type": "batch",
  "entry": [
    { "request": { "method": "GET", "url": "CodeSystem/$lookup?system=http://snomed.info/sct&code=22298006" } },
    { "request": { "method": "GET", "url": "CodeSystem/$subsumes?system=http://snomed.info/sct&codeA=46635009&codeB=73211009" } }
  ]
}'

$expand and ECL

$expand accepts the FHIR implicit SNOMED ValueSet URL. The text filter runs over FTS5; the ecl/ form runs the full sct ECL engine - so $expand supports hierarchy (<<, <!, >>, >!), refset membership (^), boolean (AND/OR/MINUS), and attribute refinement (:), well beyond simple subtype expansion. ECL and filter combine (intersection).

# Subtypes of Diabetes mellitus (URL-encoded ECL "<<73211009")
curl 'http://localhost:8080/ValueSet/$expand?url=http://snomed.info/sct?fhir_vs=ecl/%3C%3C73211009'

# Free-text expansion
curl 'http://localhost:8080/ValueSet/$expand?url=http://snomed.info/sct?fhir_vs&filter=heart+attack&count=10'

# Attribute refinement (finding site)
curl 'http://localhost:8080/ValueSet/$expand?url=http://snomed.info/sct?fhir_vs=ecl/%3C%3C404684003%20:%20363698007%20=%20%3C%3C39057004'

count (default 100, max 1000) and offset paginate; malformed or negative values return HTTP 400, and the expansion.total reflects the full match set. includeDesignations=true adds direct FHIR designation objects for each entry's FSN and synonyms. activeOnly (default true) filters the expansion to active concepts; pass activeOnly=false to also see retired concepts (e.g. to look up a code from an old record). activeOnly applies to the implicit SNOMED ValueSet only - a stored .codelist ValueSet's fixed member list always returns every member regardless of this parameter.

designation (repeatable) selects which designations come back, once designations are included - it doesn't change which concepts are returned. Each value is either system|code or a bare code (900000000000003001 for the FSN, 900000000000013009 for a Synonym), * to request everything, or a BCP-47 language tag. Passing designation at all is honoured as includeDesignations=true, even without that parameter or with it set to false - matching the R4 operation definition. Because sct loads a single English locale per database, an en/en-* language tag matches every designation and any other language tag matches none, since there is no other locale to return.

The supported implicit SNOMED ValueSet URL forms are ?fhir_vs (the whole code system), ?fhir_vs=ecl/[ecl], ?fhir_vs=isa/[sctid] (equivalent to ecl/<<[sctid], i.e. including the concept itself), and ?fhir_vs=refset/[sctid] (equivalent to ecl/^[sctid]). ?fhir_vs=refset - the set of all reference sets - is a different kind of query and is not implemented; it returns HTTP 400 naming the supported forms. A url that is neither a stored ValueSet nor a recognised implicit form returns HTTP 404. Neither case silently falls back to expanding the whole code system.

includeDefinition=true returns the value set's definition alongside its expansion. For a stored .codelist ValueSet that is its own compose (the enumerated concepts); for an implicit SNOMED ValueSet it is the R4 SNOMED template - compose.include.filter with property=constraint, op==, and the ECL as the value, or the whole code system with no filter for the bare ?fhir_vs form. The definition never overwrites the expansion's own fields. No version is emitted: SNOMED's URI specification requires the full http://snomed.info/sct/[sctid]/version/[YYYYMMDD] form and says a bare release date is not safe to publish as one, and sct records the release date but not the edition's module SCTID - so it omits the field rather than emit a non-conformant one. Use /metadata or $lookup to discover the loaded release.

check-system-version=http://snomed.info/sct|<version> lets a client refuse terminology from any other release. sct serves exactly one SNOMED CT release per process, so the pin is compared against the loaded release (its recorded release date, as shown by /metadata and $lookup): a match expands normally, and a mismatch returns HTTP 400 with an OperationOutcome naming both versions instead of an expansion. A pin naming any other code system is ignored, since no other system contributes codes here, as is a bare system with no |version. If the database records no release version the pin cannot be verified, and the request fails rather than silently serving terminology of unknown vintage - which is the failure the parameter exists to prevent. Expansions that don't pass the parameter are unaffected.

excludeNested=true is accepted and has no effect: sct never emits expansion.contains entries nested inside other contains entries (SNOMED CT has no FHIR-style abstract grouping-only concepts - every concept is a real, selectable one), so every expansion is already flat whether or not a client asks for that.

displayLanguage is resolved rather than routed: a loaded database bakes in one English dialect's preferred terms at build time (sct ndjson --locale) and does not record which, so a request whose language is English (en, en-GB, en-US, ...) is honoured verbatim and anything else falls back to bare en - the only language sct ever loads. Either way the resolved value is reported back as an expansion.parameter entry named displayLanguage, so a client can tell whether its request was actually honoured; the parameter is omitted when displayLanguage wasn't requested at all.

Stored ValueSets from .codelist files

Point --codelists <dir> (default ./codelists) at a directory of .codelist files and the server exposes each as a named FHIR ValueSet. Composition is resolved at startup, so a list that includes: others is served as its full effective member set. Security model: "public by placement" - only files in the served directory are exposed; keep private lists elsewhere. Expansion reconciles each concept's display against the live database (falling back to the stored term for concepts absent from the loaded edition).

sct serve --db snomed.db --codelists ./codelists &

# List the served ValueSets
curl 'http://localhost:8080/ValueSet'

# Read one (full resource with compose.include.concept)
curl 'http://localhost:8080/ValueSet/diabetes'

# Expand it (by id, or by its canonical URL via $expand?url=...)
curl 'http://localhost:8080/ValueSet/diabetes/$expand?count=20'

# Validate membership
curl 'http://localhost:8080/ValueSet/$validate-code?url=http://localhost:8080/ValueSet/diabetes&code=46635009'

The canonical URL of a served list is {server-base}/ValueSet/{id}, unless the .codelist front-matter sets an explicit canonical_url - then that value is used verbatim instead, so a list mirroring a value set already published elsewhere (an NHS or vendor canonical) keeps the same identity regardless of which sct serve instance hosts it. This is the same override sct codelist export --format fhir-json honours, so the exported and served forms never diverge. $validate-code also works against an implicit ECL value set (?url=http://snomed.info/sct?fhir_vs=ecl/...).

GET /ValueSet optionally filters by ?status= (draft | active | retired | unknown, the FHIR ValueSet.status value set) in addition to the existing ?url= and ?_id= filters, so a client can list only published lists or only drafts:

# Only lists not yet promoted out of draft
curl 'http://localhost:8080/ValueSet?status=draft'

Cross-terminology translation (ConceptMap/$translate)

Map a code between SNOMED CT, ICD-10, OPCS-4, CTV3, and Read v2 using the same maps as sct map. $translate is advertised in /metadata only when the loaded SQLite database has the crossmaps table. sct trud download --multi-terminology builds the full map set; manually, ICD-10 / OPCS-4 need sct ndjson --refsets all, and Read v2 needs sct read2 import over TRUD item 9.

# SNOMED CT -> ICD-10
curl 'http://localhost:8080/ConceptMap/$translate?system=http://snomed.info/sct&code=22298006&targetsystem=http://hl7.org/fhir/sid/icd-10'

# Bare names also accepted; reverse works too (ICD-10 -> SNOMED CT).
# ICD-10 input is tolerant of the undotted form (I219 as well as I21.9).
curl 'http://localhost:8080/ConceptMap/$translate?system=icd10&code=I219&targetsystem=snomed'

Returns a Parameters resource with result (boolean) and a match part per mapping. This is a drop-in target for the existing DMWB Excel add-in, which can point at a FHIR server - giving analysts the familiar worksheet workflow on a fast, offline backend.

Each match also carries an equivalence code reflecting the ICD-10/OPCS-4 map's RF2 correlationId, when the source data records one: equivalent (exact match), wider (target broader than source), narrower (target narrower than source), or inexact (partial overlap). relatedto is the fallback for CTV3/Read v2 (which carry no correlation) and for a correlation the release records as unspecified.

Examples

sct serve --db snomed.db &

curl 'http://localhost:8080/metadata'
curl 'http://localhost:8080/CodeSystem/$lookup?code=22298006&property=parent&property=designation'
curl 'http://localhost:8080/CodeSystem/$validate-code?code=22298006'
curl 'http://localhost:8080/CodeSystem/$subsumes?codeA=46635009&codeB=73211009'
curl 'http://localhost:8080/CodeSystem/sct'

Errors are FHIR OperationOutcome resources with the appropriate status (404 unknown code, 400 invalid parameter, 406 XML requested, 500 server error).


Scope and limitations

This is Phase 1. Known boundaries (see spec/commands/serve.md for the full picture):

  • Single edition / single version per process - the server serves whatever is in --db. CodeSystem/$lookup's system and version parameters are checked against the loaded release rather than logged and ignored: a system other than SNOMED CT, or a version that doesn't match what's loaded, is refused with a 400 rather than silently answered from whatever is loaded. $expand's equivalent check-system-version/system-version parameters, and $validate-code's url/system and version/systemVersion parameters (both the CodeSystem and ValueSet forms), all behave the same way.
  • Stored ValueSets come from .codelist files (read-only, served from --codelists); there is no write/CRUD API for ValueSets, and no stored ConceptMap resources. $closure, multi-version routing, and FHIR R5 are later phases.
  • ^ (refset) ECL depends on refsets being loaded (sct ndjson --refsets simple + sct sqlite); attribute refinement depends on the schema-v4 concept_relationships table (rebuild with a current sct).
  • No auth / SMART on FHIR - run it behind your own gateway if exposing it beyond localhost.
  • JSON only - XML requests get a 406.