Publicado em 16 de maio de 2026·9 min de leitura

Construindo um formulário de checkout consciente da LATAM

O formulário de checkout é o lugar onde a fricção da UX se traduz diretamente em conversão perdida. Na LATAM, grande parte dessa fricção vem de validadores rígidos que rejeitam inputs estruturalmente corretos só porque o formato não bate com a regex que o dev usou. Um CPF copiado de um email vem com pontos. Um CUIT colado de uma fatura tem hífens. Um telefone digitado num teclado mobile tem espaços involuntários. Todos esses são inputs perfeitamente válidos a nível humano e a nível matemático — mas um validador que só aceita o formato canônico os descarta. Este post mostra como montar um formulário de checkout para LATAM que seja permissivo no input, estrito na validação e comunicativo nos erros, usando a Normadata como camada de validação async.

O problema UX: validadores rígidos rejeitam inputs LATAM válidos

Quando um usuário digita ou cola seu CPF, não está pensando nele como uma string a normalizar. Está pensando nele como seu CPF: o número que viu na sua carteira de identidade uma hora atrás, copiado tal como de um PDF da Receita Federal, formatado com pontos e hífens porque assim foi impresso. Um validador que rejeita esse input com um genérico "formato inválido" obriga o usuário a apagar e reescrever — o que aumenta a probabilidade de typo e a frustração. A regra mental para uma UX permissiva é: aceite qualquer representação textual que represente inequivocamente o mesmo identificador, normalize do lado do servidor (ou cliente) e dê ao usuário feedback positivo quando o input já é válido, sem obrigar a reformatá-lo.

Casos típicos de input na LATAM

Estes são os padrões mais comuns que vemos em formulários de checkout LATAM:

  • CPF com pontos copy-paste — 111.444.777-35 colado de um email. Seu validador deve aceitá-lo igual a 11144477735.
  • CUIT com ou sem hífens — Tanto 30-50001091-2 quanto 30500010912 são representações válidas do mesmo CUIT.
  • RUT chileno com K maiúsculo ou minúsculo — 11.222.333-K deveria ser tratado igual a 11222333k. Converter para maiúsculo antes de validar.
  • Telefone mobile com espaços involuntários — O usuário digita +54 11 4321 5678 vs +5491143215678. Seu form deve striapar separadores.
  • Telefone sem código de país — Num form já geocontextualizado, 11 4321 5678 sem +54 é razoável. Inferir o código do país do checkout.
  • Email com tildes no domínio (IDN) — Embora raro, domínios LATAM internacionais (.com.ar, .com.br) ocasionalmente recebem tildes na TLD via punycode.
  • Endereço com caracteres especiais — Av. Corrientes 1234 - 4°B contém um sinal grau válido.
  • Keyboard mobile insere caracteres não visíveis — Tap-and-hold pode inserir spaces, soft-hyphens ou zero-width chars.

O padrão Normadata: on-blur + debounce 300ms

O padrão que recomendamos é validar on-blur (quando o usuário sai do campo) com um debounce de 300ms para evitar requests intermediários enquanto o usuário continua digitando. Validar on-change a cada keystroke gera tráfego demais; validar só on-submit deixa o usuário sem feedback até o final. O sweet spot é validar quando o usuário indicou que terminou (blur) mais um debounce mínimo para coalescer eventos rápidos. Vamos ver o padrão em React com useDeferredValue + um hook custom:

React + TypeScript — campo de CPF validado on-blur com debounce
import { useState, useEffect, useRef } from "react";

type ValidationState =
  | { status: "idle" }
  | { status: "validating" }
  | { status: "valid"; formatted: string }
  | { status: "invalid"; reason: string };

