Você recebeu uma lista de CEPs de clientes e precisa saber a cidade, o DDD e a região de cada um. Roda a consulta, e alguns voltam como "não encontrado", outros vêm sem rua, e o que o serviço chama de erro muda de um para outro. Eu sei como é: a lista é grande, e você não sabe se o problema está no CEP ou no seu código.

Neste artigo você vai consultar CEP pela AwesomeAPI, separar CEP inexistente de CEP inválido e de CEP de cidade, e juntar o resultado com a API do IBGE pelo código do município. O código em Python foi executado aqui. As consultas em Power Query foram montadas pela documentação oficial, e você testa no seu Power BI Desktop. É a mesma lógica do dashboard de consulta de CEP da Chora API. Bora lá.

Requisitos

  • AwesomeAPI CEP (cep.awesomeapi.com.br). Pública. Em todos os testes deste artigo, nenhuma chave foi necessária.
  • API de localidades e de malhas do IBGE (servicodados.ibge.gov.br). Públicas, sem chave.
  • Para a versão Python: Python 3.10 ou mais novo, só com a biblioteca padrão.
  • Para a versão Power Query: Power BI Desktop ou Excel com Power Query, conector Web com acesso Anônimo.
  • Demo ao vivo: choraapi.com.br/dashboards/consulta-cep/ busca as mesmas APIs direto do navegador.

Válido em: novembro/2026.

O que cada API entrega

PerguntaAPIO que devolve
Qual é o endereço, o DDD e a coordenada deste CEP?AwesomeAPILogradouro, bairro, cidade, UF, DDD, latitude e longitude, e o código IBGE do município (city_ibge)
Em que região, UF e microrregião fica esse município?IBGE, localidadesNome, UF, região e regiões imediata e intermediária
Qual é o contorno desse município?IBGE, malhasA forma do município em GeoJSON, para desenhar o mapa

A ligação entre as três é o código do município: ele sai da AwesomeAPI em city_ibge e entra no IBGE.

1 Consulte um CEP

A chamada é um GET com o CEP de 8 dígitos no fim:

curl -s "https://cep.awesomeapi.com.br/json/01001000"
{"cep":"01001000","address_type":"Praça","address_name":"da Sé","address":"Praça da Sé","state":"SP","district":"Sé","lat":"-23.5500806","lng":"-46.6340827","city":"São Paulo","city_ibge":"3550308","ddd":"11"}

Três coisas para guardar:

  • lat e lng vêm como texto, entre aspas. Converta antes de calcular qualquer coisa.
  • city_ibge é o código do município com 7 dígitos, também como texto: "3550308".
  • O hífen funciona, mesmo a documentação pedindo só os 8 números: 01001-000 voltou 200 e o campo cep veio sem o hífen. Como isso não está documentado, normalize você mesmo e não dependa dele.

Troque json por xml na URL e a resposta vem em XML.

2 404, 400 e CEP de cidade

A API tem três respostas que você precisa separar. Todas executadas em 07/11/2026:

GET /json/37925000  -> 200  {"cep":"37925000","address_type":"","address_name":"","address":"","state":"MG","district":"","lat":"-20.4742488","lng":"-45.9619861","city":"Piumhi","city_ibge":"3151503","ddd":"37"}
GET /json/99999999  -> 404  {"code":"not_found","message":"O CEP 99999999 nao foi encontrado"}
GET /json/1001000   -> 400  {"code":"invalid","message":"CEP invalido, tente: 00000000"}
  • 200 com a rua vazia é um CEP de cidade, um CEP que identifica a cidade inteira, e não uma rua. Tem cidade, UF, DDD e coordenada da cidade, mas address, address_name, address_type e district vêm como texto vazio, "".
  • 404 é um CEP de formato válido que a base não tem.
  • 400 é um CEP de formato inválido: menos ou mais de 8 dígitos, ou com letras (1001000, 010010000 e abc12345 voltaram o mesmo 400).
