Você monta uma tabela de previsão do tempo no Power BI, e às 21h ela já mostra o dia seguinte. O horário "atual" está 3 horas adiantado, e o "hoje" virou amanhã antes da hora. Eu sei como é: a API responde 200, o JSON parece certo, e o erro está num parâmetro que você nem sabia que existia.

A Open-Meteo é uma API de previsão pública, sem chave, ótima para treinar: ela tem parâmetros, listas paralelas, fuso horário e erros em JSON. Neste artigo você vai consultar a previsão de várias cidades de uma vez, acertar o fuso, transformar o JSON em linhas e entender os limites do plano gratuito. 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 API do dashboard de previsão do tempo da Chora API. Bora lá.

Requisitos

  • API de previsão da Open-Meteo (api.open-meteo.com/v1/forecast). Sem chave e sem cadastro no plano gratuito.
  • O plano gratuito é só para uso não comercial. Leia a seção "Limites e termos de uso" antes de usar num projeto de empresa.
  • 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/previsao-tempo/ busca a mesma API direto do navegador.

Válido em: novembro/2026.

O que a API devolve

Você manda as coordenadas e escolhe as variáveis em três blocos: current (agora), hourly (hora a hora) e daily (dia a dia). A resposta traz cada variável como uma lista, e todas as listas de um bloco têm o mesmo tamanho: a posição i de qualquer lista é o mesmo instante. Por isso este artigo chama de listas paralelas.

1 Faça a primeira chamada

curl -s "https://api.open-meteo.com/v1/forecast?latitude=-23.55&longitude=-46.63&current=temperature_2m,weather_code&hourly=temperature_2m,precipitation_probability&daily=temperature_2m_max,temperature_2m_min,precipitation_sum&timezone=America%2FSao_Paulo&forecast_days=3"

A resposta, resumida (consulta de 07/11/2026):

{"latitude":-23.514938,"longitude":-46.610504,"utc_offset_seconds":-10800,
 "timezone":"America/Sao_Paulo","elevation":737.0,
 "current":{"time":"2026-11-07T21:15","interval":900,"temperature_2m":22.7,"weather_code":3},
 "hourly":{"time":["2026-11-07T00:00","2026-11-07T01:00", "..."],"temperature_2m":["..."],"precipitation_probability":["..."]},
 "daily":{"time":["2026-11-07","2026-11-08","2026-11-09"],
          "temperature_2m_max":[30.1,21.2,16.2],"temperature_2m_min":[19.2,14.0,12.8],"precipitation_sum":[6.2,7.8,0.7]}}

Quatro coisas para guardar:

  • Os horários não trazem fuso ("2026-11-07T21:15"). O fuso vem à parte, em utc_offset_seconds (aqui, -10800, ou seja, UTC-3).
  • A latitude e a longitude devolvidas não são as que você pediu. Pedi -23.55, -46.63 e voltou -23.514938, -46.610504: a API escolhe a célula da grade do modelo meteorológico. Não use esse par como a coordenada da cidade.
  • current tem interval: 900: o valor "atual" muda a cada 15 minutos.
  • O weather_code é um número (código WMO): 3 é "nublado". O texto fica por sua conta, e o passo 4 mostra como.

2 Acerte o fuso horário

Aqui está o problema da abertura. Sem o parâmetro timezone, a API usa GMT. Na consulta de 07/11/2026, às 21:15 em São Paulo, current.time voltou como 2026-11-08T00:15: já era o dia 8. E o primeiro dia da lista daily foi 2026-11-08, quando com timezone=America/Sao_Paulo foi 2026-11-07.

Você tem duas saídas:

  • Um fuso fixo, no formato da base IANA: timezone=America/Sao_Paulo.
  • timezone=auto, que descobre o fuso pelas coordenadas.

A diferença aparece fora de Brasília. Em Manaus, com timezone=America/Sao_Paulo o current.time foi 21:15; com auto, 20:15, que é a hora local de Manaus (UTC-4).

Atenção

a documentação diz que, ao pedir daily, o parâmetro timezone é obrigatório. Na prática, a API aceita sem ele e responde 200 em GMT, sem nenhum aviso. Não conte com um erro para te lembrar.

3 Várias cidades numa chamada só

Separe as coordenadas por vírgula, e a API devolve uma previsão por cidade. Com timezone=auto, cada cidade recebe o seu fuso. Este bloco e o do próximo passo formam um script só, executado em 08/11/2026:

import gzip, json, time, urllib.error, urllib.parse, urllib.request
from datetime import date, datetime, timedelta, timezone

