A factory with a hundred employees, twenty-five of them Saudi. Saudization is 25%, and the band is Low Green in 2026. Nobody resigns, nobody is hired, nothing about the establishment changes — and in 2027 the band is Red.
That is not an arithmetic error. It is how the programme is built. Developer Nitaqat (نطاقات المطور) replaced the old fixed table with a curve, and the constants of that curve ratchet upward year by year. An establishment sitting on the line today falls below it next year by standing still.
Any HR system that shows "Saudization rate" as a single number for this month is hiding that fact from the person reading it. This tutorial builds the alternative: an engine that computes the band from the official formula, knows which heads actually count and which do not, and tells you which year you fall and how many people you need to hire before then.
We covered connecting HR systems to Qiwa — contract authentication, integration levels, the common API failures — in the Qiwa HR system integration guide. That article is about reaching the data. This one is about what to do with it once you have.
Prerequisites
Before starting, ensure you have:
- Node.js 20+ and TypeScript 5+
vitestor an equivalent test runner- Basic familiarity with discriminated unions and JavaScript's math functions
- A copy of the Developer Nitaqat procedural guide published by the Ministry of Human Resources and Social Development — specifically Annex 1, which is the source of every constant in this tutorial
What this engine does and does not do. This code computes the published thresholds for an entity of a given activity and size, using the same formula the ministry applies. It does not read your establishment's file: Qiwa alone knows your registered activity code, your grace periods, your subsidiary structure, and the workforce counts it will actually use. Where the engine and Qiwa disagree, Qiwa is right. The engine's job is to compute the rule and surface the gap early, not to replace the platform.
What You'll Build
One module in six pieces:
- The constants table — Annex 1 as typed TypeScript
- The threshold calculator — the y = m × ln(x) + c curve, plus band classification
- The gap calculation — how many Saudis you actually need, not how many it looks like
- Countable headcount — from payroll roster to the numbers the programme recognises
- The year forecast — the same reading against the 2027 and 2028 constants
- The alerting engine — warning before the fall, not after it
Step 1: The Formula, and Why a Static Table Fails
The old version of Nitaqat asked for a percentage you looked up in a table. Developer Nitaqat computes it:
y = m × ln(x) + c
- y the minimum Saudization rate for that band
- m a curve constant, per activity and band
- c a levelling constant, per activity, band and year
- x the entity's total workforce
- ln the natural logarithm — the guide specifies "القيمة اللوغاريثمية الطبيعية", so
Math.log, notMath.log10
That last point is worth pausing on: using the base-10 logarithm instead of the natural one yields a plausible-looking number and a completely wrong result, and no test will warn you unless you write it yourself.
The practical consequence of a curve is that the required rate moves with headcount. In most activities it rises as an entity grows. In construction and cleaning the curve constant is negative, so the requirement eases as the establishment grows. No static table can answer this, which is why the engine asks for the activity and not just a count:
| Activity | Total workforce | Low Green floor (2026) |
|---|---|---|
| IT infrastructure | 30 | 30.05% |
| Construction and building contracting | 30 | 12.91% |
| Construction and building contracting | 1000 | 11.61% |
The same headcount, and two and a half times the obligation. Any interface that displays "required Saudization rate" without knowing the activity is displaying an invented number.
Step 2: Typing Annex 1
Start with the types. The bands are ordered, and that order is part of the logic rather than an incidental detail:
// nitaqat/types.ts
export const BAND_ORDER = ['lowGreen', 'midGreen', 'highGreen', 'platinum'] as const;
export type BandKey = (typeof BAND_ORDER)[number];
/** Red is not a threshold — it is where you are when you clear none of them. */
export type BandStatus = BandKey | 'red';
export const NITAQAT_YEARS = [2026, 2027, 2028] as const;
export type NitaqatYear = (typeof NITAQAT_YEARS)[number];
/**
* Annex 1 gives, per activity and band, one curve constant and one levelling
* constant per commitment year.
*/
export type BandConstants = {
m: number;
c: readonly [number, number, number];
};
export type Activity = {
id: string;
label: string;
bands: Record<BandKey, BandConstants>;
};Then the constants. Annex 1 carries 41 activities across 656 constants; three activities are enough for this tutorial, and the rest go in the same way:
// nitaqat/annex1.ts
import type { Activity } from './types';
export const ACTIVITIES: Record<string, Activity> = {
manufacturing: {
id: 'manufacturing',
label: 'الصناعات',
bands: {
lowGreen: { m: 1.68, c: [15.08, 18.08, 21.08] },
midGreen: { m: 1.87, c: [21.87, 24.87, 27.87] },
highGreen: { m: 2.08, c: [23.97, 26.97, 29.97] },
platinum: { m: 2.08, c: [29.87, 32.87, 35.87] },
},
},
construction: {
id: 'construction',
label: 'مقاولات التشييد والبناء',
bands: {
lowGreen: { m: -0.37, c: [14.17, 16.17, 18.17] },
midGreen: { m: -0.37, c: [16.17, 18.17, 20.17] },
highGreen: { m: 0, c: [17.5, 19.5, 21.5] },
platinum: { m: 0, c: [22.5, 24.5, 26.5] },
},
},
itInfrastructure: {
id: 'itInfrastructure',
label: 'البنية التحتية لتقنية المعلومات',
bands: {
lowGreen: { m: 3.61, c: [17.77, 19.77, 21.77] },
midGreen: { m: 3.61, c: [24.64, 26.64, 28.64] },
highGreen: { m: 3.61, c: [40, 42, 44] },
platinum: { m: 3.61, c: [50, 52, 54] },
},
},
};Note the negative constant on construction.
m: -0.37is not a typo. Construction and cleaning are the two activities whose obligation eases with size, and "fixing" that sign breaks the engine silently.
Step 3: Thresholds and Band Classification
// nitaqat/thresholds.ts
import { BAND_ORDER, NITAQAT_YEARS } from './types';
import type { Activity, BandKey, BandStatus, NitaqatYear } from './types';
/** The curve applies from six workers up; below that a flat rule governs. */
export const CURVE_MIN_HEADCOUNT = 6;
export function bandThresholds(
activity: Activity,
totalWorkforce: number,
year: NitaqatYear = 2026,
): Record<BandKey, number> {
const yearIndex = Math.max(0, NITAQAT_YEARS.indexOf(year));
// ln(0) is -Infinity and ln of a fraction is negative, so floor the size.
const ln = Math.log(Math.max(totalWorkforce, 1));
const out = {} as Record<BandKey, number>;
for (const band of BAND_ORDER) {
const { m, c } = activity.bands[band];
// Clamped: the curve is an empirical fit, not an identity. A negative
// constant at a small headcount can produce a faithful negative percentage.
out[band] = Math.min(100, Math.max(0, m * ln + c[yearIndex]));
}
return out;
}
/** The highest band a rate actually clears. */
export function classifyBand(
rate: number,
thresholds: Record<BandKey, number>,
): BandStatus {
let status: BandStatus = 'red';
for (const band of BAND_ORDER) {
if (rate >= thresholds[band]) status = band;
}
return status;
}The floor (Math.max(totalWorkforce, 1)) is not decoration: an establishment with zero employees produces Math.log(0) === -Infinity, every threshold becomes -Infinity, and the engine classifies the empty establishment as platinum. That is the kind of bug that passes review and shows up in a board report.
The clamp between zero and one hundred exists for a related reason: the curve is a statistical fit rather than a mathematical identity, and a negative constant at a small headcount can produce a sub-zero percentage — arithmetically faithful, practically meaningless.
Step 4: The Small-Establishment Rule
An entity with five workers or fewer is not governed by the logarithmic formula — but the obligation does not disappear. The ministry states it plainly: an establishment with five workers or fewer is required to add exactly one Saudi employee.
The difference between "Nitaqat does not apply" and "one Saudi is required" is the difference between a compliant establishment and one that discovers the problem at its first visa request:
// nitaqat/small-entity.ts
import { CURVE_MIN_HEADCOUNT } from './thresholds';
export const SMALL_ENTITY_SAUDI_REQUIREMENT = 1;
export type SmallEntityCheck = {
applies: true;
met: boolean;
required: number;
} | null;
/** Null once the curve takes over — the caller should read the band instead. */
export function smallEntityCheck(total: number, saudis: number): SmallEntityCheck {
if (total === 0 || total >= CURVE_MIN_HEADCOUNT) return null;
return {
applies: true,
met: saudis >= SMALL_ENTITY_SAUDI_REQUIREMENT,
required: SMALL_ENTITY_SAUDI_REQUIREMENT,
};
}When you are unsure which direction to be wrong in, choose the one that overstates the obligation. Telling a client they are compliant when they are not is far worse than the reverse.
Step 5: The Gap — The Denominator Grows With You
This is where almost everyone gets it wrong, including the spreadsheets running Saudization at large companies.
Someone with 20 Saudis out of 100 aiming for 30% calculates: 30 minus 20 is 10 hires. The answer is 15. Every Saudi hire lifts the numerator and the denominator together: after ten hires you have 30 Saudis out of 110, which is 27.27%, not 30%.
The correct relation:
(saudis + x) / (total + x) >= target
therefore: x >= (target × total − saudis) / (1 − target)
// nitaqat/gap.ts
export type Gap = {
hiresNeeded: number;
nonSaudiReduction: number;
};
export function gapTo(targetPercent: number, saudis: number, total: number): Gap {
const t = targetPercent / 100;
if (t >= 1) throw new RangeError('a 100% target has no finite hiring solution');
const rate = total === 0 ? 0 : (saudis / total) * 100;
if (rate >= targetPercent) return { hiresNeeded: 0, nonSaudiReduction: 0 };
// Hiring lifts both terms: (saudis + x) / (total + x) >= t
const hiresNeeded = Math.max(0, Math.ceil((t * total - saudis) / (1 - t)));
// The other lever — shrink the denominator: saudis / (saudis + y) >= t
const nonSaudis = total - saudis;
const allowedNonSaudis = t === 0 ? Infinity : Math.floor(saudis / t) - saudis;
const nonSaudiReduction = Number.isFinite(allowedNonSaudis)
? Math.max(0, nonSaudis - Math.max(0, allowedNonSaudis))
: 0;
return { hiresNeeded, nonSaudiReduction };
}Always show both numbers. Managers make a different decision when they can see that reaching the next band costs either eight Saudi hires or eighteen departures, and showing only one of the two turns a decision into a fait accompli.
Step 6: Which Heads Actually Count
This is the step that separates a calculator from a compliance system.
Since 15 April 2026, a Saudi employee counts toward your Saudization rate only if their contract is electronically authenticated on Qiwa. A Saudi who genuinely works for you, is paid every month, and is registered with GOSI may still not be counted, because their contract was never authenticated.
Your HR system counts them; Nitaqat does not. The gap between those two numbers is what makes the dashboard lie:
// nitaqat/countable.ts
export type EmployeeRecord = {
id: string;
nationality: 'SA' | 'NON_SA';
/** Qiwa contract authentication state, mirrored from the platform. */
contractAuthenticated: boolean;
/** Whether GOSI shows an open contribution record for the period. */
gosiActive: boolean;
/** For the cases the programme weights differently than one head. */
weight?: number;
};
export type CountableWorkforce = {
/** Saudis that actually count toward the ratio. */
saudis: number;
/** Everyone on an open GOSI record, counted toward Saudization or not. */
total: number;
/** Saudis sitting in the denominator but contributing nothing to the numerator. */
uncountedSaudis: string[];
};
export function countableWorkforce(roster: EmployeeRecord[]): CountableWorkforce {
let saudis = 0;
let total = 0;
const uncountedSaudis: string[] = [];
for (const e of roster) {
// No open GOSI contribution record, no place in either term.
if (!e.gosiActive) continue;
const weight = e.weight ?? 1;
total += weight;
if (e.nationality !== 'SA') continue;
// Since 15 April 2026 a Saudi whose Qiwa contract is not authenticated
// still occupies the denominator but adds nothing to the numerator.
if (!e.contractAuthenticated) {
uncountedSaudis.push(e.id);
continue;
}
saudis += weight;
}
return { saudis, total, uncountedSaudis };
}Note the treatment of an unauthenticated Saudi: they stay in the denominator and leave the numerator. That is the conservative reading, and the right direction to be wrong in. But always reconcile total and saudis against the counts Qiwa itself displays, and treat any difference as an incident to investigate rather than a number to round. See the GOSI contribution engine for the details of reconciling insurance records.
Step 7: The Year You Fall
Now back to the factory we opened with. The levelling constant c rises three points a year across most manufacturing bands, and reading the same workforce against next year's constants exposes the cliff:
// nitaqat/forecast.ts
import { bandThresholds, classifyBand } from './thresholds';
import { NITAQAT_YEARS } from './types';
import type { Activity, BandStatus, NitaqatYear } from './types';
export type YearOutlook = {
year: NitaqatYear;
rate: number;
band: BandStatus;
lowGreenThreshold: number;
};
/** One unchanging workforce, read against each published year's constants. */
export function ratchetOutlook(
activity: Activity,
saudis: number,
total: number,
): YearOutlook[] {
const rate = total === 0 ? 0 : (saudis / total) * 100;
return NITAQAT_YEARS.map((year) => {
const thresholds = bandThresholds(activity, total, year);
return {
year,
rate: Number(rate.toFixed(2)),
band: classifyBand(rate, thresholds),
lowGreenThreshold: Number(thresholds.lowGreen.toFixed(2)),
};
});
}And its output for our factory — 25 Saudis out of 100:
| Year | Rate | Low Green floor | Band |
|---|---|---|---|
| 2026 | 25.00% | 22.82% | Low Green |
| 2027 | 25.00% | 25.82% | Red |
| 2028 | 25.00% | 28.82% | Red |
And the number that makes this actionable: reaching the 2027 floor from today's position takes two hires. Waiting for the fall into red means visa services and service transfers are suspended before you even begin recruiting. The difference between an early warning and a late crisis is, here, two people.
Step 8: Alerting Before the Edge, Not At It
A system that warns you when you fall is a late system. What you need is a margin:
// nitaqat/alerts.ts
import { bandThresholds, classifyBand } from './thresholds';
import { ratchetOutlook } from './forecast';
import { BAND_ORDER } from './types';
import type { Activity, BandKey, NitaqatYear } from './types';
export type Alert = {
level: 'info' | 'warn' | 'critical';
code: string;
message: string;
};
/** Percentage points between the current rate and the floor it sits on. */
export function bandBuffer(
rate: number,
thresholds: Record<BandKey, number>,
): number {
const current = classifyBand(rate, thresholds);
if (current === 'red') return 0;
return Number((rate - thresholds[current]).toFixed(2));
}
export function reviewCompliance(input: {
activity: Activity;
saudis: number;
total: number;
uncountedSaudis: string[];
year?: NitaqatYear;
bufferPoints?: number;
}): Alert[] {
const {
activity, saudis, total, uncountedSaudis,
year = 2026, bufferPoints = 2,
} = input;
const alerts: Alert[] = [];
const rate = total === 0 ? 0 : (saudis / total) * 100;
const thresholds = bandThresholds(activity, total, year);
const band = classifyBand(rate, thresholds);
const buffer = bandBuffer(rate, thresholds);
if (band === 'red') {
alerts.push({
level: 'critical',
code: 'BAND_RED',
message: `Red band: ${rate.toFixed(2)}% against a ${thresholds.lowGreen.toFixed(2)}% floor.`,
});
} else if (buffer < bufferPoints) {
alerts.push({
level: 'warn',
code: 'BAND_MARGIN_THIN',
message: `Only ${buffer} points above the ${band} floor — one departure may cost the band.`,
});
}
if (uncountedSaudis.length > 0) {
alerts.push({
level: 'warn',
code: 'CONTRACTS_UNAUTHENTICATED',
message: `${uncountedSaudis.length} Saudi employees have no authenticated Qiwa contract and are not counting.`,
});
}
const falls = ratchetOutlook(activity, saudis, total).find((o) => o.band === 'red');
if (falls && band !== 'red') {
alerts.push({
level: 'critical',
code: 'RATCHET_FALL',
message: `Unchanged, this workforce falls to red in ${falls.year}.`,
});
}
return alerts;
}The default margin of two percentage points is a choice, not a rule: in a hundred-person establishment, one Saudi resignation costs roughly a full point. Tune bufferPoints to the size of the entity, not to taste.
Testing the Engine
The tests here are not ceremonial. The first four numbers come from the manufacturing curve at a hundred employees, and any drift in them means someone swapped the logarithm or mistyped a constant:
// nitaqat/engine.test.ts
import { describe, expect, it } from 'vitest';
import { ACTIVITIES } from './annex1';
import { bandThresholds, classifyBand } from './thresholds';
import { gapTo } from './gap';
import { ratchetOutlook } from './forecast';
import { countableWorkforce } from './countable';
describe('thresholds', () => {
it('computes the 2026 manufacturing curve at 100 employees', () => {
const t = bandThresholds(ACTIVITIES.manufacturing, 100, 2026);
expect(t.lowGreen).toBeCloseTo(22.82, 2);
expect(t.midGreen).toBeCloseTo(30.48, 2);
expect(t.highGreen).toBeCloseTo(33.55, 2);
expect(t.platinum).toBeCloseTo(39.45, 2);
});
it('eases with size where the curve constant is negative', () => {
const small = bandThresholds(ACTIVITIES.construction, 10, 2026).lowGreen;
const large = bandThresholds(ACTIVITIES.construction, 1000, 2026).lowGreen;
expect(small).toBeCloseTo(13.32, 2);
expect(large).toBeCloseTo(11.61, 2);
expect(large).toBeLessThan(small);
});
it('separates two activities that share a headcount', () => {
const it = bandThresholds(ACTIVITIES.itInfrastructure, 30, 2026).lowGreen;
const con = bandThresholds(ACTIVITIES.construction, 30, 2026).lowGreen;
expect(it).toBeCloseTo(30.05, 2);
expect(con).toBeCloseTo(12.91, 2);
});
it('does not call an empty establishment platinum', () => {
const t = bandThresholds(ACTIVITIES.manufacturing, 0, 2026);
expect(Number.isFinite(t.lowGreen)).toBe(true);
expect(classifyBand(0, t)).toBe('red');
});
});
describe('the gap', () => {
it('accounts for the denominator growing with each hire', () => {
expect(gapTo(30.48, 25, 100).hiresNeeded).toBe(8);
});
it('offers the reduction path as well', () => {
expect(gapTo(30.48, 25, 100).nonSaudiReduction).toBe(18);
});
it('returns zero once the target is already met', () => {
expect(gapTo(20, 25, 100)).toEqual({ hiresNeeded: 0, nonSaudiReduction: 0 });
});
});
describe('the ratchet', () => {
it('drops a static workforce a band without anyone moving', () => {
const outlook = ratchetOutlook(ACTIVITIES.manufacturing, 25, 100);
expect(outlook[0].band).toBe('lowGreen');
expect(outlook[1].band).toBe('red');
expect(outlook[1].lowGreenThreshold).toBeCloseTo(25.82, 2);
});
});
describe('countable workforce', () => {
it('keeps an unauthenticated Saudi in the denominator only', () => {
const w = countableWorkforce([
{ id: 'a', nationality: 'SA', contractAuthenticated: true, gosiActive: true },
{ id: 'b', nationality: 'SA', contractAuthenticated: false, gosiActive: true },
{ id: 'c', nationality: 'NON_SA', contractAuthenticated: true, gosiActive: true },
]);
expect(w.saudis).toBe(1);
expect(w.total).toBe(3);
expect(w.uncountedSaudis).toEqual(['b']);
});
});You can try the same numbers by hand in the Nitaqat calculator before trusting the engine's output: if the two disagree, one of them has a mistyped constant.
Troubleshooting
Using Math.log10 instead of Math.log. The most common error and the hardest to spot, because the result remains a plausible-looking percentage. The guide specifies the natural logarithm.
Reading c from the wrong year column. The three constants per band are 2026, 2027 and 2028, in that order. Mixing them gives a correct band for the wrong year.
"Fixing" the negative sign on construction. -0.37 is intentional.
Counting payroll heads instead of countable heads. A contract that is not authenticated on Qiwa does not count, however faithfully the salary is paid.
Showing the rate without the band. 22% is a meaningless number: it is comfortably green for a contractor and red for an IT infrastructure firm.
Ignoring subsidiary structure. The calculation is at the entity level as Qiwa's establishment file defines it, not at the level of the branch that happens to be in your database.
Next Steps
The engine above computes the rule. What turns it into a real system is the source feeding it: a daily sync from Qiwa for authenticated contracts, from GOSI for open records, and from payroll for contract end dates. At that point ratchetOutlook becomes a monthly board report rather than a function in a file.
- Qiwa integration for HR systems — how to reach authenticated contract data
- GOSI contribution and reconciliation engine — the source of open records
- Mudad and WPS for developers — the other end of the payroll file
- Nitaqat calculator — for checking any case by hand
Conclusion
Saudization is not a single number. It is a position on a curve that moves underneath you. The engine we built computes thresholds from the Annex 1 constants instead of a frozen table, calculates the hiring gap with a growing denominator, separates countable heads from payroll heads, and reads future years against their own constants.
The real gain is not accuracy — it is time. Two hires today are cheaper than a red band four months from now.
Still tracking Saudization in a spreadsheet? If your rate is computed by hand once a month, you always learn your position late — and you discover unauthenticated contracts at the first rejected visa request. We connect HR systems to Qiwa, GOSI and payroll so the indicators compute themselves and the alerts arrive before the edge rather than after it. Talk to us for a review of your establishment's position.