écrits/tutorial/2026/08
Tutorial7 août 2026·32 min

Construire une intégration NPHIES FHIR en TypeScript : éligibilité, réclamations et suivi des rejets

Un guide pratique pour intégrer la plateforme saoudienne NPHIES avec TypeScript. Construisez des bundles de messages FHIR R4 typés, soumettez des vérifications d'éligibilité et des réclamations, interrogez les résultats d'adjudication différés, et conservez les données de rejet que le tableau de bord de votre éditeur jette.

La majorité du contenu publié sur l'intégration NPHIES en Arabie Saoudite est rédigée par des entreprises qui vous vendent un système d'information hospitalier. Il explique pourquoi vous avez besoin de NPHIES, puis s'arrête exactement là où l'ingénierie commence. Ce tutoriel démarre à cet endroit précis.

Nous allons construire un client TypeScript typé pour l'API de messages NPHIES : vérifications d'éligibilité, soumission de réclamations, interrogation des adjudications différées, et — la partie que presque toutes les implémentations éditeurs sautent — un registre des rejets qui conserve les motifs au lieu de les jeter.

Nous ne vendons ni DPI ni abonnement à une chambre de compensation, ce guide n'a donc aucun produit vers lequel vous orienter. C'est la couche d'intégration, écrite comme un ingénieur a besoin de la lire.

Ce que vous allez construire

Un service Node.js qui :

  1. Construit des bundles de messages FHIR R4 NPHIES valides, entièrement typés en TypeScript
  2. Les transporte en TLS mutuel vers le point de terminaison $process-message
  3. Distingue correctement les trois couches indépendantes où une transaction peut échouer
  4. Soumet une vérification d'éligibilité et lit le résultat d'éligibilité du site
  5. Soumet une réclamation et gère une adjudication queued via une boucle d'interrogation
  6. Persiste chaque code de rejet dans un registre interrogeable

À la fin, vous disposerez d'un client répondant à la seule question qui compte pour la direction financière : de ce que nous avons soumis, qu'est-ce qui a été payé, qu'est-ce qui a été rejeté, et pourquoi.

Prérequis

Avant de commencer, assurez-vous d'avoir :

  • Node.js 20+ et TypeScript 5.4+
  • De l'aisance avec async/await et les clients d'API typés
  • Une littératie FHIR de base — vous devez savoir ce qu'est une Resource et un Bundle. Sinon, lisez d'abord la présentation de FHIR R4 ; NPHIES repose sur FHIR 4.0.1
  • Les identifiants issus de votre onboarding CHI — le certificat, le nom d'hôte du point de terminaison et vos identifiants de licence d'établissement

À propos des identifiants. Les noms d'hôtes NPHIES, les URL de l'environnement de test et les certificats client vous sont délivrés via le processus d'onboarding du Council of Health Insurance. Ils ne sont pas publics, et aucun tutoriel ne peut vous les fournir. Tous les exemples ci-dessous les lisent depuis des variables d'environnement. Lorsque je référence une valeur que vous devez obtenir vous-même, je le dis explicitement plutôt que d'inventer un espace réservé qui aurait l'air authentique.

Le guide d'implémentation NPHIES public documente en revanche les structures de messages elles-mêmes, et c'est sur cette base qu'est écrit le code ci-dessous.

Étape 1 : comprendre le modèle de messages avant d'écrire la moindre ligne

C'est l'étape que tout le monde saute, et c'est pourquoi leur intégration prend quatre mois.

NPHIES n'est pas une API REST. Il n'y a pas de POST /claims ni de GET /claims/123. Il y a essentiellement une seule opération — le $process-message de FHIR — à laquelle vous envoyez un Bundle dont le type est message. Ce que fait la transaction est déterminé par l'eventCoding du MessageHeader contenu dans ce bundle, et non par l'URL.

La forme est donc toujours identique :

POST <base>/$process-message
  Bundle (type: message)
    ├── MessageHeader        ← doit être la première entrée ; porte l'eventCoding
    ├── CoverageEligibilityRequest | Claim | Task | ...
    ├── Patient
    ├── Coverage
    ├── Organization (établissement)
    └── Organization (assureur)

Trois conséquences en découlent immédiatement, et chacune est un bug que vous livrerez sinon :

Le MessageHeader doit être la première entrée du bundle. Pas « quelque part dans le bundle ». Si vous construisez le tableau d'entrées par un map sur une collection de ressources, vous finirez par le réordonner et obtiendrez des rejets qui ressemblent à des erreurs de schéma.

