Le portail développeurs Najiz (developers.najiz.sa) expose plus de 160 API du ministère saoudien de la Justice, dont un service de dépôt de demandes d'exécution prêt pour la production. Pourtant, une recherche d'un guide d'implémentation en arabe ou en anglais ne retourne que le portail gouvernemental lui-même, des vidéos Facebook du ministère, et Saudipedia — aucun code. Ce tutoriel comble cette lacune.
Vous construirez un EnforcementIntakeService en TypeScript qui prend une facture impayée et automatise le cycle d'exécution complet : valider le titre exécutoire, déposer la demande avec les pièces jointes, surveiller la fenêtre de notification de 5 jours, et signaler les demandes nécessitant une escalade. Ce tutoriel s'articule directement avec l'article Najiz + Nafith : Automatiser le recouvrement de créances en Arabie Saoudite, qui explique les deux voies d'exécution et les quatre causes de rejet les plus fréquentes. Cet article s'arrête là où le code commence.
Prérequis
- Node.js 20+ et TypeScript 5.5+
- Un compte institutionnel actif sur
developers.najiz.sa(demande viatakamul@moj.gov.saavec votre registre commercial et le cas d'usage) - Au moins un titre exécutoire enregistré (billet à ordre notarié via Nafith, jugement d'exécution, ou effet de commerce)
- Connaissance du flux OAuth2 Nafath pour les workflows orientés particuliers — voir le tutoriel d'intégration SSO Nafath
Ce que vous allez construire
src/
types.ts modèle de domaine et constantes de statut
port.ts interface NajizEnforcementPort
client.ts client HTTP Najiz (implémentation du port)
mock-port.ts doublure de test pour CI avant onboarding
service.ts EnforcementIntakeService
reconcile.ts scheduler de rapprochement quotidien
reconcile.test.ts tests vitest
Le pattern port (même approche que dans le tutoriel de rapprochement GOSI) découple la logique métier du transport. Vous pouvez construire et tester le pipeline complet avec MockNajizPort avant la réception des identifiants API.
Comprendre l'API d'exécution Najiz
Le portail organise ses 160+ produits en quatre domaines : Justice, Exécution, Bourse Immobilière, et Notariat. Pour l'automatisation des exécutions, deux endpoints de production sont pertinents :
Requête des instruments du créancier — retourne tous les titres exécutoires enregistrés au nom d'un créancier. À interroger en premier : soumettre contre un titre épuisé ou expiré est la première cause de rejet immédiat.
Dépôt de demande d'exécution — dépose la demande avec les données du débiteur, la ventilation du montant, et les identifiants des pièces jointes.
Un environnement de staging est disponible après approbation du compte institutionnel. Le port mock à l'étape 7 couvre la période de développement avant cette approbation.
Processus d'inscription : envoyez un email à takamul@moj.gov.sa avec le nom de votre entité (en arabe et en anglais), le numéro de registre commercial, le cas d'usage, les produits API demandés, et un contact technique. Les identifiants incluent clientId, clientSecret, et une URL de base API par environnement.
Étape 1 — Configuration du projet
npm init -y
npm install zod
npm install -D typescript @types/node tsx vitest{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"outDir": "dist"
}
}Variables d'environnement :
NAJIZ_BASE_URL=https://api.najiz.sa
NAJIZ_CLIENT_ID=your-client-id
NAJIZ_CLIENT_SECRET=your-client-secret
CREDITOR_NID=1234567890Étape 2 — Modèle de domaine
Les trois types de titres exécutoires sont la structure porteuse du système. Un union discriminant signifie que le compilateur impose les bons champs à chaque point d'appel : un billet à ordre notarié requiert nafithInstrumentId, pas caseNumber. Passer le mauvais type devient une erreur de compilation plutôt qu'un rejet en production.
// src/types.ts
import { z } from 'zod'
// Les trois types de titres exécutoires valides
export type JudicialJudgment = {
kind: 'judicial_judgment'
caseNumber: string
courtCode: string
executionCourseDate: string // date ISO, ex. "2026-07-15"
}
export type NotarizedNote = {
kind: 'notarized_note'
nafithInstrumentId: string // émis par la plateforme de notariat Nafith
notarizationDate: string
}
export type CommercialPaper = {
kind: 'commercial_paper'
paperType: 'check' | 'bill_of_exchange'
paperNumber: string
bankCode: string
dueDate: string
}
export type InstrumentKind = JudicialJudgment | NotarizedNote | CommercialPaper
// Identité du débiteur — particulier ou entité commerciale
export const NidSchema = z
.string()
.regex(/^[12]\d{9}$/, "NID : 10 chiffres commençant par 1 (Saoudien) ou 2 (Iqama)")
export const CrSchema = z.string().regex(/^\d{10}$/, "Registre commercial : 10 chiffres")
export type IndividualDebtor = { type: 'individual'; nid: string; fullName: string }
export type CommercialDebtor = {
type: 'commercial'
crNumber: string
entityName: string
representativeNid: string
}
export type Debtor = IndividualDebtor | CommercialDebtor
// Montant de la demande — toujours en SAR décimal, jamais en halalas
// (contrairement à Moyasar qui utilise les halalas entiers — voir
// le tutoriel passerelle de paiement pour la différence de convention)
export type EnforcementAmount = {
principalSar: number // créance initiale
courtFeesSar: number // frais de greffe récupérables
legalCostsSar: number // honoraires / notariat récupérables
}
export function totalSar(a: EnforcementAmount): number {
return a.principalSar + a.courtFeesSar + a.legalCostsSar
}
// Machine à états du cycle de vie de la demande
export type RequestStatus =
| 'submitted' // déposée, en attente d'attribution
| 'under_review' // juge examinant la validité du titre
| 'notice_issued' // débiteur notifié ; fenêtre de réponse 5 jours active
| 'grace_period' // délai de grâce accordé par le tribunal
| 'enforced' // exécution complète
| 'partially_enforced' // recouvrement partiel (saisie, solde restant)
| 'rejected' // titre invalide ou vice de procédure
| 'suspended' // débiteur en procédure de faillite
| 'withdrawn' // créancier a retiré la demande
export const TERMINAL_STATUSES = new Set<RequestStatus>([
'enforced',
'partially_enforced',
'rejected',
'suspended',
'withdrawn',
])Étape 3 — Interface Port
// src/port.ts
import type { InstrumentKind, EnforcementAmount, Debtor, RequestStatus } from './types.js'
export type CreditorInstrument = {
instrumentId: string
kind: InstrumentKind
amount: EnforcementAmount
issuedAt: string
status: 'valid' | 'partially_used' | 'exhausted' | 'expired'
}
export type EnforcementRequestInput = {
creditorNid: string
instrument: InstrumentKind
debtor: Debtor
amount: EnforcementAmount
sourceInvoiceRef: string // référence de la facture interne pour la traçabilité
}
export type SubmitResult = {
requestId: string
referenceNumber: string // référence Najiz figurant sur la correspondance
submittedAt: string
}
export type StatusResult = {
requestId: string
status: RequestStatus
noticeIssuedAt: string | null
gracePeriodExpiresAt: string | null
lastUpdatedAt: string
}
export interface NajizEnforcementPort {
queryCreditorInstruments(creditorNid: string): Promise<CreditorInstrument[]>
submitRequest(input: EnforcementRequestInput): Promise<SubmitResult>
uploadAttachment(
requestId: string,
file: Buffer,
filename: string,
): Promise<{ attachmentId: string }>
getStatus(requestId: string): Promise<StatusResult>
}Étape 4 — Authentification
L'accès institutionnel Najiz utilise OAuth2 Client Credentials. Le client met le token en cache avec un tampon de 30 secondes pour éviter d'envoyer un token expiré sur une connexion lente.
// src/client.ts
import { createHash } from 'crypto'
import { NidSchema, CrSchema } from './types.js'
import type {
NajizEnforcementPort,
CreditorInstrument,
EnforcementRequestInput,
SubmitResult,
StatusResult,
} from './port.js'
export class NajizHttpClient implements NajizEnforcementPort {
private cached: { value: string; expiresAt: number } | null = null
constructor(
private readonly baseUrl: string,
private readonly clientId: string,
private readonly clientSecret: string,
) {}
private async token(): Promise<string> {
if (this.cached && Date.now() < this.cached.expiresAt - 30_000) {
return this.cached.value
}
const res = await fetch(`${this.baseUrl}/oauth2/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'client_credentials',
client_id: this.clientId,
client_secret: this.clientSecret,
scope: 'enforcement:read enforcement:write',
}),
})
if (!res.ok) throw new Error(`Authentification Najiz échouée : HTTP ${res.status}`)
const body = (await res.json()) as { access_token: string; expires_in: number }
this.cached = {
value: body.access_token,
expiresAt: Date.now() + body.expires_in * 1_000,
}
return this.cached.value
}
private async get<T>(path: string): Promise<T> {
const res = await fetch(`${this.baseUrl}${path}`, {
headers: {
Authorization: `Bearer ${await this.token()}`,
Accept: 'application/json',
},
})
if (!res.ok) throw new Error(`GET ${path} → HTTP ${res.status}`)
return res.json() as Promise<T>
}
private async post<T>(path: string, body: unknown): Promise<T> {
const res = await fetch(`${this.baseUrl}${path}`, {
method: 'POST',
headers: {
Authorization: `Bearer ${await this.token()}`,
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify(body),
})
if (!res.ok) throw new Error(`POST ${path} → HTTP ${res.status}`)
return res.json() as Promise<T>
}Étape 5 — Consultation des titres et dépôt de demande
Interrogez toujours les titres avant de soumettre. Un titre avec le statut exhausted ou expired produit un rejet immédiat — c'est la cause la plus fréquente d'échec à la première tentative.
async queryCreditorInstruments(creditorNid: string): Promise<CreditorInstrument[]> {
const body = await this.get<{ instruments: CreditorInstrument[] }>(
`/v1/enforcement/instruments?creditorNid=${creditorNid}`,
)
return body.instruments
}
async submitRequest(input: EnforcementRequestInput): Promise<SubmitResult> {
// Valider l'identité du débiteur avant le round-trip réseau
if (input.debtor.type === 'individual') {
NidSchema.parse(input.debtor.nid)
} else {
CrSchema.parse(input.debtor.crNumber)
NidSchema.parse(input.debtor.representativeNid)
}
return this.post<SubmitResult>('/v1/enforcement/requests', buildPayload(input))
}buildPayload traduit l'union discriminant en objet API plat. L'instruction switch sans default est intentionnelle : la vérification d'exhaustivité TypeScript signale tout nouveau type de titre ajouté à l'union sans être traité.
function buildPayload(input: EnforcementRequestInput) {
const base = {
creditorNid: input.creditorNid,
sourceInvoiceRef: input.sourceInvoiceRef,
amount: {
principal: input.amount.principalSar,
courtFees: input.amount.courtFeesSar,
legalCosts: input.amount.legalCostsSar,
},
debtor:
input.debtor.type === 'individual'
? { type: 'individual', nid: input.debtor.nid, name: input.debtor.fullName }
: {
type: 'commercial',
crNumber: input.debtor.crNumber,
name: input.debtor.entityName,
representativeNid: input.debtor.representativeNid,
},
}
switch (input.instrument.kind) {
case 'judicial_judgment':
return {
...base,
instrumentType: 'judicial_judgment',
caseNumber: input.instrument.caseNumber,
courtCode: input.instrument.courtCode,
executionCourseDate: input.instrument.executionCourseDate,
}
case 'notarized_note':
return {
...base,
instrumentType: 'notarized_note',
nafithInstrumentId: input.instrument.nafithInstrumentId,
notarizationDate: input.instrument.notarizationDate,
}
case 'commercial_paper':
return {
...base,
instrumentType: 'commercial_paper',
paperType: input.instrument.paperType,
paperNumber: input.instrument.paperNumber,
bankCode: input.instrument.bankCode,
dueDate: input.instrument.dueDate,
}
}
}Étape 6 — Upload des pièces jointes
Les documents justificatifs (titre signé, copies de facture, procuration) sont uploadés séparément après la création de la demande. Un hash SHA-256 protège contre la corruption silencieuse lors de l'upload.
Les noms de fichiers en arabe (ex. سند-إذني-١٤٤٨.pdf) sont passés comme troisième argument à FormData.append. Ne les encodez pas en URL avant passage — FormData gère l'encodage en interne.
async uploadAttachment(
requestId: string,
file: Buffer,
filename: string,
): Promise<{ attachmentId: string }> {
const form = new FormData()
form.append('file', new Blob([file], { type: 'application/pdf' }), filename)
form.append('requestId', requestId)
form.append('contentHash', createHash('sha256').update(file).digest('hex'))
const res = await fetch(`${this.baseUrl}/v1/enforcement/attachments`, {
method: 'POST',
headers: { Authorization: `Bearer ${await this.token()}` },
body: form,
})
if (!res.ok) throw new Error(`Upload de pièce jointe échoué : HTTP ${res.status}`)
return res.json() as Promise<{ attachmentId: string }>
}
async getStatus(requestId: string): Promise<StatusResult> {
return this.get<StatusResult>(`/v1/enforcement/requests/${requestId}/status`)
}
}Étape 7 — Scheduler de rapprochement quotidien
La fenêtre de notification de 5 jours est silencieuse. Le tribunal d'exécution notifie le débiteur et votre système ne reçoit aucun webhook. Vous avez besoin d'un job quotidien qui classe les demandes en cours par état d'escalade.
Pourquoi asOf et non new Date() : un job tournant à 3h00 doit produire les mêmes résultats lors d'une ré-exécution à 4h00 si rien n'a changé dans Najiz. Passer asOf explicitement rend la fonction déterministe et permet d'écrire des tests avec des dates figées.
// src/reconcile.ts
import { TERMINAL_STATUSES, type RequestStatus } from './types.js'
import type { NajizEnforcementPort, StatusResult } from './port.js'
export type OutstandingRow = {
requestId: string
status: RequestStatus
noticeIssuedAt: string | null
}
export type ReconciliationResult = {
requestId: string
prevStatus: RequestStatus
newStatus: RequestStatus
daysSinceNotice: number | null
needsEscalation: boolean // notice_issued ET plus de 5 jours
gracePeriodExpired: boolean // grace_period ET délai dépassé
}
export async function reconcileRequests(
port: NajizEnforcementPort,
rows: OutstandingRow[],
asOf: Date,
): Promise<ReconciliationResult[]> {
const pending = rows.filter((r) => !TERMINAL_STATUSES.has(r.status))
return Promise.all(
pending.map(async (row): Promise<ReconciliationResult> => {
const current = await port.getStatus(row.requestId)
const daysSinceNotice =
row.noticeIssuedAt != null
? Math.floor(
(asOf.getTime() - new Date(row.noticeIssuedAt).getTime()) / 86_400_000,
)
: null
return {
requestId: row.requestId,
prevStatus: row.status,
newStatus: current.status,
daysSinceNotice,
needsEscalation:
current.status === 'notice_issued' && (daysSinceNotice ?? 0) > 5,
gracePeriodExpired:
current.status === 'grace_period' &&
current.gracePeriodExpiresAt != null &&
new Date(current.gracePeriodExpiresAt) < asOf,
}
}),
)
}Étape 8 — Port Mock pour les tests
Comme il n'existe pas de sandbox public, le port mock permet d'exécuter le pipeline complet en CI. Le helper advance déplace une demande vers n'importe quel statut sans toucher au réseau.
// src/mock-port.ts
import { createHash } from 'crypto'
import { TERMINAL_STATUSES, type RequestStatus } from './types.js'
import type {
NajizEnforcementPort,
CreditorInstrument,
EnforcementRequestInput,
SubmitResult,
StatusResult,
} from './port.js'
export class MockNajizPort implements NajizEnforcementPort {
private readonly store = new Map<string, StatusResult>()
private seq = 0
async queryCreditorInstruments(
_creditorNid: string,
): Promise<CreditorInstrument[]> {
return [] // peupler avec vos fixtures de test dans chaque test
}
async submitRequest(_input: EnforcementRequestInput): Promise<SubmitResult> {
const id = `MOCK-${String(++this.seq).padStart(6, '0')}`
this.store.set(id, {
requestId: id,
status: 'submitted',
noticeIssuedAt: null,
gracePeriodExpiresAt: null,
lastUpdatedAt: new Date().toISOString(),
})
return {
requestId: id,
referenceNumber: `REF-${id}`,
submittedAt: new Date().toISOString(),
}
}
async uploadAttachment(
_requestId: string,
file: Buffer,
_filename: string,
): Promise<{ attachmentId: string }> {
return {
attachmentId: `ATT-${createHash('sha256').update(file).digest('hex').slice(0, 12)}`,
}
}
async getStatus(requestId: string): Promise<StatusResult> {
const r = this.store.get(requestId)
if (!r) throw new Error(`MockNajizPort : requestId inconnu ${requestId}`)
return r
}
/** Helper de test : déplacer une demande vers un statut donné */
advance(
requestId: string,
status: RequestStatus,
patch: Partial<
Pick<StatusResult, 'noticeIssuedAt' | 'gracePeriodExpiresAt'>
> = {},
): void {
const prev = this.store.get(requestId)
if (!prev) throw new Error(`MockNajizPort : requestId inconnu ${requestId}`)
this.store.set(requestId, {
...prev,
status,
lastUpdatedAt: new Date().toISOString(),
...patch,
})
}
}Tests
// src/reconcile.test.ts
import { describe, it, expect } from 'vitest'
import { MockNajizPort } from './mock-port.js'
import { reconcileRequests } from './reconcile.js'
const SAMPLE_INPUT: import('./port.js').EnforcementRequestInput = {
creditorNid: '1234567890',
instrument: {
kind: 'notarized_note',
nafithInstrumentId: 'NF-2026-001',
notarizationDate: '2026-08-01',
},
debtor: { type: 'individual', nid: '2345678901', fullName: 'Débiteur Test' },
amount: { principalSar: 50_000, courtFeesSar: 1_000, legalCostsSar: 500 },
sourceInvoiceRef: 'INV-2026-0042',
}
describe('reconcileRequests', () => {
it("signale notice_issued dépassant 5 jours comme needsEscalation", async () => {
const port = new MockNajizPort()
const { requestId } = await port.submitRequest(SAMPLE_INPUT)
const noticeIssuedAt = '2026-08-10T08:00:00.000Z'
port.advance(requestId, 'notice_issued', { noticeIssuedAt })
const results = await reconcileRequests(
port,
[{ requestId, status: 'notice_issued', noticeIssuedAt }],
new Date('2026-08-16T08:00:00.000Z'), // 6 jours plus tard
)
expect(results[0]?.needsEscalation).toBe(true)
expect(results[0]?.daysSinceNotice).toBe(6)
})
it("ignore complètement les demandes en statut terminal", async () => {
const port = new MockNajizPort()
const { requestId } = await port.submitRequest(SAMPLE_INPUT)
port.advance(requestId, 'enforced')
const results = await reconcileRequests(
port,
[{ requestId, status: 'enforced', noticeIssuedAt: null }],
new Date('2026-08-17T00:00:00.000Z'),
)
expect(results).toHaveLength(0)
})
it("détecte l'expiration du délai de grâce", async () => {
const port = new MockNajizPort()
const { requestId } = await port.submitRequest(SAMPLE_INPUT)
port.advance(requestId, 'grace_period', {
gracePeriodExpiresAt: '2026-08-14T00:00:00.000Z',
})
const results = await reconcileRequests(
port,
[{ requestId, status: 'grace_period', noticeIssuedAt: '2026-08-10T08:00:00.000Z' }],
new Date('2026-08-15T00:00:00.000Z'),
)
expect(results[0]?.gracePeriodExpired).toBe(true)
})
})Note d'honnêteté : accès API soumis à un onboarding institutionnel
Le portail développeurs Najiz ne publie pas d'identifiants sandbox ouverts. L'environnement de staging existe mais requiert un enregistrement institutionnel via takamul@moj.gov.sa. Fournissez le nom de votre entité, le numéro de registre commercial, le cas d'usage, les produits API demandés, et un contact technique. Ce modèle est identique à celui de GOSI, WPS, et Saber — l'exécution judiciaire est une infrastructure souveraine, et les clés API ouvertes ne sont pas le modèle de déploiement des services juridiques gouvernementaux saoudiens.
Chemin pratique : construisez et testez avec MockNajizPort. Dès réception des identifiants, remplacez l'implémentation — la logique métier ne change pas.
Résolution des problèmes courants
Rejet immédiat au dépôt — presque toujours un problème d'éligibilité du titre. Le champ status dans le résultat de queryCreditorInstruments doit être valid ; un titre exhausted ou expired n'est pas une erreur dans votre code mais dans le cycle de vie du titre en amont. Renouvelez via la plateforme Nafith ou obtenez un nouveau jugement.
Incompatibilité de type de débiteur — les débiteurs particuliers requièrent un NID correspondant au pattern /^[12]\d{9}$/ ; les entités commerciales ont besoin d'un crNumber de 10 chiffres et du NID du représentant. Les mélanger produit un rejet au tribunal, pas une erreur réseau.
Pièces jointes dupliquées lors d'une nouvelle tentative — chaque appel d'upload crée un nouveau attachmentId. Si le premier upload a expiré, vérifiez qu'il a réussi avant de réessayer. Les pièces jointes dupliquées ne sont pas rejetées mais alourdissent inutilement le dossier au tribunal.
Escalade pendant le délai de grâce — un débiteur en statut grace_period signifie que le tribunal lui a accordé un délai. N'escaladez pas avant le passage de gracePeriodExpiresAt. Alerter avant entraîne l'équipe opérationnelle à ignorer le système d'alertes en moins d'une semaine.
Conclusion
Vous disposez maintenant d'un service de gestion des demandes d'exécution type-safe en TypeScript couvrant la validation des titres, le dépôt des demandes, l'upload des pièces jointes, et un rapprochement quotidien sur la fenêtre de notification de 5 jours — tout cela derrière un port qui tourne en CI dès aujourd'hui.
L'étape en amont — générer le titre exécutoire que le tribunal d'exécution accepte — est couverte dans l'article compagnon Najiz + Nafith : Automatiser le recouvrement de créances en Arabie Saoudite.
Pour la facture qui est devenue la créance que vous exécutez maintenant, le tutoriel d'intégration ZATCA Phase 2 couvre la compensation électronique — le document que le tribunal demande comme preuve de l'obligation sous-jacente.
Le pattern de rapprochement ici — interface Port, filtrage des statuts terminaux, paramètre asOf — est identique à celui du tutoriel de rapprochement des cotisations GOSI et se transpose directement à travers les domaines de conformité financière.
Si votre entreprise gère des créances en Arabie Saoudite et a besoin d'une couche d'automatisation entre votre ERP et le portail d'exécution, contactez Noqta. Nous sommes spécialisés dans la couche d'intégration au-dessus des systèmes existants — relier ce que génère votre ERP à ce qu'exige le portail gouvernemental, sans construire l'un ou l'autre.