function useTaxIdValidator(country: string, type: string) {
  const [state, setState] = useState<ValidationState>({ status: "idle" });
  const debounceRef = useRef<ReturnType<typeof setTimeout>>();

  const validate = (value: string) => {
    clearTimeout(debounceRef.current);
    if (!value) {
      setState({ status: "idle" });
      return;
    }

    debounceRef.current = setTimeout(async () => {
      setState({ status: "validating" });
      try {
        const res = await fetch("/api/validate-tax-id", {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ country, type, value }),
        });
        const data = await res.json();
        if (data.valid) {
          setState({ status: "valid", formatted: data.value.formatted });
        } else {
          setState({
            status: "invalid",
            reason: data.metadata?.reason ?? "format_invalid",
          });
        }
      } catch {
        setState({ status: "idle" });
      }
    }, 300);
  };

  return { state, validate };
}

export function CpfField() {
  const [value, setValue] = useState("");
  const { state, validate } = useTaxIdValidator("BR", "cpf");

  useEffect(() => {
    if (state.status === "valid") {
      setValue(state.formatted);
    }
  }, [state]);

  return (
    <div>
      <label htmlFor="cpf">CPF</label>
      <input
        id="cpf"
        type="text"
        value={value}
        onChange={(e) => setValue(e.target.value)}
        onBlur={() => validate(value)}
        aria-invalid={state.status === "invalid"}
      />
      {state.status === "valid" && <span role="status">CPF valido</span>}
      {state.status === "invalid" && (
        <span role="alert">{mapReasonToMessage(state.reason)}</span>
      )}
    </div>
  );
}

function mapReasonToMessage(reason: string): string {
  switch (reason) {
    case "all_equal_digits":
      return "Esse CPF tem todos os digitos iguais — nao e um CPF valido.";
    case "invalid_check_digit":
      return "O digito verificador nao bate. Talvez um typo?";
    case "invalid_length":
      return "Um CPF deve ter 11 digitos.";
    default:
      return "Esse CPF nao parece valido. Reveja.";
  }
}

Mostrar o normalized ao usuário como positive feedback

Quando o input passa na validação, mostrar o valor normalizado no field (não só um check verde) dá ao usuário dois benefícios: confirma visualmente que o sistema entendeu corretamente o input dele e oferece uma versão canônica para copiar para outro lugar se precisar. O truque é não sobrescrever o valor enquanto o usuário continua editando — só depois de um onBlur bem-sucedido. Exemplo com um campo de CUIT:

React — campo de CUIT com normalização visível pós-blur
import { useState, useRef } from "react";

export function CuitField() {
  const [value, setValue] = useState("");
  const [validated, setValidated] = useState<{
    valid: boolean;
    formatted?: string;
    reason?: string;
  } | null>(null);
  const debounceRef = useRef<ReturnType<typeof setTimeout>>();

  const handleBlur = () => {
    clearTimeout(debounceRef.current);
    if (!value.trim()) {
      setValidated(null);
      return;
    }

    debounceRef.current = setTimeout(async () => {
      const res = await fetch("/api/validate-tax-id", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          country: "AR",
          type: "cuit",
          value,
        }),
      });
      const data = await res.json();
      setValidated({
        valid: data.valid,
        formatted: data.value?.formatted,
        reason: data.metadata?.reason,
      });
      // Mostrar o formato canonico — o usuario ve que o sistema entendeu.
      if (data.valid && data.value?.formatted) {
        setValue(data.value.formatted);
      }
    }, 300);
  };

  return (
    <div>
      <label htmlFor="cuit">CUIT</label>
      <input
        id="cuit"
        type="text"
        inputMode="numeric"
        autoComplete="off"
        value={value}
        onChange={(e) => setValue(e.target.value)}
        onBlur={handleBlur}
        aria-describedby="cuit-status"
        aria-invalid={validated?.valid === false}
      />
      <div id="cuit-status" role="status">
        {validated?.valid === true && <span>CUIT valido</span>}
        {validated?.valid === false && (
          <span role="alert">CUIT invalido — reveja.</span>
        )}
      </div>
    </div>
  );
}