Atenção

a mensagem do 400 sugere 00000000 como exemplo, e o 00000000 volta 404. E a mensagem do 404 muda se o CEP vier vazio ({"code":"not_found","message":"Not found"}). Não dependa do texto: use o código HTTP.

3 Padronize a consulta em Python

Este bloco e os dois seguintes formam um script só, executado em 07/11/2026. Primeiro, a função que baixa e a que consulta o CEP:

import gzip, json, re, time, urllib.error, urllib.request
from collections import Counter
from concurrent.futures import ThreadPoolExecutor

CEP_API = "https://cep.awesomeapi.com.br/json/"
IBGE_MUN = "https://servicodados.ibge.gov.br/api/v1/localidades/municipios/"

def baixar(url, tentativas=3):
    """Devolve (status, JSON). 400 e 404 voltam como resposta, não como exceção."""
    for n in range(1, tentativas + 1):
        try:
            with urllib.request.urlopen(url, timeout=30) as r:
                status, corpo, enc = r.status, r.read(), r.headers.get("Content-Encoding")
        except urllib.error.HTTPError as e:
            status, corpo, enc = e.code, e.read(), e.headers.get("Content-Encoding")
            if status in (502, 503, 504) and n < tentativas:
                time.sleep(3 * n)
                continue
        if enc == "gzip":                      # o IBGE manda gzip, e o urllib não descomprime sozinho
            corpo = gzip.decompress(corpo)
        return status, json.loads(corpo)

def numero(s):                                 # a API manda lat e lng como texto
    try:
        v = float(s)
        return v if v != 0 else None
    except (TypeError, ValueError):
        return None

def consultar_cep(texto):
    cep = re.sub(r"\D", "", str(texto))        # aceita 01001-000 e 01.001-000
    if len(cep) != 8:                          # valide antes: poupa uma chamada
        return {"cep": str(texto), "situacao": "invalido"}
    status, j = baixar(CEP_API + cep)
    if status == 404:
        return {"cep": cep, "situacao": "nao_encontrado"}
    if status == 400:
        return {"cep": cep, "situacao": "invalido"}
    if status != 200:
        return {"cep": cep, "situacao": f"erro_{status}"}
    return {"cep": cep, "situacao": "encontrado", "logradouro": j["address"], "bairro": j["district"],
            "cidade": j["city"], "uf": j["state"], "ibge": j["city_ibge"], "ddd": j["ddd"],
            "lat": numero(j["lat"]), "lng": numero(j["lng"]), "cep_geral": j["address"] == ""}

Por que assim:

  • baixar devolve o status em vez de levantar exceção. O urllib levanta HTTPError em qualquer 4xx e 5xx. Aqui, 400 e 404 são respostas normais, que viram uma "situação" na sua tabela.
  • A retentativa só vale para 502, 503 e 504, os erros de servidor que costumam passar na segunda tentativa. Um 404 ou um 400 não muda se você repetir.
  • A validação dos 8 dígitos vem antes da chamada. Um CEP com 7 dígitos nunca chega à API: você economiza a chamada e já sabe a resposta.
  • O cep_geral marca o CEP de cidade (rua vazia), para você decidir o que fazer com ele.

4 Junte com o IBGE pelo código do município

Com o city_ibge na mão, a API de localidades do IBGE devolve nome, UF e região:

curl -s "https://servicodados.ibge.gov.br/api/v1/localidades/municipios/3550308"

A resposta traz o município com a microrregião, a região imediata e, aninhada dentro delas, a UF e a região ("regiao":{"id":3,"sigla":"SE","nome":"Sudeste"}). A função abaixo pega UF e região pela região imediata, porque o IBGE está substituindo meso e microrregiões pelas regiões imediatas e intermediárias:

