Seu painel puxa o Boletim Focus e a meta Selic do Banco Central, funciona há semanas e, de repente, o quadro da Selic aparece vazio. Sem erro de autenticação, sem mudança no código. Só o número sumiu. Eu sei como é, e foi exatamente isso que aconteceu com o dashboard de expectativas de mercado aqui da Chora API em 06/11/2026.

A causa foi a SGS, a API de séries do Banco Central, respondendo 304 Not Modified sem corpo a um pedido que não tinha pedido revalidação nenhuma. O código esperava JSON, recebeu nada, e a meta Selic sumiu da tela.

Neste artigo você vai consultar as duas APIs do zero (a Olinda, que entrega o Focus, e a SGS, que entrega a meta Selic), entender os limites que travam quem segue a documentação ao pé da letra e sair com código que aguenta o 304: em Python, executado aqui, e em Power Query, montado pela documentação oficial para você testar no seu Power BI Desktop. Bora lá.

Requisitos

  • API Olinda, serviço Expectativas, versão v1 (protocolo OData). Pública: sem login, sem chave.
  • API SGS do Banco Central (api.bcb.gov.br/dados/serie/bcdata.sgs.{codigo}/dados). Pública: sem login, sem chave. A série usada aqui é a 432 — Meta Selic definida pelo Copom.
  • Para a versão Power Query: Power BI Desktop ou Excel com Power Query, conector Web com acesso Anônimo.
  • Para a versão Python: Python 3.10 ou mais novo, só com a biblioteca padrão (urllib, json, datetime).
  • Demo ao vivo: choraapi.com.br/dashboards/expectativas-mercado/ busca as duas APIs direto do navegador, com o código desta matéria.

Válido em: novembro/2026.

O que é cada API

As duas são do Banco Central, mas respondem perguntas diferentes:

PerguntaAPIRecurso
O que o mercado espera para a Selic, o IPCA, o PIB?Olinda / ExpectativasExpectativasMercadoSelic, ExpectativasMercadoAnuais e outros
Qual é a meta Selic hoje?SGSsérie 432

O Focus traz expectativa, não a taxa vigente. Para escrever "o mercado espera X e a meta está em Y", você precisa das duas. E cada uma tem as suas manias.

1 Consulte o Focus na Olinda

A base do serviço é esta:

https://olinda.bcb.gov.br/olinda/servico/Expectativas/versao/v1/odata/

Os parâmetros são os do OData: $filter, $select, $orderby, $top, $skip e $format. Para a expectativa da Selic por reunião do Copom:

curl -s "https://olinda.bcb.gov.br/olinda/servico/Expectativas/versao/v1/odata/ExpectativasMercadoSelic?%24filter=baseCalculo%20eq%200&%24orderby=Data%20desc&%24top=2&%24format=json"

Resposta real, consultada em 06/11/2026:

{"value":[
 {"Indicador":"Selic","Data":"2026-10-30","Reuniao":"R6/2028","Media":11.3926,"Mediana":11.25,"DesvioPadrao":1.1817,"Minimo":9.0,"Maximo":14.75,"numeroRespondentes":121,"baseCalculo":0},
 {"Indicador":"Selic","Data":"2026-10-30","Reuniao":"R5/2028","Media":11.4939,"Mediana":11.5,"DesvioPadrao":1.1452,"Minimo":9.0,"Maximo":14.75,"numeroRespondentes":123,"baseCalculo":0}
]}

O que cada campo quer dizer, segundo o swagger oficial:

  • Data: a data de cálculo da estatística. Vem como texto no formato aaaa-mm-dd.
  • Reuniao: a reunião do Copom para a qual a expectativa vale (R7/2026 = sétima reunião de 2026).
  • Mediana, Media, Minimo, Maximo, DesvioPadrao: estatísticas das respostas das instituições.
  • numeroRespondentes: quantas instituições responderam.
  • baseCalculo: o prazo de validade das respostas usadas no cálculo. 0 usa as expectativas informadas a partir do 30º dia anterior à data de cálculo; 1, a partir do 4º dia útil anterior.
