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 viatakamul@moj.gov.sawith 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 NIDStep 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.