Le bundle doit être auto-suffisant. Chaque ressource référencée par la transaction voyage dans le même bundle. Vous ne créez pas un Patient une fois pour le référencer par URL indéfiniment — vous l'incluez à chaque fois. Le guide d'implémentation est explicite : toutes les ressources nécessaires à l'échange appartiennent au même paquet, afin que version et contexte restent cohérents.

La messagerie fonctionne en store-and-forward, pas en requête-réponse. NPHIES valide et route votre transaction. Elle peut la livrer en temps réel, ou la stocker pour livraison quand le système de l'assureur sera disponible. L'éligibilité est un cas d'usage temps réel ; l'adjudication des réclamations, souvent non. Si votre client suppose que la réponse HTTP contient la réponse métier, il aura tort sur une grande partie des réclamations.

Ce troisième point est celui qui coûte de l'argent, et l'étape 5 le traite correctement.

Étape 2 : mise en place du projet et enveloppe typée

Créez le projet :

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 --strict

Nous utilisons undici car nous avons besoin d'un contrôle fin sur le certificat client TLS, zod pour valider les réponses à la frontière, et pino pour des logs structurés — qui, pour une intégration tournant sans surveillance, ne sont pas optionnels.

Commencez par la terminologie. Les systèmes de codes NPHIES vivent sous l'espace de noms http://nphies.sa/terminology/, et coder ces chaînes en dur partout est la meilleure façon de transformer une faute de frappe en incident de production :

// src/terminology.ts
 
/** Espaces de noms canoniques NPHIES, selon le guide Healthcare Financial Services. */
export const NPHIES = {
  CS: 'http://nphies.sa/terminology/CodeSystem',
  SD: 'http://nphies.sa/fhir/ksa/nphies-fs/StructureDefinition',
} as const;
 
/** Codes d'événement de message — ils sélectionnent le type de transaction. */
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];

Vient ensuite le constructeur d'enveloppe. C'est la pièce de code la plus précieuse du projet, car elle rend la règle « MessageHeader en premier » structurellement impossible à violer :

// src/envelope.ts
import { NPHIES, type MessageEventCode } from './terminology.js';
 
export interface ParticipantConfig {
  /** Votre licence d'établissement, telle que délivrée à l'onboarding. */
  providerLicense: string;
  providerBaseUrl: string;
  /** L'identifiant de licence de l'assureur pour cette transaction. */
  insurerLicense: string;
}
 
interface BundleEntry {
  fullUrl: string;
  resource: Record<string, unknown>;
}
 
/**
 * Construit un bundle de message NPHIES.
 *
 * Le MessageHeader est généré en interne et toujours placé en tête,
 * de sorte que les appelants ne peuvent pas le réordonner par accident.
 */
