Skip to content

sct serve

Run a lightweight, local, read-only SNOMED CT-focused subset of the FHIR R4 terminology service over a SQLite database. It is designed for development, testing, and small deployments that need the documented operations without Elasticsearch, a JVM, or a remote dependency.

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 client can use the documented GET or standard POST Parameters forms of $lookup / $validate-code / $subsumes / $expand / $translate and you want sub-millisecond, offline, single-binary terminology. The entire server is backed by one inspectable snomed.db file. Inline SNOMED ValueSets are supported for the documented expansion and membership semantics; arbitrary transient code systems and generic terminology resources are not, so this is not a drop-in general terminology server or an approved HL7 Terminology Ecosystem server.


Usage

sct serve [--db <FILE>] [--port <PORT>] [--host <HOST>] [--fhir-base <PATH>] [--public-url <URL>] [--codelists <DIR>] [--cors-origin <ORIGIN>]...
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.
--public-url <URL> bound listener URL Externally reachable absolute HTTP(S) FHIR base URL used in metadata, derived ValueSet canonicals, and search-result fullUrl values. Credentials, invalid ports, queries, fragments, and dot path segments are rejected. Set this when binding a wildcard address or running behind a reverse proxy, for example https://fhir.example.org/fhir.
--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.
--cors-origin <ORIGIN> none (repeatable) Send CORS headers for this origin, so a browser page served from it can call this server directly. See CORS for direct browser access.
# Local dev server
sct serve --db snomed.db

# Ontoserver-compatible base path behind a public reverse proxy
sct serve --db snomed.db --host 0.0.0.0 --port 8080 --fhir-base /fhir \
  --public-url https://fhir.example.org/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.

CORS for direct browser access

sct serve itself sends no CORS headers by default, deliberately - it defaults to loopback, with no authentication, and a real deployment fronts it with the Caddy layer above (Caddyfile, CORS_ORIGINS), which already supplies CORS. That leaves a browser page unable to call sct serve directly without that proxy in front.

--cors-origin <ORIGIN> (repeatable) closes that gap for direct access: each matching request gets that exact origin echoed back in Access-Control-Allow-Origin plus Vary: Origin, and an OPTIONS preflight is answered with Access-Control-Allow-Methods: GET, POST, OPTIONS and Access-Control-Allow-Headers: Content-Type, Accept - the same values the Caddy snippet sends, so a client behind either layer sees the same answer. Pass the literal * to opt in to a bare Access-Control-Allow-Origin: * for every origin instead of naming one. An Origin that matches nothing gets no CORS headers and no error status - the request itself still succeeds; the browser is left to block it client-side, the normal CORS failure mode. Access-Control-Allow-Credentials is never sent, since the server has no authentication.

# Reach sct serve directly from a browser page on https://example.org
sct serve --db snomed.db --cors-origin https://example.org

The Caddy layer remains the right answer for a real deployment; reach for this flag only when a browser client needs to call sct serve directly, without that proxy in front.


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)
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 query parameters and a POST FHIR Parameters body. The resource routes explicitly marked GET are GET-only. Each operation has an exact parameter schema: unknown names, wrong value[x] datatypes, duplicate non-repeatable parameters, malformed primitive values, and conflicting query/body values return HTTP 400 with an OperationOutcome.issue.expression pointing at the offending input. A parameter supplied in both the query and body is accepted only when its complete ordered value list is identical. Structured Coding, CodeableConcept, inline CodeSystem/ConceptMap, and $translate.dependency inputs remain unsupported and are refused rather than discarded.

ValueSet/$expand and ValueSet/$validate-code accept an inline SNOMED ValueSet in a POST valueSet.resource parameter. The resource must include the mandatory R4 status. The supported compose forms are the whole SNOMED system, one SNOMED constraint = ECL filter, or extensional concept include/exclude groups; unsupported resource, compose, group, filter, and concept fields are refused. Supply either url or inline valueSet, not both. ValueSet/{id}/$expand accepts POST primitive controls but refuses url and inline valueSet, because the route already selects the stored ValueSet.

$expand still refuses 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.

check-system-version, system-version, $lookup's version, and ValueSet/$validate-code's systemVersion pin the request to a specific SNOMED CT release: a value that disagrees with what's loaded is refused with HTTP 400, naming the parameter the client actually sent. An empty (or whitespace-only) version part - ?version=, or a trailing-pipe canonical such as check-system-version=http://snomed.info/sct| - states no requirement at all and expands or looks up exactly as if the parameter had been omitted.

