écrits/tutorial/2026/08
Tutorial20 août 2026·26 min

API Wathq en TypeScript : vérification des entreprises saoudiennes (KYB)

Construisez un service KYB saoudien de production sur l'API Registre de Commerce de Wathq en TypeScript : un client typé pour les huit endpoints CR, la règle du numéro national 700, un cache orienté coûts, un moteur de vérification de correspondance propriétaire et un registre de re-vérification.

Toute plateforme saoudienne qui intègre des entreprises — places de marché, SaaS B2B, prêteurs, systèmes d'achats — finit par se poser la même question : ce registre de commerce est-il réel, actif, et détenu par la personne en face de moi ? La réponse programmatique officielle est Wathq (واثق), la passerelle de données du ministère du Commerce sur developer.wathq.sa. Contrairement à la plupart des plateformes gouvernementales saoudiennes, Wathq propose une API véritablement publique et en libre-service : vous vous inscrivez, souscrivez, obtenez une clé API et appelez des endpoints REST.

Nous avons couvert pourquoi la vérification du CR compte — la migration du badge Maroof, les quatre modes de défaillance, l'argumentaire métier — dans notre guide de vérification des entreprises Maroof et Wathq. Cet article-là est la lecture de la phase de décision. Celui-ci est l'implémentation : un client TypeScript typé, les règles d'identifiants qui génèrent la majorité des appels rejetés, un cache orienté coûts (chaque requête coûte de vrais riyals), un moteur de vérification de correspondance propriétaire, et le registre de re-vérification qui attrape un CR expiré après que vous avez intégré le marchand.

Prérequis

Avant de commencer, assurez-vous d'avoir :

  • Node.js 20+ et TypeScript 5+
  • Un compte Wathq sur developer.wathq.sale forfait d'essai est gratuit : 100 requêtes sur 30 jours à 5 requêtes par seconde, suffisant pour tout ce tutoriel
  • Redis (ou n'importe quel cache) pour la couche de contrôle des coûts
  • Une familiarité de base avec fetch et les unions discriminées

Note sur le périmètre. Wathq expose de nombreux services (registre de commerce, adresse nationale, immobilier, avocats). Ce tutoriel couvre l'API Registre de Commerce — spécification sandbox v6.7.0 au moment de la rédaction. Le chemin de base de production documenté publiquement est https://api.wathq.sa/v5/commercialregistration ; votre tableau de bord d'abonnement affiche le chemin versionné exact et le YAML OpenAPI de votre offre. Lisez les deux depuis la configuration, pas depuis un article de blog — y compris celui-ci.

Ce que vous allez construire

Un service verifyBusiness() qui prend un identifiant de CR et le numéro d'identité national d'un signataire, et renvoie l'un de trois verdicts typés : verified, rejected (avec des codes de raison séparant ce que le marchand peut corriger de ce qui est définitif), ou needs_review. En dessous : un client Wathq typé, une couche de normalisation des identifiants, un cache qui traite le crédit API comme la ressource rare qu'il est réellement, et une boucle de re-vérification planifiée.

Étape 1 : accès, authentification et prix d'une requête

L'authentification Wathq est un unique en-tête apiKey émis par abonnement. Ce que la plupart des tutoriels passent sous silence, c'est le modèle commercial, et il façonne l'architecture :

  • Essai : gratuit, 100 requêtes, 30 jours, 5 req/s.
  • Prépayé : à partir de 5 000 SAR, les requêtes sont déduites du solde jusqu'à épuisement ou expiration ; chaque appel est facturé à la requête (jusqu'à plusieurs dizaines de riyals pour les services les plus lourds).
  • Entreprise : volume sur mesure, 50–100 req/s.

Deux conséquences architecturales. Premièrement, un 429 de Wathq signifie « Quota Violation » — vous êtes en train d'épuiser votre limite de débit ou votre solde, et réessayer en boucle transforme un bug en facture. Deuxièmement, chaque appel évitable est de l'argent, donc le cache de l'étape 4 n'est pas une optimisation, c'est le pare-feu de facturation.

// 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 };
}

Étape 2 : le modèle d'identifiants — la règle du 700 est tout le jeu

L'échec d'intégration le plus courant est d'envoyer le mauvais type de numéro. Le nouveau régime du registre de commerce saoudien a consolidé les registres subsidiaires et migré les enregistrements actifs vers un numéro national de CR unifié — dix chiffres, commençant par 700. Les anciens numéros de CR à 10 chiffres existent toujours sur les vieux documents, les factures et dans la mémoire musculaire de vos clients.

