Use case · Healthcare

Move a patient list off the old EHR and out as FHIR Patient.

A German-labelled demographics export lands on patient_demographics_v1, gets birth dates and identifiers validated per row, and emits a FHIR R4 Patient bundle — with the rows themselves never leaving your side in schema-only mode.

POST /v1/uploads/:id/commit · output: "fhir"
Patientenstamm_2026_Q1.xlsx

What you actually get sent

  • Nachname
  • Vorname
  • Geburtsdatum
  • Geschlecht
  • AHV-Nr.
  • Telefon
  • E-Mail

One sheet of a five-sheet workbook, headers in German, dates as 10.12.1815, gender as M / W. Nothing about it is tidy, and none of it needs to be.

The job

What you are actually trying to get done

You are replacing a practice-management system and the vendor hands you an Excel export. The receiving system wants FHIR R4 Patient resources; the export has German column labels, Swiss date formatting and an AHV number where you expect an identifier. The job is not “write a parser” — it is to get every row into the new system with the identifiers still valid and an audit trail of who approved the mapping.

Where it lands

Patient demographics

patient_demographics_v1 · risk: medium · FHIR Patient

The template declares fhir_resource: "Patient", so the same rows can come back as a FHIR Bundle from output: "fhir" — shaped by lib/fhir.ts, the same code the commit path uses, so a row emits identical FHIR whichever route produced it.

The full field table →

Canonical columns

  • first_name
  • last_name
  • date_of_birth
  • gender
  • ahv_number
  • phone
  • email

7 canonical columns · 3 required · 4 validated

Step by step

The whole run, one call at a time

  1. 1

    Upload the workbook

    Multipart, up to 10 MiB. The response names every worksheet in sheets and which one it read — re-POST with sheet_name to pick another. Sample rows come back already clamped to three rows of ≤80 characters.
    POST /v1/uploads
  2. 2

    Run the cascade

    Nachname, Vorname, Geburtsdatum and Geschlecht are all hint entries on the template’s fields, so they resolve on layer 2 at 1.00 with no model, no key and no network call. The response reports which layer settled each column.
    POST /v1/uploads/:id/match
  3. 3

    Fix what you disagree with

    Your override is recorded and feeds mapping_statistics, so the same header resolves on layer 1 next quarter. The same response carries requires_hitl: true, because this template is medium-risk.
    PATCH /v1/uploads/:id/mappings
  4. 4

    Validate every row

    date_of_birth is checked against a date_range of 1900-01-01 → today; ahv_number against the Swiss 756.dddd.dddd.dd pattern; email and phone by their own validators. Errors come back per row, capped at 500.
    POST /v1/uploads/:id/validate
  5. 5

    Commit as FHIR

    output: "fhir" returns a Bundle inline, or in HMAC-signed batches of 500 to your webhook. The confirmed mapping is recorded in mapping_layouts, so the next export from the same system is a one-click reuse.
    POST /v1/uploads/:id/commit

In code

Four German headers, no AI, no key

POST /v1/match is public and unauthenticated, so you can check the mapping before you have an account. Send headers plus up to three sample rows; the response names the layer that resolved each column.

curl
curl https://api.adaptivmapr.com/v1/match \
  -H "Content-Type: application/json" \
  -d '{
    "template_id": "patient_demographics_v1",
    "headers": ["Nachname", "Vorname", "Geburtsdatum", "Geschlecht"],
    "sample_rows": [["Lovelace", "Ada", "10.12.1815", "W"]]
  }'
response
{
  "template_id": "patient_demographics_v1",
  "matches": [
    { "source_col": "Nachname",     "target_field": "last_name",     "source": "heuristic", "confidence": 1 },
    { "source_col": "Vorname",      "target_field": "first_name",    "source": "heuristic", "confidence": 1 },
    { "source_col": "Geburtsdatum", "target_field": "date_of_birth", "source": "heuristic", "confidence": 1 },
    { "source_col": "Geschlecht",   "target_field": "gender",        "source": "heuristic", "confidence": 1 }
  ],
  "auto_accept_threshold": [{ "minN": 100, "minRatio": 0.95 }, { "minN": 20, "minRatio": 1 }],
  "cascade_layers": ["statistics", "heuristic", "fuzzy", "semantic", "ai"],
  "unmapped": []
}
→ 4 of 4 resolved on layer 2 · 0 headers reached the LLM · no API key sent
  • In schema-only mode: your headers and at most three sample rows, each cell clamped to 80 characters. clampForSchemaOnly() in lib/parser.ts is the single chokepoint, re-applied at the HTTP edge in every route that takes sample rows.
  • The Geschlecht field carries a description the model reads when it gets that far — M/Männlich/Homme → male. On this file it never gets that far, because layer 2 already claimed the column.
  • Full-data mode (row-level AI cleanup) is gated on the PHI entitlement and returns 402 phi_gateway_required before any data leaves if the workspace does not hold it.
  • Uploads live in Cloudflare KV with a 24-hour TTL and are mirrored to Supabase under an explicit tenant_id. Deleting the workspace destroys both — one implementation, lib/workspaceErasure.ts.

