Troubleshooting
Common issues and solutions when working with the SHL SDK.
Installation & Setup
"Cannot find module '@fhirfly-io/shl'"
Ensure you've installed the package:
npm install @fhirfly-io/shl
The SDK requires Node.js 18+ for built-in crypto and fetch support.
"FHIRFLY_API_KEY is not defined"
FHIRfly API enrichment is optional. If you don't have an API key, skip the fhirfly parameter on clinical data methods:
// Without enrichment (manual code input)
bundle.addMedication({
code: "00071015523",
system: "http://hl7.org/fhir/sid/ndc",
display: "Lisinopril 10mg Tablet",
});
// With enrichment (adds SNOMED mappings, display names automatically)
bundle.addMedication({ byNDC: "00071015523", fhirfly: client.ndc });
Sign up at fhirfly.io for a free API key if you want enrichment.
Bundle Building
"ValidationError: bundle is required and must be an object"
The SHL.create() function expects the result of bundle.build(), not the IPS.Bundle instance:
// Wrong
const result = await SHL.create({ bundle: myBundle, storage });
// Right
const fhirBundle = await myBundle.build();
const result = await SHL.create({ bundle: fhirBundle, storage });
"Invalid NDC code" or enrichment returns null
NDC codes must be the 11-digit format (with leading zeros). Check your code format:
// Wrong: 10-digit NDC
bundle.addMedication({ byNDC: "0071015523" });
// Right: 11-digit NDC (pad segment to 5-4-2)
bundle.addMedication({ byNDC: "00071015523" });
If enrichment returns null, the code may not exist in the FHIRfly database. Use debug: true to inspect the raw bundle and verify the resource was added.
FHIR Validator warnings (dom-6, ips-2)
These are common IPS profile warnings, not errors:
| Warning | Meaning | Fix |
|---|---|---|
dom-6 |
Missing narrative text.div |
The SDK generates narrative automatically — update to latest version |
ips-2 |
Missing section.text in Composition |
Expected for machine-generated IPS; safe to ignore |
Encryption & SHL Creation
"debug mode is not allowed when NODE_ENV=production"
Debug mode stores unencrypted PHI alongside the JWE. This is blocked in production to prevent accidental data exposure. Remove debug: true from your SHL.create() options.
"Failed to store content: ..."
Storage backend errors typically indicate:
- LocalStorage: Directory doesn't exist or isn't writable. Ensure the
directorypath exists. - S3Storage: Missing AWS credentials or incorrect bucket/region. Check
AWS_ACCESS_KEY_IDandAWS_SECRET_ACCESS_KEY. - FhirflyStorage: Invalid API key or quota exceeded. Check your plan limits at Pricing.
Server & Manifest
Server returns 500 on manifest POST
- Check that the SHL files exist in your storage backend (
{shlId}/manifest.json,{shlId}/content.jwe,{shlId}/metadata.json) - Verify your
baseUrlmatches the actual URL your server is running on - Check server logs — the SDK handler logs errors via the
onAccesscallback
"Invalid passcode" (401) when passcode is correct
Passcodes are hashed with SHA-256 before storage. If you're comparing manually, ensure you're comparing hashes, not plaintext. The SDK handler does this automatically.
If you're hitting rate limits (429), wait for the retry_after_sec value in the response before retrying.
SHL returns 410 (Gone)
The SHL has either:
- Expired: The
expiresAtdate has passed - Exhausted: The
maxAccesseslimit was reached
Create a new SHL if the content still needs to be shared. Expiration and access limits cannot be modified after creation.
Consumption & Decryption
"Failed to decrypt: ..."
Common causes:
- Wrong key: The decryption key must match the one embedded in the
shlink:/URL. UseSHL.decode(url).key. - Corrupted JWE: The fetched content was modified in transit. Ensure your server returns the JWE as-is without transformation.
- Not a JWE: You may be passing HTML or an error response instead of the JWE string. Check that the content endpoint returns
application/jose.
Handling embedded vs. location manifest entries
Some SHL servers return inline JWE content instead of URLs. Use SHL.getEntryContent() to handle both:
const decoded = SHL.decode(shlUrl);
const manifest = await fetch(decoded.url, {
method: "POST",
body: JSON.stringify({ passcode }),
}).then(r => r.json());
for (const entry of manifest.files) {
const jwe = await SHL.getEntryContent(entry);
const { contentType, data } = SHL.decryptContent(jwe, decoded.key);
}
Patient Input Format
The SDK accepts two formats for patient demographics:
// Structured (FHIR-native, recommended)
new IPS.Bundle({
given: "Maria",
family: "Garcia",
birthDate: "1985-03-15",
gender: "female",
});
// Simplified (convenience)
new IPS.Bundle({
name: "Maria Garcia",
birthDate: "1985-03-15",
});
Both produce valid FHIR Patient resources. The constructor takes patient demographics directly (no patient: wrapper). Use the structured format for production to preserve given/family name separation.
Self-Hosted vs. FhirflyStorage
See Security & Compliance for a full comparison. Key differences:
| Concern | FhirflyStorage | Self-Hosted (S3/Azure/GCS) |
|---|---|---|
| Access count atomicity | Atomic (MongoDB $inc) |
Best-effort (read-modify-write) |
| Passcode hashing | SHA-256 + timingSafeEqual | SHA-256 + timingSafeEqual |
| Rate limiting | 3-tier IP-based | Your responsibility |
| Server-side encryption | AES-256 (automatic) | Depends on bucket config |
For strict one-time access links (maxAccesses: 1), use FhirflyStorage or add your own atomic counter.
Still stuck?
- Check the live exercise for working examples of every SDK path
- File an issue on GitHub