Most NPHIES integration content in Saudi Arabia is written by companies selling you an HMS. It explains why you need NPHIES and stops exactly where the engineering begins. This tutorial starts there.
We are going to build a typed TypeScript client for the NPHIES message API: eligibility checks, claim submission, polling for delayed adjudication, and — the part almost every vendor implementation skips — a denial ledger that keeps the rejection reasons instead of discarding them.
We do not sell an EMR or a clearinghouse subscription, so this guide has no product to steer you toward. It is the integration layer, written the way an engineer needs to read it.
What You'll Build
A Node.js service that:
- Builds valid NPHIES FHIR R4 message bundles with full TypeScript types
- Transports them over mutual TLS to the
$process-messageendpoint - Correctly distinguishes the three separate layers at which a transaction can fail
- Submits an eligibility check and reads the site-eligibility result
- Submits a claim and handles a
queuedadjudication with a polling loop - Persists every denial code into a queryable ledger
By the end you will have a client that answers the only question that matters to the finance team: of what we submitted, what got paid, what got denied, and why.
Prerequisites
Before starting, ensure you have:
- Node.js 20+ and TypeScript 5.4+
- Comfort with async/await and typed API clients
- Basic FHIR literacy — you should know what a Resource and a Bundle are. If not, read the FHIR R4 overview first; NPHIES is built on FHIR 4.0.1
- Access credentials from your CHI onboarding — the certificate, the endpoint hostname, and your provider licence identifiers
About credentials. The NPHIES base hostnames, test-environment URLs and client certificates are issued to you through the Council of Health Insurance onboarding process. They are not public, and no tutorial can give them to you. Every code sample here reads them from environment variables. Where I reference a value you must obtain, I say so explicitly rather than inventing a placeholder that looks real.
The public NPHIES Implementation Guide documents the message structures themselves, and that is what the code below is built against.
Step 1: Understand the Message Model Before Writing Any Code
This is the step people skip, and it is why their integration takes four months.
NPHIES is not a REST API. There is no POST /claims and no GET /claims/123. There is essentially one operation — FHIR's $process-message — and you send it a Bundle whose type is message. What the transaction does is determined by the eventCoding on the MessageHeader inside that Bundle, not by the URL.
So the shape is always the same:
POST <base>/$process-message
Bundle (type: message)
├── MessageHeader ← must be the first entry; carries eventCoding
├── CoverageEligibilityRequest | Claim | Task | ...
├── Patient
├── Coverage
├── Organization (provider)
└── Organization (insurer)
Three consequences follow immediately, and each one is a bug you will otherwise ship:
The MessageHeader must be the first entry in the bundle. Not "somewhere in the bundle". If you build the entry array by mapping over a resource collection, you will eventually reorder it and get rejections that look like schema errors.
The bundle must be self-contained. Every resource the transaction references travels inside the same bundle. You do not create a Patient once and reference it by URL forever — you include it every time. The IG is explicit that all resources needed to support the exchange belong in the same package, so that version and context stay consistent.
Messaging is store-and-forward, not request-response. NPHIES validates and routes your transaction. It may deliver it in real time, or it may store it for delivery when the insurer's system is available. Eligibility is a real-time use case; claim adjudication frequently is not. If your client assumes the HTTP response contains the business answer, it will be wrong on a large fraction of claims.
That third point is the one that costs money, and Step 5 deals with it properly.
Step 2: Project Setup and the Typed Envelope
Create the project:
mkdir nphies-client && cd nphies-client
npm init -y
npm install undici zod pino
npm install -D typescript tsx @types/node
npx tsc --init --target es2022 --module node16 --strictWe are using undici because we need fine-grained control over the TLS client certificate, zod to validate responses at the boundary, and pino for structured logs — which for an integration that runs unattended are not optional.
Start with the terminology. NPHIES code systems live under the http://nphies.sa/terminology/ namespace, and hardcoding these strings across your codebase is how typos become production incidents:
// src/terminology.ts
/** NPHIES canonical namespaces, per the Healthcare Financial Services IG. */
export const NPHIES = {
CS: 'http://nphies.sa/terminology/CodeSystem',
SD: 'http://nphies.sa/fhir/ksa/nphies-fs/StructureDefinition',
} as const;
/** Message event codes — these select the transaction type. */
export const MessageEvent = {
EligibilityRequest: 'eligibility-request',
EligibilityResponse: 'eligibility-response',
PriorAuthRequest: 'priorauth-request',
PriorAuthResponse: 'priorauth-response',
ClaimRequest: 'claim-request',
ClaimResponse: 'claim-response',
PollRequest: 'poll-request',
PollResponse: 'poll-response',
} as const;
export type MessageEventCode =
(typeof MessageEvent)[keyof typeof MessageEvent];Now the envelope builder. This is the single most valuable piece of code in the project, because it makes the "MessageHeader first" rule structurally impossible to violate:
// src/envelope.ts
import { NPHIES, type MessageEventCode } from './terminology.js';
export interface ParticipantConfig {
/** Your provider licence, e.g. the value issued at onboarding. */
providerLicense: string;
providerBaseUrl: string;
/** The insurer's licence identifier for this transaction. */
insurerLicense: string;
}
interface BundleEntry {
fullUrl: string;
resource: Record<string, unknown>;
}
/**
* Builds a NPHIES message bundle.
*
* The MessageHeader is generated internally and always prepended,
* so callers cannot accidentally reorder it.
*/
export function buildMessageBundle(opts: {
event: MessageEventCode;
/** The resource the MessageHeader points at via focus. */
focus: BundleEntry;
/** Every supporting resource: Patient, Coverage, Organizations, etc. */
supporting: BundleEntry[];
participants: ParticipantConfig;
bundleId: string;
timestamp: string;
}) {
const { event, focus, supporting, participants, bundleId, timestamp } = opts;
const headerUrl = `urn:uuid:${bundleId}-hdr`;
const messageHeader: BundleEntry = {
fullUrl: headerUrl,
resource: {
resourceType: 'MessageHeader',
id: `${bundleId}-hdr`,
meta: { profile: [`${NPHIES.SD}/message-header`] },
eventCoding: {
system: `${NPHIES.CS}/ksa-message-events`,
code: event,
},
destination: [
{
endpoint: `http://nphies.sa/license/payer-license/${participants.insurerLicense}`,
receiver: {
type: 'Organization',
identifier: {
system: 'http://nphies.sa/license/payer-license',
value: participants.insurerLicense,
},
},
},
],
sender: {
type: 'Organization',
identifier: {
system: 'http://nphies.sa/license/provider-license',
value: participants.providerLicense,
},
},
source: { endpoint: participants.providerBaseUrl },
// focus tells the receiver which resource is the subject of the message
focus: [{ reference: focus.fullUrl }],
},
};
return {
resourceType: 'Bundle' as const,
id: bundleId,
meta: { profile: [`${NPHIES.SD}/bundle`] },
type: 'message' as const,
timestamp,
// The header is prepended here, unconditionally.
entry: [messageHeader, focus, ...supporting],
};
}Note the ordering guarantee on the last line. Callers pass focus and supporting separately and never touch the entry array, so the invariant holds no matter how the calling code evolves.
On generating identifiers. Use
crypto.randomUUID()for bundle IDs, and store the value you generated. When you need to trace a claim through NPHIES support six weeks later, the bundle ID is what you will be asked for. An ID you did not persist is an ID you did not generate.
Step 3: The Transport Layer
NPHIES connections use a client certificate. With undici you configure this once in an Agent and reuse it:
// src/transport.ts
import { Agent, request } from 'undici';
import { readFileSync } from 'node:fs';
import pino from 'pino';
const log = pino({ name: 'nphies-transport' });
const agent = new Agent({
connect: {
cert: readFileSync(requireEnv('NPHIES_CLIENT_CERT_PATH')),
key: readFileSync(requireEnv('NPHIES_CLIENT_KEY_PATH')),
// Never disable certificate verification, including in the test
// environment. If the handshake fails, fix the trust chain.
rejectUnauthorized: true,
},
// Store-and-forward means slow responses are normal, not exceptional.
headersTimeout: 60_000,
bodyTimeout: 60_000,
});
function requireEnv(name: string): string {
const v = process.env[name];
if (!v) throw new Error(`Missing required environment variable: ${name}`);
return v;
}
export interface TransportResult {
status: number;
body: unknown;
}
export async function processMessage(
bundle: unknown,
correlationId: string,
): Promise<TransportResult> {
const base = requireEnv('NPHIES_BASE_URL');
const started = Date.now();
const res = await request(`${base}/$process-message`, {
dispatcher: agent,
method: 'POST',
headers: {
'content-type': 'application/fhir+json',
accept: 'application/fhir+json',
},
body: JSON.stringify(bundle),
});
const body = await res.body.json().catch(() => null);
log.info(
{ correlationId, status: res.statusCode, ms: Date.now() - started },
'process-message completed',
);
return { status: res.statusCode, body };
}Two decisions worth defending here.
The timeouts are generous on purpose. A 10-second timeout will produce intermittent failures on transactions that were actually fine — and worse, you will not know whether the transaction was received. A timeout is genuinely ambiguous: never treat it as a failed submission and retry blindly, or you will create duplicate claims.
rejectUnauthorized stays true. Every integration team hits a handshake error in the test environment and someone proposes turning verification off "just for testing". That flag has a way of reaching production. Fix the trust chain instead.
Step 4: The Eligibility Check
Eligibility is the right first transaction: it is real-time, it is low-risk, and it exercises the whole pipeline.
A CoverageEligibilityRequest carries a purpose, and the three values mean genuinely different things:
| Purpose | What you are asking | Typical use |
|---|---|---|
validation | Is this coverage in force on the service date? | Front desk check-in |
benefit | What benefits and remaining limits exist? | Before an expensive procedure |
discovery | What active coverages does this patient have at all? | Patient cannot produce a card |
Getting this wrong is a common and expensive mistake: teams send validation and then wonder why the response contains no benefit limits. It contains no limits because you did not ask for them.
// src/eligibility.ts
import { randomUUID } from 'node:crypto';
import { buildMessageBundle, type ParticipantConfig } from './envelope.js';
import { MessageEvent, NPHIES } from './terminology.js';
import { processMessage } from './transport.js';
export type EligibilityPurpose = 'validation' | 'benefit' | 'discovery';
export async function checkEligibility(input: {
purpose: EligibilityPurpose;
/** National ID or Iqama number of the patient. */
patientIdentifier: string;
patientId: string;
coverageId: string;
memberId: string;
/** ISO date, e.g. "2026-08-07" — the date of service. */
servicedDate: string;
participants: ParticipantConfig;
}) {
const bundleId = randomUUID();
const reqUrl = `urn:uuid:${randomUUID()}`;
const patientUrl = `urn:uuid:${randomUUID()}`;
const coverageUrl = `urn:uuid:${randomUUID()}`;
const focus = {
fullUrl: reqUrl,
resource: {
resourceType: 'CoverageEligibilityRequest',
id: bundleId,
meta: { profile: [`${NPHIES.SD}/eligibility-request`] },
identifier: [
{
system: `${input.participants.providerBaseUrl}/eligibility`,
value: bundleId,
},
],
status: 'active',
// Purpose is an array — you may legitimately request more than one.
purpose: [input.purpose],
patient: { reference: patientUrl },
servicedDate: input.servicedDate,
created: new Date().toISOString(),
provider: {
identifier: {
system: 'http://nphies.sa/license/provider-license',
value: input.participants.providerLicense,
},
},
insurer: {
identifier: {
system: 'http://nphies.sa/license/payer-license',
value: input.participants.insurerLicense,
},
},
insurance: [{ coverage: { reference: coverageUrl } }],
},
};
const supporting = [
{
fullUrl: patientUrl,
resource: {
resourceType: 'Patient',
id: input.patientId,
meta: { profile: [`${NPHIES.SD}/patient`] },
identifier: [
{
type: {
coding: [
{
system: 'http://terminology.hl7.org/CodeSystem/v2-0203',
code: 'NI',
},
],
},
system: 'http://nphies.sa/identifier/iqama',
value: input.patientIdentifier,
},
],
},
},
{
fullUrl: coverageUrl,
resource: {
resourceType: 'Coverage',
id: input.coverageId,
meta: { profile: [`${NPHIES.SD}/coverage`] },
status: 'active',
subscriberId: input.memberId,
beneficiary: { reference: patientUrl },
relationship: {
coding: [
{
system:
'http://terminology.hl7.org/CodeSystem/subscriber-relationship',
code: 'self',
},
],
},
payor: [
{
identifier: {
system: 'http://nphies.sa/license/payer-license',
value: input.participants.insurerLicense,
},
},
],
},
},
];
const bundle = buildMessageBundle({
event: MessageEvent.EligibilityRequest,
focus,
supporting,
participants: input.participants,
bundleId,
timestamp: new Date().toISOString(),
});
return { bundleId, result: await processMessage(bundle, bundleId) };
}The Patient and Coverage resources here are trimmed to the minimum that illustrates the pattern. Your onboarding pack specifies additional required elements — occupation, marital status, and residency fields among them — and the validator will tell you precisely which ones are missing. That feedback loop is fast; the structural understanding is the slow part, and you now have it.
Step 5: The Three Layers of Failure — Read This Twice
Here is the single most important idea in NPHIES integration, and the reason so many providers have a dashboard showing 99% success while the bank account disagrees.
A NPHIES transaction can fail at three independent layers.
| Layer | What it means | Where it shows up |
|---|---|---|
| 1. Transport | The message never arrived | HTTP status is not 200 |
| 2. Validation | NPHIES rejected the message structure | Response bundle contains an OperationOutcome |
| 3. Adjudication | The insurer refused to pay | ClaimResponse.outcome and the item-level adjudication |
Layers 1 and 2 are NPHIES telling you about your message. Layer 3 is the insurer telling you about your money. They are entirely different questions, and a large number of production integrations only check the first two.
This is the mechanism behind the pattern described in why NPHIES integration does not mean you get paid — the technical success metric and the financial outcome are measuring different layers.
There is a fourth case that trips people up: if NPHIES cannot deliver your message to the insurer within about a minute, it generates a response itself rather than leaving you hanging. That response is marked with a tag on bundle.meta.tag to distinguish it from an insurer's answer. Treat a NPHIES-generated response as "no answer yet", not as an adjudication result.
Encode all of this explicitly:
// src/outcome.ts
export type TransactionOutcome =
| { layer: 'transport'; ok: false; status: number }
| { layer: 'validation'; ok: false; issues: ValidationIssue[] }
| { layer: 'pending'; ok: true; reason: 'nphies-generated' | 'queued' }
| { layer: 'adjudication'; ok: true; outcome: 'complete' | 'partial' }
| { layer: 'adjudication'; ok: false; outcome: 'error'; denials: Denial[] };
export interface ValidationIssue {
severity: string;
code: string;
details?: string;
}
export interface Denial {
itemSequence?: number;
code: string;
display?: string;
}
interface FhirBundle {
meta?: { tag?: Array<{ system?: string; code?: string }> };
entry?: Array<{ resource?: Record<string, any> }>;
}
function findResource(bundle: FhirBundle, type: string) {
return bundle.entry?.find((e) => e.resource?.resourceType === type)?.resource;
}
export function classifyOutcome(
status: number,
body: unknown,
): TransactionOutcome {
if (status !== 200) return { layer: 'transport', ok: false, status };
const bundle = body as FhirBundle;
// Layer 2: NPHIES rejected the message itself.
const oo = findResource(bundle, 'OperationOutcome');
if (oo) {
return {
layer: 'validation',
ok: false,
issues: (oo.issue ?? []).map((i: any) => ({
severity: i.severity,
code: i.code,
details: i.details?.text,
})),
};
}
// The special case: NPHIES answered on the insurer's behalf.
const isNphiesGenerated = bundle.meta?.tag?.some((t) =>
t.system?.includes('nphies.sa'),
);
if (isNphiesGenerated) {
return { layer: 'pending', ok: true, reason: 'nphies-generated' };
}
const claimResponse = findResource(bundle, 'ClaimResponse');
if (!claimResponse) {
// Eligibility and other non-claim responses land here.
return { layer: 'adjudication', ok: true, outcome: 'complete' };
}
// "queued" means: adjudication has not happened yet. Poll for it.
if (claimResponse.outcome === 'queued') {
return { layer: 'pending', ok: true, reason: 'queued' };
}
if (claimResponse.outcome === 'error') {
return {
layer: 'adjudication',
ok: false,
outcome: 'error',
denials: extractDenials(claimResponse),
};
}
return {
layer: 'adjudication',
ok: true,
outcome: claimResponse.outcome === 'partial' ? 'partial' : 'complete',
};
}
/** Pulls denial reasons from both item-level and header-level adjudication. */
export function extractDenials(claimResponse: any): Denial[] {
const denials: Denial[] = [];
for (const item of claimResponse.item ?? []) {
for (const adj of item.adjudication ?? []) {
for (const coding of adj.reason?.coding ?? []) {
denials.push({
itemSequence: item.itemSequence,
code: coding.code,
display: coding.display,
});
}
}
}
// Header-level errors apply to the whole claim, not one line.
for (const err of claimResponse.error ?? []) {
for (const coding of err.code?.coding ?? []) {
denials.push({ code: coding.code, display: coding.display });
}
}
return denials;
}Notice that extractDenials reads both item[].adjudication[].reason and the header-level error[]. Implementations that only read one of the two silently lose a category of denial — and it is usually the header-level ones, which tend to be the systematic, fixable problems affecting every claim of a given type.
Step 6: Submitting a Claim
Claims are structurally the same envelope with a richer focus resource. The fields that carry the meaning:
type— institutional, professional, oral, pharmacy, visionsubType— inpatient or outpatientuse—claimfor reimbursement,preauthorizationfor prior approvaldiagnosis[]— ICD-10-AM coded, with at least one marked principalitem[]— the billable lines, each with a service code, quantity and net amountsupportingInfo[]— attachments and clinical context
// src/claim.ts
import { randomUUID } from 'node:crypto';
import { buildMessageBundle, type ParticipantConfig } from './envelope.js';
import { MessageEvent, NPHIES } from './terminology.js';
import { processMessage } from './transport.js';
import { classifyOutcome } from './outcome.js';
export interface ClaimLine {
sequence: number;
/** Service or procedure code from the applicable NPHIES code system. */
code: string;
codeSystem: string;
quantity: number;
unitPrice: number;
/** Sequence numbers of the diagnoses this line is justified by. */
diagnosisSequence: number[];
}
export interface ClaimDiagnosis {
sequence: number;
/** ICD-10-AM code. */
code: string;
type: 'principal' | 'secondary';
}
export async function submitClaim(input: {
patientRef: string;
coverageRef: string;
claimType: 'institutional' | 'professional' | 'pharmacy' | 'oral' | 'vision';
subType: 'ip' | 'op';
use: 'claim' | 'preauthorization';
diagnoses: ClaimDiagnosis[];
lines: ClaimLine[];
supporting: Array<{ fullUrl: string; resource: Record<string, unknown> }>;
participants: ParticipantConfig;
}) {
const bundleId = randomUUID();
const claimUrl = `urn:uuid:${randomUUID()}`;
const total = input.lines.reduce(
(sum, l) => sum + l.quantity * l.unitPrice,
0,
);
const focus = {
fullUrl: claimUrl,
resource: {
resourceType: 'Claim',
id: bundleId,
meta: { profile: [`${NPHIES.SD}/${input.claimType}-claim`] },
identifier: [
{
system: `${input.participants.providerBaseUrl}/claim`,
value: bundleId,
},
],
status: 'active',
type: {
coding: [
{
system: 'http://terminology.hl7.org/CodeSystem/claim-type',
code: input.claimType,
},
],
},
subType: {
coding: [
{ system: `${NPHIES.CS}/claim-subtype`, code: input.subType },
],
},
use: input.use,
patient: { reference: input.patientRef },
created: new Date().toISOString(),
insurer: {
identifier: {
system: 'http://nphies.sa/license/payer-license',
value: input.participants.insurerLicense,
},
},
provider: {
identifier: {
system: 'http://nphies.sa/license/provider-license',
value: input.participants.providerLicense,
},
},
priority: {
coding: [
{
system: 'http://terminology.hl7.org/CodeSystem/processpriority',
code: 'normal',
},
],
},
diagnosis: input.diagnoses.map((d) => ({
sequence: d.sequence,
diagnosisCodeableConcept: {
coding: [{ system: `${NPHIES.CS}/diagnosis-icd-10-am`, code: d.code }],
},
type: [
{
coding: [
{ system: `${NPHIES.CS}/diagnosis-type`, code: d.type },
],
},
],
})),
insurance: [
{
sequence: 1,
focal: true,
coverage: { reference: input.coverageRef },
},
],
item: input.lines.map((l) => ({
sequence: l.sequence,
diagnosisSequence: l.diagnosisSequence,
productOrService: {
coding: [{ system: l.codeSystem, code: l.code }],
},
quantity: { value: l.quantity },
unitPrice: { value: l.unitPrice, currency: 'SAR' },
net: { value: l.quantity * l.unitPrice, currency: 'SAR' },
})),
total: { value: total, currency: 'SAR' },
},
};
const bundle = buildMessageBundle({
event:
input.use === 'preauthorization'
? MessageEvent.PriorAuthRequest
: MessageEvent.ClaimRequest,
focus,
supporting: input.supporting,
participants: input.participants,
bundleId,
timestamp: new Date().toISOString(),
});
const { status, body } = await processMessage(bundle, bundleId);
return { bundleId, outcome: classifyOutcome(status, body), raw: body };
}Compute totals, never accept them.
totalis derived from the lines above rather than passed in as a parameter. A mismatch between the header total and the sum of the items is one of the most common validation rejections, and deriving it makes the class of bug impossible.
Step 7: Polling for Delayed Adjudication
When classifyOutcome returns pending, the transaction is alive but unanswered. NPHIES provides a poll transaction to retrieve queued responses.
The naive implementation — a setInterval that polls every 30 seconds forever — is how integrations get rate-limited. Use bounded exponential backoff:
// src/poll.ts
import { randomUUID } from 'node:crypto';
import { buildMessageBundle, type ParticipantConfig } from './envelope.js';
import { MessageEvent, NPHIES } from './terminology.js';
import { processMessage } from './transport.js';
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
async function pollOnce(participants: ParticipantConfig) {
const bundleId = randomUUID();
const taskUrl = `urn:uuid:${randomUUID()}`;
const focus = {
fullUrl: taskUrl,
resource: {
resourceType: 'Task',
id: bundleId,
meta: { profile: [`${NPHIES.SD}/task`] },
status: 'requested',
intent: 'order',
code: {
coding: [{ system: `${NPHIES.CS}/task-code`, code: 'poll' }],
},
authoredOn: new Date().toISOString(),
requester: {
identifier: {
system: 'http://nphies.sa/license/provider-license',
value: participants.providerLicense,
},
},
owner: {
identifier: {
system: 'http://nphies.sa/license/payer-license',
value: participants.insurerLicense,
},
},
},
};
const bundle = buildMessageBundle({
event: MessageEvent.PollRequest,
focus,
supporting: [],
participants,
bundleId,
timestamp: new Date().toISOString(),
});
return processMessage(bundle, bundleId);
}
/**
* Polls with exponential backoff, capped.
*
* Returns null when the budget is exhausted — that is a legitimate
* outcome meaning "still unanswered", NOT an error to swallow.
*/
export async function pollForResponses(
participants: ParticipantConfig,
opts: { maxAttempts?: number; baseDelayMs?: number } = {},
): Promise<unknown | null> {
const maxAttempts = opts.maxAttempts ?? 6;
const base = opts.baseDelayMs ?? 5_000;
for (let attempt = 0; attempt < maxAttempts; attempt++) {
if (attempt > 0) {
// 5s, 10s, 20s, 40s, 80s — capped at 2 minutes.
const delay = Math.min(base * 2 ** (attempt - 1), 120_000);
await sleep(delay);
}
const { status, body } = await pollOnce(participants);
if (status === 200 && hasPayload(body)) return body;
}
return null;
}
function hasPayload(body: unknown): boolean {
const bundle = body as { entry?: unknown[] };
// A poll response with only a MessageHeader means "nothing waiting".
return (bundle?.entry?.length ?? 0) > 1;
}The critical detail is what happens when the budget runs out. Returning null for "still unanswered" is correct and it is not the same as an error. A claim that has not been adjudicated after five minutes is normal; adjudication can take days. What must never happen is that your code treats exhausted polling as a failure and resubmits the claim — that creates duplicates, and duplicates create their own denial category.
For production, run polling as a scheduled job over your pending claims table rather than as an in-request loop.
Step 8: The Denial Ledger
Everything so far has been plumbing. This step is where the integration starts producing something the business could not get before.
Almost every vendor implementation reads the ClaimResponse, sets a status flag to rejected, and discards the reason codes. The claim is then re-keyed by hand, and nobody can answer "which denial reason costs us the most?" — because the data was thrown away at the moment it arrived.
Keep it. The schema is not complicated:
CREATE TABLE claim_submissions (
bundle_id UUID PRIMARY KEY,
claim_identifier TEXT NOT NULL,
patient_ref TEXT NOT NULL,
insurer_license TEXT NOT NULL,
submitted_at TIMESTAMPTZ NOT NULL,
total_sar NUMERIC(12,2) NOT NULL,
-- 'transport' | 'validation' | 'pending' | 'adjudication'
outcome_layer TEXT,
outcome_code TEXT,
resolved_at TIMESTAMPTZ
);
CREATE TABLE claim_denials (
id BIGSERIAL PRIMARY KEY,
bundle_id UUID NOT NULL REFERENCES claim_submissions(bundle_id),
item_sequence INT, -- NULL for header-level denials
denial_code TEXT NOT NULL,
denial_display TEXT,
denied_amount NUMERIC(12,2),
recorded_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_denials_code ON claim_denials(denial_code);Two tables. That is the entire difference between "we get a lot of rejections" and a ranked, costed list of causes:
-- The report the finance team has never had.
SELECT
d.denial_code,
d.denial_display,
COUNT(*) AS occurrences,
SUM(d.denied_amount) AS sar_at_risk
FROM claim_denials d
JOIN claim_submissions s ON s.bundle_id = d.bundle_id
WHERE s.submitted_at >= now() - INTERVAL '90 days'
GROUP BY d.denial_code, d.denial_display
ORDER BY sar_at_risk DESC
LIMIT 20;Wire it into the submission path:
// src/ledger.ts
import type { TransactionOutcome } from './outcome.js';
export async function recordOutcome(
db: DbClient,
bundleId: string,
outcome: TransactionOutcome,
) {
await db.query(
`UPDATE claim_submissions
SET outcome_layer = $2,
outcome_code = $3,
resolved_at = CASE WHEN $2 = 'pending' THEN NULL ELSE now() END
WHERE bundle_id = $1`,
[bundleId, outcome.layer, outcomeCode(outcome)],
);
if (outcome.layer === 'adjudication' && !outcome.ok) {
for (const denial of outcome.denials) {
await db.query(
`INSERT INTO claim_denials
(bundle_id, item_sequence, denial_code, denial_display)
VALUES ($1, $2, $3, $4)`,
[bundleId, denial.itemSequence ?? null, denial.code, denial.display],
);
}
}
}
function outcomeCode(o: TransactionOutcome): string {
if (o.layer === 'validation') return o.issues[0]?.code ?? 'unknown';
if (o.layer === 'pending') return o.reason;
if (o.layer === 'transport') return String(o.status);
return o.outcome;
}In our experience with integration work of this shape, denial reasons follow a steep distribution: a small number of codes account for most of the lost revenue, and they are usually systematic — a missing pre-authorisation for one procedure type, a coding convention one insurer accepts and another rejects. These are fixable once, permanently. But only if you can see them, and you can only see them if you stored them.
Step 9: Testing Without a Live Connection
You will not have connectivity for most of the development cycle. Build against the structure instead — the envelope shape is stable and publicly documented, so it can be tested offline:
// tests/envelope.test.ts
import { describe, it, expect } from 'vitest';
import { buildMessageBundle } from '../src/envelope.js';
import { MessageEvent } from '../src/terminology.js';
const participants = {
providerLicense: 'PR-FHIR-TEST',
providerBaseUrl: 'http://provider.example.sa',
insurerLicense: 'INS-FHIR-TEST',
};
describe('message bundle envelope', () => {
it('always places MessageHeader first', () => {
const bundle = buildMessageBundle({
event: MessageEvent.ClaimRequest,
focus: { fullUrl: 'urn:uuid:focus', resource: { resourceType: 'Claim' } },
supporting: [
{ fullUrl: 'urn:uuid:p', resource: { resourceType: 'Patient' } },
],
participants,
bundleId: 'test-bundle',
timestamp: '2026-08-07T09:00:00Z',
});
expect(bundle.entry[0].resource.resourceType).toBe('MessageHeader');
expect(bundle.type).toBe('message');
});
it('points MessageHeader.focus at the focus resource', () => {
const bundle = buildMessageBundle({
event: MessageEvent.EligibilityRequest,
focus: {
fullUrl: 'urn:uuid:elig',
resource: { resourceType: 'CoverageEligibilityRequest' },
},
supporting: [],
participants,
bundleId: 'test-bundle-2',
timestamp: '2026-08-07T09:00:00Z',
});
const header = bundle.entry[0].resource as any;
expect(header.focus[0].reference).toBe('urn:uuid:elig');
});
});Test classifyOutcome against captured response fixtures too. Save every real response body you receive during onboarding — those fixtures become your regression suite, and they are far more valuable than anything you can synthesise.
For structural validation before you have a connection, run the official HL7 FHIR validator against the NPHIES implementation guide package. It catches profile violations locally in seconds, versus a round trip that may take a day to arrange.
Troubleshooting
Validation fails with a reference error. A resource referenced by fullUrl is not in the bundle. Self-containment is strict — walk every reference in your focus resource and confirm it resolves to an entry you actually included.
The MessageHeader is rejected as invalid. Check the eventCoding.system and that the code matches the transaction you are sending. Sending claim-request with a CoverageEligibilityRequest as focus fails, correctly.
TLS handshake failure. Almost always an incomplete certificate chain rather than a bad certificate. Verify the full chain with openssl s_client -connect <host>:443 -showcerts. Do not disable verification.
Total mismatch rejection. The header total does not equal the sum of item[].net. If you followed Step 6, this cannot happen — the total is derived.
Everything succeeds but nothing gets paid. You are checking layers 1 and 2 only. Go back to Step 5. This is by a wide margin the most consequential failure mode in NPHIES integration, and it is invisible unless you look for it deliberately.
Next Steps
- Add payment reconciliation (
payment-noticeandpayment-reconciliationevents) to close the loop between what was adjudicated and what actually arrived in the account - Implement Communication transactions so insurer requests for additional information are handled automatically rather than by email
- Wire the denial ledger into a weekly report — the ranked query in Step 8 is enough to start
- If you also operate under ZATCA e-invoicing, read Odoo and ZATCA Phase 2, since the two compliance layers share a data model more than most teams expect
- For the wider architectural argument about adding an integration layer over existing systems rather than replacing them, see the ERP trap
Conclusion
The NPHIES message model is not conceptually difficult, but it is unforgiving about details, and almost all published material stops before reaching them. What you have built here is:
- An envelope builder that makes the ordering rule structurally unbreakable
- A transport layer with honest timeouts and no disabled verification
- An outcome classifier that separates transport, validation and adjudication — the distinction that decides whether your success metric is real
- A denial ledger that turns rejections from a recurring annoyance into a ranked list of fixable causes
That last piece is the one that changes the conversation. A provider who can name their top five denial codes and the riyals behind each one is in a completely different position from one who knows only that "a lot get rejected".
If you are mid-way through a NPHIES integration and the numbers are not reconciling, we do integration audits on exactly this kind of system — including implementations built by someone else. Tell us what you are seeing and we will tell you which layer the money is disappearing at.