BASE = "https://api.open-meteo.com/v1/forecast"

def baixar(url, tentativas=3):
    """Devolve o JSON. O erro 400 da Open-Meteo vira uma exceção com o motivo."""
    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":
            corpo = gzip.decompress(corpo)
        if not corpo:                              # sem latitude e longitude, a API responde 200 e corpo vazio
            raise RuntimeError(f"Open-Meteo respondeu {status} com o corpo vazio")
        j = json.loads(corpo)
        if status == 400:
            raise RuntimeError(f"Open-Meteo respondeu 400: {j['reason']}")
        return j

def previsao(cidades, dias=3):
    nomes = [c[0] for c in cidades]
    params = {
        "latitude": ",".join(str(c[1]) for c in cidades),
        "longitude": ",".join(str(c[2]) for c in cidades),
        "current": "temperature_2m,weather_code",
        "hourly": "precipitation_probability",
        "daily": "temperature_2m_max,temperature_2m_min,precipitation_sum,weather_code",
        "timezone": "auto",                        # um fuso por cidade (Manaus não é Brasília)
        "forecast_days": dias,
    }
    j = baixar(f"{BASE}?{urllib.parse.urlencode(params, safe=',')}")
    lista = j if isinstance(j, list) else [j]      # uma cidade = objeto; várias = lista
    return list(zip(nomes, lista))

CIDADES = [("São Paulo", -23.55, -46.63), ("Manaus", -3.12, -60.02), ("Recife", -8.05, -34.88),
           ("Porto Alegre", -30.03, -51.23), ("Brasília", -15.78, -47.93)]

for nome, j in previsao(CIDADES):
    fuso = timezone(timedelta(seconds=j["utc_offset_seconds"]))
    agora = datetime.fromisoformat(j["current"]["time"]).replace(tzinfo=fuso)
    d = j["daily"]
    hoje = {k: v[0] for k, v in d.items()}          # as listas são paralelas: o índice 0 é o dia 0
    print(f'{nome:<13} {j["timezone"]:<18} agora {agora:%H:%M} | hoje {hoje["temperature_2m_min"]:.0f}-{hoje["temperature_2m_max"]:.0f}°C, '
          f'{hoje["precipitation_sum"]:.1f} mm, código {hoje["weather_code"]}')
São Paulo     America/Sao_Paulo  agora 21:15 | hoje 19-30°C, 6.2 mm, código 95
Manaus        America/Manaus     agora 20:15 | hoje 27-34°C, 0.8 mm, código 51
Recife        America/Recife     agora 21:15 | hoje 26-31°C, 1.0 mm, código 51
Porto Alegre  America/Sao_Paulo  agora 21:15 | hoje 16-22°C, 13.8 mm, código 80
Brasília      America/Sao_Paulo  agora 21:15 | hoje 20-30°C, 4.5 mm, código 55

Por que assim:

  • Uma cidade volta como objeto, várias voltam como lista. O lista = j if isinstance(j, list) else [j] cobre os dois casos. Sem ele, o código funciona com 5 cidades e quebra no dia em que você consultar uma só.
  • timezone=auto deu um fuso por cidade: Manaus saiu 1 hora atrás das outras, como deveria.
  • urlencode(..., safe=',') mantém as vírgulas na URL, em vez de trocá-las por %2C.
  • O utc_offset_seconds vira um fuso com timezone(timedelta(seconds=...)), e assim o horário da API ganha o fuso que ele não traz.
  • O baixar trata o 400 e o corpo vazio, que aparecem em Erros comuns, e repete a chamada só nos erros 502, 503 e 504.

4 Das listas paralelas às linhas

Uma lista por variável é ótima para gráfico, mas, para uma tabela, você quer uma linha por dia. Como as listas têm o mesmo tamanho, basta juntá-las pela posição. O mesmo vale para o texto do código WMO:

WMO = {0: "céu limpo", 1: "predominantemente limpo", 2: "parcialmente nublado", 3: "nublado", 45: "nevoeiro",
       48: "nevoeiro com geada", 51: "garoa fraca", 53: "garoa moderada", 55: "garoa intensa",
       61: "chuva fraca", 63: "chuva moderada", 65: "chuva forte", 80: "pancadas de chuva fracas",
       81: "pancadas de chuva moderadas", 82: "pancadas de chuva violentas", 95: "trovoada",
       96: "trovoada com granizo fraco", 99: "trovoada com granizo forte"}

