Developer Documentation
Documentation for API consumers integrating with the Legemiddelgrunndata API.
Contents
- Getting Started — Quickstart guide with curl examples to get up and running
- API Reference — Complete endpoint documentation with parameters, responses, and examples (v1 API-key and v2 HelseID endpoints)
- Authentication — How to authenticate using API keys (v1) or HelseID with DPoP (v2) and expected error responses
- Versioning and Diff — How to use version-based differential sync to fetch only changes
- Data Models — Full schema documentation for TreatmentGroup, ReimbursementGroup, and related types
- Changelog — Release notes and version history
- Working with Agents — How to set up opencode and GitHub Copilot CLI for this repository, connect them to GitLab Milliways and Azure DevOps via MCP, and add project skills
- Code Flow Through Environments — GitLab Flow branch strategy for
main,test, andprod: feature promotion, patch/hotfix sync-back, and commit conventions - System Architecture — Data flow from upstream FHIR/FEST sources through ingestion, Postgres storage, and output controllers to API consumers
- FestClient — Internal WCF client library for consuming the FEST (Statens legemiddelverk) medicinal products service
- FEST Data Model Conventions — Naming/mapping conventions for persisting FEST/M30 reference data into
Fest*Postgres tables
Changelog
All notable changes to the Nompd API will be documented in this file.
The format is based on Keep a Changelog.
[Unreleased]
Breaking Changes
New product structure — The
productsentries in bothtreatment-groupandreimbursement-groupresponses (v1 and v2) have a new shape. Thereference,type, anddisplayfields are removed, and the singleidentifierobject is replaced by anidentifierslist:{ "identifiers": [ { "system": "FEST/LegemiddelPakning/Varenummer", "value": "448992" }, { "system": "FEST/LegemiddelPakning/Id", "value": "ADDED_WHEN_FEST_FILE_IS_READ" }, { "system": "FEST/LegemiddelMerkevare/Id", "value": "ADDED_WHEN_FEST_FILE_IS_READ" }, { "system": "FEST/LegemiddelVirkestoff/Id", "value": "ADDED_WHEN_FEST_FILE_IS_READ" } ] }The identifier system is renamed from
FEST/VarenummertoFEST/LegemiddelPakning/Varenummer(the previoustypevalue is folded into the system string). TheFEST/*/Ididentifiers carry the placeholder valueADDED_WHEN_FEST_FILE_IS_READuntil FEST file ingestion populates the real values.productsare empty between the deployment of this change and the next sync.Renamed
itemstoentriesin the response envelope — The list endpoints (treatment-groupandreimbursement-group, v1 and v2) now return{ "currentVersion": ..., "entries": [...] }instead of{ "currentVersion": ..., "items": [...] }. The entry shape (data,version,lastChanged,isDeleted) is unchanged. No backwards-compatible parallel field — consumers must migrate.
Added
FEST validity filtering (
IsInFest) — Products (ReimbursementGroupProduct/TreatmentAlternativeProduct) now carry an internalIsInFestflag, computed whenever their FEST identifiers are (re)resolved, from whether aFEST/LegemiddelPakning/Ididentifier with a non-"Missing"value could be found. By default (ignore-fest-validation=false), the flag is omitted from the JSON response shape.GET /api/v1/treatment-group,GET /api/v1/reimbursement-group, their/{id}and v2 counterparts now drop products without a resolved FEST match from a group's response, and omit the group entirely (404for/{id}) only if it had at least one product before filtering but is left with none afterward — a group with no products to begin with (a pre-existing, documented state) is unaffected. Incrementalsince-versionresponses emit a response-onlyisDeleted: truetombstone when a changed active group loses its final FEST-valid product, allowing clients to remove an earlier visible copy; a later FEST match reactivates it withisDeleted: false. Passignore-fest-validation=trueto restore the previous unfiltered behavior and includeisInFest: true|falseon every returned product. AnIsInFest-only change increments the owning group's version even though the canonical content hash remains unchanged. See API Reference.GET /api/internal/fest/missing-products— New internal-only endpoint listing every persisted product whose article number currently has no resolved FEST match, for data-quality monitoring of products excluded by the filtering above. See API Reference.--festsyncCLI mode — Running the API binary with--festsync(alongside the existing--sync/--migratemodes) triggers a single FEST/M30 sync (FestSyncService.ExecuteAsync().FEST sync log + automatic full/delta sync mode — New
FestSyncLogtable (see Data model (Postgres)) records one row perFestSyncService.ExecuteAsyncrun (started/completed timestamps, success, mode, theincrementalDateactually used, duration, per-category stats, refreshed product count, and any error message). The sync now automatically chooses between two modes: a full fetch (incrementalDate: null, deactivates existing rows missing from the fetch) when forced or when no previous run succeeded, and a delta fetch (incrementalDate= last successful run's start time minus one hour) otherwise, which leaves rows missing from the (small) delta response untouched instead of deactivating them. A failed run is still logged (Success: false) so it isn't mistaken for a successful baseline.FestSyncResponsegainsmode,force, andincrementalDateUsedfields reporting which path a given run took. See the related breaking change toPOST /api/internal/fest/sync's query parameters above.GET /api/internal/sync/logs— New read-only, paginated endpoint returning a combined, normalized list of past sync runs from bothFestSyncLog(FEST/M30) andSyncHistory(SHI), sorted by completion/start time descending. SameX-API-KEYauthentication and per-API-key rate limiting as the other/api/internal/*endpoints; hidden from Swagger. Supports an optionalsourcefilter (fest/shi, case-insensitive; invalid value →400) andtake(default 50, clamped 1-200) /skip(default 0; negative →400) paging parameters. Does not trigger a sync itself — see API reference for the full response shape. Known limitation:SyncHistoryhas no persisted failure/error state, sosuccessis alwaystrueforsource: "Shi"entries and a failed SHI run leaves no row at all (invisible to this endpoint); FEST entries track failures explicitly viaFestSyncLog. Not a breaking change.ConversionServicenow populates real FEST product identifiers — during a FHIR sync (POST /api/internal/sync),TreatmentAlternativeProduct/ReimbursementGroupProductidentifiers (FEST/LegemiddelPakning/Id,FEST/LegemiddelMerkevare/Id,FEST/LegemiddelVirkestoff/Id) are resolved from the syncedFest*tables by varenummer (viaIMedialProductLookupService/ProductIdentifierService), instead of theADDED_WHEN_FEST_FILE_IS_READplaceholder. Falls back to aMissingsentinel value when a varenummer has no FEST match. The identifier-building logic was extracted out ofConversionServiceinto a newIProductIdentifierService/ProductIdentifierService, shared with the FEST-sync refresh described below. Not a breaking change.Persisted product identifiers now refresh automatically after a FEST sync — when
POST /api/internal/fest/syncadds or updates aLegemiddelpakningrow (or aLegemiddelVirkestoffrow linked back to one viaRefPakning), every already-persistedTreatmentAlternativeProduct/ReimbursementGroupProductreferencing that varenummer has its FEST identifiers rebuilt via the newIProductIdentifierRefreshService.FestSyncResponsegains a newrefreshedProductCountfield reporting how many products were refreshed.LegemiddelMerkevare-only changes are not tracked since they don't affect a pakning's own identifiers. Superseded below —Version/ContentHashare now also recomputed and bumped when this changes a group (see "FEST sync now versions changedTreatmentGroup/ReimbursementGrouprows" below). Not a breaking change (additive field onFestSyncResponse).FEST sync now versions changed
TreatmentGroup/ReimbursementGrouprows and records them inSyncHistory— Previously, a FEST-triggered product identifier refresh (see above) bumped only the owning group'sUpdatedAt, leavingVersion/ContentHashstale; API consumers polling?since-version=never saw FEST-only identifier changes, and there was no audit trail for these updates. Now, afterIProductIdentifierRefreshService.RefreshAsyncrebuilds identifiers, it recomputes each touched group'sContentHash(using the same entity→response mapping the API already uses, now extracted into sharedApi.Mapping.TreatmentGroupMapper/ReimbursementGroupMapperclasses) and, only for groups whose hash actually changed, applies a single sharedVersionbump (from the existingSyncMetadata.CurrentVersioncounter, incremented once per run — the same mechanism the SHI sync uses) alongsideContentHash/UpdatedAt.RefreshAsyncnow returns aProductIdentifierRefreshResult(RefreshedProductCount,UpdatedTreatmentGroupKeys,UpdatedReimbursementGroupKeys) instead of a plainint.FestSyncResponsegainsupdatedTreatmentGroupCount/updatedReimbursementGroupCountfields. When a run changes at least one group,FestSyncServicenow also writes a newSyncHistoryrow (Source: Fest) with the appliedversionand the changed groups'BusinessKeys intreatmentGroups/reimbursementGroups— visible throughGET /api/internal/sync/logs. A run that changes nothing writes noSyncHistoryrow.SyncHistorygains a newSource(Shi/Fest) column (migrationAddSyncHistorySource; existing rows backfill toShi) to distinguish which pipeline wrote a given row — see Data model (Postgres).GET /api/internal/sync/logs's?source=festfilter now also returns theseSyncHistoryrows (previously onlyFestSyncLogrows) and?source=shiis now correctly scoped toSource: Shirows only (previously it returned everySyncHistoryrow regardless of source, since the column didn't exist yet). Publication is coordinated through a database transaction/lock shared by SHI and FEST: product/group changes, the atomic global version increment, and the correspondingSyncHistoryrow commit or roll back together, including when sync jobs overlap across API replicas. Not a breaking change (additive fields; existingSyncHistoryrows keep working via theShidefault).FestClient project — New
backend/src/FestClientclass library for consuming the FEST (Statens legemiddelverk) SOAP/WCF medicinal products service via a generateddotnet-svcutilproxy and a thinIFestServiceClient/FestServiceClientwrapper. Not yet wired into the Api (no endpoint, background job, or persistence) — see FestClient for details. Not a breaking change.FEST/M30 persistence + sync endpoint — The full FEST/M30 dataset is now persisted into ~130
Fest*-prefixed Postgres tables (see the "FEST/M30 Entities" section of Data model (Postgres) and Fest data model conventions). A newFestSyncServicemaps and upserts (byExternalId) all 15 FEST categories, derivingActivefrom FEST's ownStatus.V == A.POST /api/internal/fest/synctriggers a sync and returns only a status summary (FestSyncResponse: added/updated/unchanged/deactivated counts per category, duration, timestamp) — it never returns the raw FEST payload. The existingGET /api/vi/festraw-payload endpoint is kept for now (marked with a// TODOfor future removal) but is deprecated in favor of reading from the syncedFest*tables. Not a breaking change.HelseID (DPoP) v2 endpoints — The data endpoints are now also served under
/api/v2/, authenticated with a HelseID access token using DPoP (RFC 9449) instead of theX-API-KEYheader:GET /api/v2/treatment-group,GET /api/v2/treatment-group/{id}GET /api/v2/reimbursement-group,GET /api/v2/reimbursement-group/{id}- Same data, query parameters, and response shapes as the v1 endpoints.
- Requests send
Authorization: DPoP <access-token>plus a per-requestDPoPproof header. The access token must be issued by HelseID with audiencenhn:legemiddelgrunndataand thenhn:legemiddelgrunndata/apiscope.Bearertokens are not accepted on v2 endpoints (they return401). - New status code:
403 Forbiddenwhen authenticated via HelseID but the access token is missing thenhn:legemiddelgrunndata/apiscope. - v2 endpoints are not rate limited.
- Non-breaking — v1 endpoints and API key authentication are unchanged.
Rate limiting — The data endpoints (
/api/v1/treatment-group,/api/v1/reimbursement-group,/api/internal/sync) are now rate limited to 120 requests per minute per API key (sliding window, configurable viaRateLimiting__*environment variables). Exceeding the limit returns429 Too Many Requestswith aRetry-Afterheader (set to the rate-limit window length) and body{ "error": "Rate limit exceeded. Try again later." }. Requests without a valid API key (missing or unknown) share ananonymousbucket, keeping the number of limiter partitions bounded. Limiting can be disabled withRateLimiting__Enabled=false. The health endpoint and Swagger UI are not rate limited. Not a breaking change — only new 429 responses under load.Global exception handling — Unhandled exceptions now return
500 Internal Server Errorwith a JSON body containing acorrelationId. In the Development environment, the response also includesmessageandstackTrace. The correlation ID is reused from an incomingX-Correlation-Idheader when present, otherwise generated, and is echoed in theX-Correlation-Idresponse header. Exceptions are logged with their correlation ID.X-Correlation-Idon all responses — Every response (not only500errors) now includes anX-Correlation-Idresponse header, so any request can be traced in the server logs. A client-providedX-Correlation-Idrequest header is still reused when present. Non-breaking — only adds a response header.
Removed
- MongoDB deployment and helper scripts — Removed the MongoDB Helm chart values (
manifests/apps/mongodb), themongodbentries from the ArgoCD app-of-apps values, and the helper scriptscreate-mongodb-secret.sh,forward-db.sh, andrun-exporer.sh. PostgreSQL is the sole datastore. No API behavior or data contract changes.
Fixed
- Stale product identifiers when a
Legemiddelpakning'sVarenrchanges — If FEST changed theVarenrof an existingLegemiddelpakning(sameExternalId) between syncs,ProductIdentifierRefreshService.RefreshAsyncmatched persistedTreatmentAlternativeProduct/ReimbursementGroupProductrows only by their currently-stored (old)Varenummeridentifier, which no longer appeared in the sync's "affected article numbers" set — so those products were silently never refreshed again by any later sync.FestSyncService's Legemiddelpakning sync step now also tracks old→newVarenrrenames for the run, andRefreshAsyncmatches on both the old and new value and resolves to the newVarenrbefore rebuilding identifiers. force=trueFEST sync now also force-rebuilds every product's identifiers — Previously,force=true(POST /api/internal/fest/sync?force=true) only forced a full FEST fetch and deletion reconciliation; identifier refresh was still scoped to the diff-based "affected article numbers" computed for that run. If the forced fetch pulled back data identical to the last sync, the affected set was empty and no product identifiers were refreshed at all, even though the caller asked for a forced sync.IProductIdentifierRefreshService.RefreshAsyncgained arefreshAllparameter that, when set, rebuilds identifiers for every persistedTreatmentAlternativeProduct/ReimbursementGroupProductregardless of the diff;force=truenow always passesrefreshAll: true, so a forced sync unconditionally rebuilds all identifiers. The refresh resolves each product's current Varenummer through its stableFEST/LegemiddelPakning/Id, allowing it to repair Varenummer values that were already stale before the forced sync. If the stable package id no longer resolves to active FEST data, the stored Varenummer is retained and dependent identifiers use the existingMissingsentinel.- FEST consumer filter is now hardcoded to
Rekvirent— Both endpoints, andFestSyncService.ExecuteAsync, now always useFilterEnum.Rekvirentinternally FEST/LegemiddelVirkestoff/Idresolved from the wrong FEST category — The active-substance identifier was previously derived from an unrelated FEST category (KatLegemiddelVirkestoff/LegemiddelVirkestoff.RefPakning), a standalone aggregation category that only links a subset of packages and produced missing or incorrect data for others (e.g. varenr 017234, 491230). The correct chain, confirmed against the FEST/WCF source schema, isLegemiddelpakning.Pakningsinfo[].RefLegemiddelMerkevare→LegemiddelMerkevare→ (SortertVirkestoffMedStyrke[].RefVirkestoffMedStyrke→VirkestoffMedStyrke.RefVirkestoff) or (SortertVirkestoffUtenStyrke[].RefVirkestoff, direct) →Virkestoff.Id. The oldFestLegemiddelVirkestoff/FestLegemiddelVirkestoffPakningtables and sync logic have been removed entirely and replaced withFestLegemiddelMerkevare(+ two child ref tables) andFestVirkestoffMedStyrke, synced fromKatLegemiddelMerkevareand theVirkestoffMedStyrkemembers ofKatVirkestoff.MedialProductLookupService.GetActiveSubstanceIdsAsyncand the FEST sync's product-identifier auto-refresh (a substance-only change now correctly reverse-resolves to the affected varenummer through the full chain) have been updated accordingly. See Fest data model conventions. Operational note: this is a new category with no previously-stored data, so it requires a full FEST re-sync (not a SQL fix) to populateFestLegemiddelMerkevare/FestVirkestoffMedStyrkebeforeFEST/LegemiddelVirkestoff/Idstarts resolving correctly.- FEST sync
Activeflag was alwaysfalse—FestSyncServicederivedFestEntityBase.Activeby comparing the wrapper'sStatus.Vagainst the literal string"AKTIV", but FEST's real status code for an active listing is the short code"A"(Status.DNdisplays as"Aktiv oppføring"). Every syncedFest*row was therefore (incorrectly) marked inactive regardless of its actual FEST status.Activeis now derived fromStatus.V == "A". Operational note: a plain re-sync will not fix already-persisted rows, since unchanged rows (SourceUpdatedAt == Tidspunkt) are skipped without recomputingActive. Since the already-storedStatusCodewas always correct (only the derivedActivebool was wrong), existing rows can be repaired directly with a one-timeUPDATE "FestLegemiddelpaknings" SET "Active" = ("StatusCode" = 'A')(and the equivalent for"FestLegemiddelVirkestoffs") instead of forcing a full re-sync. - FEST sync
ExternalIdused the wrong node'sId— For 9 of the 15 synced FEST categories (Legemiddelpakning, Legemiddeldose, LegemiddelVirkestoff, LegemiddelMerkevare, Diagnose, Virkestoff/VirkestoffMedStyrke, Interaksjon, Vilkar, Byttegruppe, OrdineringVirkestoff),FestSyncServicederivedExternalIdfrom the outer "Oppf..." wrapper node'sIdinstead of the nested domain entity's ownId. Both carry independent, validIdvalues, but other categories'Ref{Target}(IDREF) fields reference the nested entity'sId— using the wrapper'sIdbroke that cross-reference semantics.ExternalIdis now correctly derived from the nested entity's ownIdfor these categories (see Fest data model conventions for the full per-category rule). Operational note: after deploying this fix, run a full FEST re-sync — rows previously stored under the old (wrapper-based)ExternalIdwill no longer match on the next sync and will be deactivated by the existing "missing" handling, while new rows are inserted with the correctedExternalId. This is expected and self-healing via the normal sync job; stale deactivated duplicates are not automatically deleted. - Access token
typvalidation on v2 (HelseID) endpoints — Access tokens whosetypheader is notat+jwtare now rejected with401 Unauthorized, as required by the HelseID security profile. Previously thetypheader was not validated, so such tokens were accepted and the request was processed. - DPoP
htuvalidation behind TLS-terminating gateway — The forwarded-headers middleware now forwardsX-Forwarded-Protoand trusts only the source networks configured viaFORWARDED_TRUSTED_NETWORKS(comma-separated CIDR list) instead of all proxies. This ensuresrequest.Schemeishttpsfor DPoPhtucomparison while preventing header spoofing from untrusted sources. - Swallowed unhandled exceptions — The request logging middleware previously swallowed unhandled exceptions, which resulted in empty
200 OKresponses with no error log. Exceptions now propagate to the exception handler. - Client aborts — A client aborting a request no longer produces a
500response or an error log.
Breaking Changes
- Removed
/filtersub-route — Filter parameters are now query params on the main endpoint.GET /api/v1/treatment-group/filter?...→GET /api/v1/treatment-group?...GET /api/v1/reimbursement-group/filter?...→GET /api/v1/reimbursement-group?...- No backwards-compatible parallel endpoint — consumers must migrate.
[4.0.0] - 2026-06-17
Breaking Changes
- Renamed
medicinestoproductson bothTreatmentAlternativeandReimbursementGroup, and renamed the referenced typeMedicinalReferencetoProductReference.- JSON field:
medicines→products(affectsGET /api/v1/treatmentGroup,GET /api/v1/reimbursementGroup,GET /api/v1/reimbursementGroup/{id}, andGET /api/v1/reimbursementGroup/filter) - The element shape (
reference,type,identifier,display) is unchanged — only the array field name and the C# type name changed. - No backwards-compatible parallel field — consumers must migrate.
- JSON field:
[3.1.0] - 2026-05-07
Added
medicinesonReimbursementGroup— exposes the medicines covered by a reimbursement authorization. Sourced fromRegulatedAuthorization.ArticleNumber; emitted as aMedicinalReference[](FEST/Varenummer). Empty list when upstream provides no article number. Non-breaking additive change.
[3.0.0] - 2026-04-30
Breaking Changes
- Renamed
Behandlingsgruppemodel toTreatmentGroup— endpoint, model, and database table all renamed for consistency withReimbursementGroup.- Endpoint:
GET /api/v1/behandlingsgrupper→GET /api/v1/treatmentGroup - Table:
Behandlingsgrupper→TreatmentGroups - Model shape unchanged (same fields:
id,name,description,diseaseGroup,jurisdictions,validityPeriod,reviewDate,indications,treatmentAlternatives) - No backwards-compatible parallel endpoint — consumers must migrate.
- Endpoint:
Added
- Per-document version metadata in response wrapper —
VersionedItem<T>now exposesversion(the sync version when this item last changed) andlastChanged(ISO-8601 timestamp). Applies to bothtreatmentGroupandreimbursementGroupendpoints.
[2.0.0] - 2026-04-28
Breaking Changes
Replaced
Reimbursementmodel withReimbursementGroup— the endpoint, model shape, and database table have all been renamed and restructured.- Endpoint:
GET /api/v1/reimbursements→GET /api/v1/reimbursementGroup - Table:
Reimbursements→ReimbursementGroups - Model is reduced to three fields:
id,legalBasis(CodeableConcept),indications(CodeableConcept[]) - The previous
medicinalReferencesarray is removed pending a redesign legalBasisnow correctly carries the hjemmel (e.g., H-resept §950) sourced from the upstreamreimbursementRegulationextension, rather than the first coding fromBasisindicationscarries the diagnoses (e.g., ICD-10 codes) sourced fromBasis.Coding, with one CodeableConcept per coding
Consumers using the old endpoint must migrate. There is no backwards-compatible parallel endpoint.
- Endpoint:
[1.1.0] - 2026-04-28
Added
Raw FHIR persistence — Upstream FHIR resources are now stored in Postgres alongside the converted output, sharing the same version number per sync.
- New versioned collections:
PlanDefinitions,ActivityDefinitions,RegulatedAuthorizations - Each wrapped in
VersionedDocument<T>with the same upsert + soft-delete semantics as the output collections - Enables re-conversion if mapping logic changes, debugging via raw-vs-converted comparison, and full audit traceability
- New versioned collections:
Sync history — Every sync run writes one entry to the new
SyncHistorycollection with:Version,SyncedAt,DurationMs- Per-collection statistics (
AddedCount,UpdatedCount,UnchangedCount,DeletedCount) - Business keys of every added, updated, and deleted record (
AddedKeys,UpdatedKeys,DeletedKeys)
unchangedfield inSyncResult— ThePOST /api/internal/syncresponse now includes a count of records that were detected as unchanged.
Changed
Content-hash-based change detection —
VersionedDocument<T>has a newContentHashfield (SHA256 of BSON). Records only receive a newVersionif their hash actually changed (or their deletion state flipped). Previously every sync bumped the version on every record, which made?sinceVersion=Nreturn everything after each sync. Now diff queries return only the records that genuinely changed.- Migration impact: The first sync after deploy bumps the version on all existing records once (because they have no hash to compare against), then subsequent syncs only touch genuinely changed records.
Sync log line — Now includes
unchangedcount in the structured log message.
Internal
PlanDefinitionno longer has a[BsonId]attribute — it is now embedded insideVersionedDocument<PlanDefinition>, and the wrapper holds the database primary key.UpsertCollection<T>inSyncServicenow returnsSyncCollectionStats(counts plus business keys) instead of a counts-only tuple.
[1.0.0] - 2026-04-13
Added
Behandlingsgrupper endpoint (
GET /api/v1/behandlingsgrupper)- Returns treatment groups with ranked treatment alternatives
- Includes jurisdictions, indications, validity periods, and medicine references
Reimbursements endpoint (
GET /api/v1/reimbursements)- Returns reimbursement authorizations with regulation basis and indication codes
- Includes medicine references (FEST and NOMPD)
Version-based diff support
- All endpoints accept
?since-version=Nto return only changes since a given version - Responses include
currentVersionfor clients to track their sync state - Deleted items are included in diff responses with
isDeleted: true
- All endpoints accept
API key authentication
- All endpoints require
X-API-KEYheader - Keys are configured server-side and issued manually
- All endpoints require
Sync endpoint (
POST /api/internal/sync)- Triggers a full data sync from upstream FHIR APIs
- Intended to be called by a Kubernetes CronJob
- Returns sync statistics (added, updated, deleted, duration)