cache_mun = {}
def municipio_ibge(codigo):
    if codigo not in cache_mun:
        status, j = baixar(IBGE_MUN + codigo)
        m = (j[0] if j else None) if isinstance(j, list) else j   # válido = objeto; inexistente = lista vazia
        uf = m and m["regiao-imediata"]["regiao-intermediaria"]["UF"]
        cache_mun[codigo] = m and {"nome": m["nome"], "uf": uf["sigla"], "regiao": uf["regiao"]["nome"]}
    return cache_mun[codigo]

Duas pegadinhas do IBGE estão nessas linhas:

  • O formato da resposta muda. Um código válido volta como objeto. Um código que não existe (9999999), ou com 6 dígitos (355030), volta 200 com a lista vazia [], sem erro. E se você pedir vários códigos de uma vez, separados por | (3550308|3304557), volta uma lista. A primeira versão deste código tratava tudo como lista e quebrou com KeyError: 0. É por isso que há o isinstance.
  • O cache_mun evita repetir a chamada: com 100 CEPs de São Paulo, são 100 consultas de CEP e uma do município. O IBGE ainda manda cache-control: max-age=2592000 (30 dias).
Atenção

o IBGE responde gzip, mesmo quando o cliente pede Accept-Encoding: identity, e o urllib manda exatamente esse cabeçalho por padrão. O navegador descomprime sozinho, mas o urllib não. Sem o gzip.decompress do baixar, a resposta vira bytes ilegíveis e dá o erro que está em Erros comuns.

5 Rode para uma lista de CEPs

A lista de teste é a do próprio dashboard, com um CEP que não existe (99999999), um com hífen (01001-000) e um inválido (1001000). Seis chamadas ao mesmo tempo, como o dashboard faz:

ceps = ["01001000", "01310100", "20040002", "30130010", "70150900", "74003010", "40026280", "50030230",
        "60165121", "69005070", "66013010", "69301000", "80020310", "90010150", "88010000", "37925000",
        "99999999", "01001-000", "1001000"]
with ThreadPoolExecutor(6) as ex:
    res = list(ex.map(consultar_cep, ceps))
print(dict(Counter(r["situacao"] for r in res)))

achados = [r for r in res if r["situacao"] == "encontrado"]
print("CEPs de cidade (sem logradouro):", [r["cep"] for r in achados if r["cep_geral"]])
regioes = Counter(municipio_ibge(r["ibge"])["regiao"] for r in achados)
print(dict(regioes))
print(municipio_ibge("3550308"))
print(municipio_ibge("9999999"))
{'encontrado': 17, 'nao_encontrado': 1, 'invalido': 1}
CEPs de cidade (sem logradouro): ['37925000']
{'Sudeste': 6, 'Centro-Oeste': 2, 'Nordeste': 3, 'Norte': 3, 'Sul': 3}
{'nome': 'São Paulo', 'uf': 'SP', 'regiao': 'Sudeste'}
None

Lendo a saída: das 19 entradas, 17 foram encontradas, 1 não existe e 1 é inválida. O 01001-000 e o 01001000 são o mesmo endereço, a Praça da Sé, por isso aparecem dois achados dele. O 37925000 é o CEP de cidade (Piumhi/MG). E a junção com o IBGE agrupou os 17 achados por região.

Nesse teste, 60 CEPs diferentes com seis chamadas ao mesmo tempo, em menos de um segundo, não trouxeram nenhum 429. Isso é o que observei: a documentação do CEP não declara limite. Por prudência, o dashboard usa no máximo seis chamadas simultâneas e 100 CEPs por consulta.

6 O mapa: a malha do IBGE

Para desenhar o município, a API de malhas devolve o contorno em GeoJSON:

curl -s "https://servicodados.ibge.gov.br/api/v3/malhas/municipios/3550308?formato=application/vnd.geo+json&qualidade=intermediaria"

A resposta é uma FeatureCollection com o polígono do município e a propriedade "codarea": "3550308". O dashboard usa qualidade=minima para o contorno dos estados e qualidade=intermediaria para o dos municípios, e o formato também aceita SVG e TopoJSON.