These refusals apply equally to type-level expansion (including a stored canonical URL), stored-ID expansion, and supported batch expansion entries. A refused parameter is rejected even when its value is empty, before pagination or value-set resolution; the response is an OperationOutcome, never an expansion.

# Standard POST Parameters form
curl -X POST 'http://localhost:8080/CodeSystem/$lookup' \
  -H 'Content-Type: application/fhir+json' \
  -d '{"resourceType":"Parameters","parameter":[{"name":"code","valueCode":"22298006"},{"name":"property","valueCode":"parent"}]}'

# Inline extensional SNOMED ValueSet
curl -X POST 'http://localhost:8080/ValueSet/$expand' \
  -H 'Content-Type: application/fhir+json' \
  -d '{"resourceType":"Parameters","parameter":[{"name":"valueSet","resource":{"resourceType":"ValueSet","status":"active","compose":{"include":[{"system":"http://snomed.info/sct","concept":[{"code":"22298006"}]}]}}}]}'

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.

An entry containing resource is refused with a per-entry HTTP 400 OperationOutcome, rather than silently discarding its inline input. This applies to every supported operation and leaves valid sibling entries unaffected. Supply parameters in request.url; batch expansion uses ValueSet/$expand?url=..., not the unsupported instance-level batch path.

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 a stored or implicit SNOMED ValueSet URL, or the inline SNOMED subset described above. The text filter runs over FTS5; the ecl/ form and inline constraint = filter run the shared sct ECL engine and therefore support its documented subset: hierarchy (<<, <!, >>, >!), refset membership (^), boolean (AND/OR/MINUS), and ungrouped attribute refinement (:). ECL and filter combine (intersection); filter is refused for an extensional inline list rather than being ignored.

# 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 implicit and inline expansions to active concepts; pass activeOnly=false to also see retired concepts (e.g. to look up a code from an old record). 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.

property (repeatable, an R5 parameter) adds expansion.property (a legend) and per-entry contains[].property values. Supported codes are exactly those CodeSystem/$lookup offers: parent, child and ancestor (each a valueCoding, one entry per related concept), inactive (valueBoolean), moduleId (valueCode) and effectiveTime (valueString, RF2 YYYYMMDD); * selects all six. An unknown property name returns HTTP 400 naming it rather than being ignored. Without property, the expansion is unchanged.

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), ?fhir_vs=refset/[sctid] (equivalent to ecl/^[sctid]), and ?fhir_vs=refset - the set of all reference sets. The last is a different kind of query, not an ECL reduction: it is answered from the same query as sct refset list, every reference set with at least one member loaded in this edition, and honours filter, count/offset, and the designation controls like any other expansion (activeOnly does not apply, since the listed set is already restricted to reference sets with loaded membership). A url that is neither a stored ValueSet nor a recognised implicit form returns HTTP 404; an unrecognised fhir_vs form returns HTTP 400 naming the supported forms. Neither case silently falls back to expanding the whole code system.

includeDefinition=true returns the value set's definition alongside its expansion. For an inline ValueSet, its validated identifying metadata, lifecycle status, and compose are retained; for a stored .codelist ValueSet that is its own metadata and 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. Definition data never overwrites resourceType or expansion. Implicit definitions do not emit version: 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. A list whose front-matter id is not safe as both a FHIR logical id and resource URL segment (1-64 ASCII letters, digits, -, or ., excluding . and ..) is skipped with a startup warning rather than interpolated into a resource URL. 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.

Distinct correlation claims for the same SNOMED pivot and target are retained as separate matches; identical claims are deduplicated. The deterministic result order is not a ranking or a choice of the preferred assertion. Code-only mapping results (sct map, SDK, Python and MCP) still return one row per pivot/target pair. Correlation reporting describes the recorded outgoing SNOMED-to-classification map; it does not compose equivalence across multiple mapping steps or invert it for reverse lookups.

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.
  • FHIR interoperability is deliberately scoped - operation endpoints accept GET query parameters and standard POST Parameters, with exact per-operation datatype and cardinality checks. Inline SNOMED ValueSets use the documented expansion and membership subset; arbitrary transient terminology resources remain unsupported. The repository regression profile is not HL7 certification; see FHIR conformance and benchmarks for the independent evidence layers and current baseline.
  • ^ (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.