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:
| Pergunta | API | Recurso |
|---|---|---|
| O que o mercado espera para a Selic, o IPCA, o PIB? | Olinda / Expectativas | ExpectativasMercadoSelic, ExpectativasMercadoAnuais e outros |
| Qual é a meta Selic hoje? | SGS | sé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 formatoaaaa-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.0usa as expectativas informadas a partir do 30º dia anterior à data de cálculo;1, a partir do 4º dia útil anterior.
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:
- Texto vai entre aspas simples:
Indicador eq 'IPCA'. Sem aspas, o OData lêIPCAcomo nome de campo. - Maiúscula importa: é
Indicador, masbaseCalculoenumeroRespondentescomeçam com minúscula. - O recurso mensal está no singular:
ExpectativaMercadoMensais, sem o "s" em "Expectativa". O mesmo vale paraExpectativaMercadoTop5Trimestral.
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'.
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/3devolveu 07/12, 08/12 e 09/12/2026. Para "a meta vigente hoje", usedataFinaligual à data que você quer, e não o/ultimos. - Série diária exige
dataIniciale 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. timedeltaem 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ã.
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
urlliblevantaHTTPErrornum 304, porque não é 2xx. Por isso oexcepttrata esse caminho e oif r.status == 304fica como rede de segurança. Cache-Control: no-cacheaqui,cache: "no-store"no JavaScript. Não é incoerência. No navegador, ono-storeé uma opção dofetchque manda o navegador não usar a cache dele. Ourllibnã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 porno-storeaqui não muda nada.- As reuniões são ordenadas por ano e depois pelo número. Ordenar como texto põe
R1/2027antes deR7/2026. linhas[-1]é o último dia da janela, que é a data pedida emdataFinal. 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:
RelativePatheQueryem 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 doWeb.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 viaValue.Metadata(Resp)[Response.Status]é o padrão usado pela comunidade.Number.FromText(_, "en-US"). Ovalorvem 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.
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
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.
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).
SGS com formato errado (HTTP 400):
{"erro":"Formato invalido. Use json, csv, application/json ou text/csv."}
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.
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.
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'.
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".
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.
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):
- Expectativas de Mercado — conjunto de dados — Portal de Dados Abertos do Banco Central
- API - Endpoint OData (Expectativas) — Portal de Dados Abertos do Banco Central
- Swagger da API Expectativas v1 — Banco Central / Olinda
- Documentação da API Expectativas — Banco Central / Olinda
- Histórico de alterações — Expectativas de Mercado — Banco Central
- Taxa de juros - Meta Selic definida pelo Copom (432) — Portal de Dados Abertos do Banco Central
- Web.Contents — Microsoft Learn
- Handle status codes by using Web.Contents — Microsoft Learn
- Refresh and dynamic data sources — Microsoft Learn
Comunidade (complemento, consultada em 06/11/2026):