Use writable extensions
Goal: ship an app that writes a "preferred pharmacy" string onto each Patient, in a way that works across every tenant that installs the app.
Prerequisites:
- A scaffolded app (
orion init). - A configured sandbox tenant.
user/Patient.readanduser/Patient.writescopes.
What writable extensions are
A writable extension is a manifest declaration that says "this app needs to write a FHIR Extension with this shape, on these resource types." You declare structural intent — a key, a value type, the resources it applies to — and the host EMR resolves a per-tenant StructureDefinition URL when the tenant admin installs your app.
Apps in the marketplace ship to many tenants. Each tenant has its own FHIR server, its own URL space, its own catalog of StructureDefinition records. If your app baked a literal URL (https://acme.example/StructureDefinition/preferred-pharmacy) into the bundle, that URL would only work for one tenant. Writable extensions move the URL out of the bundle: you declare the field by key in the manifest, the host gives you the resolved URL at install time, and your app fetches that URL at runtime.
How they work
Three stages — declare, install, write.
1. Declare. Put a writableExtensions[] entry in orion-app.json. The host's manifest validator enforces the schema (key shape, value-type allow-list, the 50-entry cap) at publish time; orion validate catches the same errors locally.
2. Install. When a tenant admin installs your app, the install dialog shows your declared writable extensions and offers three choices per entry:
- Create new — host writes a fresh
StructureDefinitionin the tenant's FHIR catalog and binds the URL to your install. - Map existing — host shows compatible
StructureDefinitionrecords already in the tenant (matched byvalue[x]type andcontext.expression) and lets the admin pick one. Useful when the tenant already has a canonical "preferred-pharmacy" definition. - Skip — admin opts your install out of this field. Your code won't see the URL at runtime and can't write the extension.
The host records the result in the install row's extension_url_map JSON column, keyed by your declared key.
3. Write. At runtime your app reads the resolved map via the FHIR $get-installation-context operation, then uses the per-tenant URL to write extensions on the host-allowed resources. Every PUT/POST that includes an Extension.url not in the install's allow-list is rejected by the host with a 403 — the allow-list is the security boundary, not a CSS-style optional hint.
Steps
Declare the writable extension in
orion-app.json.orion initscaffolds an example entry already; this is the shape:json{ "scope": [ "user/Patient.read", "user/Patient.write", "user/AppInstallation.read" ], "extensions": { "writableExtensions": [ { "key": "preferred-pharmacy", "title": "Preferred Pharmacy", "description": "Patient's preferred pharmacy for prescription routing.", "valueType": "string", "appliesTo": ["Patient"] } ] } }user/AppInstallation.readis required so your app can call$get-installation-contextat runtime to fetch the resolved URL map.Validate locally before pushing:
bashorion validatevalidatewill reject a malformedvalueType, a bad kebab-case key, an emptyappliesTo, duplicate keys, and any manifest over the 50-entrywritableExtensions[]cap.Publish, install, and observe the install dialog.
orion publishpushes the bundle; in your sandbox tenant, click Install on the app. The install dialog will show your declared writable extensions; pick Create new forpreferred-pharmacyand complete the install.Fetch the resolved URL map at runtime. Inside your app, call the
$get-installation-contextoperation throughbridge.fhirFetch. The response shape is a FHIRParametersresource. TheextensionUrlMapparameter has a nestedpart[], with one inner part per declared key, and the resolved URL on each inner part'svalueUri:tsximport { useEffect, useState } from 'react'; import { useBridge } from '@orion-ehr/app-bridge'; type ParametersPart = { name: string; valueUri?: string; part?: ParametersPart[] }; type Parameters = { parameter?: ParametersPart[] }; export function usePharmacyExtensionUrl(): string | null { const bridge = useBridge(); const [url, setUrl] = useState<string | null>(null); useEffect(() => { if (!bridge) return; bridge.fhirFetch<Parameters>('$get-installation-context').then((params) => { const entry = params.parameter?.find((p) => p.name === 'extensionUrlMap'); const resolved = entry?.part?.find((p) => p.name === 'preferred-pharmacy')?.valueUri ?? null; setUrl(resolved); }); }, [bridge]); return url; }A missing inner part (or one with no
valueUri) means the admin chose Skip at install time. Your UI should handle that case — e.g. hide the feature, or surface a message that the admin can re-install with the field enabled.Write the extension on a Patient. Build a
PUT Patient/{id}payload that includes anExtensionwith the resolved URL:tsximport type { OrionBridge } from '@orion-ehr/app-bridge'; type PatientResource = { resourceType: string; id: string; extension?: Array<{ url: string; valueString?: string }>; }; async function setPreferredPharmacy(bridge: OrionBridge, patientUuid: string, value: string, url: string) { const patient = await bridge.getResource<PatientResource>('Patient', patientUuid); const otherExtensions = (patient.extension ?? []).filter((e) => e.url !== url); const updated = { ...patient, extension: [ ...otherExtensions, { url, valueString: value }, ], }; return bridge.updateResource<PatientResource>('Patient', patientUuid, updated); }A few details that bite if you skip them:
PUTreplaces the whole resource. Always read first, merge your extension into the existing list, then write — otherwise you'll wipe extensions the host (or another app) put on the patient.- Filter out any prior entry with the same
urlbefore pushing the new one. FHIR allows multiple extensions with the same URL on a single resource, but for a single-valued field you almost always want overwrite semantics. - Use the URL you got from step 4, not a hardcoded string. The whole point of writable extensions is that you don't bake URLs into the bundle.
Confirm the write succeeded. The host returns
200with the updated Patient. If you typo a URL or use one outside the install's allow-list, the host returns403with anOperationOutcomenaming the offending URL — that's the security boundary doing its job.
Verify
Open the Patient detail page in your sandbox tenant. Trigger your "Set preferred pharmacy" UI, supply a value, and confirm:
- The Patient resource (via FHIR REST or the host's UI) now has an
extension[]entry with the resolved URL and your value. - Writing the extension with a URL not in the install's allow-list returns
403. - An admin who reinstalls the app with Skip chosen for
preferred-pharmacycauses your app to receivenullfor that key in$get-installation-context.
Common patterns
Multiple resource types. Set appliesTo: ["Patient", "Encounter"] to share one extension declaration across both. The resolved URL is the same; the host validates the URL is in the allow-list regardless of which resource type the write targets.
Multiple writable extensions. Each entry is independent. The cap is 50 entries per manifest; in practice apps that need more are usually modeling something that belongs in a custom resource, not an extension.
Conditional fields based on tenant install choices. Always treat the extensionUrlMap value as string | null. A null means Skip — don't render the field, don't try to write it. Cache the map for the duration of the session (it doesn't change while your app is mounted), but refetch on reload.
Value types beyond strings. The allow-list covers string, boolean, integer, decimal, code, date, dateTime, Coding, CodeableConcept. Pick the type that matches what you'll write — the host enforces it at write time. For coded values (e.g. a controlled vocabulary), use Coding or CodeableConcept rather than string.