AnpherosAnpheros PlatformDevelopers

How to build a healthcare API with TypeScript

Tutorial: an Express API in TypeScript that creates patient records, stores blood-pressure readings as FHIR R4 observations, reads them back, maps errors and verifies webhooks — without a medical database of your own.

This tutorial builds the backend API of a blood-pressure tracking app in TypeScript with Express: sign-up creates the user's medical record, readings are stored as coded HL7 FHIR R4 observations, the app reads them back, and a webhook endpoint hears about new data, with no medical database of your own. Your API keeps what is specific to your product (users, sessions, settings). The medical data, its history, consent and provenance live in Anpheros, which you call through the TypeScript SDK.

Architecture

 mobile / web app ──► your API (Express, TypeScript) ──► Anpheros (FHIR R4 record per patient)
                       │  users, sessions, settings        │  observations, conditions, documents,
                       │  user id → patient id              │  provenance, consent, audit
                       ◄──────────── webhooks ──────────────┘

Your database stores one extra column per user: their Anpheros patient id. Everything clinical goes through the API, so it is coded, versioned and readable by any FHIR system or by the patient's other apps they consent to. Medical app backend and database explains the split in detail.

Before you start

mkdir bp-api && cd bp-api
npm init -y && npm pkg set type=module
npm install express @anpheros/sdk
npm install -D typescript tsx @types/express @types/node
export ANPHEROS_KEY=sk_test_…

Step 1: the client and your user store

// server.ts
import express from 'express';
import { Anpheros, AnpherosError, apiKey, verifyWebhookSignature } from '@anpheros/sdk';

const anpheros = new Anpheros({ auth: apiKey(process.env.ANPHEROS_KEY!) });
const app = express();

// Your own user table. In production this is your database; the only medical link is patientId.
type User = { id: string; email: string; patientId: string };
const users = new Map<string, User>();

// Stand-in for your real authentication (session cookie, JWT…): it must set the signed-in user.
function currentUser(req: express.Request): User {
  const user = users.get(req.header('x-user-id') ?? '');
  if (!user) throw new HttpError(401, 'not signed in');
  return user;
}

class HttpError extends Error {
  constructor(readonly status: number, message: string) { super(message); }
}

Step 2: sign-up creates the medical record

When someone signs up, create their patient in Anpheros and put your own user id in identifiers, so the record can always be found from your side:

app.post('/signup', express.json(), async (req, res, next) => {
  try {
    const { email, given, family, birthDate } = req.body;
    const userId = crypto.randomUUID();
    const patient = await anpheros.patients.create(
      { given, family, birth_date: birthDate, identifiers: [{ system: 'https://bp.example/user', value: userId }] },
      { author_type: 'patient' },
    );
    users.set(userId, { id: userId, email, patientId: patient.id });
    res.status(201).json({ userId });
  } catch (e) { next(e); }
});

The response from Anpheros is the new patient, with an id that belongs to your project and its FHIR reference:

{"id": "27296656-c2ba-4970-8424-1dcd91163e0a", "given": "Ioana", "family": "Marin", "birth_date": "1979-04-12", "gender": "female",
 "identifiers": [{"system": "https://bp.example/user", "value": "u_8f2c"}], "updated_at": "2026-10-12T20:16:51.143Z",
 "fhir": "Patient/27296656-c2ba-4970-8424-1dcd91163e0a"}

Step 3: store a reading

The app sends {systolic, diastolic, measuredAt}. Your API validates it and stores one blood-pressure observation, LOINC 85354-9, with its two components:

app.post('/readings', express.json(), async (req, res, next) => {
  try {
    const user = currentUser(req);
    const { systolic, diastolic, measuredAt } = req.body;
    if (![systolic, diastolic].every((v) => Number.isFinite(v) && v > 20 && v < 300)) {
      throw new HttpError(400, 'systolic and diastolic must be numbers in mmHg');
    }
    const obs = await anpheros.observations.create(user.patientId, {
      code: '85354-9',
      category: 'vital-signs',
      effective_at: measuredAt ?? new Date().toISOString(),
      components: [
        { code: '8480-6', value: systolic, unit: 'mm[Hg]' },
        { code: '8462-4', value: diastolic, unit: 'mm[Hg]' },
      ],
      author_type: 'patient',
    }, { source_system: 'bp-app' });
    res.status(201).json({ id: obs.id, measuredAt: obs.effective_at });
  } catch (e) { next(e); }
});

What Anpheros stores and returns:

{"id": "78188270-e65e-49bd-89d0-e776226e9614", "patient": "27296656-…", "code": "85354-9", "system": "http://loinc.org",
 "display": "Blood pressure panel", "category": "vital-signs", "effective_at": "2026-10-10T08:30:00Z",
 "components": [{"code": "8480-6", "system": "http://loinc.org", "value": 138.0, "unit": "mm[Hg]"},
                {"code": "8462-4", "system": "http://loinc.org", "value": 88.0, "unit": "mm[Hg]"}],
 "status": "final", "updated_at": "2026-10-12T20:16:51.153Z", "fhir": "Observation/78188270-…"}

