Cómo validar todos los tax IDs de LATAM con una sola API
Si tu producto opera en más de un país de LATAM, eventualmente vas a chocar contra el mismo problema: cada identificador tributario tiene su propio formato, su propia autoridad emisora y su propio algoritmo de dígito verificador. Lo típico es terminar con una librería distinta por país, multiplicada por cada lenguaje de tu stack: una para CPF en Node, otra equivalente en Python para el batch job, una tercera en Go para el microservicio de pagos. Cada una se mantiene en un ritmo distinto, cada una tiene sus propios bugs y cada una expresa el resultado de forma diferente. Este post muestra cómo unificar los 17 identificadores de los 10 países en vivo en Sudamérica detrás de un solo endpoint REST de Normadata. Vamos a recorrer la tabla por país, los algoritmos resumidos, ejemplos en cURL, TypeScript y Python, performance esperada y cómo se ve la migración desde librerías open-source.
El problema: N librerías × M lenguajes
Un equipo típico que cubre Argentina, Brasil y Chile termina con un combo parecido a este: `cpf-cnpj-validator` y `validador-cuit` en Node, `python-stdnum` para el batch job nocturno, una regex casera para RUT chileno porque no encontraron una librería confiable, y un script en Go que reimplementa CPF a mano porque la librería oficial está abandonada desde hace dos años. Cada una usa un nombre distinto para el resultado (`isValid`, `valid`, `ok`, `validate()`), cada una tiene un comportamiento distinto frente a inputs con basura (algunas tiran excepción, otras devuelven `false`, otras devuelven `null`), y cada una se actualiza en un ciclo de release distinto. Cuando agregás Colombia, México y Perú, la matriz se vuelve inmanejable. La idea de Normadata es simple: un endpoint, una forma del request, una forma de la respuesta, todos los países.
Los 10 países en vivo y sus identificadores
La cobertura actual cubre 10 países sudamericanos en vivo. Esta es la lista de identificadores principales por país:
- Argentina (AR) — CUIT, CUIL, DNI, CBU, CVU. CUIT/CUIL: 11 dígitos con dígito verificador módulo 11. DNI: 7-8 dígitos sin DV, validado por longitud y rango. CBU: 22 dígitos con dos DVs por bloque. CVU: 22 dígitos siguiendo la estructura de CBU emitida por PSPs.
- Brasil (BR) — CPF, CNPJ, IBAN (cuando aplica). CPF: 11 dígitos con DV módulo 11 en dos pasos. CNPJ: 14 dígitos con DV módulo 11 en dos pasos.
- Chile (CL) — RUT, RUN. 7-9 dígitos + char verificador (0-9 o K) por módulo 11.
- Colombia (CO) — NIT, Cédula. NIT: 9 dígitos + DV módulo 11 con pesos primos.
- Uruguay (UY) — RUT, Cédula. RUT: 12 dígitos con DV.
- Paraguay (PY) — RUC. 6-8 dígitos + DV módulo 11.
- Perú (PE) — RUC, DNI, CUI. RUC: 11 dígitos empezando con 10 (natural) o 20 (jurídica), DV módulo 11.
- Ecuador (EC) — RUC, Cédula CI. CI: 10 dígitos con DV módulo 10.
- Bolivia (BO) — NIT. 7-12 dígitos.
- Venezuela (VE) — RIF, Cédula. RIF: prefijo V/E/J/G/C + 9 dígitos + DV.
Algoritmos resumidos
Aunque la familia de algoritmos es similar (la mayoría son variantes de módulo 11), cada país tiene su propia matriz de pesos y reglas de borde. Lo importante es no confundirlos. A continuación, las variantes principales:
- CUIT/CUIL (AR) — Módulo 11 ponderado con pesos [5,4,3,2,7,6,5,4,3,2] sobre los 10 primeros dígitos. Si el resultado es 10, el CUIT es inválido con ese prefijo (AFIP re-emite con un prefijo alternativo). Si es 11, el DV es 0.
- CPF (BR) — Doble módulo 11. Primer DV: pesos [10,9,8,7,6,5,4,3,2] sobre los 9 dígitos base. Segundo DV: pesos [11,10,9,8,7,6,5,4,3,2] sobre los 10 dígitos (base + primer DV). Resto < 2 → DV = 0; si no, DV = 11 - resto. Además: los CPF con los 11 dígitos iguales (000.000.000-00 a 999.999.999-99) son inválidos por regla de la Receita Federal aunque pasen el algoritmo.
- CNPJ (BR) — Doble módulo 11 con pesos [5,4,3,2,9,8,7,6,5,4,3,2] y [6,5,4,3,2,9,8,7,6,5,4,3,2]. Misma regla de all-equal.
- RUT (CL) — Módulo 11 con pesos cíclicos [2,3,4,5,6,7] desde la derecha. Si resto = 11 → DV = 0. Si resto = 10 → DV = K (mayúscula). Cualquier otro resto → DV = 11 - resto.
- NIT (CO) — Módulo 11 con pesos primos [3,7,13,17,19,23,29,37,41,43,47,53,59,67,71] aplicados desde la derecha sobre los 9 dígitos base.
- RUC (PE) — Módulo 11 con pesos [5,4,3,2,7,6,5,4,3,2] sobre los 10 primeros dígitos (mismos pesos que CUIT, distinta semántica).
- RIF (VE) — Módulo 11 con pesos específicos de SENIAT y un valor inicial dependiente de la letra de tipo (V/E/J/G/C).
- Cédula EC (CI) — Módulo 10 (algoritmo distinto a los anteriores) con pesos [2,1,2,1,2,1,2,1,2] sobre los 9 primeros dígitos.
- CBU (AR) — Dos dígitos verificadores, uno por bloque (8 dígitos del banco/sucursal, 14 dígitos de la cuenta), cada uno con su propia matriz de pesos.
Cómo Normadata unifica todo: un endpoint, un envelope
El endpoint canónico es POST /v1/verify/tax-id. Recibe tres campos en el body: country (código ISO de 2 letras), type (slug del identificador) y value (el string a validar, con o sin separadores). Devuelve un envelope plano: valid, country, type, value.raw, value.formatted y un metadata específico del identificador. Auth es X-API-Key con prefijo nd_. Veamos los mismos tres países que mencionamos al principio:
curl -X POST https://api.normadata.io/v1/verify/tax-id \
-H "X-API-Key: nd_your_key_here" \
-H "Content-Type: application/json" \
-d {
"value": "30-50001091-2",
"country": "AR",
"type": "cuit"
}curl -X POST https://api.normadata.io/v1/verify/tax-id \
-H "X-API-Key: nd_your_key_here" \
-H "Content-Type: application/json" \
-d {
"value": "111.444.777-35",
"country": "BR",
"type": "cpf"
}curl -X POST https://api.normadata.io/v1/verify/tax-id \
-H "X-API-Key: nd_your_key_here" \
-H "Content-Type: application/json" \
-d {
"value": "12.345.678-5",
"country": "CL",
"type": "rut"
}type TaxIdInput = { country: string; type: string; value: string };
type TaxIdResult = {
valid: boolean;
country: string;
type: string;
value: { raw: string; formatted: string };
metadata?: Record<string, unknown>;
};
async function verifyTaxId(input: TaxIdInput): Promise<TaxIdResult> {
const res = await fetch("https://api.normadata.io/v1/verify/tax-id", {
method: "POST",
headers: {
"X-API-Key": process.env.NORMADATA_API_KEY!,
"Content-Type": "application/json",
},
body: JSON.stringify(input),
});
if (!res.ok) {
throw new Error(`Normadata error: ${res.status}`);
}
return res.json();
}const inputs: TaxIdInput[] = [
{ country: "AR", type: "cuit", value: "30-50001091-2" },
{ country: "BR", type: "cpf", value: "111.444.777-35" },
{ country: "BR", type: "cnpj", value: "11.222.333/0001-81" },
{ country: "CL", type: "rut", value: "12.345.678-5" },
{ country: "CO", type: "nit", value: "900123456-7" },
{ country: "PE", type: "ruc", value: "20100070970" },
];
const results = await Promise.all(inputs.map(verifyTaxId));
for (const r of results) {
console.log(r.country, r.type, r.valid, r.value.formatted);
}import os
import requests
API_KEY = os.environ["NORMADATA_API_KEY"]
URL = "https://api.normadata.io/v1/verify/tax-id"
def verify_tax_id(country: str, id_type: str, value: str) -> dict:
response = requests.post(
URL,
headers={
"X-API-Key": API_KEY,
"Content-Type": "application/json",
},
json={"country": country, "type": id_type, "value": value},
timeout=2.0,
)
response.raise_for_status()
return response.json()
if __name__ == "__main__":
print(verify_tax_id("AR", "cuit", "30-50001091-2"))
print(verify_tax_id("BR", "cpf", "111.444.777-35"))
print(verify_tax_id("CL", "rut", "12.345.678-5"))Performance esperada
El endpoint de validación de tax IDs sirve respuestas en menos de 30 ms p95 medido desde São Paulo. La validación es matemática pura — no hay consulta a registros, no hay round-trip a AFIP/Receita/SAT/SII — por lo que la latencia depende casi enteramente de la red y de la región del cliente. Si tu app vive en la misma región AWS que el API, lo razonable es agregar entre 1 y 5 ms por la deserialización JSON y nada más. Para batch jobs o flujos mobile-first donde la latencia importa menos, la disponibilidad es lo que mueve la aguja: una API caída del lado del proveedor para un país específico no detiene los otros 9, porque todo corre detrás del mismo endpoint.
Migración desde librerías OSS
Si ya tenés librerías OSS instaladas, la migración rara vez es un big-bang. El patrón típico es introducir Normadata como una capa de abstracción y migrar país por país a medida que las librerías OSS dan problemas. Lo que cambia en tu code:
- Una sola dependencia HTTP — En lugar de tener `cpf-cnpj-validator`, `validador-cuit`, `rut-helpers` y un script casero de RUC en tu package.json, tu código solo necesita fetch (o requests, o net/http). Las librerías se pueden retirar gradualmente.
- Forma de respuesta unificada — En vez de mapear `lib.isValid()` vs `lib.validate().ok` vs `lib.check() === true`, todas las llamadas devuelven el mismo envelope con `valid: boolean`. Tu capa de aplicación deja de tener if/else por librería.
- Normalización integrada — Las librerías OSS rara vez normalizan el output. Normadata devuelve `value.formatted` con el formato canónico (puntos para CPF, guiones para CUIT, K mayúscula para RUT) que podés guardar en la DB directamente.
- Metadata estructurado — `entity_type`, `prefix`, `check_digit`, `region_code` se devuelven en `metadata`. Sin parsear strings vos mismo.
- Caching local — Como el envelope es estable, podés cachear localmente por `country:type:value` con un TTL razonable (24h o más) si te preocupan los round-trips. La validación es idempotente.
- Manejo de errores HTTP — Los errores se vuelven 422 (input inválido por formato), 400 (request malformado), 429 (rate limit) y 5xx (Normadata-side). Una sola tabla de mapeo en lugar de N tipos de excepción.
Próximos pasos
Si ya estás manteniendo más de dos librerías de validación de tax IDs, vale la pena al menos prototipar el reemplazo. Para empezar, leé la referencia del endpoint en /docs/api/verify-tax-id y la cobertura completa por país en /coverage. Para acceso a la API durante el período de acceso anticipado, solicitá una key en /waitlist. Si tu caso de uso específico es pre-validación antes de KYC, leé también /blog/kyc-budget-malformed-data.