DEV Community

Matias Pengue
Matias Pengue

Posted on

How check digits work for Latin American IDs (CUIT, CPF, RFC) — and an API that validates 19 of them

If you've built a signup, checkout or invoicing flow for Latin America, you've met this problem: every country has its own tax ID, national ID and bank account format, and almost all of them carry a check digit that catches typos before they reach your database.

The algorithms are public but scattered across tax-authority PDFs, and the snippets that float around in gists often get edge cases wrong. Here's how three of the most common ones actually work, followed by a free API I built that does all 19 types I needed.

CUIT / CUIL (Argentina): mod 11 with fixed weights

A CUIT has 11 digits: 2-digit prefix (20/23/24/27 for people, 30/33/34 for companies), 8-digit body (the DNI, for people), and 1 check digit.

Multiply the first 10 digits by 5,4,3,2,7,6,5,4,3,2, sum, and compute 11 − (sum % 11). A result of 11 becomes 0. A result of 10 is never valid: AFIP assigns prefix 23/33 instead to avoid it.

def cuit_ok(c: str) -> bool:
    w = [5, 4, 3, 2, 7, 6, 5, 4, 3, 2]
    dv = 11 - sum(int(a) * b for a, b in zip(c[:10], w)) % 11
    return dv != 10 and (0 if dv == 11 else dv) == int(c[10])

cuit_ok("20123456786")  # True
cuit_ok("20123456780")  # False
Enter fullscreen mode Exit fullscreen mode

Peru's RUC uses the same weights but maps 10 → 0 and 11 → 1, and Chile's RUT walks right-to-left with cyclic weights 2..7, mapping 10 to K. They're from the same family, but you can't copy one into another.

CPF (Brazil): two check digits

A CPF has 9 base digits + 2 check digits. The first uses weights 10..2, the second uses 11..2 over the first 10 digits. In both cases the digit is (sum * 10) % 11, and 10 becomes 0. Sequences of one repeated digit like 111.111.111-11 pass the math but are explicitly invalid.

def cpf_ok(c: str) -> bool:
    if len(set(c)) == 1:
        return False
    for n in (9, 10):
        s = sum(int(c[i]) * (n + 1 - i) for i in range(n))
        if (s * 10) % 11 % 10 != int(c[n]):
            return False
    return True

cpf_ok("11144477735")  # True
Enter fullscreen mode Exit fullscreen mode

CNPJ follows the same two-digit idea with different weights. Since 2026 it can also be alphanumeric: each character's value is ord(ch) - 48, so A = 17.

RFC (Mexico): letters, & and Ñ

A personal RFC is 4 letters + birth date (YYMMDD) + 3-character homoclave, where the last character is the check digit. Each character is mapped through a 39-symbol dictionary:

D = "0123456789ABCDEFGHIJKLMN&OPQRSTUVWXYZ Ñ"

def rfc_ok(r: str) -> bool:
    r = r.upper().rjust(13)          # company RFCs (12 chars) are left-padded
    s = sum(D.index(ch) * (13 - i) for i, ch in enumerate(r[:12]))
    dv = 11 - s % 11
    expected = "0" if dv == 11 else "A" if dv == 10 else str(dv)
    return r[12] == expected

rfc_ok("GODE561231GR8")  # True
Enter fullscreen mode Exit fullscreen mode

The & and Ñ in the dictionary are where hand-rolled validators usually fail.

The other ones

  • CLABE (MX) and CBU/CVU (AR) use weighted mod 10 (3,7,1 for CLABE; two blocks for CBU)
  • CURP (MX) maps A=10 … Ñ=24 … Z=36, weights 18..2, mod 10
  • IBAN: move the first 4 chars to the end, letters become numbers, mod 97 == 1

A valid check digit means the number is well-formed, not that it exists. Only the tax authority can tell you that. Still, it catches most typos at input time, with no network round-trip to a government service.

Using the API

I packaged all of this as ValidaLatam, a stateless API that validates and formats 19 types: AR_CUIT, AR_CUIL, AR_DNI, AR_CBU, AR_CVU, BR_CPF, BR_CNPJ, MX_RFC, MX_CURP, MX_CLABE, CL_RUT, CO_NIT, PE_RUC, UY_RUT, UY_CI, EC_CEDULA, EC_RUC, PY_RUC and IBAN. It's deterministic, stores nothing, and has CORS open. Docs: https://validalatam.ia-latam.app

