Every Saudi platform that onboards businesses — marketplaces, B2B SaaS, lenders, procurement systems — eventually asks the same question: is this commercial registration real, active, and owned by the person in front of me? The official programmatic answer is Wathq (واثق), the Ministry of Commerce data gateway at developer.wathq.sa. Unlike most Saudi government platforms, Wathq has a genuinely public, self-service API: you register, subscribe, get an API key, and call REST endpoints.
We covered why CR verification matters — the Maroof badge migration, the four failure modes, the business case — in our Maroof and Wathq business verification guide. That article is the decision-stage read. This one is the implementation: a typed TypeScript client, the identifier rules that generate most rejected calls, a cost-aware cache (every query costs real riyals), an owner-match verification engine, and the re-verification ledger that catches a CR that expired after you onboarded the merchant.
Prerequisites
Before starting, ensure you have:
- Node.js 20+ and TypeScript 5+
- A Wathq account at
developer.wathq.sa— the trial package is free: 100 inquiries over 30 days at 5 requests per second, enough for this entire tutorial - Redis (or any cache) for the cost-control layer
- Basic familiarity with
fetchand discriminated unions
Scope note. Wathq exposes many services (commercial registration, national address, real estate, attorneys). This tutorial covers the Commercial Registration API — sandbox spec v6.7.0 at the time of writing. The publicly documented production base path is
https://api.wathq.sa/v5/commercialregistration; your subscription dashboard shows the exact versioned path and the OpenAPI YAML for your tier. Read both from configuration, not from a blog post — including this one.
What You'll Build
A verifyBusiness() service that takes a CR identifier and a signatory's national ID and returns one of three typed verdicts: verified, rejected (with merchant-fixable vs terminal reason codes), or needs_review. Under it: a typed Wathq client, a normalizing identifier layer, a cache that treats API credit as the scarce resource it is, and a scheduled re-verification loop.
Step 1: Access, Auth, and the Price of a Query
Wathq authentication is a single apiKey header issued per subscription. What most tutorials skip is the commercial model, and it shapes the architecture:
- Trial: free, 100 inquiries, 30 days, 5 req/s.
- Prepaid: from SAR 5,000, inquiries deducted from the balance until exhausted or expired; individual API calls are priced per query (up to tens of riyals for the heavier services).
- Enterprise: custom volume, 50–100 req/s.
Two architectural consequences. First, a 429 from Wathq means "Quota Violation" — you are burning through your rate limit or your balance, and retrying in a loop converts a bug into an invoice. Second, every avoidable call is money, so the cache in Step 4 is not an optimization, it is the billing firewall.
// src/wathq/config.ts
export interface WathqConfig {
baseUrl: string; // e.g. https://api.wathq.sa/v5/commercialregistration
apiKey: string;
timeoutMs: number;
}
export function loadWathqConfig(): WathqConfig {
const baseUrl = process.env.WATHQ_CR_BASE_URL;
const apiKey = process.env.WATHQ_API_KEY;
if (!baseUrl || !apiKey) {
throw new Error("WATHQ_CR_BASE_URL and WATHQ_API_KEY must be set");
}
return { baseUrl, apiKey, timeoutMs: 10_000 };
}Step 2: The Identifier Model — the 700 Rule Is the Whole Game
The single most common integration failure is sending the wrong kind of number. Saudi Arabia's new Commercial Register regime consolidated subsidiary registers and moved active records onto a unified CR national number — ten digits, starting with 700. Legacy 10-digit CR numbers still exist on old documents, invoices, and in your customers' muscle memory.
The API enforces this with a specific business error:
400.1.5 — Active and pending records can be retrieved using the commercial registration national number (700) only
So a merchant pastes the CR number printed on their 2022 certificate, your integration forwards it verbatim, and Wathq rejects it even though the business is perfectly real. Model the distinction in the type system instead of discovering it in production logs:
// src/wathq/identifiers.ts
export type CrIdentifier =
| { kind: "national"; value: string } // 700xxxxxxx — active/pending records
| { kind: "legacy"; value: string }; // pre-unification CR number
export class CrIdentifierError extends Error {
constructor(public readonly code: "NOT_DIGITS" | "NOT_TEN_DIGITS") {
super(code);
}
}
export function parseCrIdentifier(raw: string): CrIdentifier {
// Merchants paste from PDFs: strip spaces, dashes, and Arabic-Indic digits.
const ARABIC_INDIC = "٠١٢٣٤٥٦٧٨٩";
const normalized = [...raw.trim()]
.map((ch) => {
const i = ARABIC_INDIC.indexOf(ch);
return i === -1 ? ch : String(i);
})
.join("")
.replace(/[\s-]/g, "");
// Mirror the API's own validation: 400.1.2 (digits only), 400.1.3 (10 digits)
if (!/^\d+$/.test(normalized)) throw new CrIdentifierError("NOT_DIGITS");
if (normalized.length !== 10) throw new CrIdentifierError("NOT_TEN_DIGITS");
return normalized.startsWith("700")
? { kind: "national", value: normalized }
: { kind: "legacy", value: normalized };
}The Arabic-Indic digit normalization is not hypothetical: identifiers copied from Arabic PDFs and government SMS messages arrive as ٧٠٠١٢٣٤٥٦٧ often enough that skipping this line guarantees a support queue. Validating locally before calling also means the two cheapest error classes (400.1.2, 400.1.3) never consume a billable inquiry.
When you hold a legacy identifier for a business the merchant claims is active, don't call the record endpoints with it — resolve it first through your onboarding UI ("enter the unified number starting with 700 shown in your current certificate") or via the /related lookup from an owner ID. Burning an inquiry to receive 400.1.5 teaches you nothing you didn't already know from the prefix.
Step 3: The Typed Client
The Commercial Registration API (sandbox spec v6.7.0) exposes eight read endpoints:
| Endpoint | Returns |
|---|---|
GET /info/{id} | Basic data: dates, status, activities |
GET /fullinfo/{id} | Complete record: parties, capital, address |
GET /owners/{id} | Owner of an establishment, or partners with shares |
GET /managers/{id} | Managers and board of directors |
GET /capital/{id} | Capital details |
GET /branches/{id} | Branch registrations |
GET /related/{id}/{idType} | All CRs related to a given identity |
GET /owns/{id}/{idType} | Boolean: does this identity own a CR |
All record endpoints accept a language parameter constrained to ar or en (error 400.1.4 otherwise); /related and /owns take an identity number of 3–20 digits (400.1.7) with a validated idType (400.1.6). The client below maps the documented business-error taxonomy into a discriminated error type so callers can branch on meaning, not on string matching:
// src/wathq/client.ts
import type { WathqConfig } from "./config";
const BUSINESS_ERRORS = {
"400.1.1": "INPUT_REQUIRED",
"400.1.2": "NOT_DIGITS",
"400.1.3": "NOT_TEN_DIGITS",
"400.1.4": "BAD_LANGUAGE",
"400.1.5": "NEEDS_NATIONAL_700_NUMBER",
"400.1.6": "INVALID_ID_TYPE",
"400.1.7": "BAD_ID_LENGTH",
"404.2.1": "NO_RESULTS",
} as const;
export type WathqBusinessCode =
(typeof BUSINESS_ERRORS)[keyof typeof BUSINESS_ERRORS];
export class WathqError extends Error {
constructor(
public readonly kind:
| { type: "business"; code: WathqBusinessCode }
| { type: "auth" } // 401 / 403 — key invalid or lacks scope
| { type: "quota" } // 429 — do NOT blind-retry: costs money
| { type: "upstream"; status: number }, // 5xx gateway family
) {
super(JSON.stringify(kind));
}
}
export class WathqClient {
constructor(private readonly config: WathqConfig) {}
private async get<T>(path: string): Promise<T> {
const res = await fetch(`${this.config.baseUrl}${path}`, {
headers: { apiKey: this.config.apiKey, Accept: "application/json" },
signal: AbortSignal.timeout(this.config.timeoutMs),
});
if (res.ok) return (await res.json()) as T;
if (res.status === 401 || res.status === 403)
throw new WathqError({ type: "auth" });
if (res.status === 429) throw new WathqError({ type: "quota" });
if (res.status >= 500)
throw new WathqError({ type: "upstream", status: res.status });
// 400/404 carry a business code in the body
const body = (await res.json().catch(() => null)) as
| { code?: string }
| null;
const mapped =
body?.code && body.code in BUSINESS_ERRORS
? BUSINESS_ERRORS[body.code as keyof typeof BUSINESS_ERRORS]
: undefined;
if (mapped) throw new WathqError({ type: "business", code: mapped });
throw new WathqError({ type: "upstream", status: res.status });
}
info(id: string, language: "ar" | "en" = "ar") {
return this.get<CrInfo>(`/info/${id}?language=${language}`);
}
fullInfo(id: string, language: "ar" | "en" = "ar") {
return this.get<CrFullInfo>(`/fullinfo/${id}?language=${language}`);
}
owners(id: string, language: "ar" | "en" = "ar") {
return this.get<CrOwners>(`/owners/${id}?language=${language}`);
}
managers(id: string, language: "ar" | "en" = "ar") {
return this.get<CrManagers>(`/managers/${id}?language=${language}`);
}
}On response types. The public rendering of the sandbox spec documents endpoints and error codes precisely but not full response models. Generate
CrInfo/CrFullInfo/CrOwners/CrManagersfrom the OpenAPI YAML attached to your subscription (npx openapi-typescript wathq-cr.yaml) rather than hand-copying interfaces from anyone's article. The shapes below show only the fields the verification engine relies on.
// src/wathq/types.ts — minimal shapes the engine depends on
export interface CrInfo {
crNationalNumber: string;
name: string;
status: { name: string }; // e.g. active / suspended / cancelled
expiryDate?: string; // present on records that still carry one
activities: Array<{ id: string; name: string }>;
}
export interface CrOwners {
parties: Array<{
identity: { id: string; type: string };
name: string;
sharesPercentage?: number;
}>;
}
export interface CrManagers {
parties: Array<{ identity: { id: string; type: string }; name: string }>;
}
export type CrFullInfo = CrInfo & { owners?: CrOwners["parties"] };Step 4: The Cache Is the Billing Firewall
A CR record does not change minute to minute, and each fullinfo call is deducted from a prepaid balance. Cache positive lookups for 24 hours; cache NO_RESULTS for one hour (typos get corrected and re-submitted); never cache auth, quota, or upstream errors.
// src/wathq/cached-client.ts
import type { Redis } from "ioredis";
import { WathqClient, WathqError } from "./client";
import type { CrFullInfo } from "./types";
const POSITIVE_TTL = 24 * 60 * 60; // seconds
const NEGATIVE_TTL = 60 * 60;
export class CachedWathqClient {
constructor(
private readonly inner: WathqClient,
private readonly redis: Redis,
) {}
async fullInfo(id: string): Promise<CrFullInfo | null> {
const key = `wathq:cr:fullinfo:${id}`;
const hit = await this.redis.get(key);
if (hit !== null) {
return hit === "" ? null : (JSON.parse(hit) as CrFullInfo);
}
try {
const fresh = await this.inner.fullInfo(id);
await this.redis.set(key, JSON.stringify(fresh), "EX", POSITIVE_TTL);
return fresh;
} catch (err) {
if (
err instanceof WathqError &&
err.kind.type === "business" &&
err.kind.code === "NO_RESULTS"
) {
await this.redis.set(key, "", "EX", NEGATIVE_TTL);
return null;
}
throw err; // quota/auth/upstream: never cached, always surfaced
}
}
}One subtlety: the sentinel for a cached negative is the empty string, not a JSON null, so a cache hit is distinguishable from a cache miss without a second round trip. At 5 requests per second on the trial tier, the cache is also what keeps a burst of onboarding submissions from tripping the rate limit.
Step 5: The Verification Engine
Verification is not "the API returned 200." A CR can exist and be suspended; it can be active with an expiry date behind it; it can be active but registered for general contracting while the merchant is selling cosmetics on your marketplace; it can be real but owned by someone other than the person signing your terms. Each of those is a different conversation with the merchant, so the engine returns reason codes grouped by who can fix them:
// src/verify/engine.ts
import type { CachedWathqClient } from "../wathq/cached-client";
import { parseCrIdentifier, CrIdentifierError } from "../wathq/identifiers";
// Same national-ID shape used in our Nafath tutorial: 10 digits, 1=citizen, 2=resident
const NID = /^[12]\d{9}$/;
export type Verdict =
| { outcome: "verified"; crNationalNumber: string; verifiedAt: string }
| { outcome: "rejected"; reasons: RejectReason[] }
| { outcome: "needs_review"; reasons: RejectReason[] };
export type RejectReason =
// merchant-fixable — ask for corrected input
| "MALFORMED_CR_INPUT"
| "LEGACY_NUMBER_PROVIDED"
| "CR_NOT_FOUND"
// terminal — do not onboard
| "CR_NOT_ACTIVE"
| "CR_EXPIRED"
// judgement calls — route to a human
| "ACTIVITY_MISMATCH"
| "SIGNATORY_NOT_OWNER_OR_MANAGER";
export async function verifyBusiness(
client: CachedWathqClient,
input: { crRaw: string; signatoryNid: string; requiredActivityIds: string[] },
asOf: Date, // injected, never new Date() inside — keeps replays honest
): Promise<Verdict> {
if (!NID.test(input.signatoryNid)) {
return { outcome: "rejected", reasons: ["MALFORMED_CR_INPUT"] };
}
let id;
try {
id = parseCrIdentifier(input.crRaw);
} catch (err) {
if (err instanceof CrIdentifierError) {
return { outcome: "rejected", reasons: ["MALFORMED_CR_INPUT"] };
}
throw err;
}
if (id.kind === "legacy") {
// Don't spend an inquiry to be told 400.1.5 — we already know.
return { outcome: "rejected", reasons: ["LEGACY_NUMBER_PROVIDED"] };
}
const record = await client.fullInfo(id.value);
if (record === null) {
return { outcome: "rejected", reasons: ["CR_NOT_FOUND"] };
}
const reasons: RejectReason[] = [];
if (record.status.name.toLowerCase() !== "active") {
reasons.push("CR_NOT_ACTIVE");
}
if (record.expiryDate && new Date(record.expiryDate) < asOf) {
reasons.push("CR_EXPIRED");
}
if (reasons.length > 0) return { outcome: "rejected", reasons };
const activityOk = record.activities.some((a) =>
input.requiredActivityIds.includes(a.id),
);
if (!activityOk) reasons.push("ACTIVITY_MISMATCH");
const parties = record.owners ?? [];
const signatoryListed = parties.some(
(p) => p.identity.id === input.signatoryNid,
);
if (!signatoryListed) reasons.push("SIGNATORY_NOT_OWNER_OR_MANAGER");
if (reasons.length > 0) return { outcome: "needs_review", reasons };
return {
outcome: "verified",
crNationalNumber: record.crNationalNumber,
verifiedAt: asOf.toISOString(),
};
}Three design choices worth defending:
- Activity mismatch and owner mismatch are
needs_review, notrejected. The activity list on a CR is coarse, and the person operating a company legitimately may be an authorized manager rather than a listed owner (cross-check/managers/{id}before escalating). Auto-rejecting on these two produces angry, legitimate merchants; auto-accepting produces fraud. A human queue is the honest answer. asOfis a parameter. The engine can be replayed against yesterday's cached records in tests and audits and produce identical verdicts. The reconciliation pattern in our Najiz enforcement tutorial uses the same principle for the same reason.- Status matching is deliberately conservative. Match the status vocabulary from your own subscription's spec, log every value you have never seen before, and treat unknown statuses as
needs_review. New statuses appearing without notice is normal for government upstreams.
Step 6: The Re-Verification Ledger
The blog's four failure modes include the nastiest one: an expired CR behind a live storefront badge. Verification decays. A merchant verified in March can be suspended in June, and nothing calls you back to say so. The fix is a ledger with staleness as a first-class column:
// src/verify/ledger.ts
export interface VerificationRow {
crNationalNumber: string;
lastVerifiedAt: string; // ISO date of last confirmed-good check
lastOutcome: "verified" | "rejected" | "needs_review";
}
const RECHECK_AFTER_DAYS = 30;
export function dueForRecheck(rows: VerificationRow[], asOf: Date) {
const cutoff = new Date(asOf);
cutoff.setUTCDate(cutoff.getUTCDate() - RECHECK_AFTER_DAYS);
return rows.filter(
(r) => r.lastOutcome === "verified" && new Date(r.lastVerifiedAt) < cutoff,
);
}Run dueForRecheck daily, feed the due rows back through verifyBusiness, and — critically — alert on transitions, not on states. The signal is "this CR was verified and is now suspended," raised once, at the moment it flips. Re-alerting every day on every stale row trains your operations team to ignore the report within a week; we saw the same failure shape in payroll and settlement reconciliation. Budget note: at 30-day rechecks, a 3,000-merchant book costs about 100 inquiries a day from your prepaid balance — visible, planned spend rather than a surprise.
Testing Without Burning Inquiries
Put the client behind a one-method port and test the engine against fixtures — including the cases that are expensive or impossible to reproduce against the live API:
// test/engine.test.ts
import { describe, it, expect } from "vitest";
import { verifyBusiness } from "../src/verify/engine";
const ASOF = new Date("2026-08-20T00:00:00.000Z");
function stubClient(record: unknown) {
return { fullInfo: async () => record } as never;
}
const ACTIVE = {
crNationalNumber: "7001234567",
name: "مؤسسة المثال التجارية",
status: { name: "active" },
activities: [{ id: "4791", name: "Retail via internet" }],
owners: [{ identity: { id: "1234567890", type: "nid" }, name: "صاحب السجل" }],
};
describe("verifyBusiness", () => {
it("rejects a legacy CR number without spending an inquiry", async () => {
const v = await verifyBusiness(
stubClient(ACTIVE),
{ crRaw: "1010123456", signatoryNid: "1234567890", requiredActivityIds: ["4791"] },
ASOF,
);
expect(v).toEqual({ outcome: "rejected", reasons: ["LEGACY_NUMBER_PROVIDED"] });
});
it("normalizes Arabic-Indic digits before classifying", async () => {
const v = await verifyBusiness(
stubClient(ACTIVE),
{ crRaw: "٧٠٠١٢٣٤٥٦٧", signatoryNid: "1234567890", requiredActivityIds: ["4791"] },
ASOF,
);
expect(v.outcome).toBe("verified");
});
it("routes an unlisted signatory to review, not rejection", async () => {
const v = await verifyBusiness(
stubClient(ACTIVE),
{ crRaw: "7001234567", signatoryNid: "2999999999", requiredActivityIds: ["4791"] },
ASOF,
);
expect(v).toEqual({
outcome: "needs_review",
reasons: ["SIGNATORY_NOT_OWNER_OR_MANAGER"],
});
});
});The first test is the one that pays for itself: it pins the promise that malformed and legacy inputs are handled before the network, which is both a correctness property and a cost property.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
400.1.5 on a business you know is active | Legacy CR number sent for an active record | Collect the unified 700 number; the merchant's current certificate shows it |
404.2.1 for a freshly issued CR | Registry propagation lag | Cache the negative for no more than an hour; retry next day before escalating |
429 Quota Violation in bursts | Onboarding spike over 5 req/s, or balance exhausted | Queue lookups behind the cache; check remaining balance in the dashboard — do not blind-retry |
401/403 after rotation | Key invalid, or valid but unsubscribed to this service | Keys are scoped per subscription; confirm the CR service is on your active package |
| Owner match fails for a real owner | Name-based matching instead of identity matching | Match on identity.id; never on Arabic names — spelling variants make names unreliable keys |
Next Steps
- Read the decision-stage companion for the Maroof context and the business framing: Maroof and Wathq: Saudi business verification, and the three-layer identity model for where CR data sits next to Yakeen and Nafath.
- Pair CR verification with signatory authentication: Nafath OAuth2/OIDC in TypeScript proves who is signing; Wathq proves what they own.
- If the verified business will invoice you, validate their VAT registration format with our free Saudi VAT number checker, and see the ZATCA Phase 2 integration guide for the e-invoicing side.
Conclusion
The Wathq Commercial Registration API is the rare Saudi government surface with real self-service access, and the integration is not hard — the judgement is. The 700-number rule belongs in your type system, the cache is a billing control before it is a performance one, verdicts need a needs_review lane because registry data is coarser than your risk questions, and verification is a schedule, not an event.
If you are building merchant onboarding, supplier KYB, or a compliance layer over Saudi registry data and want a second pair of eyes on the architecture before you commit a prepaid balance to it, talk to us — we build exactly this kind of integration layer between ERPs, platforms, and Saudi government APIs.