Atenção

sem baseCalculo eq 0 (ou eq 1) no filtro, cada data vem duas vezes, uma por base. Se você jogar isso no Power BI e fizer uma média, mistura as duas sem perceber.

Para as projeções anuais (IPCA, PIB, câmbio), o recurso é ExpectativasMercadoAnuais. Ele troca Reuniao por DataReferencia (o ano, como texto) e tem IndicadorDetalhe:

curl -s "https://olinda.bcb.gov.br/olinda/servico/Expectativas/versao/v1/odata/ExpectativasMercadoAnuais?%24filter=Indicador%20eq%20%27IPCA%27%20and%20baseCalculo%20eq%200&%24orderby=Data%20desc&%24top=3&%24format=json"

Na consulta de 06/11/2026, a Data mais recente era 2026-10-30: mediana do IPCA de 5,2166% para 2026, 4,7646% para 2027 e 4,0% para 2028, com 144 respondentes nos dois primeiros anos.

Três detalhes do OData que derrubam muita gente:

  1. Texto vai entre aspas simples: Indicador eq 'IPCA'. Sem aspas, o OData lê IPCA como nome de campo.
  2. Maiúscula importa: é Indicador, mas baseCalculo e numeroRespondentes começam com minúscula.
  3. O recurso mensal está no singular: ExpectativaMercadoMensais, sem o "s" em "Expectativa". O mesmo vale para ExpectativaMercadoTop5Trimestral.

2 Ache o Focus mais recente

O Banco Central publica o boletim todo primeiro dia útil da semana, e as estatísticas são calculadas diariamente. Na prática, para "o Focus desta semana", não chute a data: peça a maior Data que existe.

ExpectativasMercadoAnuais?$filter=Indicador eq 'IPCA' and baseCalculo eq 0&$select=Data&$orderby=Data desc&$top=1&$format=json

É o que o dashboard faz antes de qualquer outra consulta. Como Data é texto no formato ISO, o filtro usa aspas e a comparação funciona: Data ge '2026-01-01'.

Dica

não confie na ordem das linhas sem $orderby. Ela não é garantida. E o $count=true é ignorado por esse serviço: a resposta não traz @odata.count.

3 Busque a meta Selic na SGS

A SGS tem outra cara. Nada de OData; as datas vão em dd/mm/aaaa:

curl -s "https://api.bcb.gov.br/dados/serie/bcdata.sgs.432/dados?formato=json&dataInicial=20/09/2026&dataFinal=30/10/2026"
[{"data":"20/09/2026","valor":"13.75"},{"data":"21/09/2026","valor":"13.75"}, ...]

Repare em quatro coisas, todas conferidas em 06/11/2026:

  • valor é texto, com ponto decimal: "13.75". No Power Query com cultura pt-BR, isso vira 1375 se você não indicar a cultura.
  • A 432 é diária e tem um valor para cada dia corrido, sábado e domingo inclusive. Por isso a janela de 20/09 a 30/10 devolve 41 linhas.
  • A série vai além de hoje. Em 06/11/2026, o /ultimos/3 devolveu 07/12, 08/12 e 09/12/2026. Para "a meta vigente hoje", use dataFinal igual à data que você quer, e não o /ultimos.
  • Série diária exige dataInicial e aceita no máximo 10 anos por pedido. Sem a data, a resposta é HTTP 406 (a mensagem completa está em Erros comuns).

Precisa do histórico inteiro? Fatie em pedaços e junte. Este script baixa a série 432 desde o início, em 05/03/1999, em pedaços de 5 anos:

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

def baixar(url, tentativas=3):
    for n in range(1, tentativas + 1):
        try:
            with urllib.request.urlopen(url, timeout=120) as r:
                return json.loads(r.read())
        except urllib.error.HTTPError as e:
            if e.code in (502, 503, 504) and n < tentativas:
                time.sleep(5 * n)          # a SGS às vezes devolve 502 em janela longa: espere e tente de novo
                continue
            raise