export function buildMessageBundle(opts: {
  event: MessageEventCode;
  /** La ressource visée par le MessageHeader via focus. */
  focus: BundleEntry;
  /** Toutes les ressources de support : 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 indique au destinataire quelle ressource est le sujet du message
      focus: [{ reference: focus.fullUrl }],
    },
  };
 
  return {
    resourceType: 'Bundle' as const,
    id: bundleId,
    meta: { profile: [`${NPHIES.SD}/bundle`] },
    type: 'message' as const,
    timestamp,
    // L'en-tête est placé en tête ici, sans condition.
    entry: [messageHeader, focus, ...supporting],
  };
}

Remarquez la garantie d'ordre sur la dernière ligne. Les appelants passent focus et supporting séparément et ne touchent jamais au tableau d'entrées : l'invariant tient quelle que soit l'évolution du code appelant.

À propos de la génération d'identifiants. Utilisez crypto.randomUUID() pour les identifiants de bundle, et stockez la valeur générée. Quand vous devrez tracer une réclamation auprès du support NPHIES six semaines plus tard, c'est l'identifiant de bundle qu'on vous demandera. Un identifiant que vous n'avez pas persisté est un identifiant que vous n'avez pas généré.

Étape 3 : la couche de transport

Les connexions NPHIES utilisent un certificat client. Avec undici, vous le configurez une fois dans un Agent que vous réutilisez :

// 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')),
    // Ne désactivez jamais la vérification du certificat, pas même en
    // environnement de test. Si le handshake échoue, réparez la chaîne
    // de confiance.
    rejectUnauthorized: true,
  },
  // Le store-and-forward implique que des réponses lentes sont normales.
  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 };
}

Deux décisions méritent d'être défendues ici.

Les délais d'attente sont généreux à dessein. Un timeout de 10 secondes produira des échecs intermittents sur des transactions parfaitement valides — et pire, vous ne saurez pas si la transaction a été reçue. Un timeout est réellement ambigu : ne le traitez jamais comme une soumission échouée avec réessai aveugle, sous peine de créer des réclamations en double.

rejectUnauthorized reste à true. Chaque équipe d'intégration rencontre une erreur de handshake en environnement de test, et quelqu'un propose alors de désactiver la vérification « juste pour tester ». Ce drapeau a une fâcheuse tendance à atteindre la production. Réparez plutôt la chaîne de confiance.

Étape 4 : la vérification d'éligibilité

L'éligibilité est la bonne première transaction : elle est temps réel, peu risquée, et exerce tout le pipeline.

Un CoverageEligibilityRequest porte un purpose, et les trois valeurs signifient des choses réellement différentes :

PurposeCe que vous demandezUsage typique
validationCette couverture est-elle en vigueur à la date de service ?Accueil du patient
benefitQuelles prestations et quels plafonds restants ?Avant un acte coûteux
discoveryQuelles couvertures actives ce patient a-t-il ?Le patient n'a pas sa carte

Se tromper ici est une erreur fréquente et coûteuse : des équipes envoient validation puis s'étonnent que la réponse ne contienne aucun plafond de prestation. Elle n'en contient pas parce que vous ne les avez pas demandés.

// 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;
  /** Numéro d'identité nationale ou d'Iqama du patient. */
  patientIdentifier: string;
  patientId: string;
  coverageId: string;
  memberId: string;
  /** Date ISO, ex. "2026-08-07" — la date de 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 est un tableau — demander plusieurs finalités est légitime.
      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) };
}

Les ressources Patient et Coverage sont ici réduites au minimum illustrant le motif. Votre dossier d'onboarding précise des éléments supplémentaires obligatoires — profession, situation matrimoniale et champs de résidence, entre autres — et le validateur vous dira précisément lesquels manquent. Cette boucle de retour est rapide ; c'est la compréhension structurelle qui est lente, et vous l'avez désormais.

Étape 5 : les trois couches d'échec — à lire deux fois

Voici l'idée la plus importante de l'intégration NPHIES, et la raison pour laquelle tant d'établissements affichent un tableau de bord à 99 % de succès pendant que le compte bancaire dit le contraire.

Une transaction NPHIES peut échouer à trois couches indépendantes.

CoucheSignificationOù cela apparaît
1. TransportLe message n'est jamais arrivéLe statut HTTP n'est pas 200
2. ValidationNPHIES a rejeté la structure du messageLe bundle de réponse contient un OperationOutcome
3. AdjudicationL'assureur a refusé de payerClaimResponse.outcome et l'adjudication au niveau ligne

Les couches 1 et 2, c'est NPHIES qui vous parle de votre message. La couche 3, c'est l'assureur qui vous parle de votre argent. Ce sont des questions entièrement différentes, et un grand nombre d'intégrations en production ne vérifient que les deux premières.

C'est le mécanisme derrière le constat décrit dans intégrer NPHIES ne veut pas dire être payé — l'indicateur de succès technique et le résultat financier mesurent des couches différentes.

Un quatrième cas piège les équipes : si NPHIES ne parvient pas à livrer votre message à l'assureur en une minute environ, elle génère elle-même une réponse plutôt que de vous laisser sans nouvelle. Cette réponse est marquée par un tag sur bundle.meta.tag pour la distinguer de la réponse d'un assureur. Traitez une réponse générée par NPHIES comme « pas encore de réponse », et non comme un résultat d'adjudication.

Encodez tout cela explicitement :

// 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;
 
  // Couche 2 : NPHIES a rejeté le message lui-même.
  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,
      })),
    };
  }
 
  // Cas particulier : NPHIES a répondu à la place de l'assureur.
  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) {
    // Les réponses d'éligibilité et autres non-réclamations arrivent ici.
    return { layer: 'adjudication', ok: true, outcome: 'complete' };
  }
 
  // "queued" signifie : l'adjudication n'a pas encore eu lieu. Interrogez.
  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',
  };
}
 
/** Extrait les motifs de rejet au niveau ligne ET au niveau en-tête. */
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,
        });
      }
    }
  }
 
  // Les erreurs d'en-tête concernent toute la réclamation, pas une ligne.
  for (const err of claimResponse.error ?? []) {
    for (const coding of err.code?.coding ?? []) {
      denials.push({ code: coding.code, display: coding.display });
    }
  }
 
  return denials;
}

