.NET intake APIs, Azure Functions, and Windows services still receive South African ID numbers as plain strings. This C# validator mirrors CheckID's structural checks — length, calendar date, citizenship encoding, and Luhn — so you can reject impossible values in your own pipeline. It does not query a government identity database.
8001015009087 is a fictional, structurally valid demonstration number used throughout CheckID docs. Do not treat tutorial IDs as real people.
The algorithm, in the order CheckID applies it
A South African ID number is a 13-digit code defined in regulation 3 of the Identification Regulations, 1998. CheckID validates that structure in memory. The snippets on these pages follow the same order as validateIDNumber in production: date, gender sequence, citizenship, then Luhn. Digit 12 (the population-register index, usually 8) is not used as a validity gate.
| Positions | Segment | What to check |
|---|---|---|
| 1–6 | YYMMDD | Must be a real calendar date after century inference |
| 7–10 | SSSS | 0000–4999 female, 5000–9999 male (use the first of these four digits) |
| 11 | C | 0 SA citizen, 1 non-SA citizen, 2 refugee — reject anything else |
| 12 | A | Population-register index (usually 8). Not a fail/pass rule here |
| 13 | Z | Luhn check digit over the first 12 digits |
Date of birth and century inference
Digits 1–6 are YYMMDD, not a four-digit year. CheckID infers the century from today's two-digit year: if YY is less than or equal to the current year's last two digits, the year is 20YY; otherwise it is 19YY. In 2026 that means 26 becomes 2026 and 27 becomes 1927. Impossible dates such as 31 February are rejected before a Date object is trusted. Encoded dates in the future (age below zero) are also rejected.
That two-digit year rule is a real limitation: a person born in 1926 and a child born in 2026 share the same YY. The snippets document the same rule CheckID uses rather than inventing a third century.
Citizenship digit
Position 11 must be 0 (SA citizen), 1 (non-SA citizen / permanent resident encoding), or 2 (refugee). Any other digit fails validation even if the date and checksum look plausible.
Luhn checksum
Using 0-based indexes, double the digits at positions 1, 3, 5, 7, 9 and 11 (the 2nd, 4th, … 12th digits). If doubling produces two digits, add those digits together. Sum the undoubled odd-position digits (indexes 0, 2, 4, 6, 8, 10). The check digit is (10 − (total mod 10)) mod 10 and must equal digit 13. A full worked example for 8001015009087 is in the SA ID checksum guide. Changing only the last digit to produce 8001015009086 fails Luhn while the birth-date segment still reads as 1980-01-01.
Edge cases the snippets must catch
- Length and characters: after stripping spaces and hyphens, the value must be exactly 13 digits. Letters, punctuation, and scientific-notation damage from spreadsheets all fail. CheckID's hosted API expects a 13-digit path segment — do not send spaces in the URL.
- Impossible dates: month 13, day 32, 31 April, and 29 February in a non-leap year (for example YY
99→ 1999). Leap-year 29 February in 2000 is valid. - Citizenship: digits other than 0, 1, or 2.
- Luhn failures: transposed digits and a mistyped final digit, which are the usual keyboard errors.
- Whitespace and formatting: the local snippets strip spaces and hyphens as a courtesy. Production CheckID still requires 13 digits after your own normalisation.
For the digit map in more detail, use the South African ID number structure guide or the interactive explainer.
Working C# source
Pair the snippet with the synthetic fixtures on the SA ID validator and test cases page if you are writing unit tests. Copy the function below. It strips spaces and hyphens, then applies CheckID's date, citizenship, and Luhn rules. Keep it in your repo only if you are prepared to maintain century inference and leap-year behaviour yourself.
csharp
using System;
using System.Globalization;
using System.Linq;
using System.Text.RegularExpressions;
/// <summary>
/// Structural South African ID validation for .NET.
/// Example ID 8001015009087 is fictional and labelled for demonstration only.
/// Do not log or persist ID numbers.
/// </summary>
public static class SouthAfricanIdValidator
{
private static readonly Regex ThirteenDigits = new(@"^\d{13}$", RegexOptions.CultureInvariant);
public static SaIdValidationResult Validate(string input, DateTime? now = null)
{
var reference = now ?? DateTime.Today;
var id = Regex.Replace(input ?? string.Empty, @"[\s-]", string.Empty);
if (!ThirteenDigits.IsMatch(id))
{
return SaIdValidationResult.Fail("Enter exactly 13 digits.");
}
var yy = int.Parse(id.Substring(0, 2), CultureInfo.InvariantCulture);
var month = int.Parse(id.Substring(2, 2), CultureInfo.InvariantCulture);
var day = int.Parse(id.Substring(4, 2), CultureInfo.InvariantCulture);
var currentYearShort = reference.Year % 100;
var year = (yy <= currentYearShort ? 2000 : 1900) + yy;
if (!IsRealDate(year, month, day))
{
return SaIdValidationResult.Fail("YYMMDD is not a real calendar date.");
}
var dob = new DateTime(year, month, day);
var age = reference.Year - year;
if (reference.Month < month || (reference.Month == month && reference.Day < day))
{
age--;
}
if (age < 0)
{
return SaIdValidationResult.Fail("Encoded date of birth is in the future.");
}
var gender = (id[6] - '0') <= 4 ? "Female" : "Male";
var citizenship = id[10] switch
{
'0' => "SA Citizen",
'1' => "Non-SA Citizen",
'2' => "Refugee",
_ => null
};
if (citizenship is null)
{
return SaIdValidationResult.Fail("Citizenship digit must be 0, 1, or 2.");
}
var digits = id.Select(ch => ch - '0').ToArray();
var evenSum = new[] { 1, 3, 5, 7, 9, 11 }.Sum(i => DigitSum(digits[i] * 2));
var oddSum = new[] { 0, 2, 4, 6, 8, 10 }.Sum(i => digits[i]);
var checkDigit = (10 - ((evenSum + oddSum) % 10)) % 10;
if (digits[12] != checkDigit)
{
return SaIdValidationResult.Fail("Luhn checksum failed.");
}
return new SaIdValidationResult(true, id, dob.ToString("yyyy-MM-dd"), age, gender, citizenship, null);
}
private static bool IsRealDate(int year, int month, int day) =>
DateTime.TryParseExact(
year.ToString("0000") + month.ToString("00") + day.ToString("00"),
"yyyyMMdd",
CultureInfo.InvariantCulture,
DateTimeStyles.None,
out _);
private static int DigitSum(int value) => value.ToString().Sum(ch => ch - '0');
}
public sealed record SaIdValidationResult(
bool IsValid,
string? Id,
string? DateOfBirth,
int? Age,
string? Gender,
string? Citizenship,
string? Error)
{
public static SaIdValidationResult Fail(string error) =>
new(false, null, null, null, null, null, error);
}
// Demonstration only — fictional structurally valid ID
// Console.WriteLine(SouthAfricanIdValidator.Validate("8001015009087"));C#-specific notes
DateTime versus DateOnly
The snippet uses DateTime so it compiles on older .NET targets. On .NET 6+ you can swap the date-of-birth field to DateOnly without changing the century or Luhn rules.
Culture-invariant parsing
Digits are parsed with CultureInfo.InvariantCulture. Do not use the thread culture to parse YYMMDD; a comma decimal separator would corrupt the ID.
Logging
ILogger templates must not include the raw ID. Log a correlation id or a masked value instead. CheckID applies the same zero-storage rule on the hosted API.
Use CheckID's API instead of owning the snippet
A local function is enough for learning and for small internal tools. It becomes a liability when leap-year bugs, century collisions, or a copied blog snippet drift from the rules your product claims to use. CheckID exposes the same structural validation as a maintained REST endpoint: GET https://api.checkid.co.za/api/v1/validate/{idNumber} with Authorization: Bearer <token>. The JSON fields (isValid, dob, age, gender, citizenship) are documented in the developer guide — this page does not invent a second contract.
API Starter is R99/month for 1,000 single production calls. Professional is R299/month for bulk and team workflows, subject to fair usage. A free development test key is available with an account. Processing is in memory over TLS; CheckID does not store the ID number or the decoded result.
csharp · CheckID API
using var client = new HttpClient();
client.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", apiKey);
var response = await client.GetAsync($"https://api.checkid.co.za/api/v1/validate/{idNumber}");
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync();
// Fields: isValid, dob, age, gender, citizenship (see the developer guide)Walk through authentication and bulk requests in the 5-minute API tutorial, then compare quotas on pricing. Privacy-minded teams should also read POPIA and ID validation.
Other languages in this series
The algorithm above is shared. Each language page keeps its own title, runtime notes, and copy-paste source:
- Validate a South African ID number in JavaScript (Browser and Node.js)
- Validate a South African ID number in PHP (PHP 8+)
- Validate a South African ID number in Python (Python 3.10+)