def sgs_historico(codigo, inicio, fim, dias_por_pedaco=5 * 365):
    br = lambda d: d.strftime("%d/%m/%Y")
    linhas, de = [], inicio
    while de <= fim:
        ate = min(de + timedelta(days=dias_por_pedaco - 1), fim)
        linhas += baixar(f"https://api.bcb.gov.br/dados/serie/bcdata.sgs.{codigo}/dados"
                         f"?formato=json&dataInicial={br(de)}&dataFinal={br(ate)}")
        de = ate + timedelta(days=1)   # o próximo pedaço começa no dia seguinte: nenhuma data repetida
    return linhas

historico = sgs_historico(432, date(1999, 3, 5), date(2026, 11, 6))
print(len(historico), "linhas")
print(historico[0], historico[-1])

Saída, executada em 06/11/2026:

10109 linhas
{'data': '05/03/1999', 'valor': '45.00'} {'data': '06/11/2026', 'valor': '13.75'}

Por que assim:

  • Pedaços de 5 anos, não de 10. Na pesquisa, uma janela de 10 anos voltou 502. Pedaço menor custa mais pedidos, mas cada um responde rápido.
  • A retentativa em 502, 503 e 504 não está aí de enfeite. Na primeira execução deste script, um pedaço de 5 anos voltou HTTP Error 502: Bad Gateway. Na segunda, passou.
  • timedelta em vez de somar anos na data. date(ano + 5, mes, dia) quebra quando o pedaço começa em 29/02.
  • O pedaço seguinte começa no dia seguinte ao fim do anterior. Assim nenhuma data aparece duas vezes na emenda.

4 O 304 sem corpo: o que aconteceu e como contornar

A SGS manda nos cabeçalhos cache-control: public, max-age=900 e um etag. Isso é normal: se o cliente perguntar "mudou desde esta versão?" com If-None-Match, a SGS responde 304 sem corpo, e o cliente usa a cópia que já tem. Testamos esse caminho e ele funciona como manda o figurino:

curl -s -i -H 'If-None-Match: W/"617-Fm6xQ8UciNCe0X9QQvVrZefiBH0"' "https://api.bcb.gov.br/dados/serie/bcdata.sgs.432/dados?formato=json&dataInicial=20/09/2026&dataFinal=30/10/2026"

Resposta: HTTP 304, corpo vazio. (O ETag muda quando a série muda; use o que vier na sua primeira resposta.)

O problema de 06/11/2026 foi outro. No ar, a SGS respondeu 304 sem corpo a um pedido sem nenhum cabeçalho condicional, para a janela de datas que o dashboard pedia na série 432: dos 40 dias anteriores à data do relatório até ela. Outras janelas da mesma série voltavam 200. Pendurar um parâmetro extra na URL não adiantou. E não era cache do navegador: o mesmo pedido voltava 304 para qualquer cliente, não só no navegador. Para piorar, o navegador ainda guardava esse 304 por 15 minutos, por causa do max-age=900.

O código antigo fazia isto:

const resp = await fetch(url, { signal });
if (!resp.ok) throw new Error(`a SGS respondeu ${resp.status}`);

Um 304 não é ok (não está na faixa 2xx). Virou erro, e o quadro da Selic perdeu o ponto "Atual" e a frase da meta.

A correção, que está no ar no dashboard, tem duas partes: pedir sem cache e, num 304, tentar outra janela de datas. Para o servidor, outra janela é outra URL:

async function buscarMetaSelic(dia, signal) {
  const br = (s) => s.split("-").reverse().join("/"); // a SGS quer dd/mm/aaaa
  for (const folga of [40, 41, 42]) {
    const url = `${SGS_META_SELIC}?formato=json&dataInicial=${br(somaDias(dia, -folga))}&dataFinal=${br(dia)}`;
    if (cache.has(url)) return cache.get(url);
    const resp = await fetch(url, { signal, cache: "no-store" });
    if (resp.status === 304) continue;
    if (!resp.ok) throw new Error(`a SGS respondeu ${resp.status}`);
    const linhas = (await resp.json())
      .map((r) => ({ data: r.data.split("/").reverse().join("-"), valor: Number(r.valor) }))
      .filter((r) => Number.isFinite(r.valor))
      .sort((a, b) => a.data.localeCompare(b.data));
    cache.set(url, linhas);
    return linhas;
  }
  throw new Error("a SGS respondeu 304 sem dados em três janelas de datas");
}

