Publicado el 16 de mayo de 2026·10 min de lectura

El costo oculto de los errores de mod-11 en tu onboarding LATAM

Módulo 11 es el algoritmo de dígito verificador más usado en identificadores tributarios de LATAM. Lo usan CUIT/CUIL (Argentina), CPF y CNPJ (Brasil), RUT (Chile), NIT (Colombia), RUC (Perú) y RIF (Venezuela). Pero esa frase es engañosa, porque "mod-11" no describe un algoritmo único — describe una familia de algoritmos. Cada país tiene su propia variante con su propia matriz de pesos, su propia regla para el caso resto=10, su propia regla para el caso de patrones uniformes, y su propia convención de capitalización. Las implementaciones caseras que tratan "mod-11" como un solo cálculo y solo cambian la longitud son las que generan onboardings rotos, falsos negativos en flujos B2B y batch jobs que rechazan empresas perfectamente válidas. Este post recorre las variantes país por país, los errores más frecuentes que vemos y una implementación de referencia open de cómo manejamos CUIT en Normadata.

Mod-11 no es uno — es una familia

El esqueleto común del módulo 11: multiplicás cada dígito del cuerpo por un peso de una matriz, sumás los productos, calculás suma mod 11, y aplicás una regla para convertir el resto en el dígito verificador. Lo que cambia entre países es: (a) qué dígitos forman el cuerpo, (b) qué matriz de pesos se usa, (c) qué hacer cuando el resto es 10 (algunos países lo convierten en K, otros en 0, otros declaran el ID inválido), (d) si el algoritmo se aplica una sola vez o dos veces (CPF y CNPJ usan dos pasos consecutivos), (e) si existen patrones uniformes que pasan el algoritmo pero son inválidos por regla del registro. Tratar todos estos como variantes triviales del mismo cálculo es la fuente de la mayoría de los bugs que vemos en código de producción.

Variantes principales país por país

Acá va la matriz concreta de las seis variantes mod-11 más comunes en LATAM, escritas como las usaríamos en una implementación:

  • CUIT (AR) — Cuerpo: 10 primeros dígitos (prefijo de 2 + número de 8). Pesos: [5,4,3,2,7,6,5,4,3,2]. Regla: check = 11 - (suma mod 11). Si check = 11 → DV = 0. Si check = 10 → CUIT inválido con ese prefijo (AFIP re-emite con prefijo alternativo 23/24/33/34).
  • CPF (BR) — Dos DVs. Primer DV: cuerpo = 9 dígitos base, pesos = [10,9,8,7,6,5,4,3,2]. suma mod 11; si resto < 2 → DV = 0, si no → DV = 11 - resto. Segundo DV: cuerpo = 10 dígitos (base + primer DV), pesos = [11,10,9,8,7,6,5,4,3,2]. Misma regla. Además: los 10 patrones de todos los dígitos iguales (000.000.000-00 a 999.999.999-99) son inválidos por regla de la Receita aunque pasen el algoritmo.
  • CNPJ (BR) — Dos DVs. Primer DV: cuerpo = 12 dígitos base, pesos = [5,4,3,2,9,8,7,6,5,4,3,2]. Notar que los pesos no son monotónicamente descendentes — bajan a 2 y luego saltan a 9. Segundo DV: cuerpo = 13 dígitos (base + primer DV), pesos = [6,5,4,3,2,9,8,7,6,5,4,3,2]. Misma regla de all-equal que CPF.
  • RUT (CL) — Cuerpo: dígitos del RUT excluyendo el DV. Pesos cíclicos [2,3,4,5,6,7] aplicados desde la derecha. suma mod 11. Si resto = 11 → DV = 0. Si resto = 10 → DV = 'K' (mayúscula). Otro resto → DV = 11 - resto.
  • NIT (CO) — Cuerpo: hasta 15 dígitos base (típicamente 9). Pesos primos [3,7,13,17,19,23,29,37,41,43,47,53,59,67,71] aplicados desde la derecha. Regla: si resto < 2 → DV = resto, si no → DV = 11 - resto.
  • RUC (PE) — Cuerpo: 10 primeros dígitos. Pesos: [5,4,3,2,7,6,5,4,3,2]. Mismos pesos que CUIT pero distinta semántica (RUC empieza con 10 para naturales o 20 para jurídicas). suma mod 11; resto en {0,1} → DV = 0/1 según convención SUNAT, otros → DV = 11 - resto.

Errores comunes en implementaciones caseras

