KSA Integration Guide

Saudi ID & VAT Validation with React and Next.js

Build an English/Arabic form for Saudi National IDs, Iqama, and VAT numbers. The examples keep your API key on the server and show accessible loading, success, invalid, and request-error states.

Use the live service-first endpoints

Saudi National ID and Iqama values are 10 digits (prefix 1 for National ID, 2 for Iqama). Saudi VAT numbers use 15 digits, starting and ending in 3. Send identifiers as text so formatting and leading zeroes are not lost.

Saudi National ID or Iqama — GET

/api/v1/saudi-id/validate?id=…

Requires the validation:saudi-id scope. The response is wrapped in data; valid results include type, while invalid results include an errors array.

bash
curl --get "https://khaleejiapi.dev/api/v1/saudi-id/validate" \
--data-urlencode "id=1012345607" \
-H "Authorization: Bearer $KHALEEJI_API_KEY"

Saudi VAT — GET

/api/v1/vat/validate?tin=…&country=SA

Requires the validation:vat scope. This endpoint checks the number's format; it is not a live ZATCA registration lookup. Treat checkDigitValid as diagnostic information, not proof of registration.

bash
curl --get "https://khaleejiapi.dev/api/v1/vat/validate" \
--data-urlencode "tin=310122393500073" \
--data-urlencode "country=SA" \
-H "Authorization: Bearer $KHALEEJI_API_KEY"

See the Saudi ID API reference and VAT API reference for parameters and response details.

1. Add a server-only Next.js proxy

This route accepts a narrow input shape and selects only the two documented upstream URLs. Add your application's authentication, abuse protection, and request-size limits before exposing it to users.

typescript
// app/api/ksa/validate/route.ts
import { NextResponse } from "next/server"
export async function POST(request: Request) {
const body: unknown = await request.json().catch(() => null)
if (
!body ||
typeof body !== "object" ||
!("kind" in body) ||
!("value" in body) ||
typeof body.kind !== "string" ||
typeof body.value !== "string" ||
!["saudi-id", "vat"].includes(body.kind)
) {
return NextResponse.json(
{ error: { code: "INVALID_INPUT", message: "Invalid validation request" } },
{ status: 400 },
)
}
const apiKey = process.env.KHALEEJI_API_KEY
if (!apiKey) {
return NextResponse.json(
{ error: { code: "SERVER_CONFIG", message: "Validation is unavailable" } },
{ status: 500 },
)
}
const endpoint = new URL(
body.kind === "saudi-id"
? "https://khaleejiapi.dev/api/v1/saudi-id/validate"
: "https://khaleejiapi.dev/api/v1/vat/validate",
)
endpoint.searchParams.set(body.kind === "saudi-id" ? "id" : "tin", body.value)
if (body.kind === "vat") endpoint.searchParams.set("country", "SA")
try {
const upstream = await fetch(endpoint, {
headers: { Authorization: "Bearer " + apiKey },
cache: "no-store",
})
const result: unknown = await upstream.json()
return NextResponse.json(result, { status: upstream.status })
} catch {
return NextResponse.json(
{ error: { code: "UPSTREAM_ERROR", message: "Could not reach the validation service" } },
{ status: 502 },
)
}
}

2. Use a bilingual React validation form

The text input preserves the identifier as entered. Inline status text is announced to assistive technology; the hint also appears as a native tooltip on the input.

tsx
"use client"
import { useState, type FormEvent } from "react"
type Locale = "en" | "ar"
type Kind = "saudi-id" | "vat"
type ResultBody = {
data?: { valid?: boolean; reason?: string; errors?: string[] }
error?: { code?: string; message?: string }
}
export function KsaValidationForm({ locale = "en" }: { locale?: Locale }) {
const [kind, setKind] = useState<Kind>("saudi-id")
const [value, setValue] = useState("")
const [status, setStatus] = useState<"idle" | "loading" | "valid" | "invalid" | "error">("idle")
const [message, setMessage] = useState("")
const isArabic = locale === "ar"
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
setStatus("loading")
setMessage("")
try {
const response = await fetch("/api/ksa/validate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ kind, value }),
})
const result = (await response.json()) as ResultBody
if (!response.ok || result.error) {
throw new Error(result.error?.message ?? "Validation request failed")
}
const valid = result.data?.valid === true
setStatus(valid ? "valid" : "invalid")
setMessage(
valid
? isArabic ? "الرقم مطابق للتنسيق." : "The number matches the expected format."
: isArabic ? "تعذر التحقق من التنسيق." :
result.data?.reason ?? result.data?.errors?.join(" ") ??
"The number does not match the expected format.",
)
} catch (error) {
setStatus("error")
setMessage(
isArabic ? "تعذر الاتصال. حاول مرة أخرى." :
error instanceof Error
? error.message
: "Could not connect. Please try again.",
)
}
}
const hint = kind === "saudi-id"
? isArabic ? "10 أرقام؛ الهوية تبدأ بـ 1 والإقامة بـ 2." : "10 digits; National IDs start with 1 and Iqama with 2."
: isArabic ? "رقم ضريبة القيمة المضافة السعودي: 15 رقماً، يبدأ وينتهي بـ 3." : "Saudi VAT number: 15 digits, starting and ending with 3."
return (
<form onSubmit={submit} dir={isArabic ? "rtl" : "ltr"} className="space-y-4">
<label className="block space-y-1">
<span>{isArabic ? "نوع التحقق" : "Validation type"}</span>
<select value={kind} disabled={status === "loading"} onChange={(event) => {
setKind(event.target.value as Kind)
setValue("")
setStatus("idle")
setMessage("")
}} className="block w-full rounded-md border bg-background px-3 py-2">
<option value="saudi-id">{isArabic ? "الهوية الوطنية / الإقامة" : "National ID / Iqama"}</option>
<option value="vat">{isArabic ? "رقم ضريبة القيمة المضافة" : "VAT number"}</option>
</select>
</label>
<label className="block space-y-1">
<span>{isArabic ? "الرقم" : "Number"}</span>
<input
value={value}
disabled={status === "loading"}
onChange={(event) => {
setValue(event.target.value)
setStatus("idle")
setMessage("")
}}
type="text"
inputMode="numeric"
autoComplete="off"
required
aria-describedby="ksa-validation-hint"
title={hint}
className="block w-full rounded-md border bg-background px-3 py-2 font-mono"
/>
<span id="ksa-validation-hint" className="text-sm text-muted-foreground">{hint}</span>
</label>
<button
type="submit"
disabled={status === "loading"}
className="rounded-md bg-primary px-4 py-2 text-primary-foreground disabled:opacity-60"
>
{status === "loading"
? isArabic ? "جارٍ التحقق…" : "Checking…"
: isArabic ? "تحقق" : "Validate"}
</button>
{message && (
<p
role="status"
aria-live="polite"
title={message}
className={status === "valid" ? "text-green-700" : status === "invalid" || status === "error" ? "text-destructive" : "text-muted-foreground"}
>
{message}
</p>
)}
</form>
)
}

Handling results

  • Show a pending state while the request is in flight, and prevent duplicate submits.
  • Use data.valid for the valid/invalid result; display Saudi ID errors or VAT reason as contextual guidance.
  • For non-2xx responses, inspect error.code and error.message. A 401 means the key is missing or invalid; 403 can indicate a missing scope; 429 means retry after the rate-limit window.
  • Network and upstream failures should offer a retry without treating the identifier as invalid. Do not log or retain full ID, Iqama, or VAT values unnecessarily.