Seja honesto com o seu código: o Banco Central não documenta esse comportamento, e ninguém publicou a causa. Quando escrevemos este artigo, ele não se repetiu: 15 janelas seguidas, de 35 a 49 dias, voltaram 200. É intermitente. Por isso a defesa é barata e fica no código de vez, em vez de esperar o problema voltar numa segunda de manhã.

Atenção

só repetir a mesma URL com no-store resolve o 304 que ficou guardado no navegador, mas não resolve o 304 que vem do servidor para aquela URL. No nosso caso, a saída foi mudar a janela.

5 Python: Focus e meta Selic juntos

Este script junta as duas APIs e trata o 304 do mesmo jeito. Executado em 06/11/2026:

import json, urllib.parse, urllib.request
from datetime import date, timedelta

OLINDA = "https://olinda.bcb.gov.br/olinda/servico/Expectativas/versao/v1/odata"
SGS_432 = "https://api.bcb.gov.br/dados/serie/bcdata.sgs.432/dados"

def get_json(url):
    req = urllib.request.Request(url, headers={"Cache-Control": "no-cache"})
    with urllib.request.urlopen(req, timeout=60) as r:
        if r.status == 304:              # sem corpo: não há JSON para ler
            return None
        return json.loads(r.read())

def focus_selic(data_relatorio):
    params = {
        "$filter": f"baseCalculo eq 0 and Data eq '{data_relatorio}'",
        "$select": "Data,Reuniao,Mediana,numeroRespondentes",
        "$format": "json",
    }
    url = f"{OLINDA}/ExpectativasMercadoSelic?{urllib.parse.urlencode(params, quote_via=urllib.parse.quote)}"
    return get_json(url)["value"]

def meta_selic(ate, folgas=(40, 41, 42)):
    br = lambda d: d.strftime("%d/%m/%Y")
    for folga in folgas:                 # num 304, outra janela = outra URL
        url = f"{SGS_432}?formato=json&dataInicial={br(ate - timedelta(days=folga))}&dataFinal={br(ate)}"
        try:
            linhas = get_json(url)
        except urllib.error.HTTPError as e:
            if e.code == 304:
                continue
            raise
        if linhas:
            return float(linhas[-1]["valor"])   # "valor" vem como texto: "13.75"
    raise RuntimeError("a SGS respondeu 304 sem dados em três janelas")

dia = "2026-10-30"
reunioes = focus_selic(dia)
proxima = sorted(reunioes, key=lambda r: (r["Reuniao"][-4:], int(r["Reuniao"][1:].split("/")[0])))[0]
print(f"Focus de {dia}: mediana para {proxima['Reuniao']} = {proxima['Mediana']}% ({proxima['numeroRespondentes']} respondentes)")
print(f"Meta Selic vigente em {dia}: {meta_selic(date(2026, 10, 30))}%")

Saída:

Focus de 2026-10-30: mediana para R7/2026 = 13.5% (139 respondentes)
Meta Selic vigente em 2026-10-30: 13.75%

Por que cada escolha:

  • O urllib levanta HTTPError num 304, porque não é 2xx. Por isso o except trata esse caminho e o if r.status == 304 fica como rede de segurança.
  • Cache-Control: no-cache aqui, cache: "no-store" no JavaScript. Não é incoerência. No navegador, o no-store é uma opção do fetch que manda o navegador não usar a cache dele. O urllib não tem cache nenhuma, então no Python o cabeçalho só pede a quem estiver no meio do caminho que não devolva uma cópia guardada. Trocar por no-store aqui não muda nada.
  • As reuniões são ordenadas por ano e depois pelo número. Ordenar como texto põe R1/2027 antes de R7/2026.
  • linhas[-1] é o último dia da janela, que é a data pedida em dataFinal. Por isso a data futura da série não entra.