Where the work lands

Which layer resolves this file

LayerWhat it does hereAuto-accepts atCost
1 · statisticsHeader→field counts your workspace already confirmed on earlier exports from this vendor≥100 @ 95% · ≥20 @ 100%Free · deterministic
2 · heuristic“Nachname”, “Vorname”, “Geburtsdatum”, “Geschlecht” — all shipped hints on the template≥ 0.85Free · deterministic
3 · fuzzy“AHV-Nr.” against the ahv_number field after punctuation is stripped≥ 0.80Free · pure compute
4 · semanticA house label with no shared vocabulary — “Pat.-Kennung”, “Stammdaten-ID”≥ 0.78Cheap, cached · off without an embedding key
5 · aiWhatever is genuinely ambiguous, resolved in ONE batched call over the leftoversModel pick, constrained to the unclaimed column setMetered · the only paid layer

Layers run in strict cost order and short-circuit — the first layer that resolves a column claims the target field and no later, dearer layer sees it. Layers 4 and 5 are config-gated (MAPR_EMBEDDING_API_KEY, MAPR_LLM_API_KEY) and fail soft to OFF, so an unconfigured deployment still maps on the deterministic layers rather than erroring.

Before you commit

Review, cost and where it routes

It comes back flagged for review

patient_demographics_v1 is a medium-risk template, so PATCH /v1/uploads/:id/mappings returns requires_hitl: true and hitl_status: "pending_review" — automatically, from the template’s own risk field.

Honest limit · The flag is advisory in v1: it tells your importer to gate, it does not block the commit server-side. Native AgentGate queue integration is roadmap Q3 2026.

Usually just the flat fee

When every column resolves on layers 1–3 — the common case for a file you receive regularly — no model runs, so there are no AI tokens to bill. A small flat per-map fee ($0.0010) is drawn on every map — a fully deterministic one and a layout-cache re-map included. There is no free tier and no subscription; you top up a prepaid wallet from $10 and it draws down.

Rate card · AI tokens are billed only when a model actually ran, at provider cost ×2 (×0.5 with your own LLM key). A PHI-routed run multiplies the whole charge — flat fee included — by 1.2. See lib/pricing.ts and GET /v1/pricing.

PHI routing is opt-in, and gated

Standard is what an unconfigured workspace gets. Switch a run to PHI · enterprise and the layer-5 call goes to phi-cloud with X-PHI and a region pin, so a PHI-eligible in-region model handles it under the BAA.

Gate · Locked until the workspace accepts the BAA in-app (Settings → Security & Data, POST /v1/me/agreements). An explicit PHI ask without one is 403 agreement_required with a settings_url pointer — never a silent downgrade. PHI adds 20% to the whole map charge.

Questions

The things people actually ask

In schema-only mode, no — only the column headers and at most three sample rows, each clamped to 80 characters, and only to decide the mapping. The rows themselves are transformed in-process. Full-data mode, where row values are sent for AI cleanup, is a separate opt-in gated on the PHI entitlement.
No one is — HIPAA is not a certification anyone can hold. AdaptivMapr is HIPAA-ready: we sign a BAA, hold a HIPAA security risk assessment, and route PHI in-region through phi-cloud under its BAA. SOC 2 is in progress. PHI routing is locked until your workspace accepts the BAA in-app.
No. AdaptivMapr maps arbitrary files to a schema and can emit FHIR R4 resources; it is not an interface engine and does not manage HL7 v2 feeds, connection lifecycles or clinical routing. It is the layer that gets a messy export into the right shape — the engine, or your own code, takes it from there.
Fourteen R4 shapes are wired in lib/fhir.ts, including Patient, Observation, Medication, Appointment, Coverage, Practitioner, Claim and Claim.item, Encounter, Condition, Immunization, Organization, Location and AllergyIntolerance. A template must declare a supported fhir_resource; anything else returns 422 rather than a Bundle with silently dropped entries.
The confirmed mapping for that exact header row was recorded in mapping_layouts, keyed by a sha256 fingerprint of the normalized headers. POST /v1/layouts/lookup returns it for one-click reuse — no cascade, no AI. The flat per-map fee still applies; a cache hit is cheap, not free.

Ready when you are

Stop hand-mapping this file. Map it once.

Start with a $10 prepaid wallet. In schema-only mode only headers and up to three clamped sample rows ever leave you.

$10 minimum to start · pay only for what you map · PHI under BAA coverage
EHR patient demographics migration to FHIR — AdaptivMapr — AdaptivMapr