Skip to content

SMART on FHIR apps

Health Data Safe (HDS) exposes a read-only FHIR R4 API to SMART on FHIR apps. A patient launches your app, signs in to their HDS account, chooses what to share, and your app reads FHIR resources rendered from their vault. Nothing is copied: every request is read from the patient’s account with your app’s own token.

HDS conforms to SMART App Launch 2.2.0, tested with Inferno (details under Conformance and testing).

Each patient’s data lives in one region, chosen when they created their account. The patient picks their region when they connect your app; your app then uses that region’s issuer and FHIR base for the whole session.

RegionIssuerFHIR baseProfiles
Switzerlandhttps://auth-ch.api.datasafe.devhttps://auth-ch.api.datasafe.dev/adapter/fhir/r4IPS 2.0.1 (International Patient Summary)
USAhttps://auth-us.api.datasafe.devhttps://auth-us.api.datasafe.dev/adapter/fhir/r4US Core 6.1.0
Sandboxhttps://demo.datasafe.devhttps://demo.datasafe.dev/adapter/fhir/r4US Core 6.1.0
  • A token issued in one region is refused by the other region’s FHIR server.
  • Use the sandbox for development. Test patient accounts can be created freely there.
  • A machine-readable list of the production endpoints is published as a SMART User-access Brand Bundle at https://healthdatasafe.github.io/assets-prod/smart/brands.json.

Instead of asking for a region, your app may ask the patient for their HDS username and use https://<username>.api.datasafe.dev/adapter/fhir/r4 as the FHIR base (iss). The address leads straight to the patient’s region. It exists in production only, not in the sandbox.

  • Discovery works on that base as on a region base. Its smart-configuration names the region’s issuer and OAuth endpoints: take them from there.
  • aud must be that same base. At sign-in HDS checks that the name is the signed-in patient’s own; another account’s address is refused with access_denied.
  • A token issued for a patient’s own address is accepted there only, not on the region base, and a region token is not accepted on the patient’s address.
  • fhirUser in the id_token, and every link in the responses, are on the patient’s address.
  • Your app learns the patient’s username, which the patient chooses to give it. If your app should not hold it, use the region bases.
  • GET <FHIR base>/.well-known/smart-configuration: the SMART configuration, including scopes_supported and capabilities.
  • GET <FHIR base>/metadata: the CapabilityStatement, with the profiles each resource type claims on that endpoint (supportedProfile).
  • The issuer also serves /.well-known/openid-configuration and /.well-known/oauth-authorization-server, and a JWKS (jwks_uri) to verify id_tokens.
  • The OAuth endpoints are on the issuer host: /oauth2/authorize, /oauth2/token and /oauth2/revoke. Always take them from the discovery document rather than hard-coding them.

Launch and authorization

  • SMART App Launch 2.2.0 standalone patient launch (launch/patient).
  • Authorization code flow with PKCE S256, required for every client (plain is not offered).
  • Public clients, and confidential clients authenticating with client_secret_basic or with private_key_jwt (RS384 or ES384 assertions).
  • aud is required on the authorize request and must be the FHIR base your app uses: the region’s, or the patient’s own address (above).
  • The authorize request may be sent as GET or POST.