L'API l'impose avec une erreur métier spécifique :

400.1.5 — Active and pending records can be retrieved using the commercial registration national number (700) only

Ainsi, un marchand colle le numéro de CR imprimé sur son certificat de 2022, votre intégration le transmet tel quel, et Wathq le rejette alors que l'entreprise est parfaitement réelle. Modélisez la distinction dans le système de types plutôt que de la découvrir dans les logs de production :

// 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 };
}

La normalisation des chiffres arabes-indiens n'est pas hypothétique : les identifiants copiés depuis des PDF arabes et des SMS gouvernementaux arrivent sous la forme ٧٠٠١٢٣٤٥٦٧ assez souvent pour que sauter cette ligne garantisse une file de support. Valider localement avant d'appeler signifie aussi que les deux classes d'erreurs les moins chères (400.1.2, 400.1.3) ne consomment jamais une requête facturable.

Quand vous détenez un identifiant legacy pour une entreprise que le marchand affirme active, n'appelez pas les endpoints d'enregistrement avec — résolvez-le d'abord via votre interface d'onboarding (« saisissez le numéro unifié commençant par 700 figurant sur votre certificat actuel ») ou via la recherche /related à partir de l'identité d'un propriétaire. Brûler une requête pour recevoir 400.1.5 ne vous apprend rien que le préfixe ne vous avait pas déjà dit.

Étape 3 : le client typé

L'API Registre de Commerce (spécification sandbox v6.7.0) expose huit endpoints en lecture :

EndpointRenvoie
GET /info/{id}Données de base : dates, statut, activités
GET /fullinfo/{id}Enregistrement complet : parties, capital, adresse
GET /owners/{id}Propriétaire d'un établissement, ou associés avec leurs parts
GET /managers/{id}Gérants et conseil d'administration
GET /capital/{id}Détails du capital
GET /branches/{id}Enregistrements des succursales
GET /related/{id}/{idType}Tous les CR liés à une identité donnée
GET /owns/{id}/{idType}Booléen : cette identité possède-t-elle un CR

Tous les endpoints d'enregistrement acceptent un paramètre language contraint à ar ou en (erreur 400.1.4 sinon) ; /related et /owns prennent un numéro d'identité de 3 à 20 chiffres (400.1.7) avec un idType validé (400.1.6). Le client ci-dessous mappe la taxonomie documentée des erreurs métier vers un type d'erreur discriminé, pour que les appelants branchent sur le sens, pas sur de la comparaison de chaînes :

// 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}`);
  }
}

Sur les types de réponse. Le rendu public de la spécification sandbox documente précisément les endpoints et les codes d'erreur, mais pas les modèles de réponse complets. Générez CrInfo / CrFullInfo / CrOwners / CrManagers depuis le YAML OpenAPI attaché à votre abonnement (npx openapi-typescript wathq-cr.yaml) plutôt que de recopier à la main des interfaces depuis l'article de qui que ce soit. Les formes ci-dessous ne montrent que les champs sur lesquels s'appuie le moteur de vérification.

// 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"] };

Étape 4 : le cache est le pare-feu de facturation

Un enregistrement de CR ne change pas d'une minute à l'autre, et chaque appel fullinfo est déduit d'un solde prépayé. Mettez en cache les résultats positifs pendant 24 heures ; mettez en cache NO_RESULTS pendant une heure (les fautes de frappe sont corrigées et re-soumises) ; ne mettez jamais en cache les erreurs d'authentification, de quota ou d'amont.

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

Une subtilité : la sentinelle d'un négatif mis en cache est la chaîne vide, pas un null JSON, de sorte qu'un hit de cache se distingue d'un miss sans second aller-retour. À 5 requêtes par seconde sur l'offre d'essai, le cache est aussi ce qui empêche une rafale de soumissions d'onboarding de déclencher la limite de débit.

Étape 5 : le moteur de vérification

La vérification, ce n'est pas « l'API a renvoyé 200 ». Un CR peut exister et être suspendu ; il peut être actif avec une date d'expiration dépassée ; il peut être actif mais enregistré pour la construction générale alors que le marchand vend des cosmétiques sur votre place de marché ; il peut être réel mais détenu par quelqu'un d'autre que la personne qui signe vos conditions. Chacun de ces cas est une conversation différente avec le marchand, donc le moteur renvoie des codes de raison groupés selon qui peut les corriger :

// 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(),
  };
}

