Step by step

Launch a SMART app from the chart

By the end, a clinician opens a person’s record in HealthOS, clicks Apps, picks yours, allows it once, and your app shows that person and their latest results. Allow an afternoon, most of it on your side. The provider’s HealthOS administrator registers the app and decides who sees it.

You will need

  • A web app with a server side. Ours is one Node.js file with no dependencies.
  • Two https addresses on it: a launch URL HealthOS opens, and a redirect URI it sends the clinician back to.
  • The provider’s HealthOS address, a test organisation, and a clinician login there.

Before you start

This is the SMART on FHIR “EHR launch”. HealthOS starts it: it opens your launch URL with iss (the FHIR base URL) and a one-off launch id. Your app sends the browser to HealthOS to authorise, with PKCE, and exchanges the code it gets back for a token that is bound to that person (and the encounter, when launched from one).

Do the token exchange and the FHIR calls from your server, as our sample does. HealthOS does not send CORS headers to an app’s own origin, so a browser-only app cannot call /smart/token or /fhir from JavaScript.

The steps below were run against a local HealthOS with demo data. Hosts are shown as https://healthos.example.org for HealthOS and https://app.example.com for the app. In the screenshots the app ran on localhost:3290.

Build the app

  1. Handle the launch

    You

    Your launch URL receives ?iss=https://healthos.example.org/fhir&launch=…. Read the SMART configuration from the server root, make a PKCE verifier and challenge, remember them against a random state, and redirect to the authorisation endpoint. aud must be the iss you were given, and the scope must include launch.

    import { createHash, createPublicKey, randomBytes, verify } from 'node:crypto';
    
    const CLIENT_ID = process.env.CLIENT_ID;                  // from HealthOS Admin
    const REDIRECT_URI = 'https://app.example.com/callback';
    const pending = new Map();                                // state -> what the callback needs
    const b64url = (buf) => Buffer.from(buf).toString('base64url');
    
    async function discover(iss) {
      // HealthOS publishes SMART discovery at the server root.
      const res = await fetch(new URL('/.well-known/smart-configuration', iss));
      if (!res.ok) throw new Error(`discovery failed: ${res.status}`);
      return res.json();
    }
    
    // GET /launch?iss=…&launch=…
    async function authorize(res, iss, scope, launch) {
      const config = await discover(iss);
      const verifier = b64url(randomBytes(32));
      const challenge = b64url(createHash('sha256').update(verifier).digest());
      const state = b64url(randomBytes(16));
      const nonce = b64url(randomBytes(16));
      pending.set(state, { iss, verifier, nonce, config });
    
      const url = new URL(config.authorization_endpoint);
      url.search = new URLSearchParams({
        response_type: 'code',
        client_id: CLIENT_ID,
        redirect_uri: REDIRECT_URI,
        scope,                         // 'launch openid fhirUser patient/*.rs'
        state,
        nonce,
        aud: iss,
        code_challenge: challenge,
        code_challenge_method: 'S256',
        ...(launch ? { launch } : {}),
      }).toString();
      res.writeHead(302, { Location: url.toString() }).end();
    }
  2. Exchange the code

    You

    HealthOS sends the clinician back to your redirect URI with code and state. Look up the state, then post the code with the PKCE verifier. A public client sends its client_id and no secret.

    // GET /callback?code=…&state=…
    async function callback(res, params) {
      if (params.get('error')) return page(res, `Authorisation refused: ${params.get('error')}`);
      const entry = pending.get(params.get('state'));
      if (!entry) return page(res, 'Unknown or expired state.', 400);
      pending.delete(params.get('state'));
    
      const tokenRes = await fetch(entry.config.token_endpoint, {
        method: 'POST',
        headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
        body: new URLSearchParams({
          grant_type: 'authorization_code',
          code: params.get('code'),
          redirect_uri: REDIRECT_URI,
          client_id: CLIENT_ID,
          code_verifier: entry.verifier,
        }),
      });
      const token = await tokenRes.json();
      if (!tokenRes.ok) return page(res, JSON.stringify(token), 400);
      const { claims } = await verifyIdToken(token.id_token, entry.config, entry);
      // token.patient is the person the chart was open on
      …
    }

    The response we got (tokens shortened):

    {
      "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…",
      "token_type": "Bearer",
      "expires_in": 3600,
      "scope": "launch openid fhirUser patient/*.rs",
      "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…",
      "id_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6ImJZdEZtcnhtT1B6cGJfOUFLbGV1SjNxOS16V2N5UmdncTI5LWs0Y3o5WjgiLCJ0eXAiOiJKV1QifQ…",
      "patient": "0d72ded0-776a-445d-8476-ad0c9fdbb494"
    }

    Launched from an encounter, the response also carries encounter. The access token lasts an hour and the refresh token 24 hours.

  3. Verify the id_token

    You

    Because you asked for openid fhirUser, the response includes an OpenID Connect id_token, signed RS256. Check it before you trust who signed in: the signature against the key named by kid in the JWKS that discovery lists (/smart/jwks.json), then iss, aud, exp and the nonce you sent.

    async function verifyIdToken(idToken, config, expected) {
      const [h, p, s] = idToken.split('.');
      const header = JSON.parse(Buffer.from(h, 'base64url'));
      const claims = JSON.parse(Buffer.from(p, 'base64url'));
      const jwks = await (await fetch(config.jwks_uri)).json();
      const jwk = jwks.keys.find((k) => k.kid === header.kid);
      if (header.alg !== 'RS256' || !jwk) throw new Error('id_token: unknown key');
      const key = createPublicKey({ key: jwk, format: 'jwk' });
      const ok = verify('sha256', Buffer.from(`${h}.${p}`), key, Buffer.from(s, 'base64url'));
      if (!ok) throw new Error('id_token: bad signature');
      if (claims.iss !== expected.iss) throw new Error('id_token: wrong iss');
      if (claims.aud !== CLIENT_ID) throw new Error('id_token: wrong aud');
      if (claims.exp * 1000 < Date.now()) throw new Error('id_token: expired');
      if (claims.nonce !== expected.nonce) throw new Error('id_token: wrong nonce');
      return { header, claims };
    }

    The claims from our run. fhirUser names the clinician as a Practitioner, and sub is their stable HealthOS user id:

    {
      "iss": "https://healthos.example.org/fhir",
      "sub": "b71616e4-deb3-4e9d-82b7-8cf1c5d715dd",
      "aud": "smart-e9f7031f30a11ba5fe05f170",
      "fhirUser": "https://healthos.example.org/fhir/Practitioner/b71616e4-deb3-4e9d-82b7-8cf1c5d715dd",
      "nonce": "z0F3bqlDD_4_Cc3jv0W-9A",
      "auth_time": 1792286317,
      "iat": 1792286327,
      "exp": 1792289927
    }
  4. Read the patient and their results

    You

    Call the FHIR API at iss with the access token. A patient/ scope only reaches the person in token.patient.

    GET /fhir/Patient/0d72ded0-776a-445d-8476-ad0c9fdbb494
    GET /fhir/Observation?patient=0d72ded0-776a-445d-8476-ad0c9fdbb494&_sort=-date&_count=5
    Authorization: Bearer {access_token}
    Accept: application/fhir+json

    Then render them however your app does. Ours prints the name, date of birth and a table of the five most recent observations.