6 Power Query: as duas consultas 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.

Primeiro, o Focus da Selic:

let
    Fonte = Json.Document(Web.Contents("https://olinda.bcb.gov.br", [
        RelativePath = "olinda/servico/Expectativas/versao/v1/odata/ExpectativasMercadoSelic",
        Query = [
            #"$filter" = "baseCalculo eq 0 and Data ge '2026-01-01'",
            #"$select" = "Data,Reuniao,Mediana,Minimo,Maximo,numeroRespondentes",
            #"$format" = "json"
        ]
    ])),
    Tabela = Table.FromRecords(Fonte[value]),
    Tipada = Table.TransformColumnTypes(Tabela, {
        {"Data", type date}, {"Mediana", type number}, {"Minimo", type number},
        {"Maximo", type number}, {"numeroRespondentes", Int64.Type}
    }, "en-US")
in
    Tipada

Depois, a meta Selic, com a mesma defesa contra o 304:

let
    DataFinal = Date.From(DateTime.LocalNow()),
    Br = (d as date) as text => Date.ToText(d, "dd/MM/yyyy"),
    Pedir = (folga as number) =>
        let
            Resp = Web.Contents("https://api.bcb.gov.br", [
                RelativePath = "dados/serie/bcdata.sgs.432/dados",
                Query = [
                    formato = "json",
                    dataInicial = Br(Date.AddDays(DataFinal, -folga)),
                    dataFinal = Br(DataFinal)
                ],
                Headers = [#"Cache-Control" = "no-cache"],
                ManualStatusHandling = {304}
            ]),
            Status = Value.Metadata(Resp)[Response.Status]
        in
            if Status = 304 then null else Json.Document(Resp),
    Buscar = (folgas as list) =>
        if List.IsEmpty(folgas) then error "a SGS respondeu 304 em todas as janelas"
        else
            let r = Pedir(List.First(folgas))
            in if r = null then @Buscar(List.Skip(folgas, 1)) else r,
    Linhas = Buscar({40, 41, 42}),
    Tabela = Table.FromRecords(Linhas),
    Tipada = Table.TransformColumns(Tabela, {
        {"data", each Date.FromText(_, [Format = "dd/MM/yyyy"]), type date},
        {"valor", each Number.FromText(_, "en-US"), type number}
    })
in
    Tipada

Por que montar assim:

  • 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.
  • ManualStatusHandling = {304}. Pela documentação, a lista desliga o tratamento embutido para esses status. Assim você consegue ler o status em vez de levar um erro. A leitura via Value.Metadata(Resp)[Response.Status] é o padrão usado pela comunidade.
  • Number.FromText(_, "en-US"). O valor vem como "13.75". Com cultura pt-BR, o ponto vira separador de milhar.
  • A recursão com @Buscar. Ela só chama a próxima janela se a anterior voltou 304, e para no primeiro 200.
Atenção

o Web.Contents só retenta sozinho os status 408, 429, 503, 504 e 509. Um 502 ou um 304 da SGS não entra nessa lista: ou você trata, ou a atualização falha.

Erros comuns

Erro 1

SGS sem dataInicial ou com janela maior que 10 anos (HTTP 406):

{"error":"O sistema aceita uma janela de consulta de, no máximo, 10 anos em séries de periodicidade diária","message":"Para acessar uma série de periodicidade diária, é necessário informar a dataInicial - parâmetro textual que representa a data de início da consulta, no formato dd/mm/aaaa. Pode-se também especificar a dataFinal (opcional) - parâmetro textual que representa a data final da consulta, no formato dd/mm/aaaa"}

Causa: série diária pedida sem janela, ou janela acima de 10 anos. Correção: informe dataInicial e, para histórico longo, fatie em pedaços menores e junte em ordem de data (o script está na seção 3). A própria resposta traz um campo syntax com a URL dos últimos 10 anos pronta.

Erro 2

SGS com data em ISO (HTTP 400):

{"erro":"dataInicial invalida. Informe uma data existente no formato dd/mm/aaaa."}

Causa: 2026-10-01 em vez de 01/10/2026. Correção: na SGS é sempre dd/mm/aaaa (na Olinda é o contrário).

Erro 3

SGS com formato errado (HTTP 400):

{"erro":"Formato invalido. Use json, csv, application/json ou text/csv."}
Erro 4

O 304 sem corpo, como aparecia no dashboard antes da correção:

Error: a SGS respondeu 304

Causa: a SGS devolveu 304 sem corpo a um pedido sem condição, e resp.ok é falso para 304. Correção: cache: "no-store" e outra janela de datas, como na seção 4.

Erro 5

Olinda: campo com maiúscula errada (HTTP 400):

"mensagem" : "The property 'indicador', used in a query expression, is not defined in type 'br.gov.bcb.olinda.servico.Expectativas.ExpectativaMercadoAnual'."

Causa: o OData diferencia maiúscula de minúscula. Correção: Indicador.

Erro 6

Olinda: texto sem aspas no filtro (HTTP 400):

"mensagem" : "The property 'IPCA', used in a query expression, is not defined in type 'br.gov.bcb.olinda.servico.Expectativas.ExpectativaMercadoAnual'."

Causa: Indicador eq IPCA. Correção: Indicador eq 'IPCA'.

Erro 7

Olinda: recurso mensal no plural (HTTP 400):

"mensagem" : "Cannot find EntitySet, Singleton, ActionImport or FunctionImport with name 'ExpectativasMercadoMensais'."

Causa: o nome certo é ExpectativaMercadoMensais. Correção: tire o "s" de "Expectativa".

Dica

os erros da Olinda vêm com o JSON entre /* e */. Se o seu código tentar ler como JSON direto, vai dar erro de parse em cima do erro original. Leia o corpo como texto primeiro.

Na prática, em Python:

import json, urllib.error, urllib.request

def ler_olinda(url):
    try:
        with urllib.request.urlopen(url, timeout=60) as r:
            return json.loads(r.read())
    except urllib.error.HTTPError as e:
        texto = e.read().decode("utf-8").strip()
        if texto.startswith("/*") and texto.endswith("*/"):
            texto = texto[2:-2]            # tira o /* e o */ antes de ler o JSON
        try:
            msg = json.loads(texto).get("mensagem", texto)
        except json.JSONDecodeError:
            msg = texto
        raise RuntimeError(f"Olinda respondeu {e.code}: {msg}") from None

ler_olinda("https://olinda.bcb.gov.br/olinda/servico/Expectativas/versao/v1/odata/"
           "ExpectativasMercadoMensais?$top=1&$format=json")

Executado em 06/11/2026, com o nome no plural de propósito:

RuntimeError: Olinda respondeu 400: Cannot find EntitySet, Singleton, ActionImport or FunctionImport with name 'ExpectativasMercadoMensais'.

A função não devolve None quando falha: ela levanta o erro com a mensagem da Olinda, e quem chamou sabe o que deu errado. As respostas de sucesso não vêm entre /* */, então o caminho normal continua sendo um json.loads direto.

Bônus

Dica bônus: a distância entre o mercado e a meta

Com as duas consultas no mesmo modelo, a métrica mais útil sai de graça: quanto o mercado espera que a Selic mude até a próxima reunião. Com os números de 30/10/2026: a mediana do Focus para a R7/2026 era 13,5% e a meta vigente, 13,75%. O mercado esperava um corte de 0,25 ponto percentual.

É o que o quadro da Selic do dashboard ao vivo mostra, com o ponto "Atual" vindo da SGS e as reuniões vindo do Focus. Abra, mude a data final e veja a conta refeita na hora.

Conclusão

Agora você consulta o Focus e a meta Selic sem depender de planilha baixada: sabe filtrar baseCalculo, achar o boletim mais recente, respeitar as datas e os limites da SGS e, principalmente, não deixar um 304 sem corpo apagar o seu painel.

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.

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 06/11/2026):

Comunidade (complemento, consultada em 06/11/2026):