Estos son los bugs que más vemos en code review de implementaciones internas de validación:

  • Olvidar que CPF y CNPJ rechazan all-equal patterns — Un CPF como 111.111.111-11 pasa el algoritmo mod-11 matemáticamente, pero la Receita Federal nunca lo emitió y todo validador correcto lo debe rechazar. Implementaciones caseras frecuentemente lo dejan pasar porque solo testean el algoritmo en abstracto.
  • Confundir K mayúscula vs minúscula en RUT — La convención SII es K mayúscula. Algunos sistemas almacenan k minúscula, otros la convierten a 10. Si comparás strings directos, 11.222.333-K vs 11.222.333-k vs 11.222.333-10 dan resultados distintos. Siempre normalizá a mayúscula antes de comparar.
  • Aplicar el algoritmo de CUIT al CUIL (o viceversa) — Mismo algoritmo, distinta semántica. Un código que rechaza un CUIL por "no es CUIT" o que valida cualquier CUIT como CUIL sin contexto está mal en el momento equivocado. Tener un campo "tipo" separado del valor.
  • Calcular DV de CNPJ con pesos de CPF — Los pesos del primer DV de CNPJ son [5,4,3,2,9,8,7,6,5,4,3,2], no una extensión monotónica de los pesos de CPF. Esta confusión es sorprendentemente frecuente en code copiado de StackOverflow.
  • No manejar el caso resto = 10 correctamente — Para CUIT, resto = 10 significa CUIT inválido con ese prefijo (no DV = 10). Para RUT, significa DV = 'K'. Tratar ambos casos como DV = 10 numérico produce IDs imposibles.
  • Validar con regex de longitud sin chequear DV — Una regex que solo verifica `^\d{11}$` para CPF rechaza CPFs con puntos válidos y acepta strings como 12345678901 que matemáticamente no son válidos. La regex no reemplaza el cálculo.
  • Usar integers para el valor — JavaScript / Python tratan ceros a la izquierda como significativos solo en strings. Convertir el ID a número pierde ceros iniciales y rompe RUTs de 7 dígitos o RUCs que empiezan con cero (raro pero existe en algunos países).
  • No normalizar separadores antes de validar — Un CUIT puede llegar como 30-50001091-2, 30.50001091.2, 30 50001091 2, o 30500010912. Todos son el mismo identificador. Un strip de no-dígitos (excepto K para RUT) antes de calcular es obligatorio.
  • Ignorar el orden de iteración de los pesos — Algunos algoritmos aplican los pesos de izquierda a derecha (CUIT), otros de derecha a izquierda (NIT, RUT). Reverse del array vs iteración inversa son cosas distintas y producen DVs distintos.

Cómo Normadata maneja todas las variantes

En el catálogo interno (lib/catalog/validator-data.ts) cada país × identifier mapea a un descriptor con su algoritmo específico, su matriz de pesos, sus reglas de borde y sus patrones inválidos por regla del registro. El validador client-side (los tools en /tools) mirrorea exactamente la lógica del backend — no es una aproximación, es la misma función ejecutada en JavaScript en el browser. Eso significa que el mismo input que el backend valida como inválido, el frontend lo marca como inválido instantáneamente, sin round-trip a la API. La fuente de verdad es una sola.

Caveat estructural: el DV no confirma registro

Un punto que vale repetir: validar matemáticamente un dígito verificador es validar estructura. No es validar que el identificador exista en AFIP, Receita Federal, SAT, SII, DIAN, SUNAT o SENIAT. Un CPF estructuralmente válido puede ser de una persona que no existe, de una persona que ya falleció, o de una persona viva pero cuyo CPF fue cancelado. Para confirmar registro o estado activo, necesitás un servicio que efectivamente consulte el registro oficial — y ese servicio es independiente de Normadata. Lo que sí podés afirmar después de la validación de Normadata es que el identificador podría existir: cumple toda la estructura matemática y semántica que un identificador real cumple. Esa es la garantía explícita; la inversa (que existe) no la podemos hacer sin tocar el registro.

Implementación de referencia (TypeScript)

Acá va cómo se ve la implementación de CUIT y CPF en TypeScript, escrita con claridad sobre concisión para que sirva como referencia. La idea es que si vas a implementar mod-11 caseramente, al menos uses estas como punto de partida y no algo bajado de StackOverflow sin entender los edge cases.

