writing/tutorial/2026/08
TutorialAug 17, 2026·30 min read

Automate Saudi Debt Enforcement Requests with the Najiz API in TypeScript

Build a type-safe service that submits and tracks Saudi debt enforcement requests via the Najiz Ministry of Justice developer API — covering OAuth2 authentication, instrument classification, attachment upload, and a daily reconciliation scheduler.

The Najiz developer portal (developers.najiz.sa) exposes 160+ Ministry of Justice APIs, including a production-ready enforcement request submission service. Yet a search for an Arabic or English implementation guide returns only the government portal itself, Facebook ministry videos, and Saudipedia — no code. This tutorial fills that gap.

You'll build an EnforcementIntakeService in TypeScript that takes an unpaid invoice and automates the full enforcement lifecycle: validate the executive instrument, submit the enforcement request with attachments, poll the 5-day notice window, and flag requests that need escalation. It connects directly to the companion article Najiz + Nafith: Automating Saudi Debt Enforcement, which explains the two enforcement tracks (notarized Nafith instrument vs. judicial judgment) and the four failure modes to avoid. That article ends where the code begins.

Prerequisites

  • Node.js 20+ and TypeScript 5.5+
  • A corporate account on developers.najiz.sa (apply via takamul@moj.gov.sa with your CR number and use case)
  • At least one registered executive instrument (notarized Nafith note, judicial judgment, or commercial paper)
  • Familiarity with the Nafath OAuth2 flow for individual-facing workflows — see the Nafath national SSO tutorial

What You'll Build

src/
  types.ts          domain model and status constants
  port.ts           NajizEnforcementPort interface
  client.ts         Najiz HTTP client (implements the port)
  mock-port.ts      test double for CI before onboarding
  service.ts        EnforcementIntakeService
  reconcile.ts      daily reconciliation scheduler
  reconcile.test.ts vitest tests

The port pattern (same approach as in the GOSI reconciliation tutorial) decouples business logic from the transport. You can build and test the full pipeline with MockNajizPort before the API credentials arrive.

Understanding the Najiz Enforcement API

The portal organizes its 160+ products into four domains: Judiciary, Enforcement, Real Estate Exchange, and Notarization. For enforcement automation, two production endpoints are relevant:

Creditor Instruments Inquiry — returns all executive instruments registered to a creditor NID. Query this first: submitting against an exhausted or expired instrument is the top cause of instant rejection.

Execution Request Submission — files an enforcement request with debtor details, amount breakdown, and attachment IDs.

A staging environment is available once your institutional account is approved. The mock port in Step 7 handles the development period before that approval.

Registration flow: email takamul@moj.gov.sa with your entity name (Arabic and English), CR number, intended use case, the specific API products requested, and a technical contact. Credentials include a clientId, clientSecret, and an API base URL per environment.

Step 1 — Project Setup

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"
  }
}

Environment variables:

NAJIZ_BASE_URL=https://api.najiz.sa        # from onboarding email
NAJIZ_CLIENT_ID=your-client-id
NAJIZ_CLIENT_SECRET=your-client-secret
CREDITOR_NID=1234567890                    # your entity's NID

Step 2 — Domain Model

The three instrument kinds are the load-bearing type in this system. A discriminated union means the compiler enforces correct fields at every call site: a notarized note requires nafithInstrumentId, not a caseNumber. Passing the wrong instrument type becomes a compile error rather than a runtime rejection.

// src/types.ts
import { z } from 'zod'
 
// Three valid executive instrument kinds
export type JudicialJudgment = {
  kind: 'judicial_judgment'
  caseNumber: string
  courtCode: string
  executionCourseDate: string  // ISO date, e.g. "2026-07-15"
}
 
export type NotarizedNote = {
  kind: 'notarized_note'
  nafithInstrumentId: string   // issued by the Nafith notarization platform
  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
 
// Debtor identity — individual or commercial entity
export const NidSchema = z
  .string()
  .regex(/^[12]\d{9}$/, 'NID must be 10 digits starting with 1 (Saudi) or 2 (Iqama)')
 
export const CrSchema = z.string().regex(/^\d{10}$/, 'CR number must be 10 digits')
 
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
 
// Enforcement amount — always in SAR decimal, not halalas
// (unlike Moyasar, Najiz enforcement amounts are SAR — see the
// payment gateway tutorial for the halala convention difference)
export type EnforcementAmount = {
  principalSar: number    // original receivable
  courtFeesSar: number    // recoverable court filing fees
  legalCostsSar: number   // recoverable attorney / notarization costs
}
 
export function totalSar(a: EnforcementAmount): number {
  return a.principalSar + a.courtFeesSar + a.legalCostsSar
}
 
// Request lifecycle state machine
export type RequestStatus =
  | 'submitted'           // filed, awaiting court assignment
  | 'under_review'        // judge reviewing instrument validity
  | 'notice_issued'       // debtor notified; 5-day response window active
  | 'grace_period'        // court-granted debtor extension
  | 'enforced'            // full enforcement executed
  | 'partially_enforced'  // partial recovery (assets seized, shortfall remains)
  | 'rejected'            // instrument invalid or procedural defect
  | 'suspended'           // debtor entered insolvency proceedings
  | 'withdrawn'           // creditor withdrew the request
 
export const TERMINAL_STATUSES = new Set<RequestStatus>([
  'enforced',
  'partially_enforced',
  'rejected',
  'suspended',
  'withdrawn',
])

Step 3 — Port Interface

// 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   // your internal invoice number for traceability
}
 
