A Saudi retail chain with forty branches holds at least forty commercial activity licences (رخصة نشاط تجاري) from منصة بلدي, forty civil-defence safety permits, and a scattering of signage, warehouse and mobile-cart permits on top. Every one of them expires. Most of them expire on a date computed in the Umm al-Qura Hijri calendar, which means the renewal date drifts roughly eleven days earlier every Gregorian year.
Operators track this in a spreadsheet. The spreadsheet stores Gregorian dates, someone adds 365 to last year's date, and eleven days later than they expected, a branch is trading on an expired licence. The municipality fines per violation and can seal the premises.
This tutorial builds the system that spreadsheet should have been: a compliance tracker that models Balady licences correctly, computes expiry in the calendar the licence was actually issued in, escalates renewals before they become fines, and reconciles what you believe against what the portal says.
Prerequisites
Before starting, ensure you have:
- Node.js 20+ installed (the tutorial relies on full ICU, which ships by default from Node 14 onward)
- TypeScript 5.x and working knowledge of it
- Familiarity with Zod or a similar runtime validation library
- A PostgreSQL database, or any store you prefer — the schema translates cleanly
- Basic understanding of cron or a job scheduler
You do not need Balady portal credentials to follow along. That is the whole point of Step 1.
What You'll Build
A service with four parts:
- A domain model covering the licence types Balady actually issues, with dual-calendar dates.
- An Umm al-Qura calendar engine that converts both directions and adds Hijri years correctly.
- A renewal escalation state machine that turns "days remaining" into owned, actionable work.
- A reconciliation loop that detects drift between your register and reality.
By the end you will have a tested library you can drop into an existing back office.
Step 1: Understand What You Are Integrating With (and What You Are Not)
This is the step that saves you a month.
منصة بلدي (balady.gov.sa) is the digital services platform of the Ministry of Municipality and Housing (وزارة البلديات والإسكان). It issues, renews and cancels municipal licences across every أمانة and بلدية in the Kingdom. The services you will care about most:
| Service | Arabic | What it covers |
|---|---|---|
| Commercial activity licence | رخصة نشاط تجاري | The core permit to trade at a given address |
| Building permit | رخصة بناء | Construction, demolition, restoration |
| Safety permit | تصريح السلامة | Issued via Civil Defence, bundled with the commercial licence |
| Signage licence | رخصة لوحة | Exterior shop signage |
| Mobile cart licence | رخصة عربة متنقلة | Food trucks and mobile vendors |
| "Rukhsati" tracker | رخصي | The portal's own read-only licence status view |
Here is the thing nobody writes down: Balady does not publish a public developer API. There is no OAuth app registration, no sandbox, no api.balady.gov.sa with rate limits and a token endpoint. The portal authenticates humans through النفاذ الوطني الموحد (National Single Sign-On, via Absher) and, for establishments, through business.balady.sa.
That leaves you three honest integration paths:
- Structured manual entry plus document ingestion. An operations user enters or uploads each licence once; you parse the PDF and normalise it. This is what almost everyone actually does, and it is the path this tutorial takes.
- Delegated access through a licensed service office. Many operators already pay a مكتب خدمات عامة to file renewals. Some will export a licence register on request.
- Government integration via the establishment's own channels. Large enterprises with a formal agreement can sometimes obtain data feeds through the ministry, brokered the same way GOSI and Muqeem access is brokered. Credentials belong to the establishment, not to you as a vendor.
Do not build against an undocumented portal endpoint you found in DevTools. It has no stability contract, it is bound to a human session, and scraping a National Single Sign-On session is a compliance problem in its own right. Build the register, and make the human step cheap and auditable instead.
This constraint is the same one you hit with Muqeem and, to a lesser degree, GOSI. If you have integrated those, the shape here will feel familiar — see our GOSI contribution engine tutorial for the reconciliation pattern applied to a different registry.
Step 2: Model the Domain
Start with the types. The single most important decision is right here: store both calendars, and store which one is authoritative.
mkdir balady-tracker && cd balady-tracker
npm init -y
npm install zod
npm install -D typescript tsx vitest @types/node
npx tsc --initCreate src/domain.ts:
import { z } from 'zod';
/** The calendar a licence's expiry is legally expressed in. */
export type CalendarSystem = 'hijri' | 'gregorian';
export const LicenceKind = z.enum([
'commercial_activity', // رخصة نشاط تجاري
'building_permit', // رخصة بناء
'safety_permit', // تصريح السلامة
'signage', // رخصة لوحة
'mobile_cart', // رخصة عربة متنقلة
]);
export type LicenceKind = z.infer<typeof LicenceKind>;
/** A Hijri (Umm al-Qura) calendar date. Not a timestamp — a civil date. */
export const HijriDate = z.object({
year: z.number().int().min(1300).max(1600),
month: z.number().int().min(1).max(12),
day: z.number().int().min(1).max(30),
});
export type HijriDate = z.infer<typeof HijriDate>;
/**
* Saudi commercial registration: 10 digits.
* The leading digit encodes the issuing city group.
*/
export const CommercialRegistration = z
.string()
.regex(/^[12347]\d{9}$/, 'CR must be 10 digits starting with 1, 2, 3, 4 or 7');
/** Balady licence numbers are 10-digit numeric strings. */
export const LicenceNumber = z
.string()
.regex(/^\d{10}$/, 'Balady licence number must be exactly 10 digits');
export const Licence = z.object({
id: z.string().uuid(),
kind: LicenceKind,
licenceNumber: LicenceNumber,
commercialRegistration: CommercialRegistration,
/** Free-text branch label plus the municipality that issued it. */
branchName: z.string().min(1),
municipality: z.string().min(1), // e.g. "أمانة منطقة الرياض"
/** The calendar the printed expiry date is expressed in. */
authoritativeCalendar: z.enum(['hijri', 'gregorian']),
/** Always populated, both of them. One is derived from the other. */
expiresOnHijri: HijriDate,
expiresOnGregorian: z.string().date(), // ISO YYYY-MM-DD
/** Set when we last saw this licence confirmed against the portal. */
lastVerifiedAt: z.string().datetime().nullable(),
/** Who chases the renewal. Unowned expiries are how fines happen. */
ownerEmail: z.string().email(),
});
export type Licence = z.infer<typeof Licence>;Two details worth defending.
authoritativeCalendar is not decoration. Commercial activity licences are typically issued and renewed against Hijri dates; some permits, and most contracts you will cross-reference, are Gregorian. If you normalise everything to Gregorian on ingestion and throw away which was authoritative, you cannot recompute a renewal window correctly next year. Keep it.
Both date fields are always populated. You index and query on expiresOnGregorian because that is what your database, your cron, and your humans understand. You compute the next expiry from expiresOnHijri. Storing only one and deriving the other on read is how the eleven-day drift bug gets reintroduced by a well-meaning refactor.
Step 3: Build the Umm al-Qura Calendar Engine
This is the technical core of the tutorial, and the part most implementations get wrong.
Saudi Arabia uses the Umm al-Qura calendar (التقويم الأم القرى), which is a specific tabular Hijri calendar — not the astronomical one, and not the arithmetic Hijri variants used elsewhere. Getting this right matters: a generic "add 354 days" approach is wrong for roughly half of all years, because Hijri years alternate between 354 and 355 days.
Node has this built in through ICU. Create src/hijri.ts:
const UMALQURA = 'en-u-ca-islamic-umalqura-nu-latn';
const RIYADH = 'Asia/Riyadh';
const DAY_MS = 86_400_000;
const partsFormatter = new Intl.DateTimeFormat(UMALQURA, {
year: 'numeric',
month: 'numeric',
day: 'numeric',
timeZone: RIYADH,
});
export interface HijriParts {
year: number;
month: number;
day: number;
}
/** Convert a Gregorian instant to its Umm al-Qura civil date in Riyadh. */
export function toHijri(date: Date): HijriParts {
const parts = Object.fromEntries(
partsFormatter
.formatToParts(date)
.filter((p) => p.type !== 'literal')
.map((p) => [p.type, p.value]),
);
return {
year: Number(parts.year),
month: Number(parts.month),
day: Number(parts.day),
};
}
function compareHijri(a: HijriParts, b: HijriParts): number {
return a.year - b.year || a.month - b.month || a.day - b.day;
}Now the harder direction. ICU gives us Gregorian to Hijri, but not the reverse. Rather than embedding an Umm al-Qura lookup table that will rot, binary-search the conversion we already trust:
/**
* Find the Gregorian date whose Umm al-Qura date is exactly `target`.
* Returns null when the date does not exist (e.g. day 30 of a 29-day month).
*/
export function fromHijri(target: HijriParts): Date | null {
// Seed from the Hijri epoch (622-07-19 CE) and the mean year length.
const seed = Date.UTC(622, 6, 19) + (target.year - 1) * 354.367 * DAY_MS;
let lo = seed - 60 * DAY_MS;
let hi = seed + 420 * DAY_MS;
while (hi - lo > DAY_MS) {
const mid = lo + Math.floor((hi - lo) / 2 / DAY_MS) * DAY_MS;
if (compareHijri(toHijri(new Date(mid)), target) < 0) {
lo = mid;
} else {
hi = mid;
}
}
const candidate = new Date(hi);
return compareHijri(toHijri(candidate), target) === 0 ? candidate : null;
}
/**
* Add whole Hijri years to a Gregorian date, preserving the Hijri day-of-month.
* Clamps day 30 down to 29 when the target month is short.
*/
export function addHijriYears(date: Date, years: number): Date {
const current = toHijri(date);
const wanted: HijriParts = {
year: current.year + years,
month: current.month,
day: current.day,
};
const exact = fromHijri(wanted);
if (exact) return exact;
// Day 30 does not exist in every Hijri month — fall back to the 29th.
const clamped = fromHijri({ ...wanted, day: 29 });
if (!clamped) {
throw new Error(
`Cannot resolve Hijri date ${wanted.year}-${wanted.month}-${wanted.day}`,
);
}
return clamped;
}
/** ISO YYYY-MM-DD, which is what you store and index on. */
export function toIsoDate(date: Date): string {
return date.toISOString().slice(0, 10);
}The binary search runs in about nine iterations of a cheap Intl format call. It is fast enough to run per licence per night and correct by construction, because it can only ever return a date that ICU itself agrees maps to the target.
Why this matters, concretely
Run the engine against a licence issued today:
const issued = new Date('2026-08-12T00:00:00Z');
console.log(toHijri(issued));
// { year: 1448, month: 2, day: 29 } → 29 Safar 1448 AH
console.log(toIsoDate(addHijriYears(issued, 1)));
// 2027-08-02 — not 2027-08-12
console.log(toIsoDate(addHijriYears(issued, 5)));
// 2031-06-19 — not 2031-08-12One Hijri year lands ten days before the naive Gregorian anniversary. Five Hijri years land fifty-four days before it. A spreadsheet that adds a year to the Gregorian date puts a five-year-old licence almost two months into expiry before anyone looks.
Step 4: Normalise Licences on Ingestion
Whatever the source — a form, a CSV from a service office, a parsed PDF — everything funnels through one normaliser that fills in the calendar you did not provide.
Create src/ingest.ts:
import { z } from 'zod';
import { addHijriYears, fromHijri, toHijri, toIsoDate } from './hijri';
import { HijriDate, Licence, LicenceKind, LicenceNumber } from './domain';
/** What an operations user or an import actually gives us. */
export const LicenceInput = z
.object({
kind: LicenceKind,
licenceNumber: LicenceNumber,
commercialRegistration: z.string(),
branchName: z.string().min(1),
municipality: z.string().min(1),
ownerEmail: z.string().email(),
expiresOnHijri: HijriDate.optional(),
expiresOnGregorian: z.string().date().optional(),
})
.refine(
(input) => input.expiresOnHijri || input.expiresOnGregorian,
'Provide at least one expiry date',
);
export type LicenceInput = z.infer<typeof LicenceInput>;
export function normaliseLicence(
raw: unknown,
id: string,
): Omit<Licence, 'lastVerifiedAt'> & { lastVerifiedAt: null } {
const input = LicenceInput.parse(raw);
let hijri = input.expiresOnHijri;
let gregorian = input.expiresOnGregorian;
// The calendar the user supplied is the one the licence is legally in.
const authoritativeCalendar = input.expiresOnHijri ? 'hijri' : 'gregorian';
if (hijri && !gregorian) {
const resolved = fromHijri(hijri);
if (!resolved) {
throw new Error(
`Hijri date ${hijri.year}-${hijri.month}-${hijri.day} does not exist in Umm al-Qura`,
);
}
gregorian = toIsoDate(resolved);
}
if (gregorian && !hijri) {
hijri = toHijri(new Date(`${gregorian}T00:00:00Z`));
}
return {
id,
kind: input.kind,
licenceNumber: input.licenceNumber,
commercialRegistration: input.commercialRegistration,
branchName: input.branchName,
municipality: input.municipality,
ownerEmail: input.ownerEmail,
authoritativeCalendar,
expiresOnHijri: hijri!,
expiresOnGregorian: gregorian!,
lastVerifiedAt: null,
};
}
/** Project the next renewal, honouring the authoritative calendar. */
export function nextExpiry(licence: Licence, terms = 1): string {
const current = new Date(`${licence.expiresOnGregorian}T00:00:00Z`);
if (licence.authoritativeCalendar === 'hijri') {
return toIsoDate(addHijriYears(current, terms));
}
const projected = new Date(current);
projected.setUTCFullYear(projected.getUTCFullYear() + terms);
return toIsoDate(projected);
}Note that normaliseLicence rejects a Hijri date that does not exist rather than silently rounding it. A licence recorded as expiring on 30 Dhul-Qi'dah in a year where that month has 29 days is a transcription error, and you want to hear about it at import time, not eleven months later.
Step 5: The Renewal Escalation State Machine
"Days remaining" is data. An owner, a severity and a next action is a system. Create src/escalation.ts:
import type { Licence } from './domain';
export type Severity = 'none' | 'low' | 'medium' | 'high' | 'critical';
export interface Stage {
id: string;
severity: Severity;
/** Inclusive upper bound, in days remaining. */
withinDays: number;
action: string;
}
/**
* Ordered narrowest-first. Balady renewals realistically need three to four
* weeks when a safety permit re-inspection is involved, so 'urgent' starts
* well before the deadline rather than at it.
*/
export const STAGES: Stage[] = [
{ id: 'grace', severity: 'critical', withinDays: 7, action: 'Escalate to operations lead today' },
{ id: 'urgent', severity: 'high', withinDays: 30, action: 'File renewal now; book safety re-inspection' },
{ id: 'due', severity: 'medium', withinDays: 60, action: 'Confirm lease and CR are valid for renewal' },
{ id: 'upcoming', severity: 'low', withinDays: 90, action: 'Add to next renewal batch' },
];
export interface Assessment {
licenceId: string;
branchName: string;
daysRemaining: number;
stage: string;
severity: Severity;
action: string;
ownerEmail: string;
}
const DAY_MS = 86_400_000;
export function daysRemaining(licence: Licence, now: Date): number {
const expiry = Date.parse(`${licence.expiresOnGregorian}T00:00:00Z`);
const today = Date.parse(`${now.toISOString().slice(0, 10)}T00:00:00Z`);
return Math.round((expiry - today) / DAY_MS);
}
export function assess(licence: Licence, now: Date): Assessment {
const remaining = daysRemaining(licence, now);
const base = {
licenceId: licence.id,
branchName: licence.branchName,
daysRemaining: remaining,
ownerEmail: licence.ownerEmail,
};
if (remaining < 0) {
return {
...base,
stage: 'expired',
severity: 'critical',
action: 'Trading without a valid licence — stop-loss review required',
};
}
const stage = STAGES.find((s) => remaining <= s.withinDays);
return stage
? { ...base, stage: stage.id, severity: stage.severity, action: stage.action }
: { ...base, stage: 'ok', severity: 'none', action: 'No action' };
}The boundary case worth being deliberate about: remaining === 0 means the licence expires today, which is grace and critical — not expired. Off-by-one here is the difference between an alert that fires and one that fires a day late.
Step 6: Reconcile Against Reality
Your register is a belief. Beliefs go stale: a branch manager renews through a service office and tells nobody, or a licence is cancelled when a branch closes. Without reconciliation, your tracker confidently reports green on a licence that no longer exists.
Since there is no API to poll, reconciliation is about surfacing staleness as a first-class signal and making the human verification step cheap.
Create src/reconcile.ts:
import type { Licence } from './domain';
import { assess, type Assessment } from './escalation';
const DAY_MS = 86_400_000;
export interface ReconciliationFlag {
licenceId: string;
branchName: string;
reason: 'never_verified' | 'stale_verification' | 'expired_unverified';
detail: string;
}
/**
* Verification half-life. A licence checked 120 days ago is not evidence,
* it is a memory. Tighten this for high-risk branches.
*/
const STALE_AFTER_DAYS = 120;
export function reconcile(licences: Licence[], now: Date): {
assessments: Assessment[];
flags: ReconciliationFlag[];
} {
const assessments = licences.map((l) => assess(l, now));
const flags: ReconciliationFlag[] = [];
for (const licence of licences) {
const verdict = assessments.find((a) => a.licenceId === licence.id)!;
if (!licence.lastVerifiedAt) {
flags.push({
licenceId: licence.id,
branchName: licence.branchName,
reason: 'never_verified',
detail: 'Imported but never confirmed against منصة بلدي',
});
continue;
}
const ageDays = Math.floor(
(now.getTime() - Date.parse(licence.lastVerifiedAt)) / DAY_MS,
);
if (verdict.stage === 'expired') {
flags.push({
licenceId: licence.id,
branchName: licence.branchName,
reason: 'expired_unverified',
detail: `Recorded as expired ${Math.abs(verdict.daysRemaining)} days ago — confirm it was not renewed offline`,
});
} else if (ageDays > STALE_AFTER_DAYS) {
flags.push({
licenceId: licence.id,
branchName: licence.branchName,
reason: 'stale_verification',
detail: `Last verified ${ageDays} days ago`,
});
}
}
return { assessments, flags };
}The expired_unverified flag is the one that earns its keep. An expired licence in your register is far more often a renewal that happened without you than an actual violation — and treating every one as a fire alarm trains people to ignore the alarm.
Pair this with a one-click "verify" action in your back office that deep-links the operations user straight to the Balady inquiry service for that licence number, then stamps lastVerifiedAt when they confirm. That is the whole integration: you cannot automate the read, so you make the manual read take fifteen seconds and record that it happened.
Step 7: Wire Up the Nightly Job
// src/job.ts
import { reconcile } from './reconcile';
import type { Licence } from './domain';
interface Notifier {
send(to: string, subject: string, body: string): Promise<void>;
}
export async function runDailyComplianceJob(
licences: Licence[],
notify: Notifier,
now = new Date(),
): Promise<void> {
const { assessments, flags } = reconcile(licences, now);
const actionable = assessments.filter((a) => a.severity !== 'none');
// Group by owner so nobody gets forty separate emails.
const byOwner = new Map<string, typeof actionable>();
for (const item of actionable) {
const bucket = byOwner.get(item.ownerEmail) ?? [];
bucket.push(item);
byOwner.set(item.ownerEmail, bucket);
}
for (const [owner, items] of byOwner) {
const critical = items.filter((i) => i.severity === 'critical').length;
const subject = critical
? `[URGENT] ${critical} Balady licence(s) need action today`
: `${items.length} Balady licence(s) approaching renewal`;
const body = items
.sort((a, b) => a.daysRemaining - b.daysRemaining)
.map((i) => `${i.branchName}: ${i.daysRemaining}d — ${i.action}`)
.join('\n');
await notify.send(owner, subject, body);
}
if (flags.length > 0) {
console.warn(`[balady] ${flags.length} reconciliation flag(s) raised`);
}
}Schedule it once a day, early, in Asia/Riyadh. Do not run it hourly — renewal work happens on a scale of days, and hourly mail is how alerts get filtered to a folder nobody opens.
Testing Your Implementation
The calendar engine deserves real tests, because it is the part where a subtle error stays invisible for a year. Create src/hijri.test.ts:
import { describe, expect, it } from 'vitest';
import { addHijriYears, fromHijri, toHijri, toIsoDate } from './hijri';
describe('Umm al-Qura engine', () => {
it('converts a known date', () => {
expect(toHijri(new Date('2026-08-12T00:00:00Z'))).toEqual({
year: 1448,
month: 2,
day: 29,
});
});
it('round-trips every week over eleven years', () => {
const start = Date.UTC(2018, 0, 1);
for (let i = 0; i < 4000; i += 7) {
const gregorian = new Date(start + i * 86_400_000);
const hijri = toHijri(gregorian);
const back = fromHijri(hijri);
expect(back, `no reverse for ${JSON.stringify(hijri)}`).not.toBeNull();
expect(toHijri(back!)).toEqual(hijri);
}
});
it('drifts ten days behind the Gregorian anniversary', () => {
const issued = new Date('2026-08-12T00:00:00Z');
expect(toIsoDate(addHijriYears(issued, 1))).toBe('2027-08-02');
});
it('accumulates drift over a five-year term', () => {
const issued = new Date('2026-08-12T00:00:00Z');
expect(toIsoDate(addHijriYears(issued, 5))).toBe('2031-06-19');
});
it('returns null for a day that does not exist', () => {
// Sweep a year for short months and confirm we never invent a date.
for (let month = 1; month <= 12; month++) {
const resolved = fromHijri({ year: 1448, month, day: 30 });
if (resolved) expect(toHijri(resolved).day).toBe(30);
}
});
});Run with npx vitest run. The round-trip test is the important one: it asserts the binary search against ICU across 572 sample dates, which is far stronger evidence than a handful of hand-picked fixtures.
For the escalation machine, test the boundaries explicitly — day 0, day 7, day 8, day 30, day 31, and a negative — since every one of those is a place an inequality can be written the wrong way round.
Troubleshooting
toHijri returns the wrong year on a small Node build. You are on a small-icu build without full locale data. Check with node -p "process.config.variables.icu_small". Install full-icu or use an official Node distribution.
Dates shift by one day depending on when the job runs. You are comparing a timestamp against a civil date. Always normalise both sides to midnight UTC on the ISO date string, as daysRemaining does above, and set the formatter's timeZone to Asia/Riyadh so a job running at 23:00 UTC does not read yesterday's Hijri date.
fromHijri returns null for a date printed on a real licence. Two likely causes: the licence uses a different Hijri variant than Umm al-Qura (rare on official documents, common on hand-written ones), or the date was transcribed wrong. Surface it to the user rather than clamping silently.
Licence numbers fail validation. Formats vary across أمانات and older permits predate current numbering. Loosen the regex to a length range and log rejects for review rather than blocking the import — a strict validator that stops an operations user from recording a real licence is worse than a permissive one.
Next Steps
- Cross-reference each licence's CR against the Wathq API so a cancelled commercial registration invalidates the whole branch's licences automatically — see Maroof and Wathq business verification.
- Extend the same escalation machine to Qiwa and Nitaqat obligations, which follow an almost identical own-it-or-get-fined pattern: Qiwa HR systems integration.
- Add the tracker's output to the reporting layer above your ERP, so licence risk shows up next to revenue per branch rather than in a separate tool.
- If you are already running ZATCA Phase 2 e-invoicing, reuse its certificate-expiry scheduler — the shape is the same.
Conclusion
The hard part of Balady compliance was never the API, because there is no API. It is that the deadline lives in a calendar your stack does not use, spread across branches nobody owns, in a register nobody verifies.
You have built the three pieces that fix that: an Umm al-Qura engine that is correct by construction and proven by round-trip tests, an escalation machine that assigns every approaching expiry to a person with an action, and a reconciliation loop that treats your own register as a claim rather than a fact.
That combination is worth more than an API would be. An API would tell you the expiry date; it would not tell you who is going to renew it.
Building the reporting and compliance layer over Saudi government platforms is the work we do most. If you are stitching Balady, ZATCA, Qiwa and GOSI deadlines into one view and want a second opinion on the architecture before you commit to it, tell us what you are integrating and we will walk through it with you.