Error UX: mapeie warnings do response para mensagens específicas

Um "formato inválido" genérico não serve para ninguém. A API da Normadata devolve um metadata.reason ou warnings com o motivo específico da rejeição. Mapear esses códigos para mensagens acionáveis transforma um dead-end num guia. Os códigos típicos que você vai ver:

  • invalid_length — "Um CPF tem 11 dígitos. Você inseriu N."
  • invalid_check_digit — "Tem um typo no dígito verificador. Revise os últimos dígitos."
  • all_equal_digits — "Esse CPF tem todos os dígitos iguais e não é um CPF emitido. Revise se colou o correto."
  • invalid_prefix — (CUIT) "O prefixo do CUIT deve ser 20, 23, 24, 27, 30, 33 ou 34."
  • invalid_character — "O identificador contém caracteres inválidos (espaços, símbolos)."
  • invalid_check_char — (RUT CL) "O caractere verificador deve ser um dígito ou K."
  • unknown_country_type — "Não reconhecemos esse combo país/tipo. Verifique a configuração do form."
cURL — request direto ao endpoint (referência para o backend do form)
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"
  }

Performance: regex client-side como first-gate, Normadata como check exaustivo

Não faz sentido mandar para a Normadata um input que claramente não pode ser um CPF (por exemplo, uma string com letras ou de comprimento claramente errado). Uma regex client-side simples atua como primeiro gate e só dispara a chamada async quando o input tem forma plausível. Isso reduz ruído no seu rate-limit e dá ao usuário feedback instantâneo em casos óbvios. A regra: a regex valida estrutura superficial (comprimento, charset). A Normadata valida estrutura completa (comprimento, charset, dígito verificador, padrões inválidos como all-equal, prefixos válidos). A regex não faz dígito verificador — essa parte é o que justamente queremos que seja trabalho do backend. Para CPF, por exemplo: a regex `^[\d.\s-]{11,14}$` e depois strip + length === 11 já filtra 90% do ruído sem tocar a rede.

Conclusão

Um formulário de checkout LATAM bom é permissivo no input, claro no feedback e exaustivo na validação. A combinação de regex local como primeiro filtro + Normadata async on-blur com debounce + mensagens de erro mapeadas a partir do metadata do response dá uma UX que aceita qualquer representação válida do identificador, confirma ao usuário que o sistema entendeu, e diz especificamente o que corrigir quando não entendeu. Para a referência do endpoint, veja /docs/api/verify-tax-id. Para o caso de uso completo com padrões adicionais (multi-país, fallback offline, integrações com form libraries), veja /use-cases/checkout-form-validation. Para acesso à API, /waitlist.

Pronto para começar a desenvolver?

Solicitar acessoLer documentação

Artigos relacionados

5 de maio de 2026IDs Fiscais Latino-Americanos: Um Guia para Desenvolvedores5 de maio de 2026Validando o CUIT Argentino: Algoritmo, Formato e API5 de maio de 2026Validação de CPF: Algoritmo, Exemplos e API REST5 de maio de 2026RFC vs CURP no México: Quando Usar Cada Um15 de março de 2026Como Validar um Número de CUIT com uma API1 de abril de 2026Validação de CPF: Formato, Algoritmo e Integração com API para o Brasil2 de abril de 2026RFC no México: Formato, Estrutura e Validação para Desenvolvedores10 de março de 2026O Guia Completo de IDs Fiscais na América Latina1 de março de 2026Boas Práticas para Integrar APIs de Terceiros em Aplicações LATAM11 de maio de 2026Quanto orçamento KYC você desperdiça com dados malformados (e como medir)16 de maio de 2026Como validar todos os tax IDs da LATAM com uma única API16 de maio de 2026Por que pré-validar dados antes do KYC economiza dinheiro — com números16 de maio de 2026O custo oculto dos erros de mod-11 no seu onboarding LATAM