curl

curl "https://validalatam.ia-latam.app/validate/AR_CUIT?value=20-12345678-6"
Enter fullscreen mode Exit fullscreen mode
{"valid":true,"type":"AR_CUIT","country":"AR","input":"20-12345678-6","normalized":"20123456786","formatted":"20-12345678-6","error":null,"details":{"entity":"person","dni":"12345678"}}
Enter fullscreen mode Exit fullscreen mode

If you don't know which type you have, use AUTO:

curl -X POST https://validalatam.ia-latam.app/validate \
  -H 'content-type: application/json' \
  -d '{"type":"AUTO","value":"11.222.333/0001-81"}'
Enter fullscreen mode Exit fullscreen mode
{"valid":true,"type":"BR_CNPJ","country":"BR","input":"11.222.333/0001-81","normalized":"11222333000181","formatted":"11.222.333/0001-81","error":null,"details":{"branch":"0001","alphanumeric":false}}
Enter fullscreen mode Exit fullscreen mode

Invalid values return valid: false with a reason:

{"valid":false,"type":"AR_CUIT","country":"AR","input":"20123456780","normalized":"20123456780","formatted":null,"error":"check digit mismatch","details":null}
Enter fullscreen mode Exit fullscreen mode

Python (batch)

import requests

r = requests.post("https://validalatam.ia-latam.app/batch", json={"items": [
    {"type": "CL_RUT", "value": "11.111.111-1"},
    {"value": "002010077777777771", "country": "MX"},   # AUTO + country hint
    {"type": "BR_CPF", "value": "111.111.111-11"},
]})
for res in r.json()["results"]:
    print(res["type"], res["valid"], res["formatted"] or res["error"])
Enter fullscreen mode Exit fullscreen mode
CL_RUT True 11.111.111-1
MX_CLABE True 002 010 07777777777 1
BR_CPF False repeated digits are invalid
Enter fullscreen mode Exit fullscreen mode

The batch endpoint takes up to 1000 items.

JavaScript (browser or Node)

Because CORS is open, you can call it straight from a form:

const res = await fetch(
  "https://validalatam.ia-latam.app/validate/MX_RFC?value=GODE561231GR8"
);
const { valid, formatted, error } = await res.json();
console.log(valid ? `OK: ${formatted}` : `Invalid: ${error}`);
Enter fullscreen mode Exit fullscreen mode

Test data

GET /generate/{type}?count=N&seed=S returns synthetic, valid-looking values for fixtures. Each one is flagged "fake": true.

curl "https://validalatam.ia-latam.app/generate/BR_CPF?count=2&seed=42"
# {"fake":true,"items":[{"value":"15860461607","formatted":"158.604.616-07",...}, ...]}
Enter fullscreen mode Exit fullscreen mode

For production use with a key and rate limits, there's a RapidAPI listing with a free tier (500 req/month): https://rapidapi.com/MatiasPengue/api/validalatam

For AI agents: MCP server

LLMs are bad at check digits. They'll confidently tell you a CPF is valid. If you use Claude Desktop, Cursor or another MCP client, you can give the agent a deterministic tool instead:

{
  "mcpServers": {
    "validalatam": { "command": "uvx", "args": ["validalatam-mcp"] }
  }
}
Enter fullscreen mode Exit fullscreen mode

Source: https://github.com/elmatis172/validalatam-mcp (it's also listed in the official MCP registry). Then you can ask things like "validate these 50 CUITs from the spreadsheet and format the valid ones", and the agent calls the tool instead of guessing.

There's also an Apify actor for bulk jobs: https://apify.com/ialatam/latam-document-validator

Wrapping up

If you only need one document, the snippets above are enough. Copy them and add tests with known-good values. If you deal with several countries, or want formatting plus AUTO detection, the API may save you some time.

I'm Matias, a developer from Argentina. Which document should I add next? Bolivia's NIT, Venezuela's RIF and Costa Rica's cédula are on my list. Bug reports on edge cases are very welcome.

Top comments (0)