The SDK sends an Idempotency-Key with every create, so a retry after a network error does not store the reading twice. author_type: 'patient' and source_system go into the reading's provenance: a doctor, another app or an AI model can later tell that the person measured this at home with your app.

Step 4: read readings back

app.get('/readings', async (req, res, next) => {
  try {
    const user = currentUser(req);
    const page = await anpheros.observations.list(user.patientId, {
      code: '85354-9',
      from: typeof req.query.from === 'string' ? req.query.from : undefined,
      limit: 50,
    });
    res.json({
      readings: page.data.map((o) => ({
        id: o.id,
        measuredAt: o.effective_at,
        systolic: o.components.find((c) => c.code === '8480-6')?.value,
        diastolic: o.components.find((c) => c.code === '8462-4')?.value,
      })),
      next: page.next_cursor,
    });
  } catch (e) { next(e); }
});

Your API returns the shape your app wants. The stored data stays standard. Because the user id is checked by currentUser, one user can never ask for another user's patient id.

Step 5: errors

Anpheros errors arrive as AnpherosError with status, type, message, field and a requestId. Pass validation errors to the app and hide the rest:

app.use((err: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
  if (err instanceof HttpError) return res.status(err.status).json({ error: err.message });
  if (err instanceof AnpherosError && err.status === 400) {
    return res.status(400).json({ error: err.message, field: err.field });
  }
  console.error(err instanceof AnpherosError ? `anpheros ${err.status} ${err.type} request=${err.requestId}` : err);
  res.status(502).json({ error: 'medical record service unavailable, try again' });
});

For example, a reading without a date is refused by Anpheros with {"error": {"type": "invalid", "message": "Field required (at effective_at)", "field": "effective_at"}}. Rate limits (429) and server errors are retried by the SDK before you see them. Log the requestId and quote it if you contact support. The full list is in Errors.

Step 6: hear about new data with a webhook

Readings can also arrive from elsewhere: a clinic writing to the same record, a lab report, another app the person connected. Register an endpoint once:

curl -X POST https://platform.anpheros.com/v1/webhooks -H "Authorization: Bearer $ANPHEROS_KEY" \
     -H "Content-Type: application/json" \
     -d '{"url": "https://bp.example/webhooks/anpheros", "events": ["resource.created", "consent.revoked"]}'

The response contains a secret (whsec_…), shown once. Verify every delivery against the raw body before trusting it:

app.post('/webhooks/anpheros', express.raw({ type: 'application/json' }), async (req, res) => {
  const ok = await verifyWebhookSignature({
    secret: process.env.ANPHEROS_WEBHOOK_SECRET!,
    header: req.header('anpheros-signature'),
    body: req.body as Buffer,
  });
  if (!ok) return res.status(400).end();
  const event = JSON.parse((req.body as Buffer).toString('utf8'));
  // Events carry ids and versions, not clinical data: fetch what you need, then update caches or notify.
  console.log(event.type, event.data);
  res.status(204).end();
});

app.listen(3000, () => console.log('bp-api on http://localhost:3000'));

Event types are resource.created, resource.updated, resource.deleted, consent.granted, consent.revoked, document.finalized and ping. Deliveries are signed, retried and can be replayed (Webhooks).

Run it

npx tsx server.ts
curl -X POST localhost:3000/signup -H 'content-type: application/json' \
     -d '{"email":"[email protected]","given":"Ioana","family":"Marin","birthDate":"1979-04-12"}'
# {"userId":"…"}
curl -X POST localhost:3000/readings -H 'content-type: application/json' -H 'x-user-id: <userId>' \
     -d '{"systolic":138,"diastolic":88,"measuredAt":"2026-10-10T08:30:00Z"}'
curl localhost:3000/readings -H 'x-user-id: <userId>'

Replace the x-user-id stand-in with your real authentication before anything leaves your machine.

Going further

Frequently asked questions

Do I need my own database for medical data?

Not for the medical data itself. Your database keeps users, sessions and product settings, plus the patient id for each user. Observations, conditions, medications, documents, their history and who wrote them are stored in the FHIR record.

Can the mobile app call Anpheros directly?

With an API key, no: the key reaches every patient of your project and must stay on your server. A mobile app can call Anpheros directly with an OAuth token that the person granted for their own record, as in How to build a healthcare app with Flutter.

Which framework does this work with?

Any. The SDK only needs fetch, so it runs in Express, Fastify, Hono, NestJS, Next.js route handlers, Deno or Bun.

Related