Register it and open it

  1. Register the app

    Provider administrator

    In HealthOS Admin, go to Interoperability → FHIR / SMART on FHIR, check Viewing shows the right organisation, and click Register SMART Client. Fill in:

    • Display name: the name clinicians see in the Apps menu.
    • Description: one line, shown under the name in the menu.
    • Client type: Public (no secret, PKCE only). An app with a server that can keep a secret may use Confidential – client secret instead.
    • Redirect URIs: your callback, exactly as your app sends it.
    • Allowed scopes: launch openid fhirUser patient/*.rs, or narrower types if the app needs less.
    • Launch mode: EHR-launch only.
    • Who signs in: Clinicians and staff (HealthOS sign-in).
    The Register SMART client form for Results Viewer: Public client type, a callback redirect URI, scopes launch openid fhirUser patient/*.rs, EHR-launch only, clinicians and staff sign in
    The top half of the form.
  2. Put it in the chart

    Provider administrator

    Lower down, under In the clinician app:

    • Launch URL: your app’s launch handler. HealthOS adds ?iss=…&launch=… to it. Without it the app is not offered in the chart.
    • Show in: tick Person’s chart header to launch with the person, and Encounter view to launch with the person and the open encounter.
    • Only for roles: role codes such as doctor and registered_nurse. Leave it empty to offer the app to every role that may launch apps.
    • Logo URL: a square https image. At the time of writing the clinician app only loads images from its own host, so a logo on your host shows as the first letter of the app’s name instead (you can see this in step 3).
    The In the clinician app section: a launch URL, both Show in boxes ticked, roles doctor and registered_nurse, a logo URL, and Enabled switched on
    Offered in the chart header and the encounter view, to doctors and registered nurses.

    Click OK. The row shows where the app appears and how many roles see it. Send the vendor the Client ID.

    The client list with Results Viewer, type public, launch ehr-launch, signs in staff, in the clinician app chart and encounter for 2 roles, enabled
    Results Viewer, registered alongside two backend systems.

    Clinicians also need the Launch SMART Apps permission. The clinical system roles hold it already. If someone cannot see the Apps button, check their role under Organisation → Users & Roles.

  3. Open the app from a person’s chart

    Clinician

    In HealthOS, open the person and click Apps in the patient header. Pick the app.

    A person's header in HealthOS with the Apps menu open, listing Results Viewer with its description
    The Apps menu lists only the apps offered here and to this clinician’s role.

    The app opens in a new tab. HealthOS hands the clinician’s sign-in to the authorisation page, so they are not asked to sign in again.

  4. Allow access

    Clinician

    The authorisation page lists the scopes the app asked for and the person it was launched for. Allow sends the browser back to your app with a code. Deny sends it back with error=access_denied.

    The Authorize access page listing the app's client ID, the scopes launch, openid, fhirUser and patient/*.rs, the line Launched from the clinical record for NEVILLE, Lisa, and Allow and Deny buttons
    The page names the app by its client ID, not its display name. Tell the provider’s clinicians which ID is yours.
  5. Your app shows the patient

    You

    The callback exchanges the code, verifies the id_token, reads the patient and shows them.

    The sample Results Viewer app showing Lisa NEVILLE, born 1967-01-16, four recent laboratory observations, and the verified id_token's fhirUser and granted scope
    Our sample app, showing the person the chart was open on. The results are the ones written in the backend services walkthrough.

Check it worked

  • The token response carries patient, and it is the person whose chart the clinician had open.
  • The id_token verifies, and its fhirUser is a Practitioner.
  • Sign in as a clinician whose role is not in Only for roles. The Apps menu does not list the app.

If something goes wrong

These are the answers we saw, or that the server is written to give.

What you seeWhyWhat to do
No Apps button in the headerThe clinician lacks the Launch SMART Apps permission, or no app is offered thereCheck the role, the Show in boxes and Only for roles
A letter instead of your logo in the Apps menuThe clinician app’s content security policy only loads images from its own host, so it blocked the logoNothing on your side. The app still launches
400 invalid_request, “launch is unknown, expired, or was not issued to this app and user”A launch id lasts ten minutes and works once, for that app and that clinicianStart again from Apps. Don’t cache launch ids
400 invalid_request, “aud must be this server’s FHIR base URL”aud is not the iss you were launched withSend iss back unchanged as aud
400 invalid_request, “An EHR launch must request the launch scope”You sent launch without the launch scopeAdd launch to the scope
400 invalid_request, “redirect_uri is not registered for this client”The redirect URI differs from the registration, even by a trailing slashMake them match exactly
400 invalid_scope, “Requested scope exceeds what this client is authorised for”You asked for more than Allowed scopesAsk for less, or ask the administrator to widen it
401 OperationOutcome from {iss}/.well-known/smart-configurationHealthOS serves discovery at the server root, not under /fhirRead /.well-known/smart-configuration on the same host
The browser call to /smart/token fails with a CORS errorHealthOS does not answer cross-origin calls from your app’s originExchange the code from your server

Ready to try it in a test organisation?

Send us your launch URL and redirect URI. We’ll register the app with you and open it from a demo chart together.