PSHD (Patient-Shared Health Document)
The Patient-Shared Health Document (PSHD) spec (v0.4.0) is a CMS-aligned profile of SMART Health Links designed for patient-to-provider data sharing at the point of care. A patient presents a QR code at a clinic visit, and the provider scans it to retrieve the patient's summary in a single round trip.
How PSHD Differs from Standard SHLinks
| Feature | Standard SHL | PSHD |
|---|---|---|
| Retrieval mode | Manifest (POST, flag L) |
Direct (GET, flag U) |
| Bundle type | document (with Composition) |
collection (no Composition) |
| Passcode | Optional (flag P) |
Forbidden (incompatible with flag U) |
| Expiration | Optional | Required (short-lived) |
| DocumentReference type | LOINC 34133-9 |
LOINC 60591-5 |
| DocumentReference category | None | CMS patient-shared |
| Security label | None | PATAST (patient-asserted) |
| DocumentReference author | SDK / Organization | Patient reference |
| Round trips to retrieve | 2 (POST manifest, GET content) | 1 (GET content directly) |
Quick Start
End-to-end: build a PSHD-compliant bundle, create a SMART Health Link, and serve it.
import { IPS, SHL, CODE_SYSTEMS } from "@fhirfly-io/shl";
import { readFileSync } from "node:fs";
// 1. Build the FHIR Bundle with PSHD profile
const bundle = new IPS.Bundle({
given: "Jane",
family: "Doe",
birthDate: "1990-01-15",
gender: "female",
});
bundle.addMedication({
code: "860975",
system: CODE_SYSTEMS.RXNORM,
display: "Metformin 500 MG Oral Tablet",
status: "active",
});
bundle.addCondition({
code: "44054006",
system: CODE_SYSTEMS.SNOMED,
display: "Type 2 diabetes mellitus",
clinicalStatus: "active",
});
// PSHD requires at least one PDF document
const pdfContent = readFileSync("./patient-summary.pdf");
bundle.addDocument({ title: "Patient Summary", content: pdfContent });
// Validate before building
const validation = bundle.validate({ profile: "pshd" });
if (!validation.valid) {
console.error("Validation errors:", validation.issues);
process.exit(1);
}
const fhirBundle = await bundle.build({ profile: "pshd" });
// 2. Create the SMART Health Link with PSHD compliance
const result = await SHL.create({
bundle: fhirBundle,
storage: new SHL.FhirflyStorage({
apiKey: process.env.FHIRFLY_API_KEY,
}),
compliance: "pshd",
expiresAt: "point-of-care", // 15 minutes (named preset)
label: "Jane Doe - Patient Summary",
});
console.log(result.url); // shlink:/eyJ1cmwiOiJodHRwcz...
console.log(result.qrCode); // data:image/png;base64,...
The compliance: "pshd" preset automatically:
- Sets direct retrieval mode (flag
U— single GET request) - Rejects passcode (incompatible with direct mode per the SHL spec)
- Requires
expiresAt(short-lived links for point-of-care use)
Two-Layer API
The SDK supports PSHD through two layers. Use the high-level preset for most cases, or compose the low-level options when you need fine-grained control.
High-Level: compliance: "pshd"
Enforces all PSHD constraints in a single option:
const result = await SHL.create({
bundle: fhirBundle,
storage,
compliance: "pshd", // enforces everything
expiresAt: new Date(Date.now() + 15 * 60_000),
});
Low-Level: mode + profile
Use mode: "direct" and profile: "pshd" independently:
// Direct mode without PSHD bundle constraints
const result = await SHL.create({
bundle: anyFhirBundle, // does not need to be a PSHD bundle
storage,
mode: "direct", // flag U, single-request retrieval
expiresAt: new Date(Date.now() + 60 * 60_000), // optional here
});
// PSHD bundle profile without direct mode
const fhirBundle = await bundle.build({ profile: "pshd" });
// produces a collection bundle with CMS DocumentReference constraints
| Option | Where | Values | Default |
|---|---|---|---|
compliance |
SHL.create() |
"pshd" |
(none) |
mode |
SHL.create() |
"manifest" | "direct" |
"manifest" |
profile |
bundle.build() |
"ips" | "r4" | "pshd" |
"ips" |
Bundle Structure
A PSHD bundle differs from a standard IPS bundle:
PSHD Bundle (type: "collection") IPS Bundle (type: "document")
├── Patient ├── Composition ← not in PSHD
├── MedicationStatement(s) ├── Patient
├── Condition(s) ├── MedicationStatement(s)
├── AllergyIntolerance(s) ├── Condition(s)
├── Immunization(s) ├── AllergyIntolerance(s)
├── Observation(s) ├── Immunization(s)
├── DocumentReference (1..1, PDF) ├── Observation(s)
├── Binary └── DocumentReference + Binary
Key differences:
- Bundle type is
collection, notdocument - No Composition resource — PSHD doesn't require one
- Patient is the first entry
- DocumentReference is required (1..1) and must contain a PDF
- No
meta.profileon resources (PSHD strips IPS profile URIs)
DocumentReference Constraints
When building with profile: "pshd", the SDK automatically applies CMS-required overrides to every DocumentReference:
| Field | IPS / R4 Default | PSHD Value |
|---|---|---|
type.coding[0].code |
34133-9 |
60591-5 |
type.coding[0].display |
"Summarization of episode note" | "Patient summary Document" |
category |
(none) | patient-shared (CMS code system) |
author |
(none) | Patient reference |
meta.security |
(none) | PATAST (patient-asserted) |
meta.profile |
IPS URI | (none — stripped) |
Example output:
{
"resourceType": "DocumentReference",
"status": "current",
"type": {
"coding": [{
"system": "http://loinc.org",
"code": "60591-5",
"display": "Patient summary Document"
}]
},
"category": [{
"coding": [{
"system": "https://cms.gov/fhir/CodeSystem/patient-shared-category",
"code": "patient-shared",
"display": "Patient Shared"
}]
}],
"author": [{ "reference": "urn:uuid:<patient-id>" }],
"meta": {
"security": [{
"system": "http://terminology.hl7.org/CodeSystem/v3-ActCode",
"code": "PATAST",
"display": "patient asserted"
}]
},
"content": [{
"attachment": {
"contentType": "application/pdf",
"url": "urn:uuid:<binary-id>",
"title": "Patient Summary"
}
}]
}
Validation
bundle.validate({ profile: "pshd" }) checks PSHD-specific requirements before you build:
| Rule | Severity | Message |
|---|---|---|
| No documents added | Error | "PSHD requires at least one DocumentReference (1..1)" |
| No PDF document | Error | "PSHD requires at least one PDF document" |
Missing Patient.gender |
Warning | "Patient.gender recommended for PSHD demographic matching" |
Invalid birthDate format |
Error | "birthDate must be in YYYY-MM-DD format" |
const result = bundle.validate({ profile: "pshd" });
if (!result.valid) {
// result.issues contains { severity, message } objects
for (const issue of result.issues) {
console.error(`[${issue.severity}] ${issue.message}`);
}
}
Direct Mode Protocol
Standard SHLinks use a two-step manifest flow. PSHD uses direct retrieval for a single round trip:
Manifest mode (flag L): Direct mode (flag U):
1. Scan QR → shlink:/ 1. Scan QR → shlink:/
2. POST /{shlId} → manifest JSON 2. GET /{shlId} → content.jwe
3. GET /{shlId}/content → content.jwe (done — single round trip)
The server handler automatically routes based on the mode stored in the SHL metadata. Manifest-mode SHLs return 405 Method Not Allowed if someone attempts a GET, and direct-mode SHLs are served on GET. See the Server Guide for setup details.
Expiration Guidance
PSHD links are designed for point-of-care use. Choose expiration based on the sharing scenario:
| Scenario | Recommended TTL | Example |
|---|---|---|
| Walk-in / urgent care | 10-15 minutes | Patient shows QR at front desk |
| Scheduled appointment | 1-4 hours | Patient shares before the visit |
| Pre-visit preparation | 24 hours | Shared day before appointment |
| Emergency transfer | 30-60 minutes | Patient transferred between facilities |
Use named presets or raw Date objects:
// Named presets (v0.5.0+)
expiresAt: "point-of-care" // 15 minutes
expiresAt: "appointment" // 24 hours
// Raw Date (still works)
expiresAt: new Date(Date.now() + 4 * 60 * 60_000) // 4 hours
| Preset | Duration | Typical Use |
|---|---|---|
"point-of-care" |
15 minutes | Walk-in, urgent care |
"appointment" |
24 hours | Scheduled visit, pre-visit prep |
"travel" |
90 days | International travel |
"permanent" |
No expiration | Not recommended for PSHD |
Combine expiration with maxAccesses for defense in depth:
await SHL.create({
bundle: fhirBundle,
storage,
compliance: "pshd",
expiresAt: new Date(Date.now() + 15 * 60_000),
maxAccesses: 3, // fail-safe: max 3 scans even within the time window
});
Audit Logging
There are two ways to capture access events:
onAccess callback (handler-level)
The onAccess callback fires on every successful access:
const handler = createHandler({
storage,
onAccess: (event) => {
auditLog.write({
action: "patient_data_accessed",
shlId: event.shlId,
recipient: event.recipient,
mode: event.mode,
timestamp: event.timestamp,
});
},
});
AuditableStorage (storage-level)
For storage-level audit logging, implement the AuditableStorage interface. This is opt-in — existing storage implementations work unchanged.
import { AuditableStorage, AccessEvent, isAuditableStorage } from "@fhirfly-io/shl/server";
class AuditedStorage extends ServerLocalStorage implements AuditableStorage {
async onAccess(shlId: string, event: AccessEvent): Promise<void> {
await db.auditLog.insert({
shlId,
recipient: event.recipient,
ip: event.ip,
timestamp: event.timestamp,
});
}
}
app.use("/shl", expressMiddleware({ storage: new AuditedStorage({ ... }) }));
The server handler detects AuditableStorage at runtime via isAuditableStorage(). Both mechanisms can be used together — the handler-level callback fires first, then the storage-level hook.
Recipient Tracking
SHL viewers pass ?recipient= to identify the provider scanning the QR code:
GET /shl/{shlId}?recipient=Dr.%20Smith
This supports the PSHD spec's requirement for audit trails of who accessed patient-shared data and when.
Server Setup
All three framework adapters support PSHD out of the box. The server automatically handles the direct-mode GET /{shlId} route alongside the existing manifest-mode POST /{shlId} route.
// Express
import { expressMiddleware } from "@fhirfly-io/shl/express";
app.use("/shl", expressMiddleware({
storage,
onAccess: (event) => {
console.log(`[SHL] ${event.mode} access to ${event.shlId}`, {
recipient: event.recipient,
count: event.accessCount,
});
},
}));
// Fastify
import { fastifyPlugin } from "@fhirfly-io/shl/fastify";
app.register(fastifyPlugin({
storage,
onAccess: (event) => {
console.log(`[SHL] ${event.mode} access to ${event.shlId}`);
},
}), { prefix: "/shl" });
// AWS Lambda
import { lambdaHandler } from "@fhirfly-io/shl/lambda";
export const handler = lambdaHandler({
storage,
pathPrefix: "/shl",
onAccess: async (event) => {
console.log(JSON.stringify({
type: "shl_access",
shlId: event.shlId,
mode: event.mode,
recipient: event.recipient,
}));
},
});
For full server setup including CORS, storage adapters, and concurrency details, see the Server Guide.
Migration
All changes are backward-compatible. Existing code continues to work without modification.
- Default
SHL.create()still uses manifest mode (flagL) - Default
bundle.build()still uses IPS profile - Existing
onAccesscallbacks still work (new fields are optional) - Server handler routes for manifest mode are unchanged
To adopt PSHD:
- Update
@fhirfly-io/shlto the latest version - Add
profile: "pshd"tobundle.build()calls - Add
compliance: "pshd"andexpiresAttoSHL.create()calls - Optionally update your
onAccesscallback to logmodeandrecipient