FHIR for developers — get started in 30 minutes from zero

In 30 minutes, you will stand up a FHIR server, create your first Vietnamese Patient, run searches, validate against the VN Core profile, and build a Bundle transaction — no prior HL7 v2 knowledge required.

This page is a hands-on FHIR tutorial for web and mobile developers already comfortable with REST and JSON. Each lesson takes 5–10 minutes and ships code samples for the four most common languages (cURL, JavaScript, Python, Java HAPI). Every payload is copy-paste-runnable on the HAPI public sandbox or a Docker self-host.

TL;DR

  • 30 minutes across 4 lessons: Hello World → Search → VN Core profile validation → Bundle transaction.
  • Stack: HAPI FHIR public sandbox http://hapi.fhir.org/baseR4 (free) or Docker hapiproject/hapi:latest for self-hosting.
  • Samples in all four languages: cURL, JavaScript (fetch), Python (fhirpy), Java (HAPI client).
  • VN Core identifier URI: http://fhir.hl7.org.vn/core/sid/cccd — used consistently everywhere.
  • Install the VN Core IG into HAPI via application.yaml auto-load (there is NO upload-definitions CLI subcommand).

1. Set up your environment (5 minutes)

FHIR R4 (4.0.1, 146 resources, mixed Normative + STU) is a REST API that returns JSON conforming to a standard schema. To learn quickly you do not need to spin up a cluster — pick one of the two options below and you will have a live endpoint in under 5 minutes.

Option A — HAPI FHIR public sandbox (1 minute)

HAPI FHIR is the most popular open-source FHIR server, written in Java. The HAPI team hosts a public sandbox at http://hapi.fhir.org/baseR4: no signup, no auth required. It is great for running your first curl commands, but the data is shared globally and may be reset at any time — DO NOT use it for production or real patient data.

curl http://hapi.fhir.org/baseR4/metadata | head -50

That command returns a CapabilityStatement — the server's self-declaration listing which resources it supports and which search parameters are valid.

Option B — Docker self-host (5 minutes)

For your own endpoint (data is not shared and you can load Implementation Guides), run the official hapiproject/hapi:latest image:

docker run -d -p 8080:8080 \
  --name hapi-fhir \
  hapiproject/hapi:latest

# Wait ~30 seconds for HAPI to finish booting
curl http://localhost:8080/fhir/metadata | head -20

The base endpoint is http://localhost:8080/fhir. Every example below uses this endpoint; if you are on the public sandbox, swap to http://hapi.fhir.org/baseR4.

2. Lesson 1 — Hello World: create a Vietnamese Patient (5 minutes)

The Patient resource is the canonical starting point of every FHIR tutorial. This lesson creates a 40-year-old female Patient in Hanoi with a 12-digit national ID card (CCCD) following Vietnamese conventions. Note that identifier.system must be the VN Core URI http://fhir.hl7.org.vn/core/sid/cccd — this is the official canonical of the VN Core IG and must be used consistently for every POST and every search query later.

curl -X POST http://localhost:8080/fhir/Patient \
  -H "Content-Type: application/fhir+json" \
  -d '{
    "resourceType": "Patient",
    "identifier": [{
      "system": "http://fhir.hl7.org.vn/core/sid/cccd",
      "value": "001234567890"
    }],
    "name": [{"family": "Nguyễn", "given": ["Thị", "Lan"]}],
    "gender": "female",
    "birthDate": "1985-03-15",
    "address": [{
      "country": "VN",
      "city": "Hà Nội",
      "line": ["123 Lê Lợi, P. Bến Thành"]
    }]
  }'

The server returns HTTP 201 Created; the Location header contains the URL with the freshly assigned id, for example http://localhost:8080/fhir/Patient/12345/_history/1. The response body is the full Patient resource with added meta.versionId and meta.lastUpdated. That is the entire Hello World — you have just created a valid FHIR R4 resource.

Tip: if you see HTTP 415 Unsupported Media Type, check the header Content-Type: application/fhir+json — that is the required MIME type, not the plain application/json.

3. Lesson 2 — Search Patient (5 minutes)

