A TypeScript client for interacting with a FHIR server.
The client is created with the makeClient function:
const baseUrl = "https://fhir-server.address";
const client = new AidboxClient(
baseUrl,
new BrowserAuthProvider(baseUrl),
);
Documentation is generated automatically, and can be found here.
This project is designed around the type generator that provides FHIR types based on the specified package.
However, not all types are provided in the client itself, only the necessary ones, like Bundle, and OperationOutcome.
If your application requires more types, use atomic-ehr/codegen to generate more types.
For example, using atomic-ehr/codegen, we can generate and import an Observation type, and ensure that all fields are provided when creating a resource:
import type { Observation } from "hl7-fhir-r4-core";
client.create<Observation>({
resourceType: "Observation",
status: "final",
code: {
coding: [{
system: "http://loinc.org",
code: "59408-5",
display: "Blood pressure systolic & diastolic"
}],
text: "Blood pressure"
},
subject: {
reference: "Patient/pt-1"
},
effectiveDateTime: "2025-12-05T00:00:00Z",
valueString: "minimal"
})
The default set of types in the client is based on FHIR R4 Core. If your application requires a different set of types, it is possible to override that through type parameters when creating a client:
import type * as R5 from "hl7-fhir-r5-core";
import type { User } from "@health-samurai/aidbox-client";
const baseUrl = "https://fhir-server.address";
const client = new AidboxClient<R5.Bundle, R5.OperationOutcome, User> (
baseUrl,
new BrowserAuthProvider(baseUrl),
);
This client provides a set of methods to work with a FHIR server in a more convenient way:
read - Read the current state of the resourcevread - Read the state of a specific version of the resourceupdate - Update an existing resource by its id (or create it if it is new)conditionalUpdate - Update an existing resource based on some identification criteria (or create it if it is new).patch - Update an existing resource by posting a set of changes to it.conditionalPatch - Update an existing resource, based on some identification criteria, by posting a set of changes to it.delete - Delete a resource.deleteHistory - Delete all historical versions of a resource.deleteHistoryVersion - Delete a specific version of a resource.history - Retrieve the change history for a particular resource.create - Create a new resource with a server assigned idconditionalCreate - Create a new resource with a server assigned id if an equivalent resource does not already exist.search - Search the resource type based on some filter criteria.conditionalDelete - Conditional delete a single or multiple resources based on some identification criteria.history - Retrieve the change history for a particular resource type.capabilities - Get a capability statement for the system.batch/transaction - Perform multiple interactions (e.g., create, read, update, delete, patch, and/or [extended operations]) in a single interaction.delete - Conditional Delete across all resource types based on some filter criteria.history - Retrieve the change history for all resources.search - Search across all resource types based on some filter criteria.search - Search resources associated with a specific compartment instance (see Search Contexts and Compartments)operation - Perform an operation as defined by an OperationDefinition.validate - Perform the Validate Operation.Here's an example of
import { AidboxClient, BrowserAuthProvider } from "@health-samurai/aidbox-client";
import type { Patient } from "hl7-fhir-r4-core";
import { formatOperationOutcome } from "utils";
const client = new AidboxClient(
"http://localhost:8080",
new BrowserAuthProvider("http://localhost:8080"),
);
// Create a new Patient resource
const result = await client.create<Patient>({
type: "Patient",
resource: {
gender: "female",
resourceType: "Patient",
},
});
// Check if interaction was successful
if (result.isErr())
throw Error(formatOperationOutcome(result.value.resource), {
cause: result.value.resource,
});
const patient = result.value.resource;
if (!patient.id)
throw Error(
"id is optional in FHIR, so we check it to satisfy the type checker",
);
// Updating the patient
patient.name = [
{
given: ["Jane"],
family: "Doe",
},
];
const updateResult = await client.update<Patient>({
id: patient.id,
type: "Patient",
resource: patient,
});
if (updateResult.isErr())
throw Error(formatOperationOutcome(updateResult.value.resource), {
cause: updateResult.value.resource,
});
// Deleting the patient
const deleteResult = await client.delete<Patient>({
id: patient.id,
type: "Patient",
});
if (deleteResult.isErr())
throw Error(formatOperationOutcome(deleteResult.value.resource), {
cause: deleteResult.value.resource,
});
As seen in the example above, most methods return a Result<T, E> object.
This object represents a successful or erroneous state of the response.
A general usage pattern is as follows:
const result = await client.read<Patient>({ type: 'Patient', id: 'patient-id' });
if (result.isErr())
throw new Error("error reading Patient", { cause: result.value.resource })
const patient = result.value.resource;
// work with patient.
It is also possible to work with resources without unwrapping the Result object:
const result = await client.read<Patient>({ type: 'Patient', id: 'patient-id' });
return result
.map(({resource}: {resource: Patient}): Patient => {
/* work with Patient resource */
})
.mapErr(({resource}: {resource: OperationOutcome}): OperationOutcome => {
/* work with OperationOutcome resource */
});
// result is still Result<Patient, OperationOutcome>
See the documentation for more info.
The client provides two basic methods for writing custom interactions:
rawRequest - send request to the FHIR server and receive response in a raw formatrequest<T> - send request to the FHIR server and receive response with its body parsed to the specified type TIn a successful case, the rawRequest returns an object with JavaScript Response and additional meta information.
When the server responds with an error code, this function throws an error:
const result = await client.rawRequest({
method: "GET",
url: "/fhir/Patient/patient-id",
headers: {Accept: "application/json"},
params: [["some" "parameters"], ["if", "needed"]],
}).then((result) => {
const patient: Patient = await result.response.json();
// ...
}).catch((error) => {
if (error instanceof ErrorResponse) {
const outcome = await error.responseWithMeta.response.json
// ...
}
});
Alternatively, the request method can be used.
It returns a Result<T, OperationOutcome>, which contains an already parsed result, coerced to the specified type T.
const result: Result<Patient, OperationOutcome> = client.request<Patient>({
method: "GET",
url: "/fhir/Patient/patient-id",
headers: {Accept: "application/json"},
params: [["some" "parameters"], ["if", "needed"]],
});
if (result.isOk()) {
const patient: Patient = result.value.resource;
// work with patient
}
if (result.isErr()) {
const outcome: OperationOutcome = result.value.resource;
// process OperationOutcome
}
Both methods can throw the RequestError class if the error happened before the request was actually made.
Authentication is managed via the AuthProvider interface. The client ships with four built-in providers:
| Provider | Environment | Auth Method |
|---|---|---|
BrowserAuthProvider |
Browser | Cookie-based sessions |
BasicAuthProvider |
Any | HTTP Basic Auth |
SmartBackendServicesAuthProvider |
Server-side | OAuth 2.0 client_credentials with JWT bearer |
SmartAppLaunchAuthProvider |
Browser or server | SMART App Launch (OAuth 2.0 authorization_code with PKCE) |
For browser applications. Uses cookie-based sessions and redirects to the login page on 401.
import { AidboxClient, BrowserAuthProvider } from "@health-samurai/aidbox-client";
const baseUrl = "https://fhir-server.address";
const client = new AidboxClient(baseUrl, new BrowserAuthProvider(baseUrl));
For server-side applications using HTTP Basic Auth.
import { AidboxClient, BasicAuthProvider } from "@health-samurai/aidbox-client";
const baseUrl = "https://fhir-server.address";
const client = new AidboxClient(
baseUrl,
new BasicAuthProvider(baseUrl, "username", "password"),
);
For server-to-server authentication using SMART Backend Services (OAuth 2.0 client_credentials grant with JWT bearer assertion).
Features:
.well-known/smart-configurationimport { AidboxClient, SmartBackendServicesAuthProvider } from "@health-samurai/aidbox-client";
// Generate or import your private key using Web Crypto API
const privateKey = await crypto.subtle.generateKey(
{ name: "RSASSA-PKCS1-v1_5", modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: "SHA-384" },
true,
["sign", "verify"]
).then(kp => kp.privateKey);
const auth = new SmartBackendServicesAuthProvider({
baseUrl: "https://fhir-server.address",
clientId: "my-service",
privateKey: privateKey, // CryptoKey from Web Crypto API
keyId: "key-001", // Must match kid in JWKS
scope: "system/*.read",
// tokenExpirationBuffer: 30, // Optional: seconds before expiry to refresh (default: 30)
});
const client = new AidboxClient("https://fhir-server.address", auth);
For provider-facing applications that authenticate users via SMART App Launch (OAuth 2.0 authorization_code grant). Supports both standalone launch (user opens your app and selects a FHIR server) and EHR launch (the EHR opens your app via ?iss=...&launch=...).
The provider holds no session state of its own — your application stores the SmartSession (in a cookie session, Redis, sessionStorage, etc.) and supplies getSession / setSession callbacks. This works the same way in a browser SPA and in a server app.
For confidential SMART clients, the clientSecret is intentionally not stored in PendingAuthorization or SmartSession. Persist those objects freely, but pass the secret separately in server-side code when calling exchangeCode, refreshSession, revokeSession, or SmartAppLaunchAuthProvider.
exchangeCode() stores token endpoint data in the session. When Aidbox includes userinfo in the token response, it is available as session.userinfo.
The flow has three stages, each backed by a top-level function:
authorize(config) — at the launch URL, returns { redirectUrl, pending }. Persist pending keyed by pending.stateNonce, then redirect the user-agent to redirectUrl.exchangeCode({ url, pending }) — at the redirect URL, exchanges the ?code=... for a SmartSession. Look up the previously stored pending using the ?state=... query parameter. Confidential clients also pass clientSecret here.new SmartAppLaunchAuthProvider(...) — for subsequent FHIR requests. Adds Authorization: Bearer ..., refreshes proactively before expiry, retries once on 401.Features:
.well-known/smart-configurationS256 (configurable: ifSupported / required / disabled)issMatch allow-list for the resolved iss (CSRF-style protection)clientSecret) supported via HTTP Basic on the token endpoint without persisting secrets in pending or sessionimport {
authorize,
exchangeCode,
AidboxClient,
SmartAppLaunchAuthProvider,
type PendingAuthorization,
type SmartSession,
} from "@health-samurai/aidbox-client";
// 1. Launch route — both standalone and EHR launches arrive here.
// For EHR launch the EHR appends `?iss=...&launch=...` to this URL.
app.get("/launch", async (req, res) => {
const { redirectUrl, pending } = await authorize({
iss: "https://fhir.example.com", // fallback for standalone; query iss wins for EHR
clientId: process.env.SMART_CLIENT_ID,
clientSecret: process.env.SMART_CLIENT_SECRET, // sets usesClientSecret without persisting the secret
scope: "launch openid fhirUser patient/*.read offline_access",
redirectUri: `${process.env.BASE_URL}/callback`,
launchUrl: req.url, // lets the helper extract iss/launch from query params
issMatch: /^https:\/\/(fhir|aidbox)\.example\.com$/,
});
req.session.pending = { [pending.stateNonce]: pending };
res.redirect(redirectUrl);
});
// 2. Callback route — exchange the code for a session.
app.get("/callback", async (req, res) => {
const stateNonce = new URL(req.url, process.env.BASE_URL).searchParams.get("state");
const pending: PendingAuthorization = req.session.pending?.[stateNonce!];
if (!pending) return res.status(400).send("Unknown state");
const session = await exchangeCode({
url: req.url,
pending,
clientSecret: process.env.SMART_CLIENT_SECRET,
});
req.session.smart = session;
delete req.session.pending;
res.redirect("/app");
});
// 3. Application route — use the provider for FHIR requests.
app.get("/app", async (req, res) => {
const session: SmartSession | undefined = req.session.smart;
if (!session) return res.redirect("/launch");
const auth = new SmartAppLaunchAuthProvider({
baseUrl: session.serverUrl,
getSession: () => req.session.smart,
setSession: (s) => { req.session.smart = s; },
getClientSecret: () => process.env.SMART_CLIENT_SECRET,
});
const client = new AidboxClient(session.serverUrl, auth);
const result = await client.read({ type: "Patient", id: session.patient! });
res.json(result.value.resource);
});
The same three calls work in the browser — only the storage backend changes (here sessionStorage). Browser apps must use public SMART clients; confidential client secrets belong on the server only:
import {
authorize,
exchangeCode,
AidboxClient,
SmartAppLaunchAuthProvider,
type SmartSession,
} from "@health-samurai/aidbox-client";
// On the launch page (e.g. /launch.html)
const { redirectUrl, pending } = await authorize({
iss: new URL(location.href).searchParams.get("iss") ?? "https://fhir.example.com",
launchUrl: location.href,
clientId: "my-spa",
scope: "launch openid fhirUser patient/*.read",
redirectUri: `${location.origin}/callback.html`,
});
sessionStorage.setItem(`smart:pending:${pending.stateNonce}`, JSON.stringify(pending));
location.href = redirectUrl;
// On the callback page (e.g. /callback.html)
const stateNonce = new URL(location.href).searchParams.get("state")!;
const pending = JSON.parse(sessionStorage.getItem(`smart:pending:${stateNonce}`)!);
const session = await exchangeCode({ url: location.href, pending });
sessionStorage.setItem("smart:session", JSON.stringify(session));
sessionStorage.removeItem(`smart:pending:${stateNonce}`);
location.href = "/app.html";
// On any application page
const auth = new SmartAppLaunchAuthProvider({
baseUrl: JSON.parse(sessionStorage.getItem("smart:session")!).serverUrl,
getSession: () => JSON.parse(sessionStorage.getItem("smart:session")!) as SmartSession,
setSession: (s) => sessionStorage.setItem("smart:session", JSON.stringify(s)),
});
const client = new AidboxClient(auth.baseUrl, auth);
authorize, exchangeCode, refreshSession, and revokeSession are exported as standalone functions and can be used without SmartAppLaunchAuthProvider if you want to manage tokens manually:
import { refreshSession, revokeSession } from "@health-samurai/aidbox-client";
const refreshed = await refreshSession(session); // returns a new SmartSession
await revokeSession(session); // best-effort revocation at the auth server
// Confidential clients pass the secret explicitly in server-side code:
const refreshedConfidential = await refreshSession(session, {
clientSecret: process.env.SMART_CLIENT_SECRET,
});
await revokeSession(session, {
clientSecret: process.env.SMART_CLIENT_SECRET,
});
Security note: when scope includes
openid, the token response carries anid_tokenJWT. This library does not validate the JWT signature,iss,aud, orexp— it stores the raw token insession.idToken. If you use id_token claims for authorization decisions, validate the JWT yourself first.
For other authentication methods, implement the AuthProvider interface:
import type { AuthProvider } from "@health-samurai/aidbox-client";
export class CustomAuthProvider implements AuthProvider {
public baseUrl: string;
constructor(baseUrl: string) {
this.baseUrl = baseUrl;
}
public async establishSession() {
/* code to establish a session */
}
public async revokeSession() {
/* code to revoke the session */
}
/**
* A wrapper around the `fetch` function, that does all the
* necessary preparations and argument patching required for the
* request to go through.
*
* Optionally, security checks can be implemented, like verifying
* that the request indeed goes to the `baseUrl`, and not
* somewhere else.
*/
public async fetch(
input: RequestInfo | URL,
init?: RequestInit,
): Promise<Response> {
/* ... */
}
}