url = (BASE + "?latitude=-23.55&longitude=-46.63&hourly=precipitation_probability"
       "&daily=temperature_2m_max,temperature_2m_min,precipitation_sum,weather_code&timezone=auto&forecast_days=3")
j = baixar(url)
d = j["daily"]
linhas = [dict(zip(d.keys(), valores)) for valores in zip(*d.values())]   # listas paralelas -> uma linha por dia
for l in linhas:
    dia = date.fromisoformat(l["time"])
    print(f'{dia:%d/%m} {l["temperature_2m_min"]:.0f}-{l["temperature_2m_max"]:.0f}°C {l["precipitation_sum"]:.1f} mm  {WMO.get(l["weather_code"], "código " + str(l["weather_code"]))}')

fuso = timezone(timedelta(seconds=j["utc_offset_seconds"]))
agora = datetime(2026, 11, 7, 21, 0, tzinfo=fuso)
h = j["hourly"]
horas = [(datetime.fromisoformat(t).replace(tzinfo=fuso), p) for t, p in zip(h["time"], h["precipitation_probability"])]
prox = next(((t, p) for t, p in horas if t >= agora and p >= 50), None)
print("próxima hora com chuva >= 50%:", f"{prox[0]:%d/%m %H:%M} ({prox[1]}%)" if prox else "nenhuma")
07/11 19-30°C 6.2 mm  trovoada
08/11 14-21°C 7.8 mm  pancadas de chuva fracas
09/11 13-16°C 0.7 mm  garoa fraca
próxima hora com chuva >= 50%: 07/11 23:00 (61%)

Duas observações:

  • O zip(*d.values()) pega a posição 0 de todas as listas, depois a posição 1, e assim por diante. O dict(zip(d.keys(), ...)) devolve cada dia como um registro com o nome das variáveis.
  • O "agora" está fixo em 07/11/2026 às 21:00 só para o resultado ser reproduzível. No seu código, use datetime.now(fuso).
  • A tradução dos códigos WMO é minha. A documentação traz a tabela em inglês e a API devolve só o número. O dicionário cobre os códigos mais comuns, e o WMO.get(...) mostra "código N" para os outros.
Dica

não confunda precipitation_sum com precipitation_probability_max. A primeira é quanto vai chover (mm); a segunda, a chance de chover. Em São Paulo, no dia 07/11, deu 6,2 mm com probabilidade máxima de 100%; no dia 09/11, 0,7 mm com 10%.

5 Power Query: do JSON às colunas

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
    Fonte = Json.Document(Web.Contents("https://api.open-meteo.com", [
        RelativePath = "v1/forecast",
        Query = [
            latitude = "-23.55",
            longitude = "-46.63",
            daily = "temperature_2m_max,temperature_2m_min,precipitation_sum,weather_code",
            timezone = "auto",
            forecast_days = "7"
        ]
    ])),
    Diario = Fonte[daily],
    Tabela = Table.FromColumns(
        {Diario[time], Diario[temperature_2m_max], Diario[temperature_2m_min], Diario[precipitation_sum], Diario[weather_code]},
        {"Data", "Maxima", "Minima", "ChuvaMm", "CodigoTempo"}),
    Tipada = Table.TransformColumnTypes(Tabela,
        {{"Data", type date}, {"Maxima", type number}, {"Minima", type number},
         {"ChuvaMm", type number}, {"CodigoTempo", Int64.Type}}, "en-US")
in
    Tipada

Por que montar assim:

  • Table.FromColumns recebe uma lista de listas, e as listas paralelas da API encaixam direto. Cada lista vira uma coluna, sem laço.
  • RelativePath e Query em vez de concatenar a URL. O serviço do Power BI não atualiza fonte dinâmica, aquela cuja URL só se conhece quando a consulta roda. Com a base fixa no primeiro argumento do Web.Contents, a atualização agendada funciona.
  • "en-US" na conversão de tipos garante a leitura de 2026-11-07 e dos números com ponto decimal.
  • timezone = "auto" pelo mesmo motivo do passo 2: sem ele, o Data do Power BI vem em GMT.

A API também entrega CSV com format=csv. Fica mais fácil de ler, mas tem uma pegadinha: antes da tabela vêm duas linhas de metadados e uma linha em branco, e o nome de cada coluna carrega a unidade entre parênteses (temperature_2m_max (°C)). Esta consulta, também não executada, pula as três linhas:

let
    Bruto = Csv.Document(Web.Contents("https://api.open-meteo.com", [
        RelativePath = "v1/forecast",
        Query = [latitude = "-23.55", longitude = "-46.63",
                 daily = "temperature_2m_max,temperature_2m_min,precipitation_sum",
                 timezone = "auto", forecast_days = "7", format = "csv"]
    ]), [Delimiter = ",", Encoding = 65001]),
    Tabela = Table.PromoteHeaders(Table.Skip(Bruto, 3), [PromoteAllScalars = true])
in
    Tabela

A estrutura do CSV (os metadados, a linha em branco e o cabeçalho com unidades) eu confirmei com uma chamada real. O que não pude testar é como o Csv.Document trata a linha em branco; se ela não contar como linha, ajuste o Table.Skip para 2.

Limites e termos de uso

O plano gratuito tem regras que a documentação deixa claras. Todas estão nas páginas oficiais de preços e de termos, consultadas em 08/11/2026:

  • Limites: menos de 10.000 chamadas por dia, 5.000 por hora e 600 por minuto, sem garantia de disponibilidade. Os limites contam por IP e aplicação; em plataformas como Vercel, Netlify, Render ou Google Sheets, você pode ser limitado mais cedo.
  • Uso não comercial. A página de termos lista como não comercial os sites e apps privados ou sem fins lucrativos, sem assinatura nem anúncio, a automação doméstica, a pesquisa pública e o "uso em conteúdo educacional". E lista como comercial os sites e apps com assinatura ou anúncios e os "produtos comerciais ou atividades promocionais". Para uso comercial, há o plano pago, em outro endereço (customer-api.open-meteo.com) e com chave.
  • Licença CC BY 4.0, que pede atribuição: cite a Open-Meteo.
  • Sem garantia: a página diz que a exatidão e a disponibilidade dos dados não são garantidas.

Na prática, você acompanha o seu consumo pelo cabeçalho x-ratelimit-remaining da resposta. Na consulta de teste, ele veio com x-ratelimit-limit: 600.0 e caiu de 599 para 581 e depois 580 e 579 em chamadas seguidas. Se o seu projeto é de uma empresa, leia a página de termos e, na dúvida, veja o plano pago.

Erros comuns

Erro 1

Sem latitude e longitude: 200 e corpo vazio. Não é um erro 400, e a resposta tem Content-Type: application/json e nenhum byte. O json.loads("") dá JSONDecodeError, e o baixar do passo 3 avisa antes disso:

RuntimeError: Open-Meteo respondeu 200 com o corpo vazio
Erro 2

Variável que não existe (HTTP 400):

{"error":true,"reason":"Invalid value: Cannot initialize ForecastVariableDaily from invalid String value temperatura"}

Causa: temperatura em vez de temperature_2m_max. Correção: copie o nome exato da lista de variáveis da documentação. Para o bloco hourly, a mensagem cita outro tipo (SurfacePressureAndHeightVariable<...>), mas o formato é o mesmo.

Erro 3

forecast_days fora da faixa (HTTP 400):

{"reason":"Forecast days is invalid. Allowed range 0 to 16. Given 17.","error":true}

Causa: a previsão vai até 16 dias. Correção: use de 0 a 16. O padrão são 7 dias.

Erro 4

Fuso inexistente (HTTP 400):

{"reason":"Invalid timezone","error":true}

Causa: timezone=Brasil/Sao_Paulo. Correção: use o nome da base IANA (America/Sao_Paulo) ou auto.

Erro 5

Latitude fora da faixa (HTTP 400):

{"error":true,"reason":"Latitude must be in range of -90 to 90°. Given: 95.0."}
Erro 6

Latitude que não é número (HTTP 400):

{"reason":"Invalid value: Cannot initialize Float from invalid String value abc","error":true}
Dica

nos erros 400, as chaves error e reason aparecem em ordens diferentes de uma resposta para outra. Leia pelo nome, nunca pela posição.

Bônus

Dica bônus: o passado e o futuro

Dois parâmetros ampliam a janela de datas. forecast_days vai de 0 a 16, e past_days vai de 0 a 92. Juntos, eles devolvem o passado recente e o futuro de uma vez. Com past_days=2&forecast_days=16, a consulta de 07/11/2026 devolveu 18 dias, de 05/11 a 22/11. É a forma mais simples de ter uma série contínua, sem juntar duas consultas.

Conclusão

Agora você consulta a Open-Meteo sabendo que o timezone define o dia, que uma cidade volta como objeto e várias como lista, que as listas paralelas viram linhas pela posição e que um corpo vazio com status 200 também é erro.

Se você quer ligar o Power BI a APIs como essa 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 08/11/2026):