Atenção

um código de município que não existe (9999999) devolve HTTP 500, não 404. Não é falha do servidor: o erro é permanente, e repetir não adianta. Por isso a retentativa do passo 3 só vale para 502, 503 e 504. Use sempre o código que veio no city_ibge.

7 Power Query: o CEP no Power BI

Conforme a documentação oficial do Web.Contents (link nas referências); não executado aqui. Teste no seu Power BI Desktop antes de publicar.

let
    ConsultaCep = (cep as text) as record =>
        let
            Limpo = Text.Select(cep, {"0".."9"}),
            Resp = if Text.Length(Limpo) <> 8 then null else Web.Contents("https://cep.awesomeapi.com.br", [
                RelativePath = "json/" & Limpo,
                ManualStatusHandling = {400, 404}
            ]),
            Status = if Resp = null then 400 else Value.Metadata(Resp)[Response.Status],
            Json = if Status = 200 then Json.Document(Resp) else null,
            Situacao = if Status = 200 then "encontrado" else if Status = 404 then "nao_encontrado" else "invalido"
        in
            [
                CEP = Limpo,
                Situacao = Situacao,
                Logradouro = if Json = null then null else Json[address],
                Cidade = if Json = null then null else Json[city],
                UF = if Json = null then null else Json[state],
                IBGE = if Json = null then null else Json[city_ibge],
                DDD = if Json = null then null else Json[ddd],
                Lat = if Json = null then null else try Number.FromText(Json[lat], "en-US") otherwise null,
                Lng = if Json = null then null else try Number.FromText(Json[lng], "en-US") otherwise null
            ],
    Fonte = #table({"CEP"}, {{"01001000"}, {"37925000"}, {"99999999"}, {"1001000"}}),
    Consultado = Table.AddColumn(Fonte, "R", each ConsultaCep([CEP])),
    Colunas = Table.ExpandRecordColumn(Consultado, "R",
        {"Situacao", "Logradouro", "Cidade", "UF", "IBGE", "DDD", "Lat", "Lng"})
in
    Colunas

Por que montar assim:

  • RelativePath com o CEP no fim. A base fica fixa no primeiro argumento do Web.Contents, e o Power BI Service consegue atualizar. Só o caminho muda a cada CEP.
  • ManualStatusHandling = {400, 404}. Por padrão, um 404 vira erro na consulta inteira. Com a lista, os dois status viram uma linha com Situacao, como no Python.
  • Number.FromText(..., "en-US"). A latitude vem como "-23.5500806", com ponto. Num Power BI em português, sem a cultura, o ponto vira separador de milhar.
  • Todos os registros têm os mesmos campos, com null. Sem isso, o Table.ExpandRecordColumn falha na primeira linha que não tiver o campo.
  • Troque o #table da Fonte pela sua coluna de CEPs (por exemplo, uma tabela do Excel).

O que eu não consegui testar sem o Power BI: se o Web.Contents aceita o RelativePath montado com variável e como ele lê o status com ManualStatusHandling. E, para a junção com o IBGE no Power Query, repare que o IBGE devolve objeto, lista ou [] conforme o código (passo 4): trate os três antes de expandir.

ViaCEP e BrasilAPI: onde cada uma muda

Quem consulta CEP costuma conhecer também o ViaCEP e a BrasilAPI. Testei as três com o mesmo CEP em 07/11/2026. A diferença está no que cada uma chama de "não encontrado":

AwesomeAPIViaCEPBrasilAPI (v2)
CEP 01001000200200200
CEP não encontrado404 com not_found200 com {"erro":"true"}404 com CepPromiseError
CEP de formato inválido400 com invalid400 com página HTML400 com validation_error
Latitude e longitudeSimNãoSim
CORS**não testado
Atenção

o CEP 99999999 voltou 404 na AwesomeAPI, {"erro":"true"} no ViaCEP e 200 na BrasilAPI, com a Avenida das Torres, em Sarandi/PR. Cada serviço usa uma base. "Não encontrado" numa API não prova que o CEP não existe: é o resultado daquela base.