TypeScript — validación de CUIT (mod-11 con pesos [5,4,3,2,7,6,5,4,3,2])
const CUIT_WEIGHTS = [5, 4, 3, 2, 7, 6, 5, 4, 3, 2] as const;
const VALID_PREFIXES = new Set(["20", "23", "24", "27", "30", "33", "34"]);

export function isValidCuit(input: string): boolean {
  const digits = input.replace(/\D/g, "");
  if (digits.length !== 11) return false;

  const prefix = digits.slice(0, 2);
  if (!VALID_PREFIXES.has(prefix)) return false;

  const body = digits.slice(0, 10).split("").map(Number);
  const dv = Number(digits[10]);

  let sum = 0;
  for (let i = 0; i < 10; i++) {
    sum += body[i] * CUIT_WEIGHTS[i];
  }

  const remainder = sum % 11;
  const check = 11 - remainder;

  if (check === 11) return dv === 0;
  if (check === 10) return false; // CUIT invalido con ese prefijo
  return dv === check;
}
TypeScript — validación de CPF (doble mod-11 + rechazo de all-equal)
function calcCpfCheckDigit(digits: number[], weights: number[]): number {
  let sum = 0;
  for (let i = 0; i < digits.length; i++) {
    sum += digits[i] * weights[i];
  }
  const remainder = sum % 11;
  return remainder < 2 ? 0 : 11 - remainder;
}

export function isValidCpf(input: string): boolean {
  const digits = input.replace(/\D/g, "");
  if (digits.length !== 11) return false;

  // Regla Receita: rechazar patrones con los 11 digitos iguales.
  if (/^(\d)\1{10}$/.test(digits)) return false;

  const nums = digits.split("").map(Number);
  const base = nums.slice(0, 9);

  const dv1 = calcCpfCheckDigit(base, [10, 9, 8, 7, 6, 5, 4, 3, 2]);
  if (dv1 !== nums[9]) return false;

  const base2 = nums.slice(0, 10);
  const dv2 = calcCpfCheckDigit(base2, [11, 10, 9, 8, 7, 6, 5, 4, 3, 2]);
  return dv2 === nums[10];
}
cURL — validar un CPF (incluye el rechazo del patrón all-equal)
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.111.111-11",
    "country": "BR",
    "type": "cpf"
  }
cURL — validar un CUIT con prefijo alternativo
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": "23-25861053-4",
    "country": "AR",
    "type": "cuit"
  }

Conclusión

Si tu producto opera en un solo país de LATAM, escribir y mantener una implementación correcta de mod-11 para ese país es totalmente factible — usá las referencias de arriba como punto de partida y testealos contra casos conocidos en producción. Si operás en dos o más países, la pregunta no es si vale la pena escribir N implementaciones caseras; es si vale la pena mantenerlas todas en sincronía cuando cada país publica edge cases nuevos. El stack típico — N variantes × M lenguajes × bugs de borde — escala mal. La alternativa es delegar el algoritmo a Normadata via /v1/verify/tax-id (mismo endpoint, todos los países) o, si preferís quedarte con OSS, basarse en las librerías canónicas mantenidas (como python-stdnum para Python). Para la referencia completa del endpoint, ver /docs/api/verify-tax-id. Para la cobertura por país e identificador, /coverage. Para la guía completa de tax IDs LATAM, /blog/latam-tax-ids-developer-guide.

¿Listo para empezar a construir?

Solicitá tu accesoLeer documentación

Artículos relacionados

5 de mayo de 2026Tax IDs en Latinoamérica: guía para desarrolladores5 de mayo de 2026Validar CUIT en Argentina: algoritmo, formato y API5 de mayo de 2026Validación de CPF: algoritmo, ejemplos y API REST5 de mayo de 2026RFC vs CURP en México: cuando usar cada uno15 de marzo de 2026Cómo validar un número de CUIT con una API1 de abril de 2026Validación de CPF: Formato, algoritmo e integración con API para Brasil2 de abril de 2026RFC en México: Formato, estructura y validación para desarrolladores10 de marzo de 2026La guía completa de tax IDs en Latinoamérica1 de marzo de 2026Mejores prácticas para integrar APIs de terceros en aplicaciones de LATAM11 de mayo de 2026Cuánto presupuesto KYC se te va en data malformada (y cómo medirlo)16 de mayo de 2026Cómo validar todos los tax IDs de LATAM con una sola API16 de mayo de 2026Por qué pre-validar datos antes del KYC te ahorra dinero — con números16 de mayo de 2026Construyendo un formulario de checkout consciente de LATAM