export type SubmitResult = {
  requestId: string
  referenceNumber: string   // Najiz reference shown on correspondence
  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>
}

Step 4 — Authentication

Najiz corporate access uses client credentials OAuth2. The client caches the token with a 30-second buffer so it never sends an expired token on a slow connection.

// src/client.ts
import { createHash } from 'crypto'
import { NidSchema, CrSchema, TERMINAL_STATUSES } 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(`Najiz auth failed: 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>
  }

Step 5 — Instrument Inquiry and Request Submission

Always query instruments before submitting. An exhausted or expired instrument produces an instant rejection — it's the most common first-attempt failure.

  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> {
    // Validate debtor identity before the network round trip
    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 maps the discriminated union to a flat API object. The switch statement with no default is intentional: TypeScript's exhaustiveness check will catch any new instrument kind added to the union that hasn't been handled.

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,
      }
  }
}

Step 6 — Attachment Upload

Supporting documents (the signed instrument, invoice copies, power of attorney) are uploaded separately after the request is created. A SHA-256 content hash guards against silent upload corruption.

Arabic filenames (e.g., سند-إذني-١٤٤٨.pdf) are passed as the third argument to FormData.append. Do not URL-encode them before passing — FormData handles encoding internally.

  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(`Attachment upload failed: 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`)
  }
}

Step 7 — Reconciliation Scheduler

The 5-day notice window is silent. The enforcement court issues a notice to the debtor and your system receives no webhook. You need a daily job that classifies outstanding requests by escalation state.

Why asOf not new Date(): a reconciliation job run at 03:00 should produce the same results as a rerun at 04:00 if nothing changed in Najiz. Passing asOf explicitly makes the function deterministic and lets you write tests with frozen dates.

// 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 AND past 5 days
  gracePeriodExpired: boolean   // grace_period AND expiry has passed
}
 
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,
      }
    }),
  )
}

Step 8 — Mock Port for Testing

Since no public sandbox exists, the mock port lets you run the full pipeline in CI. The advance helper method lets tests move a request to any status without touching the network.

// 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 []  // populate in each test with your fixture instruments
  }
 
  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: unknown requestId ${requestId}`)
    return r
  }
 
  /** Test helper: move a request to a given status */
  advance(
    requestId: string,
    status: RequestStatus,
    patch: Partial<
      Pick<StatusResult, 'noticeIssuedAt' | 'gracePeriodExpiresAt'>
    > = {},
  ): void {
    const prev = this.store.get(requestId)
    if (!prev) throw new Error(`MockNajizPort: unknown requestId ${requestId}`)
    this.store.set(requestId, {
      ...prev,
      status,
      lastUpdatedAt: new Date().toISOString(),
      ...patch,
    })
  }
}

Testing

// 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: 'Test Debtor' },
  amount: { principalSar: 50_000, courtFeesSar: 1_000, legalCostsSar: 500 },
  sourceInvoiceRef: 'INV-2026-0042',
}
 
describe('reconcileRequests', () => {
  it('flags notice_issued past 5 days as 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 days later
    )
 
    expect(results[0]?.needsEscalation).toBe(true)
    expect(results[0]?.daysSinceNotice).toBe(6)
  })
 
  it('skips terminal-status requests entirely', 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('detects expired grace period', 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)
  })
})

Honesty Note: API Access Requires Institutional Onboarding

The Najiz developer portal does not publish open sandbox credentials. The staging environment exists but requires institutional registration via takamul@moj.gov.sa. Provide your entity name, CR number, intended use case, API products requested, and a technical contact. This mirrors the model used by GOSI, WPS, and Saber — enforcement is sovereignty-adjacent infrastructure, and open API keys are not the deployment model for Saudi government legal services.

Practical path: build and test with MockNajizPort. Once credentials arrive, swap the implementation — the business logic does not change.

Troubleshooting

Instant rejection on submission — almost always an instrument eligibility issue. The status field in queryCreditorInstruments should be valid; exhausted and expired instruments are not an error in your code but in the instrument lifecycle upstream. Renew via the Nafith portal or obtain a new judgment.

Debtor type mismatch — individual debtors require an NID matching /^[12]\d{9}$/; commercial debtors need a 10-digit crNumber and the representative's NID. Mixing the two produces a validation rejection at the court, not a network error.

Duplicate attachments from retry — each upload call creates a new attachmentId. If a first upload timed out, check whether it succeeded before retrying. Duplicate attachment IDs on a request are not rejected but do create unnecessary volume in the court file.

Grace period re-escalation — a debtor in grace_period requested an extension the court granted. Do not re-escalate until gracePeriodExpiresAt has passed. Alerting before then trains your operations team to ignore the alert system.

Conclusion

You now have a type-safe enforcement intake service that validates instruments, submits requests, uploads attachments, and runs a daily reconciliation against the 5-day notice window — all behind a port that runs in CI before your institutional onboarding completes.

The upstream step — generating the executive instrument the enforcement court accepts — is covered in the companion article Najiz + Nafith: Automating Saudi Debt Enforcement.

For the invoice that became the receivable you're now enforcing, the ZATCA e-invoicing integration tutorial covers phase-2 clearance — the document the court wants to see as proof of the underlying obligation.

The reconciliation pattern here — port interface, terminal-state filtering, asOf parameter — appears in the GOSI contribution reconciliation tutorial and transfers directly across financial compliance domains.

If your business manages receivables in Saudi Arabia and needs an automation layer between your ERP and the enforcement portal, get in touch with Noqta. We specialize in the integration layer above existing systems — connecting what the ERP generates to what the government portal requires, without building either.