Scopes

  • SMART v2 resource scopes patient/<Type>.rs (read and search). patient/*.rs asks for every type. The v1 form patient/<Type>.read is accepted too.
  • A v2 scope may carry one ?category= to narrow it, for example patient/Observation.rs?category=vital-signs.
  • offline_access for a refresh token.
  • openid fhirUser for an id_token: signed RS256, valid 300 seconds, with fhirUser set to the patient’s absolute URL (<FHIR base>/Patient/<id>).
  • hds/pseudonym (HDS-specific): the patient id your app receives is a pseudonymous alias rather than the patient’s HDS user id.
  • Nothing writes: no .c, .u or .d scope is granted.
  • The scope returned with the token is what the patient actually granted. Read it rather than assuming your request was granted as asked. The full list of scopes a region offers is its scopes_supported.

Resources and search

ResourceInteractionsSearch parameters
Patientread, search_id
Observationread, searchpatient, category, code, date; _include=Observation:device; _revinclude=Provenance:target
Conditionread, searchpatient, category, code; _revinclude=Provenance:target
Provenanceread(reached through _revinclude)
  • Observation categories served: vital signs, laboratory, survey and social history.
  • Search also works as POST <Type>/_search with an application/x-www-form-urlencoded body.
  • date takes the prefixes eq, ge, gt, le and lt, at year, month, day or date-time precision.
  • _count defaults to 50, with a maximum of 200. Follow the bundle’s next link for more.
  • patient (or subject) must be the patient in your token; another id is refused with 403. Patient/<id> reads only that patient.
  • Responses are application/fhir+json only. CORS is open, so browser apps can call the API directly.
  • Errors are returned as an OperationOutcome. A missing, inactive or wrong-audience token gets 401 with a WWW-Authenticate header.

Rate limit. Authenticated requests are limited to 600 per minute per client IP address, with bursts up to 120. Above it the server answers 429 with a Retry-After header.

HDS does not offer

  • EHR launch (launch), or any persona other than the patient;
  • backend services (system/ scopes) or bulk data export;
  • writes of any kind;
  • DPoP-bound tokens;
  • dynamic client registration (see Registering your app).
  • At launch the patient sees what your app asks for, grouped by category, and can narrow it per data stream before accepting. Access is always read-only.
  • A grant has no expiry: it lives until the patient revokes it in their HDS account.
  • Revocation takes effect immediately. The FHIR server checks every request with the authorization server and never caches an active token, so the next call after a revocation gets 401.
  • Your app can revoke its own tokens at the issuer’s /oauth2/revoke.
  • Everything the patient consents to is readable. Data that has no FHIR mapping yet is served as a generic Observation, tagged in meta.tag with system https://hds.ngo/fhir/CodeSystem/rendering, code generic. It follows no profile, carries an HDS-only category (hds-item), and is returned by an Observation search without a category, never by one with a category.

Third-party apps are registered pairwise. The patient id and the API address your app sees are unique to your app, so two apps cannot match a patient by id.

Pairwise does not unlink the data itself. Resource ids are the vault’s record ids, and they are the same for every app the patient shares a record with. Two apps that both read the same record could therefore link their pseudonyms through it, as they could through the content and timestamps of the data. HDS accepts this and states it here; publishers accept this statement in writing when they register.

Registration is curated by HDS: there is no dynamic registration, on the sandbox or in production. To register, write to HDS at tech@healthdatasafe.org with:

  1. Who you are: the legal entity, a contact address, and the person accountable for the app.
  2. What the app does with the data: its purpose, whether data leaves the device, retention, onward sharing and hosting region; the URLs of your privacy policy and terms of use.
  3. Redirect URIs: each one https on a host you control, or an RFC 8252 loopback form for a native app. Plain http redirects are not accepted.
  4. Scopes: the smallest set the app needs. Ask for offline_access only if the app really refreshes tokens.
  5. Client type: a browser or mobile app is a public client (PKCE is mandatory). A server-side app uses private_key_jwt with a JWKS URL; client_secret_basic is discouraged for third parties.
  6. What the patient sees: the app name (localized names optional), a one-line description shown at consent, and the app’s website.
  7. Your written acceptance of the linkability statement.

HDS vets the application and, once approved, registers the app pairwise for both regions, so a patient in either region can use it, at the region bases and at the patient’s own address. You receive your client_id, the issuer and FHIR base of each region, and the support contact.

After registration

  • Changing a vetted field (redirect URIs, scopes, audiences, key set, authentication method, grant types, token lifetimes, offline access, launch type) puts the registration under review, and the app stops working until HDS approves the change. Plan changes ahead with HDS.
  • If what the app may be granted is narrowed, existing grants are withdrawn and patients consent again.
  • HDS may suspend an app (for example during an incident or an open vetting question): it is refused within 30 seconds, and patients’ sessions survive for when it resumes.
  • An app removed for good has every grant withdrawn in both regions before its registration is revoked.

The examples use the sandbox; swap in the region’s issuer and FHIR base for production. Your client_id and redirect URI come from your registration.

1. Discover

Terminal window
curl https://demo.datasafe.dev/adapter/fhir/r4/.well-known/smart-configuration

Read authorization_endpoint, token_endpoint, scopes_supported and capabilities from the answer.

2. Send the patient to the authorize endpoint

Generate a random state and a PKCE code_verifier, then redirect the browser to authorization_endpoint with these parameters (shown decoded; URL-encode them in the query):

response_type=code
client_id=<your client id>
redirect_uri=https://app.example.com/callback
scope=launch/patient openid fhirUser patient/Patient.rs patient/Observation.rs
state=<random>
aud=https://demo.datasafe.dev/adapter/fhir/r4
code_challenge=<BASE64URL(SHA256(code_verifier))>
code_challenge_method=S256

The patient signs in, reviews the request and accepts. The browser comes back to your redirect_uri with code and state; check that state matches.

3. Exchange the code for a token

Terminal window
curl -X POST https://demo.datasafe.dev/oauth2/token \
-H 'Content-Type: application/x-www-form-urlencoded' \
-d grant_type=authorization_code \
-d code=<code> \
-d redirect_uri=https://app.example.com/callback \
-d client_id=<your client id> \
-d code_verifier=<code_verifier>

The answer carries access_token, token_type (Bearer), expires_in, the granted scope, patient (the patient id to use in your requests) and, with openid fhirUser, an id_token. A confidential client authenticates here as registered.

4. Read the patient and their observations

Terminal window
curl -H 'Authorization: Bearer <access_token>' -H 'Accept: application/fhir+json' \
https://demo.datasafe.dev/adapter/fhir/r4/Patient/<patient>
curl -H 'Authorization: Bearer <access_token>' -H 'Accept: application/fhir+json' \
'https://demo.datasafe.dev/adapter/fhir/r4/Observation?patient=<patient>&category=vital-signs&_count=20'

The search returns a searchset Bundle; follow its next link for further pages.

The SMART project’s own JavaScript library, fhirclient 2.x, works unmodified against the sandbox (tested on 2026-11-04 with a standalone launch). It runs discovery, PKCE and the token exchange for you:

// launch page
FHIR.oauth2.authorize({
clientId: '<your client id>',
iss: 'https://demo.datasafe.dev/adapter/fhir/r4',
scope: 'launch/patient openid fhirUser patient/Patient.rs patient/Observation.rs',
redirectUri: './callback.html'
});
// callback page
const client = await FHIR.oauth2.ready();
const patient = await client.patient.read();
const vitals = await client.request(`Observation?patient=${client.patient.id}&category=vital-signs`);

HDS conforms to SMART App Launch 2.2.0, tested with Inferno.

What was run, precisely: on 2026-11-04 the Inferno SMART App Launch STU2.2 test kit and the Inferno US Core 6.1.0 test kit were run against the sandbox.

  • SMART App Launch: every test passed for the standalone launch (with OpenID Connect and token refresh) with a public client and with a pairwise client, and for token introspection. With a confidential client_secret_basic client, every test run passed; the kit omits one CORS test for non-public clients.
  • US Core 6.1.0: no test failed on a resource type HDS serves. Some of those tests were skipped for lack of sample data. Tests for resource types HDS does not hold fail, as those types are absent from the CapabilityStatement.

The Inferno kits were not run against the production regions, and they do not cover the Swiss region’s IPS rendering. HDS holds no ONC certification.