A escolha depende do campo de que você precisa. O dashboard usa a AwesomeAPI porque ela traz coordenada, DDD e código IBGE na mesma chamada. Se você não precisa de mapa, o ViaCEP também devolve o código IBGE e ainda traz regiao, mas sem coordenadas.

Erros comuns

Erro 1

CEP com menos ou mais de 8 dígitos, ou com letras (HTTP 400):

{"code":"invalid","message":"CEP invalido, tente: 00000000"}

Causa: 1001000, 010010000 ou abc12345. Correção: tire tudo que não for dígito e confira se sobraram 8 antes de chamar, como no passo 3.

Erro 2

CEP que a base não tem (HTTP 404):

{"code":"not_found","message":"O CEP 99999999 nao foi encontrado"}

Causa: formato válido, mas a base da AwesomeAPI não tem o CEP. Correção: trate como "não encontrado" e, se importar, confira em outra base (veja a tabela acima).

Erro 3

Resposta do IBGE ilegível no Python:

UnicodeDecodeError: 'utf-8' codec can't decode byte 0x8b in position 1: invalid start byte

Causa: o IBGE respondeu gzip e o urllib não descomprime. O 0x8b é o segundo byte do cabeçalho gzip. Correção: gzip.decompress quando o cabeçalho Content-Encoding for gzip, como no baixar.

Erro 4

KeyError: 0 ao ler o município do IBGE: o código válido volta como objeto, e não como lista. Correção: o isinstance do passo 4.

Erro 5

Malha de município inexistente (HTTP 500):

{"statusCode":500,"message":"Internal server error"}

Causa: código de município que não existe (9999999). Correção: use o city_ibge que veio da consulta do CEP. Não repita a chamada.

Erro 6

Município inexistente nas localidades: 9999999 e 355030 (6 dígitos) voltam 200 com [], sem erro. Correção: trate a lista vazia, como o None do passo 4.

Dica

a AwesomeAPI manda cache-control: max-age=604800, public em todas as respostas, inclusive no 404. O que eu vi foi o cabeçalho; o efeito, que um navegador guarde por até 7 dias o "não encontrado" de um CEP novo, eu não testei. Se um CEP recém-criado seguir "não encontrado" no navegador, esse cache é a primeira suspeita.

Bônus

Dica bônus: ache o CEP pelo endereço

A AwesomeAPI tem também uma busca por texto, que devolve até 10 resultados por chamada:

curl -s "https://cep.awesomeapi.com.br/search?q=paulista&state=SP&limit=2"

Executada em 07/11/2026, ela devolveu {"results":[{...},{...}]}, e cada item tem os mesmos campos da consulta por CEP, mais state_name. Na documentação, há filtros por coordenada e distância (lat, lng e d, em km), e a paginação (page) vale só para quem tem chave. Não é o que o dashboard usa, mas resolve o caminho contrário: do nome da rua ao CEP.

Conclusão

Agora você consulta CEP separando o que é "não existe" (404), "formato errado" (400) e "CEP de cidade" (rua vazia), junta o resultado com o IBGE pelo código do município e sabe por que a resposta do IBGE precisa do gzip.decompress no Python e de três tratamentos de formato.

Se você quer ligar o Power BI a APIs como essas e montar o painel com dados que se atualizam sozinhos, é isso que o curso Chora API ensina, com Linguagem M e DAX no contexto real. Qualquer dúvida, comenta aqui, na comunidade #conectados.

Quer ligar o Power BI a APIs como essas?

Na Formação Chora API você aprende a ligar o Power BI a uma API e montar o painel com dados atualizados, com Linguagem M e DAX no contexto do curso.

Ver a Formação →

Referências

Documentação oficial (consultada em 07/11/2026):

Outros serviços de CEP (para comparação, consultados em 07/11/2026):