FHIR ships a very rich search mechanism with filters by identifier, name, birth date, gender, and any combination via standard query strings. With the CCCD we just created, the search query must use the exact identifier system we set on POST — getting one character of the system wrong yields an empty result.

# Search by CCCD (system|value — must match the system used on POST exactly)
curl "http://localhost:8080/fhir/Patient?identifier=http://fhir.hl7.org.vn/core/sid/cccd|001234567890"

# Search by family name
curl "http://localhost:8080/fhir/Patient?family=Nguyễn"

# Female patients born between 1980 and 1989
curl "http://localhost:8080/fhir/Patient?gender=female&birthdate=ge1980&birthdate=lt1990"

# Pagination + sort
curl "http://localhost:8080/fhir/Patient?family=Nguyễn&_count=10&_sort=birthdate"

The response is a Bundle with type="searchset". Each entry contains one matching Patient. To get the next page, follow Bundle.link with relation="next" — DO NOT hand-craft the pagination URL yourself, because servers may use cursors or offsets depending on implementation.

Useful search modifiers: :exact (exact match, case sensitive), :contains (substring), :missing=true (resources missing the field). Prefixes for date and number values: eq (default), ne, gt, ge, lt, le.

4. Lesson 3 — Validate against the VN Core profile (5 minutes)

The Patient from Lesson 1 is base FHIR — the server accepts it as long as the JSON conforms to the R4 schema. To make sure the data complies with Vietnamese conventions (mandatory CCCD, valid ethnicity, address with ward or commune level), you need to validate against the VN Core profile. The official profile lives at canonical http://fhir.hl7.org.vn/core/StructureDefinition/vn-core-patient.

Install the VN Core IG into HAPI

Important note: the HAPI FHIR CLI does not have an upload-definitions subcommand (this is a common error in older tutorials). The correct approach is to configure the HAPI JPA Starter to auto-load the Implementation Guide via application.yaml, or to download the .tgz package and PUT each StructureDefinition over REST.

Method 1 — Auto-load via application.yaml (recommended):

# application.yaml (mount into /app/config/application.yaml in the container)
hapi:
  fhir:
    implementationguides:
      vn-core:
        name: hl7.fhir.vn.core
        version: 0.5.0
        url: https://downloads.fhir.hl7.org.vn/core/0.5.0/hl7.fhir.vn.core-0.5.0.tgz

Restart the container — HAPI downloads the package, parses it, and loads every StructureDefinition, ValueSet, and CodeSystem into its validation registry.

Method 2 — PUT each StructureDefinition manually:

# Download the IG package
curl -L -o vn-core.tgz \
  https://downloads.fhir.hl7.org.vn/core/0.5.0/hl7.fhir.vn.core-0.5.0.tgz
tar xzf vn-core.tgz

# PUT each StructureDefinition (id derived from filename)
for sd in package/StructureDefinition-*.json; do
  id=$(basename "$sd" .json | sed 's/StructureDefinition-//')
  curl -X PUT -H "Content-Type: application/fhir+json" \
    --data-binary @"$sd" \
    "http://localhost:8080/fhir/StructureDefinition/$id"
done

Validate a Patient against the profile

FHIR ships a standard $validate operation to check whether a resource is valid against a given profile:

curl -X POST \
  "http://localhost:8080/fhir/Patient/$validate?profile=http://fhir.hl7.org.vn/core/StructureDefinition/vn-core-patient" \
  -H "Content-Type: application/fhir+json" \
  -d @patient-vn.json

The server returns an OperationOutcome with an issue array:

  • severity="error" → the Patient violates a constraint (e.g., missing CCCD, gender outside the value set, invalid ethnicity code).
  • severity="warning" → a Must Support field is missing but not strictly required.
  • severity="information" or an OperationOutcome with no issues → pass.

5. Lesson 4 — Bundle transaction (10 minutes)

In the real world, you rarely create resources one at a time. A typical use case: an outpatient visit → create Patient + Encounter + Observation (heart rate), atomically — if any entry fails, roll back everything. FHIR provides Bundle with type="transaction" for exactly this.

Key tip: use a fullUrl in the urn:uuid: form so entries can reference each other before the server assigns real ids. The server rewrites references like urn:uuid:patient-1 to Patient/<real-id> after the commit.