Trois choix de conception qui méritent d'être défendus :

  1. L'écart d'activité et l'écart de propriétaire donnent needs_review, pas rejected. La liste d'activités d'un CR est grossière, et la personne qui exploite légitimement une société peut être un gérant autorisé plutôt qu'un propriétaire listé (croisez avec /managers/{id} avant d'escalader). Rejeter automatiquement sur ces deux cas produit des marchands légitimes en colère ; accepter automatiquement produit de la fraude. Une file de revue humaine est la réponse honnête.
  2. asOf est un paramètre. Le moteur peut être rejoué sur les enregistrements en cache d'hier dans les tests et les audits et produire des verdicts identiques. Le patron de réconciliation de notre tutoriel d'exécution Najiz applique le même principe pour la même raison.
  3. La comparaison de statut est délibérément conservatrice. Alignez-vous sur le vocabulaire de statuts de la spécification de votre propre abonnement, loggez chaque valeur jamais vue auparavant, et traitez les statuts inconnus comme needs_review. L'apparition de nouveaux statuts sans préavis est normale pour les systèmes gouvernementaux en amont.

Étape 6 : le registre de re-vérification

Parmi les quatre modes de défaillance du blog figure le plus vicieux : un CR expiré derrière un badge de boutique en ligne actif. La vérification se dégrade. Un marchand vérifié en mars peut être suspendu en juin, et rien ne vous rappelle pour vous le dire. Le remède est un registre (ledger) où l'ancienneté est une colonne de premier rang :

// 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,
  );
}

Exécutez dueForRecheck quotidiennement, repassez les lignes dues dans verifyBusiness, et — point crucial — alertez sur les transitions, pas sur les états. Le signal est « ce CR était vérifié et est maintenant suspendu », levé une seule fois, au moment où il bascule. Ré-alerter chaque jour sur chaque ligne périmée entraîne votre équipe opérationnelle à ignorer le rapport en une semaine ; nous avons vu la même forme d'échec dans la réconciliation de paie et de règlements. Note budgétaire : à 30 jours entre re-vérifications, un portefeuille de 3 000 marchands coûte environ 100 requêtes par jour sur votre solde prépayé — une dépense visible et planifiée plutôt qu'une surprise.

Tester sans brûler de requêtes

Placez le client derrière un port à une méthode et testez le moteur sur des fixtures — y compris les cas coûteux ou impossibles à reproduire contre l'API réelle :

// 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"],
    });
  });
});

Le premier test est celui qui se rentabilise tout seul : il fige la promesse que les entrées malformées et héritées sont traitées avant le réseau, ce qui est à la fois une propriété de correction et une propriété de coût.

Dépannage

SymptômeCauseCorrectif
400.1.5 sur une entreprise que vous savez activeAncien numéro de CR envoyé pour un enregistrement actifCollectez le numéro unifié 700 ; le certificat actuel du marchand l'affiche
404.2.1 pour un CR fraîchement émisLatence de propagation du registreCachez le négatif une heure au plus ; réessayez le lendemain avant d'escalader
429 Quota Violation par rafalesPic d'onboarding au-delà de 5 req/s, ou solde épuiséMettez les recherches en file derrière le cache ; vérifiez le solde restant dans le tableau de bord — ne réessayez pas à l'aveugle
401/403 après rotationClé invalide, ou valide mais non abonnée à ce serviceLes clés sont scopées par abonnement ; confirmez que le service CR est dans votre offre active
La correspondance propriétaire échoue pour un vrai propriétaireComparaison par nom au lieu de l'identitéComparez sur identity.id ; jamais sur les noms arabes — les variantes orthographiques rendent les noms inutilisables comme clés

Prochaines étapes

Conclusion

L'API Registre de Commerce de Wathq est la rare surface gouvernementale saoudienne avec un véritable accès en libre-service, et l'intégration n'est pas difficile — c'est le jugement qui l'est. La règle du numéro 700 appartient à votre système de types, le cache est un contrôle de facturation avant d'être un contrôle de performance, les verdicts ont besoin d'une voie needs_review parce que les données du registre sont plus grossières que vos questions de risque, et la vérification est un calendrier, pas un événement.

Si vous construisez de l'onboarding de marchands, du KYB fournisseurs ou une couche de conformité sur les données des registres saoudiens et que vous voulez un second regard sur l'architecture avant d'y engager un solde prépayé, parlez-nous-en — nous construisons exactement ce type de couche d'intégration entre ERP, plateformes et API gouvernementales saoudiennes.