Notez que extractDenials lit les deux : item[].adjudication[].reason et error[] au niveau en-tête. Les implémentations qui n'en lisent qu'une seule perdent silencieusement toute une catégorie de rejets — généralement ceux de niveau en-tête, qui sont justement les problèmes systématiques et corrigeables affectant chaque réclamation d'un type donné.

Étape 6 : soumettre une réclamation

Les réclamations utilisent structurellement la même enveloppe, avec une ressource focus plus riche. Les champs porteurs de sens :

  • type — institutional, professional, oral, pharmacy, vision
  • subType — hospitalisation ou ambulatoire
  • useclaim pour le remboursement, preauthorization pour l'accord préalable
  • diagnosis[] — codés en ICD-10-AM, avec au moins un diagnostic principal
  • item[] — les lignes facturables, chacune avec code d'acte, quantité et montant net
  • supportingInfo[] — pièces jointes et contexte clinique
// 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;
  /** Code d'acte ou de service issu du système de codes NPHIES applicable. */
  code: string;
  codeSystem: string;
  quantity: number;
  unitPrice: number;
  /** Numéros de séquence des diagnostics justifiant cette ligne. */
  diagnosisSequence: number[];
}
 
export interface ClaimDiagnosis {
  sequence: number;
  /** Code ICD-10-AM. */
  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 };
}

Calculez les totaux, ne les acceptez pas. total est dérivé des lignes ci-dessus plutôt que passé en paramètre. Un écart entre le total d'en-tête et la somme des lignes est l'un des rejets de validation les plus fréquents, et le dériver rend cette classe de bug impossible.

Étape 7 : interroger une adjudication différée

Quand classifyOutcome renvoie pending, la transaction est vivante mais sans réponse. NPHIES fournit une transaction d'interrogation pour récupérer les réponses en attente.

L'implémentation naïve — un setInterval qui interroge toutes les 30 secondes indéfiniment — est la meilleure façon de se faire limiter. Utilisez un backoff exponentiel borné :

// 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);
}
 
/**
 * Interroge avec un backoff exponentiel plafonné.
 *
 * Renvoie null quand le budget est épuisé — c'est un résultat légitime
 * signifiant « toujours sans réponse », PAS une erreur à avaler.
 */
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) {
      // 5 s, 10 s, 20 s, 40 s, 80 s — plafonné à 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[] };
  // Une réponse ne contenant qu'un MessageHeader signifie « rien en attente ».
  return (bundle?.entry?.length ?? 0) > 1;
}

Le détail critique est ce qui se passe à l'épuisement du budget. Renvoyer null pour « toujours sans réponse » est correct, et ce n'est pas une erreur. Une réclamation non adjugée après cinq minutes est normale ; l'adjudication peut prendre des jours. Ce qui ne doit jamais arriver, c'est que votre code traite l'épuisement de l'interrogation comme un échec et resoumette la réclamation — cela crée des doublons, et les doublons créent leur propre catégorie de rejet.

En production, exécutez l'interrogation comme un job planifié sur votre table des réclamations en attente, pas comme une boucle dans la requête.

Étape 8 : le registre des rejets

Tout ce qui précède était de la plomberie. Cette étape est celle où l'intégration commence à produire quelque chose que le métier n'avait pas auparavant.

Presque toutes les implémentations éditeurs lisent le ClaimResponse, positionnent un statut à rejected, et jettent les codes de motif. La réclamation est ensuite ressaisie à la main, et personne ne peut répondre à « quel motif de rejet nous coûte le plus ? » — parce que la donnée a été détruite à l'instant où elle est arrivée.

Conservez-la. Le schéma n'a rien de compliqué :

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 pour un rejet de niveau en-tête
  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);

Deux tables. C'est toute la différence entre « nous avons beaucoup de rejets » et une liste de causes classée et chiffrée :

-- Le rapport que la direction financière n'a jamais eu.
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;

Branchez-le sur le chemin de soumission :

// 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;
}