{
  "resourceType": "Bundle",
  "type": "transaction",
  "entry": [
    {
      "fullUrl": "urn:uuid:patient-1",
      "resource": {
        "resourceType": "Patient",
        "identifier": [{
          "system": "http://fhir.hl7.org.vn/core/sid/cccd",
          "value": "001234567890"
        }],
        "name": [{"family": "Nguyễn", "given": ["Thị", "Lan"]}],
        "gender": "female",
        "birthDate": "1985-03-15"
      },
      "request": {"method": "POST", "url": "Patient"}
    },
    {
      "fullUrl": "urn:uuid:encounter-1",
      "resource": {
        "resourceType": "Encounter",
        "status": "in-progress",
        "class": {
          "system": "http://terminology.hl7.org/CodeSystem/v3-ActCode",
          "code": "AMB",
          "display": "ambulatory"
        },
        "subject": {"reference": "urn:uuid:patient-1"},
        "period": {"start": "2026-04-30T08:00:00+07:00"}
      },
      "request": {"method": "POST", "url": "Encounter"}
    },
    {
      "fullUrl": "urn:uuid:obs-1",
      "resource": {
        "resourceType": "Observation",
        "status": "final",
        "code": {
          "coding": [{
            "system": "http://loinc.org",
            "code": "8867-4",
            "display": "Heart rate"
          }]
        },
        "subject": {"reference": "urn:uuid:patient-1"},
        "encounter": {"reference": "urn:uuid:encounter-1"},
        "valueQuantity": {
          "value": 78,
          "unit": "/min",
          "system": "http://unitsofmeasure.org",
          "code": "/min"
        }
      },
      "request": {"method": "POST", "url": "Observation"}
    }
  ]
}

Submit the Bundle with a POST to the root endpoint:

curl -X POST http://localhost:8080/fhir \
  -H "Content-Type: application/fhir+json" \
  -d @bundle-transaction.json

The response is another Bundle with type="transaction-response"; each entry has a response.status (201 Created) and a response.location (URL of the created resource). If any entry fails, the entire bundle rolls back and the server returns an OperationOutcome with severity error.

Encounter uses class.system = "http://terminology.hl7.org/CodeSystem/v3-ActCode" with code AMB (ambulatory) — that is the standard HL7 v3 CodeSystem, NOT a placeholder URL. Observation uses LOINC code 8867-4 for heart rate and the UCUM unit /min.

6. Samples in 4 languages — create a Vietnamese Patient

Same business case as Lesson 1 (create a female Patient with CCCD 001234567890), four parallel implementations so you can pick the language that fits your stack. cURL is already covered in Lesson 1. The remaining three samples use today's most mainstream clients.

JavaScript / TypeScript (plain fetch)

The fhir.js library was archived back in 2023 and its API is no longer maintained — we recommend plain fetch (Node 18+ and modern browsers). If you need a FHIR object model, pair it with the fhir package or with @types/fhir for TypeScript types only.

const baseUrl = 'http://localhost:8080/fhir';

const patient = {
  resourceType: 'Patient',
  identifier: [{
    system: 'http://fhir.hl7.org.vn/core/sid/cccd',
    value: '001234567890',
  }],
  name: [{ family: 'Nguyễn', given: ['Thị', 'Lan'] }],
  gender: 'female',
  birthDate: '1985-03-15',
};

const res = await fetch(`${baseUrl}/Patient`, {
  method: 'POST',
  headers: { 'Content-Type': 'application/fhir+json' },
  body: JSON.stringify(patient),
});

if (!res.ok) {
  throw new Error(`FHIR error ${res.status}: ${await res.text()}`);
}

const created = await res.json();
console.log('Created Patient id:', created.id);

Python (fhirpy)

fhirpy (by beda-software) is the most popular async/sync Python client, with support for CRUD, search, and validation. Install with pip install fhirpy.

from fhirpy import SyncFHIRClient

client = SyncFHIRClient('http://localhost:8080/fhir')

