Dashboard

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.

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, not document
  • 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.profile on 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 (flag L)
  • Default bundle.build() still uses IPS profile
  • Existing onAccess callbacks still work (new fields are optional)
  • Server handler routes for manifest mode are unchanged

To adopt PSHD:

  1. Update @fhirfly-io/shl to the latest version
  2. Add profile: "pshd" to bundle.build() calls
  3. Add compliance: "pshd" and expiresAt to SHL.create() calls
  4. Optionally update your onAccess callback to log mode and recipient