TL;DR (English): Bulk-validating Brazilian company IDs (CNPJ) and postal codes (CEP) from a spreadsheet has four traps: Excel strips leading zeros (so
191may be a CNPJ, not a CEP), check digits must handle the new alphanumeric CNPJ, free public APIs have rate limits you should respect, and you must keep one output row per input. Below is a tested Python script using free public APIs (BrasilAPI, Minha Receita, ViaCEP). At the end we mention our paid Apify Actor that does the same at US$0.002 per found lookup. The script works without it.Transparência: este post é da Brasil Utils. Nós criamos a ferramenta paga citada na última seção, mas todo o código aqui funciona sem ela, usando APIs públicas e gratuitas.
Quem mexe com cadastro de fornecedores, CRM ou ERP no Brasil conhece a cena: chega uma planilha com milhares de CNPJs e CEPs "para validar". Parece só chamar uma API em loop. Aqui estão as quatro armadilhas que encontramos no caminho, com um script que testamos contra as APIs públicas.
Armadilha 1: 191 é CNPJ ou CEP?
O Excel trata CNPJ e CEP como número e apaga os zeros à esquerda. O CNPJ do Banco do Brasil, 00.000.000/0001-91, vira 191, e o CEP 01001-000 vira 1001000.
Completar com zeros resolve, desde que você saiba o que é cada coisa. A regra ingênua "até 8 dígitos é CEP" transforma o 191 do Banco do Brasil no CEP 00000191, que não existe. Nós caímos nisso numa versão anterior da nossa ferramenta.
A regra que usamos hoje para colunas misturadas olha o tamanho com mais cuidado:
- tem letra, ou tem de 9 a 14 dígitos: CNPJ;
- tem 7 ou 8 dígitos: CEP (um CEP que perdeu zeros fica com 7);
- tem de 1 a 6 dígitos: CNPJ que perdeu os zeros (
191→00.000.000/0001-91). Nenhum CEP real perde tantos zeros: o menor começa com01.
O caso que nenhuma regra resolve sozinha: um CNPJ que ficou com 7 ou 8 dígitos (ex.: 00.001.234/0001-xx). Se a coluna é só de CNPJs, diga isso ao código em vez de deixar adivinhar. O melhor continua sendo manter CNPJ e CEP em colunas separadas.
import re
def limpa(v):
return re.sub(r"[^0-9A-Za-z]", "", str(v)).upper()
def normaliza_cnpj(v):
s = limpa(v)
return s.zfill(14) if s.isdigit() else s # alfanumérico: não completa
def normaliza_cep(v):
return limpa(v).zfill(8)
def detecta_tipo(v):
"""Para colunas misturadas: a regra acima."""
s = limpa(v)
if not s.isdigit():
return "cnpj" # tem letra: CNPJ alfanumérico
if 7 <= len(s) <= 8:
return "cep"
return "cnpj" # 1 a 6 dígitos (zeros perdidos) ou 9 a 14
Repare que removemos só a pontuação, não as letras: o CNPJ alfanumérico (IN RFB 2.229/2024) usa letras nas 12 primeiras posições.
Armadilha 2: validar o DV não basta
Validar o dígito verificador localmente economiza requisições. No CNPJ alfanumérico, a regra é a mesma de sempre (módulo 11, pesos de 2 a 9), e cada caractere vale o código ASCII menos 48: os dígitos continuam valendo 0 a 9 e A passa a valer 17.
def _dv(base):
pesos = [2, 3, 4, 5, 6, 7, 8, 9]
soma = sum((ord(c) - 48) * pesos[i % 8] for i, c in enumerate(reversed(base)))
r = soma % 11
return "0" if r < 2 else str(11 - r)
def cnpj_valido(cnpj):
if not re.fullmatch(r"[0-9A-Z]{12}[0-9]{2}", cnpj) or len(set(cnpj)) == 1:
return False
d1 = _dv(cnpj[:12])
return cnpj[12:] == d1 + _dv(cnpj[:12] + d1)
A pegadinha: 00000000000000 passa na conta do DV. Por isso a checagem len(set(cnpj)) == 1 barra sequências repetidas. (O exemplo oficial de CNPJ alfanumérico, 12ABC34501DE35, passa.)
Armadilha 3: respeite as APIs públicas (e tenha um plano B)
BrasilAPI, Minha Receita, ViaCEP e OpenCEP são projetos públicos e gratuitos, mantidos pela comunidade ou por voluntários. Eles têm limites, e com razão. Em lotes grandes você vai ver 429 (limite de requisições), 5xx e timeouts.
O que aprendemos:
- Coloque um teto próprio de chamadas por minuto, por serviço, em vez de descobrir o limite na base do 429.
- Recebeu 429? Não insista. Martelar a mesma API com retries só piora a fila para todo mundo. Passe para a próxima fonte.
- Retry com backoff faz sentido para 5xx e erro de rede, que costumam ser passageiros.
- Detalhe do ViaCEP: CEP inexistente volta com status 200 e
{"erro": "true"}, não com 404.
import time
import requests
sessao = requests.Session()
sessao.headers["User-Agent"] = "exemplo-artigo-cnpj-cep/1.0"
LIMITE_POR_MINUTO = 90
_chamadas = {} # host -> horários das chamadas no último minuto
def _pode_chamar(url):
host = url.split("/")[2]
agora = time.monotonic()
janela = [t for t in _chamadas.get(host, []) if agora - t < 60]
_chamadas[host] = janela
if len(janela) >= LIMITE_POR_MINUTO:
return False
janela.append(agora)
return True
def get_json(urls, tentativas=3):
for url in urls:
for tentativa in range(tentativas):
if not _pode_chamar(url):
break # teto local atingido: próxima fonte
try:
r = sessao.get(url, timeout=15)
except requests.RequestException:
time.sleep(2 ** tentativa)
continue
if r.status_code == 200:
dados = r.json()
if isinstance(dados, dict) and dados.get("erro"): # ViaCEP: 200 + {"erro": "true"}
break
return dados
if r.status_code >= 500:
time.sleep(2 ** tentativa) # 1, 2, 4 s
continue
break # 429: não insiste nessa fonte. 400/404: idem. Vai para a próxima.
return None
O número 90 é o teto que usamos para a BrasilAPI; ajuste para cada serviço e leia a documentação de cada um. Se o seu lote é grande e sem pressa, a opção mais educada é simplesmente ir mais devagar.
Armadilha 4: perder o alinhamento com a planilha
Se você grava só os sucessos, o PROCV/merge com a planilha original quebra. Grave uma linha por entrada, sempre com um status:
def consulta_cnpj(valor):
cnpj = normaliza_cnpj(valor)
if not cnpj_valido(cnpj):
return {"entrada": valor, "cnpj": cnpj, "status": "invalido"}
d = get_json([f"https://brasilapi.com.br/api/cnpj/v1/{cnpj}",
f"https://minhareceita.org/{cnpj}"])
if not d:
return {"entrada": valor, "cnpj": cnpj, "status": "nao_encontrado"}
return {"entrada": valor, "cnpj": cnpj, "status": "ok",
"razao_social": d.get("razao_social"),
"situacao": d.get("descricao_situacao_cadastral"),
"uf": d.get("uf")}
def consulta_cep(valor):
cep = normaliza_cep(valor)
if not re.fullmatch(r"\d{8}", cep):
return {"entrada": valor, "cep": cep, "status": "invalido"}
d = get_json([f"https://brasilapi.com.br/api/cep/v2/{cep}",
f"https://viacep.com.br/ws/{cep}/json/"])
if not d:
return {"entrada": valor, "cep": cep, "status": "nao_encontrado"}
return {"entrada": valor, "cep": cep, "status": "ok",
"logradouro": d.get("street") or d.get("logradouro"),
"cidade": d.get("city") or d.get("localidade"),
"uf": d.get("state") or d.get("uf")}
def consulta(valor):
return consulta_cnpj(valor) if detecta_tipo(valor) == "cnpj" else consulta_cep(valor)
for v in ["33.000.167/0001-01", "191", "11.111.111/1111-11", "1001000", "01310-100"]:
print(consulta(v))
Saída quando rodamos (coluna misturada, do jeito que vem do Excel):
{'entrada': '33.000.167/0001-01', 'cnpj': '33000167000101', 'status': 'ok', 'razao_social': 'PETROLEO BRASILEIRO S A PETROBRAS', 'situacao': 'ATIVA', 'uf': 'RJ'}
{'entrada': '191', 'cnpj': '00000000000191', 'status': 'ok', 'razao_social': 'BANCO DO BRASIL SA', 'situacao': 'ATIVA', 'uf': 'DF'}
{'entrada': '11.111.111/1111-11', 'cnpj': '11111111111111', 'status': 'invalido'}
{'entrada': '1001000', 'cep': '01001000', 'status': 'ok', 'logradouro': 'Praça da Sé', 'cidade': 'São Paulo', 'uf': 'SP'}
{'entrada': '01310-100', 'cep': '01310100', 'status': 'ok', 'logradouro': 'Avenida Paulista', 'cidade': 'São Paulo', 'uf': 'SP'}
Bônus de LGPD: a resposta de CNPJ traz o QSA (nomes de sócios), e no MEI a razão social costuma incluir o CPF do titular. Se você só quer saber se a empresa existe e está ativa, não guarde o resto.
Um agradecimento sincero aos mantenedores da BrasilAPI, do Minha Receita, do ViaCEP e do OpenCEP: nada disso existiria sem esses serviços gratuitos.
Se você não quiser manter isso (a parte paga)
Empacotamos esse fluxo num Actor na Apify, que é pago: CNPJ & CEP Bulk Lookup Brazil — Consulta CNPJ e CEP em Lote, hoje na v0.2.8 (https://apify.com/brasil_utils/brazil-cnpj-cep-bulk-lookup). Como funciona:
-
Colar a coluna da planilha: no campo
bulkTextele aplica a regra da armadilha 1 (letras ou 9 a 14 dígitos = CNPJ; 7 ou 8 = CEP; 1 a 6 = CNPJ com zeros restaurados, então191vira00.000.000/0001-91). Se a coluna tem um tipo só, dá para forçar CNPJ ou CEP. Também há os campos separadoscnpjseceps. - Fontes: CNPJ na BrasilAPI e, se falhar, Minha Receita. CEP na BrasilAPI, depois ViaCEP, depois OpenCEP.
- Limite: no máximo 90 chamadas por minuto à BrasilAPI por execução. Acima disso, ou se a BrasilAPI responder 429 ou 403, ele passa na hora para a fonte seguinte, sem retentar, e pausa a BrasilAPI por 60 s naquela execução. Erros passageiros (timeout, rede, 5xx) em qualquer fonte são retentados com backoff exponencial, respeitando o Retry-After, antes de ir para a próxima. Se todas as fontes falharem, a linha sai como erro e não é cobrada.
-
Saída: uma linha por entrada, com
status, deduplicação e sem dados pessoais de sócios. - Preço: US$0,002 por consulta encontrada, cobrado por evento. Não há cobrança extra de compute. Inválidos, duplicados e não encontrados são grátis, e uma execução sem entrada não custa nada. Se a execução reinicia no meio, ela retoma de onde parou sem cobrar de novo o que já foi cobrado.
Exemplo via API, colando a coluna misturada:
curl -X POST "https://api.apify.com/v2/acts/brasil_utils~brazil-cnpj-cep-bulk-lookup/run-sync-get-dataset-items" \
-H "Authorization: Bearer $APIFY_TOKEN" \
-H "Content-Type: application/json" \
-d '{"bulkText": "33.000.167/0001-01\n191\n1001000", "bulkTextType": "auto"}'
Tem também um exemplo pronto para rodar: https://apify.com/brasil_utils/brazil-cnpj-cep-bulk-lookup/examples/consulta-cnpj-e-cep-em-lote-exemplo. E um template n8n (lista no Google Sheets → consulta → planilha enriquecida) está disponível sob pedido: é só comentar aqui.
Sendo sinceros: para poucas centenas de consultas por mês, o script acima resolve. O Actor compensa quando o volume cresce ou quando a consulta roda dentro do Make, Zapier ou n8n.
E vocês, já adaptaram os sistemas para o CNPJ alfanumérico? Contem nos comentários o que quebrou.
Top comments (0)