patient = client.resource(
    'Patient',
    identifier=[{
        'system': 'http://fhir.hl7.org.vn/core/sid/cccd',
        'value': '001234567890',
    }],
    name=[{'family': 'Nguyễn', 'given': ['Thị', 'Lan']}],
    gender='female',
    birthDate='1985-03-15',
)
patient.save()
print('Created Patient id:', patient.id)

Java (HAPI FHIR client)

HAPI FHIR ships both server and client in the same SDK. Maven dependencies: ca.uhn.hapi.fhir:hapi-fhir-structures-r4 + ca.uhn.hapi.fhir:hapi-fhir-client.

import ca.uhn.fhir.context.FhirContext;
import ca.uhn.fhir.rest.client.api.IGenericClient;
import ca.uhn.fhir.rest.api.MethodOutcome;
import org.hl7.fhir.r4.model.*;

FhirContext ctx = FhirContext.forR4();
IGenericClient client = ctx.newRestfulGenericClient("http://localhost:8080/fhir");

Patient patient = new Patient();
patient.addIdentifier()
    .setSystem("http://fhir.hl7.org.vn/core/sid/cccd")
    .setValue("001234567890");
patient.addName()
    .setFamily("Nguyễn")
    .addGiven("Thị")
    .addGiven("Lan");
patient.setGender(Enumerations.AdministrativeGender.FEMALE);
patient.setBirthDateElement(new DateType("1985-03-15"));

MethodOutcome outcome = client.create().resource(patient).execute();
System.out.println("Created Patient id: " + outcome.getId().getIdPart());

7. Recommended tooling

The FHIR R4 ecosystem is mature and most tools are free or open-source. Below are the tools developers should bookmark for each phase — server, client, validation, and profile authoring.

Tool Purpose License
HAPI FHIRServer + client (Java) — the de facto standardApache 2.0
Microsoft FHIR ServerServer for the Azure / .NET stackMIT
Firely .NET SDKClient and parser for .NETBSD-3
fhirpyPython client (async + sync)MIT
fhir.resourcesPydantic models for FHIRBSD-3
FHIR Validator (CLI)Offline validation against IGsApache 2.0
SUSHIFSH → FHIR JSON compilerApache 2.0
Postman + FHIR collectionManual REST testingFree tier

8. 10 common mistakes new developers make

These are the errors most developers hit during their first week with FHIR. Each one comes with how to spot it and a quick fix.

  1. Forgetting the Content-Type: application/fhir+json header → the server returns 415 Unsupported Media Type. Plain application/json is NOT accepted by the spec.
  2. Identifier system mismatch between POST and search → search returns an empty Bundle even though the resource exists. Always use the canonical URI http://fhir.hl7.org.vn/core/sid/cccd.
  3. Wrong birthDate format (sending 15/03/1985 instead of 1985-03-15) → 422 Unprocessable Entity. FHIR always uses ISO 8601.
  4. Mixing relative and absolute references: inside a transaction Bundle you must use urn:uuid:...; outside a Bundle use Patient/123. Mixing them prevents the server from resolving references.
  5. Bundle entry missing request → HAPI reports "Missing required element 'request'". Every transaction entry MUST have request.method and request.url.
  6. Calling $validate before loading the IG into the server → OperationOutcome reports "Profile reference cannot be resolved". Configure application.yaml or PUT the StructureDefinitions first.
  7. Wrong search modifier syntax: it is name:exact=Lan, NOT name=:exact:Lan. The modifier sits after the parameter name, separated by a colon.
  8. Hand-rolling pagination URLs with _count + _offset: many servers do not support _offset. Always follow Bundle.link[relation="next"] from the server.
  9. Confusing meta.security with meta.tag: security is an access-control label, while tag is a free-form workflow flag. Their search behaviors differ.
  10. Skipping Accept-Charset: utf-8 or sending a non-UTF-8 body: Vietnamese diacritics get garbled (Nguy?n instead of Nguyễn). FHIR JSON is always UTF-8 — make sure the client encodes correctly and that Content-Type does not declare a different charset.

9. References

This page was authored by the OmiGroup team (Omi HealthTech), with technical review by HungPM (Phan Mạnh Hùng), factual fixes from a Codex review (HAPI CLI semantics, identifier URI, Encounter.class CodeSystem), and language polish from a Gemini review.