D'après notre expérience sur ce type de travaux d'intégration, les motifs de rejet suivent une distribution très inégale : un petit nombre de codes concentre l'essentiel du revenu perdu, et ils sont généralement systématiques — une pré-autorisation manquante pour un type d'acte, une convention de codage acceptée par un assureur et refusée par un autre. Ce sont des problèmes corrigeables une fois pour toutes. Mais uniquement si vous pouvez les voir, et vous ne pouvez les voir que si vous les avez stockés.

Étape 9 : tester sans connexion réelle

Vous n'aurez pas de connectivité pendant la majeure partie du cycle de développement. Développez plutôt contre la structure — la forme de l'enveloppe est stable et publiquement documentée, donc testable hors ligne :

// 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');
  });
});

Testez aussi classifyOutcome contre des fixtures de réponses capturées. Sauvegardez chaque corps de réponse réel reçu pendant l'onboarding — ces fixtures deviennent votre suite de non-régression, et valent bien plus que tout ce que vous pourriez synthétiser.

Pour la validation structurelle avant d'avoir une connexion, exécutez le validateur officiel HL7 FHIR contre le paquet du guide d'implémentation NPHIES. Il détecte les violations de profil localement en quelques secondes, contre un aller-retour dont l'organisation peut prendre une journée.

Dépannage

La validation échoue sur une erreur de référence. Une ressource référencée par fullUrl n'est pas dans le bundle. L'auto-suffisance est stricte — parcourez chaque référence de votre ressource focus et vérifiez qu'elle se résout vers une entrée réellement incluse.

Le MessageHeader est rejeté comme invalide. Vérifiez l'eventCoding.system et que le code correspond à la transaction envoyée. Envoyer claim-request avec un CoverageEligibilityRequest en focus échoue, à juste titre.

Échec du handshake TLS. Presque toujours une chaîne de certificats incomplète plutôt qu'un mauvais certificat. Vérifiez la chaîne complète avec openssl s_client -connect <host>:443 -showcerts. Ne désactivez pas la vérification.

Rejet pour total incohérent. Le total d'en-tête ne vaut pas la somme des item[].net. Si vous avez suivi l'étape 6, c'est impossible — le total est dérivé.

Tout réussit mais rien n'est payé. Vous ne vérifiez que les couches 1 et 2. Retournez à l'étape 5. C'est de loin le mode d'échec le plus lourd de conséquences dans l'intégration NPHIES, et il reste invisible tant qu'on ne le cherche pas délibérément.

Prochaines étapes

  • Ajoutez la réconciliation des paiements (événements payment-notice et payment-reconciliation) pour boucler la boucle entre ce qui a été adjugé et ce qui est réellement arrivé sur le compte
  • Implémentez les transactions Communication afin que les demandes d'informations complémentaires des assureurs soient traitées automatiquement plutôt que par e-mail
  • Branchez le registre des rejets sur un rapport hebdomadaire — la requête classée de l'étape 8 suffit pour démarrer
  • Si vous opérez aussi sous la facturation électronique ZATCA, lisez Odoo et ZATCA Phase 2 : les deux couches de conformité partagent un modèle de données plus proche que la plupart des équipes ne l'imaginent
  • Pour l'argument architectural plus large en faveur d'une couche d'intégration au-dessus des systèmes existants plutôt que de leur remplacement, voyez le piège de l'ERP

Conclusion

Le modèle de messages NPHIES n'est pas conceptuellement difficile, mais il est intransigeant sur les détails, et presque toute la documentation publiée s'arrête avant de les atteindre. Ce que vous avez construit ici, c'est :

  • Un constructeur d'enveloppe rendant la règle d'ordre structurellement incassable
  • Une couche de transport avec des délais honnêtes et sans vérification désactivée
  • Un classificateur de résultats qui sépare transport, validation et adjudication — la distinction qui décide si votre indicateur de succès est réel
  • Un registre des rejets qui transforme les refus d'une gêne récurrente en une liste classée de causes corrigeables

C'est cette dernière pièce qui change la conversation. Un établissement capable de nommer ses cinq principaux codes de rejet et les riyals derrière chacun se trouve dans une position radicalement différente de celui qui sait seulement que « beaucoup sont rejetées ».

Si vous êtes à mi-parcours d'une intégration NPHIES et que les chiffres ne se réconcilient pas, nous réalisons des audits d'intégration sur exactement ce type de système — y compris sur des implémentations réalisées par un tiers. Dites-nous ce que vous observez et nous vous dirons à quelle couche l'argent disparaît.