6. Machine testing via the API
The web validator is good for learning. For real testing, let your software — or your test suite —
talk to the sandbox directly. This chapter covers API keys, the $validate operation and your
company's private FHIR test server, with copy-paste examples in curl, Postman, Python, Node.js,
Java (HAPI FHIR) and .NET (Firely SDK).
Approved companies only
API keys and the FHIR test server are available once MoHFS has approved your company.
Endpoints at a glance#
Base URL of this sandbox: https://fhir-sandbox.sahasratech.com.np — FHIR base: https://fhir-sandbox.sahasratech.com.np/fhir
| Method & path | Purpose |
|---|---|
POST /fhir/$validate?profile=<canonical> |
Validate a resource; returns an explained OperationOutcome. Nothing is stored. |
POST /fhir/<Type> |
Create a resource on your test server. Validated first (422 if not conformant). |
GET /fhir/<Type>/<id> |
Read the current version |
GET /fhir/<Type>/<id>/_history/<vid> |
vread an older version |
GET /fhir/<Type>/<id>/_history |
History of a resource |
PUT /fhir/<Type>/<id> |
Update (or create with your own id). Validated first. Supports If-Match. |
DELETE /fhir/<Type>/<id> |
Delete (soft delete, 204) |
GET /fhir/<Type>?<params> or POST /fhir/<Type>/_search |
Search (see the supported parameters below) |
POST /fhir/<Type>/$validate, POST /fhir/<Type>/<id>/$validate |
$validate at type / instance level (same result) |
POST /fhir |
transaction or batch Bundle |
GET /fhir/metadata |
CapabilityStatement (public, no key needed) |
POST /api/validate |
The sandbox's JSON ValidationReport (same as the web UI) |
GET /api/runs, GET /api/runs/<id> |
Your validation history |
GET /api/scenarios, POST /api/scenarios/<id>/submit |
Conformance scenarios |
GET /api/company/report |
Conformance summary |
| Reset my test data (dashboard) | Delete all resources your company stored |
Full reference: API documentation.
1. Create an API key#
- Go to API keys in the dashboard.
- Enter a name that says where the key will be used, e.g. GitHub Actions or QA laptop — Sita.
- Press Create key. The key starts with
npfs_and is shown once — copy it now.
Send it in every request:
Authorization: Bearer npfs_xxxxxxxxxxxxxxxxxxxxxxxx
Keep keys secret
Store keys in a secret store or CI secret variable, never in source code. If a key leaks, revoke it on the API keys page and create a new one. Revoking takes effect immediately.
The examples below read the key and base URL from environment variables:
export NPFS_API_KEY="npfs_...your key..."
export NPFS_BASE="https://fhir-sandbox.sahasratech.com.np"
2. Validate with $validate#
$validate checks a resource and stores nothing — the quickest way to test your export code.
Pass the profile as a query parameter, or leave it out to use meta.profile (or the NP Core default
profile for the resource type).
curl -sS -X POST "$NPFS_BASE/fhir/\$validate?profile=http://fhir.digitalhealth.gov.np/core/StructureDefinition/np-core-patient" \
-H "Authorization: Bearer $NPFS_API_KEY" \
-H "Content-Type: application/fhir+json" \
--data-binary @patient.json
The response (HTTP 200, valid or not) is an OperationOutcome. Each issue has the raw validator text in
details.text, a plain-language explanation and fix in diagnostics, the location in expression, and a
structured explanation in an extension:
{
"resourceType": "OperationOutcome",
"issue": [ {
"severity": "error",
"code": "structure",
"details": { "text": "Patient.identifier:healthId: minimum required = 1, but only found 0 (from …)" },
"diagnostics": "Nepal Health ID is required for every Patient — Add an entry to identifier with system …",
"expression": [ "Patient" ],
"extension": [ {
"url": "https://fhir-sandbox.mohp.gov.np/StructureDefinition/explanation",
"extension": [ { "url": "title", "valueString": "…" }, { "url": "fix", "valueString": "…" } ]
} ]
} ]
}
The resource conforms when there is no issue with severity error or fatal.
3. Your private FHIR test server#
Every approved company gets its own FHIR R4 test server at https://fhir-sandbox.sahasratech.com.np/fhir, built into the sandbox.
Other companies cannot see your data, and references must point to resources in your own company's data
(or to entries of the same Bundle).
JSON only
The test server stores and returns JSON (application/fhir+json). XML writes get 415, and
_format=xml gets 406. XML is still fine for $validate and the web validator.
How it behaves:
| Situation | Response |
|---|---|
| Create succeeded | 201 Created, Location: …/fhir/Patient/<id>/_history/1, ETag: W/"1". The server assigns the id. Prefer: return=minimal | representation | OperationOutcome controls the body (default: the stored resource) |
PUT to a new id |
201 Created (create with your own id); existing id → 200. The body id must equal the URL id (else 400) |
| Read / vread / history / search succeeded | 200 OK (If-None-Match with the current ETag → 304) |
| Delete | 204 No Content (soft delete, idempotent); reading it afterwards → 410 Gone |
| Write is not NP Core conformant, or a reference is wrong | 422 Unprocessable Entity + explained OperationOutcome; nothing stored |
| Unsupported search parameter or modifier, malformed request, conditional request | 400 Bad Request; the message lists the supported parameters |
| Missing / revoked API key | 401 |
| Company not approved or suspended, or storage quota reached | 403 (for the quota: delete data or reset) |
| Resource does not exist | 404 |
PATCH |
405 (not supported) |
_format=xml |
406; XML request body on a write → 415 |
If-Match version does not match the current version |
412 Precondition Failed |
| Body larger than 5 MB | 413 |
| Too many requests | 429 |
Not supported: whole-system search (GET /fhir?…), whole-system and type-level _history, PATCH,
conditional create/update/delete. Browser JavaScript can call /fhir directly (CORS is enabled there only).
Search with GET /fhir/<Type>?… or POST /fhir/<Type>/_search (form-encoded body). Supported
parameters:
| Parameter | Applies to | Example |
|---|---|---|
_id |
all types | GET /fhir/Patient?_id=123 |
identifier (system|value or just value) |
all types | GET /fhir/Patient?identifier=http://fhir.digitalhealth.gov.np/core/NamingSystem/nepal-health-id|12345678901 |
_lastUpdated with eq/gt/ge/lt/le (repeat for a range) |
all types | GET /fhir/Encounter?_lastUpdated=ge2026-01-01&_lastUpdated=lt2026-02-01 |
patient (Patient/x or x) |
types with a subject or patient | GET /fhir/Observation?patient=Patient/123 |
subject (Type/x or x) |
types with a subject | GET /fhir/DiagnosticReport?subject=Patient/123 |
Comma-separated values mean OR: _id=a,b,c. Result controls: _count (default 20, max 100), _offset,
_sort=_lastUpdated or -_lastUpdated (default, newest first), _summary=count (only the total) and
_pretty=true. Results are a searchset Bundle with total, self/next/previous links and fullUrl.
Any other parameter, any modifier (such as :exact) and any other _sort/_summary value returns
400, so you never get silently unfiltered results.
References#
- A relative reference
Type/idmust point to a resource that exists in your company's store, or to an entry created in the same Bundle (urn:uuid:…). Otherwise → 422. - The target type must be one the NP Core profile allows for that element (e.g.
Encounter.subject→ an NP Core Patient). A wrong type → 422 with message idSANDBOX_REFERENCE_TARGET_TYPE. - Absolute URLs pointing to this sandbox are normalised to
Type/id. Other absolute URLs are accepted with a warning.#containedreferences are allowed. Conditional references (Patient?identifier=…) are rejected.
3a. Create and read — curl#
# Create (validated first)
curl -sS -i -X POST "$NPFS_BASE/fhir/Patient" \
-H "Authorization: Bearer $NPFS_API_KEY" \
-H "Content-Type: application/fhir+json" \
--data-binary @patient.json
# Read (use the id from the Location header)
curl -sS "$NPFS_BASE/fhir/Patient/<id>" -H "Authorization: Bearer $NPFS_API_KEY"
# Search by Nepal Health ID
curl -sS -G "$NPFS_BASE/fhir/Patient" -H "Authorization: Bearer $NPFS_API_KEY" \
--data-urlencode "identifier=http://fhir.digitalhealth.gov.np/core/NamingSystem/nepal-health-id|12345678901"
# Update only if nobody changed it since version 1 (else 412)
curl -sS -X PUT "$NPFS_BASE/fhir/Patient/<id>" \
-H "Authorization: Bearer $NPFS_API_KEY" -H "Content-Type: application/fhir+json" \
-H 'If-Match: W/"1"' --data-binary @patient-with-id.json
# Delete
curl -sS -X DELETE "$NPFS_BASE/fhir/Patient/<id>" -H "Authorization: Bearer $NPFS_API_KEY"
3b. Transaction Bundles#
POST a transaction or batch Bundle to /fhir. Each entry needs a request: POST Type,
PUT Type/id, DELETE Type/id, or GET Type/id / GET Type?search. ifMatch works in entries;
ifNoneExist and other conditional requests → 400.
- Give each new entry a
urn:uuid:fullUrl. References to those values are rewritten to the assigned ids before validation, and every entry is validated against its NP Core profile. - transaction is all-or-nothing: any invalid entry → 422 and nothing is stored; issue locations
point into
Bundle.entry[n]. Entries run in the order DELETE, POST, PUT, GET. The response is atransaction-responsewithstatus,location,etagandlastModifiedper entry. - batch handles each entry independently; the
batch-responsehas a status per entry and anoutcomefor failed ones.
{
"resourceType": "Bundle",
"type": "transaction",
"entry": [
{
"fullUrl": "urn:uuid:6f1c2a52-6a3b-4d6e-9a43-0c7c2b6f8a01",
"resource": { "resourceType": "Patient", "...": "NP Core Patient content" },
"request": { "method": "POST", "url": "Patient" }
},
{
"fullUrl": "urn:uuid:0b9e6f35-2a8e-4b8a-8a51-7d8f3c1e9d02",
"resource": {
"resourceType": "Encounter",
"subject": { "reference": "urn:uuid:6f1c2a52-6a3b-4d6e-9a43-0c7c2b6f8a01" },
"...": "NP Core Encounter content"
},
"request": { "method": "POST", "url": "Encounter" }
}
]
}
curl -sS -X POST "$NPFS_BASE/fhir" \
-H "Authorization: Bearer $NPFS_API_KEY" \
-H "Content-Type: application/fhir+json" \
--data-binary @bundle.json
The Postman collection (section 5) contains a ready-made transaction built from the official NP Core Patient and Encounter examples.
3c. Reset your test data#
Reset my test data on the dashboard deletes every resource (and all versions) your company stored.
Use it before a clean test run, or when you reach the storage quota. Validation history and scenario
results are kept. (From a signed-in browser session, GET /api/company/fhir-data shows usage and
DELETE /api/company/fhir-data does the same reset.)
4. Code examples: create, read, search, transaction and errors#
Treat 422 as "your data is not conformant" (fail the test and print the issues), 400 as "the request itself is wrong" (e.g. an unsupported search parameter), and 412 as "someone changed the resource — read it again and retry".
Python (requests)#
import json, os, sys
import requests
BASE = os.environ["NPFS_BASE"] + "/fhir"
S = requests.Session()
S.headers.update({
"Authorization": f"Bearer {os.environ['NPFS_API_KEY']}",
"Content-Type": "application/fhir+json",
"Accept": "application/fhir+json",
})
def show_issues(resp):
for i in resp.json().get("issue", []):
if i["severity"] in ("fatal", "error", "warning"):
print(f" [{i['severity']}] {', '.join(i.get('expression', []))}: {i.get('diagnostics')}")
patient = json.load(open("patient.json", encoding="utf-8"))
# $validate (nothing stored)
r = S.post(f"{BASE}/$validate", json=patient, params={"profile": "http://fhir.digitalhealth.gov.np/core/StructureDefinition/np-core-patient"}, timeout=120)
show_issues(r)
# Create
r = S.post(f"{BASE}/Patient", json=patient, timeout=120)
if r.status_code == 422:
print("Not conformant:"); show_issues(r); sys.exit(1)
r.raise_for_status()
pid, etag = r.json()["id"], r.headers.get("ETag")
print("Created Patient", pid, etag)
# Read and search
p = S.get(f"{BASE}/Patient/{pid}", timeout=60).json()
hid = next(i for i in p["identifier"] if i["system"] == "http://fhir.digitalhealth.gov.np/core/NamingSystem/nepal-health-id")
bundle = S.get(f"{BASE}/Patient", params={"identifier": f"{hid['system']}|{hid['value']}"}, timeout=60).json()
print("Found", bundle.get("total"), "patient(s)")
# Update with optimistic locking
p["active"] = True
r = S.put(f"{BASE}/Patient/{pid}", json=p, headers={"If-Match": etag}, timeout=120)
if r.status_code == 412:
print("Changed by someone else - read again and retry")
# Unsupported search parameter -> 400
r = S.get(f"{BASE}/Patient", params={"name:contains": "Shr"}, timeout=60)
if r.status_code == 400:
show_issues(r)
# Transaction
tx = json.load(open("bundle.json", encoding="utf-8"))
r = S.post(BASE, json=tx, timeout=180)
if r.status_code in (400, 422):
show_issues(r); sys.exit(1)
for e in r.json()["entry"]:
print(e["response"]["status"], e["response"].get("location"))
Node.js (18+, built-in fetch)#
import { readFile } from "node:fs/promises";
const BASE = `${process.env.NPFS_BASE}/fhir`;
const headers = {
Authorization: `Bearer ${process.env.NPFS_API_KEY}`,
"Content-Type": "application/fhir+json",
Accept: "application/fhir+json",
};
function printIssues(oo) {
for (const i of oo.issue ?? []) {
if (i.severity !== "information") console.error(`[${i.severity}] ${(i.expression ?? []).join(", ")}: ${i.diagnostics}`);
}
}
const patient = await readFile("patient.json", "utf8");
// Create
let res = await fetch(`${BASE}/Patient`, { method: "POST", headers, body: patient });
if (res.status === 422) { printIssues(await res.json()); process.exit(1); }
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const created = await res.json();
const etag = res.headers.get("etag");
console.log("Created", created.id, etag);
// Read
res = await fetch(`${BASE}/Patient/${created.id}`, { headers });
const current = await res.json();
// Search, newest first
res = await fetch(`${BASE}/Patient?_sort=-_lastUpdated&_count=10`, { headers });
console.log("Patients:", (await res.json()).entry?.length ?? 0);
// Update with If-Match (412 if the version changed)
res = await fetch(`${BASE}/Patient/${created.id}`, {
method: "PUT", headers: { ...headers, "If-Match": etag }, body: JSON.stringify({ ...current, active: true }),
});
if (res.status === 412) console.warn("Version conflict - read again and retry");
// Transaction
res = await fetch(BASE, { method: "POST", headers, body: await readFile("bundle.json", "utf8") });
if (res.status === 400 || res.status === 422) { printIssues(await res.json()); process.exit(1); }
for (const e of (await res.json()).entry) console.log(e.response.status, e.response.location);
Java (HAPI FHIR client)#
Add ca.uhn.hapi.fhir:hapi-fhir-client and hapi-fhir-structures-r4 to your build.
import ca.uhn.fhir.context.FhirContext;
import ca.uhn.fhir.rest.api.MethodOutcome;
import ca.uhn.fhir.rest.client.api.IGenericClient;
import ca.uhn.fhir.rest.client.interceptor.BearerTokenAuthInterceptor;
import ca.uhn.fhir.rest.server.exceptions.InvalidRequestException;
import ca.uhn.fhir.rest.server.exceptions.PreconditionFailedException;
import ca.uhn.fhir.rest.server.exceptions.UnprocessableEntityException;
import org.hl7.fhir.r4.model.*;
import java.nio.file.Files;
import java.nio.file.Path;
public class NpCoreSandboxTest {
public static void main(String[] args) throws Exception {
FhirContext ctx = FhirContext.forR4();
ctx.getRestfulClientFactory().setSocketTimeout(120_000);
IGenericClient client = ctx.newRestfulGenericClient(System.getenv("NPFS_BASE") + "/fhir");
client.registerInterceptor(new BearerTokenAuthInterceptor(System.getenv("NPFS_API_KEY")));
Patient patient = ctx.newJsonParser().parseResource(Patient.class, Files.readString(Path.of("patient.json")));
try {
// Create
MethodOutcome out = client.create().resource(patient).execute();
IdType id = (IdType) out.getId();
System.out.println("Created " + id.toUnqualifiedVersionless());
// Read
Patient p = client.read().resource(Patient.class).withId(id.getIdPart()).execute();
// Search by Health ID
Bundle found = client.search().forResource(Patient.class)
.where(Patient.IDENTIFIER.exactly().systemAndCode(
"http://fhir.digitalhealth.gov.np/core/NamingSystem/nepal-health-id", "12345678901"))
.returnBundle(Bundle.class).execute();
System.out.println("Found " + found.getTotal());
// Update with If-Match: 412 if someone changed it since we read it
p.setActive(true);
client.update().resource(p).withId(p.getIdElement().toUnqualifiedVersionless())
.withAdditionalHeader("If-Match", "W/\"" + p.getMeta().getVersionId() + "\"").execute();
// Transaction
Bundle tx = ctx.newJsonParser().parseResource(Bundle.class, Files.readString(Path.of("bundle.json")));
Bundle resp = client.transaction().withBundle(tx).execute();
resp.getEntry().forEach(e -> System.out.println(e.getResponse().getStatus() + " " + e.getResponse().getLocation()));
} catch (UnprocessableEntityException e) { // 422: not NP Core conformant
printIssues((OperationOutcome) e.getOperationOutcome());
System.exit(1);
} catch (InvalidRequestException e) { // 400: bad request / unsupported search parameter
printIssues((OperationOutcome) e.getOperationOutcome());
System.exit(1);
} catch (PreconditionFailedException e) { // 412: If-Match version conflict
System.err.println("Version conflict - read again and retry");
}
}
static void printIssues(OperationOutcome oo) {
if (oo == null) return;
for (OperationOutcome.OperationOutcomeIssueComponent i : oo.getIssue()) {
System.err.printf("[%s] %s: %s%n", i.getSeverity().toCode(), i.getExpression(), i.getDiagnostics());
}
}
}
To call $validate from Java, POST the resource JSON to /fhir/$validate with java.net.http.HttpClient
(same headers as the curl example) and parse the response with
ctx.newJsonParser().parseResource(OperationOutcome.class, body).
.NET (Firely SDK)#
Add the Hl7.Fhir.R4 NuGet package.
using System.Net;
using System.Net.Http.Headers;
using Hl7.Fhir.Model;
using Hl7.Fhir.Rest;
using Hl7.Fhir.Serialization;
var http = new HttpClient { Timeout = TimeSpan.FromSeconds(120) };
http.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", Environment.GetEnvironmentVariable("NPFS_API_KEY"));
var client = new FhirClient(Environment.GetEnvironmentVariable("NPFS_BASE") + "/fhir", http,
new FhirClientSettings { PreferredFormat = ResourceFormat.Json });
var parser = new FhirJsonParser();
try
{
// $validate (nothing stored)
var patient = parser.Parse<Patient>(await File.ReadAllTextAsync("patient.json"));
var outcome = await client.ValidateResourceAsync(patient);
PrintIssues(outcome);
// Create, read, search
var created = await client.CreateAsync(patient);
var read = await client.ReadAsync<Patient>($"Patient/{created.Id}");
var found = await client.SearchAsync<Patient>(new[] {
"identifier=http://fhir.digitalhealth.gov.np/core/NamingSystem/nepal-health-id|12345678901" });
Console.WriteLine($"Created {created.Id}, found {found.Total}");
// Update: Firely sends If-Match when versionAware is true (412 on conflict)
read.Active = true;
await client.UpdateAsync(read, versionAware: true);
// Transaction
var tx = parser.Parse<Bundle>(await File.ReadAllTextAsync("bundle.json"));
var resp = await client.TransactionAsync(tx);
foreach (var e in resp.Entry) Console.WriteLine($"{e.Response.Status} {e.Response.Location}");
}
catch (FhirOperationException ex) when (ex.Status == (HttpStatusCode)422 || ex.Status == HttpStatusCode.BadRequest)
{
PrintIssues(ex.Outcome);
Environment.Exit(1);
}
catch (FhirOperationException ex) when (ex.Status == HttpStatusCode.PreconditionFailed)
{
Console.Error.WriteLine("Version conflict - read again and retry");
}
static void PrintIssues(OperationOutcome? oo)
{
foreach (var i in oo?.Issue ?? new())
Console.Error.WriteLine($"[{i.Severity}] {string.Join(", ", i.Expression)}: {i.Diagnostics}");
}
5. Postman#
Download the ready-made collection: np-fhir-sandbox.postman_collection.json.
- In Postman choose Import and select the file.
- Open the collection's Variables tab and set
baseUrltohttps://fhir-sandbox.sahasratech.com.npandapiKeyto your key (use the Current value column so the key is not synced). - Run the requests in order: $validate, Create Patient (stores the new id in
patientId), Read, Search, Transaction Bundle and Validate (JSON report). Use Reset my test data on the dashboard to start again from an empty store.
6. The JSON validation API#
POST /api/validate returns the same ValidationReport as the web UI — useful if you want the
structured explanations (what, why, fix, example, igLink, category) as plain JSON:
curl -sS -X POST "$NPFS_BASE/api/validate" \
-H "Authorization: Bearer $NPFS_API_KEY" \
-H "Content-Type: application/json" \
-d "{\"profile\": \"http://fhir.digitalhealth.gov.np/core/StructureDefinition/np-core-patient\", \"resource\": $(cat patient.json)}"
{
"id": "…", "valid": false, "resourceType": "Patient",
"profile": "http://fhir.digitalhealth.gov.np/core/StructureDefinition/np-core-patient",
"summary": { "fatal": 0, "error": 1, "warning": 0, "information": 0 },
"issues": [ {
"severity": "error", "location": "Patient", "line": 1, "col": 2,
"message": "Patient.identifier:healthId: minimum required = 1, but only found 0 (…)",
"explanation": { "title": "…", "what": "…", "why": "…", "fix": "…", "example": "…", "igLink": "…", "category": "identifier" }
} ]
}
Limits and good manners#
- Requests are rate-limited per key. If you get 429 Too Many Requests, wait and retry with back-off.
- Maximum request size is 5 MB and 500 entries per Bundle; split larger Bundles.
- Each company has a storage quota on the test server (403 when reached); reset your data when you start a new test cycle.
- Validation of big resources can take several seconds; use a timeout of at least 60 seconds.
- The test server is for testing only. Do not rely on it for storage.
- Never send real patient data.