BestsOddsApi — Guia de 5 minutos
Este guia leva você do zero até receber odds em tempo real. A referência completa do contrato está em /docs e o arquivo OpenAPI 3.1 em /docs/openapi.yaml.
| Endereço | |
|---|---|
| API REST | https://api.bestoddsapi.com/api/v1 |
| Token OAuth2 | https://api.bestoddsapi.com/oauth/token |
| WebSocket | wss://ws.bestoddsapi.com (o endereço exato vem no ticket) |
| Portal do cliente | https://api.bestoddsapi.com/portal |
Exemplos executáveis: cURL, PHP e Node.js. Nunca coloque credenciais no código: todos os exemplos leem variáveis de ambiente.
export BESTSODDSAPI_BASE_URL="https://api.bestoddsapi.com"
export BESTSODDSAPI_CLIENT_ID="<client_id>"
export BESTSODDSAPI_CLIENT_SECRET="<client_secret>"
1. Obter credenciais
As credenciais são do tipo OAuth2 Client Credentials (máquina-a-máquina):
client_id— público, identifica a credencial.client_secret— sigiloso, exibido somente na criação/rotação.
Elas são emitidas pelo administrador BestsOddsApi ou pelo portal do cliente (/portal). Uma conta pode ter várias credenciais (ex.: produção e homologação), cada uma revogável individualmente. Guarde o secret em um cofre (variável de ambiente, secret manager) e rotacione periodicamente.
2. Solicitar um access token
curl -s -X POST "$BESTSODDSAPI_BASE_URL/oauth/token" \
-H "Accept: application/json" \
-d grant_type=client_credentials \
-d client_id="$BESTSODDSAPI_CLIENT_ID" \
-d client_secret="$BESTSODDSAPI_CLIENT_SECRET" \
-d scope="events:read odds:read results:read usage:read ws:connect"
Resposta:
{ "token_type": "Bearer", "expires_in": 3600, "access_token": "eyJ..." }
Envie o token em todas as chamadas: Authorization: Bearer <access_token>.
Reutilize o token até perto de expires_in — não peça um token por requisição.
Solicite apenas os scopes de que precisa.
3. Consultar eventos ao vivo
curl -s "$BESTSODDSAPI_BASE_URL/api/v1/events/live?sport=soccer&per_page=20" \
-H "Authorization: Bearer $TOKEN" -H "Accept: application/json"
{
"data": [
{
"id": "9f1c2d3e-4b5a-4c6d-8e7f-0a1b2c3d4e5f",
"sport": "soccer",
"league": { "id": 101, "name": "Brasileirão Série A", "country": "Brazil" },
"home_team": { "id": 5001, "name": "Time Casa" },
"away_team": { "id": 5002, "name": "Time Visitante" },
"starts_at": "2026-10-16T12:00:00Z",
"status": "live",
"scores": { "home": 1, "away": 0 },
"source_updated_at": "2026-10-16T12:59:58Z"
}
],
"meta": {
"served_at": "2026-10-16T13:00:00Z",
"source_updated_at": "2026-10-16T12:59:58Z",
"stale": false,
"request_id": "01J9Z6Y0F3QW8K2V7T4N5M6P7R",
"pagination": { "per_page": 20, "next_cursor": null, "prev_cursor": null }
}
}
Use sempre o id interno (UUID) do BestsOddsApi. IDs do fornecedor de origem não
são expostos.
4. Consultar odds de um evento
curl -s "$BESTSODDSAPI_BASE_URL/api/v1/events/$EVENT_ID/odds?market=1x2,totals&bookmaker=bet365" \
-H "Authorization: Bearer $TOKEN" -H "Accept: application/json"
{
"data": [
{
"event_id": "9f1c2d3e-4b5a-4c6d-8e7f-0a1b2c3d4e5f",
"bookmaker": { "id": 3, "slug": "bet365", "name": "Bet365" },
"market": "1x2",
"selection_key": "home",
"line": null,
"price": "1.8500",
"suspended": false,
"source_updated_at": "2026-10-16T12:59:58Z"
}
],
"meta": { "served_at": "...", "source_updated_at": "...", "stale": false,
"request_id": "...", "sequence": 1842 }
}
priceelinesão strings decimais. Use um tipo decimal (bcmath,decimal.js,BigDecimal) — nunca compare como float.suspended: true= seleção temporariamente fechada no fornecedor.meta.sequenceé a última sequência do stream de odds incluída no snapshot; usada para reconciliar com o WebSocket.
5. Conectar ao WebSocket
O streaming usa o protocolo Pusher 7 (Laravel Reverb) atrás de um gateway de
admissão: a conexão só é aceita com um ticket de uso único emitido pela API.
Detalhes completos: docs/streaming/websocket-admission.md.
Fluxo:
-
Com o Bearer token (scope
ws:connect; o plano precisa incluirwebsocket), peça um ticket (TTL ~45 s). Recomendado: uma conexão por esporte, enviandosport— a conexão aceita só os canais desse esporte e o limite de conexões (max_ws_connections) passa a valer por esporte, cada uma com atémax_subscriptionscanais (padrão 100):curl -s -X POST "$BESTSODDSAPI_BASE_URL/api/v1/ws/authorize" \ -H "Authorization: Bearer $TOKEN" -H "Accept: application/json" \ -H "Content-Type: application/json" -d '{"sport":"soccer"}'{ "data": { "ticket": "wst_...", "expires_in": 45, "expires_at": "2026-10-16T13:00:45Z", "connection": { "protocol": "pusher", "url": "wss://ws.bestoddsapi.com/app/APP_KEY?protocol=7&client=bestsoddsapi&version=1.0&ticket=wst_...", "app_key": "APP_KEY", "ws_path": "/t/wst_...", "channel_template": "private-customer.<seu customerId>.event.{eventId}.odds", "live_channel_template": "private-customer.<seu customerId>.live.{sport}", "event": "odds.changed", "max_subscriptions": 100, "sport": "soccer", "max_connections": 1 } }, "meta": { "...": "..." } } -
Conecte uma única vez, dentro do TTL:
- qualquer cliente WebSocket:
connection.url(wss://HOST/app/{APP_KEY}?protocol=7&client=…&version=…&ticket={ticket}); pusher-js/ Laravel Echo (não adicionam query string): usewsPath: connection.ws_path(wss://HOST/t/{ticket}/app/{APP_KEY}).
Rejeições no handshake são respostas HTTP simples (sem upgrade):
401ticket ausente/inválido/expirado/reutilizado,403ticket revogado ou origem não permitida,429limite de conexões simultâneas (max_ws_connections) atingido,503indisponível. - qualquer cliente WebSocket:
-
Aguarde
pusher:connection_established. -
Assine o canal do evento trocando
{eventId}emchannel_template:{"event":"pusher:subscribe","data":{"channel":"private-customer.{customerId}.event.{eventId}.odds"}}. O campoauthé opcional e ignorado — o gateway autoriza pelo ticket (cliente e plano); você não pode forjar ocustomerId. Em pusher-js usechannelAuthorization: { customHandler: (p, cb) => cb(null, { auth: '' }) }. Sucesso:pusher_internal:subscription_succeeded; falha:pusher:subscription_errorcomstatus403 (canal de outro cliente, evento de esporte fora do seu plano, evento inexistente/expurgado), 429 (limite de canais) ou 503. -
Logo após a assinatura chega
odds.snapshotsó para a sua conexão, com o estado atual (do armazenamento BestsOddsApi — Redis, com PostgreSQL de reserva —, nunca uma chamada à origem):{ "event_id": "9f1c...", "sequence": 1842, "source_updated_at": "...", "stale": false, "emitted_at": "...", "odds": [{ "event_id": "9f1c...", "bookmaker": { "id": 3, "slug": "bet365", "name": "Bet365" }, "market": "1x2", "selection_key": "home", "line": null, "price": "1.8500", "suspended": false, "source_updated_at": "..." }] }oddstem o mesmo formato deGET /events/{id}/odds. Depois chegam deltasodds.changed: uma mensagem por jogo e por atualização, com todos os mercados que mudaram. Aplique só os comsequencemaior que a do snapshot (sequence: null= o stream ainda não emitiu nada; aplique qualquer delta). Cada item demarketstraz o estado completo daquele mercado (substitua o mercado inteiro;selections: []= mercado removido), então reaplicar é inofensivo:{ "event_id": "9f1c...", "sequence": 1843, "markets": [ { "bookmaker_id": 3, "market": "match_winner", "source_updated_at": "...", "selections": [{ "selection_key": "home", "line": null, "price": "1.8700", "suspended": false, "source_updated_at": "..." }] } ], "source_updated_at": "...", "emitted_at": "...", "stale": false } -
Keepalive:
pusher:ping→pusher:pong. Eventos de cliente (client-*) são rejeitados.
Feed de eventos ao vivo por esporte
Para acompanhar a lista ao vivo sem polling, assine
private-customer.{customerId}.live.{sport} (live_channel_template, troque
{sport} pelo slug, ex.: soccer). O esporte precisa estar no seu plano e
habilitado (senão 403); o canal conta para o limite de canais da conexão.
live.snapshot(logo após a assinatura):sport,sequence,events[](mesmo formato dos itens deGET /events/live),source_updated_at(última sincronização bem-sucedida),stale,emitted_at.live.updated(após cada coleta que mudou algo):sport,sequence,events[]apenas com eventos cujo status/placar/timer mudou ou que entraram ao vivo,removed[]com ids que deixaram de estar ao vivo,source_updated_at,emitted_at. Asequenceé por esporte: salto ⇒ recarregueGET /events/live?sport=….
A coleta na origem é única e centralizada: todos os clientes recebem o mesmo dado armazenado do nosso lado, independentemente de quantos estejam conectados.
Encerramentos iniciados pelo servidor chegam como pusher:error seguido do
fechamento da conexão:
| Código | Significado | Ação |
|---|---|---|
4009 |
sessão revogada (credencial revogada/conta suspensa) | não reconecte com a mesma credencial |
4301 |
limite de mensagens por segundo excedido | reduza a taxa, reconecte com backoff |
4100 |
limite de conexões (lease perdido) | reconecte com backoff |
4201 |
pong timeout (sessão zumbi) | reconecte |
4200 |
servidor reiniciando | reconecte com backoff |
Resultados em tempo real
Nos canais do evento e do feed ao vivo do esporte chega result.updated quando
o resultado final de um jogo é gravado ou corrigido. O conteúdo é o mesmo de
GET /events/{id}/results (placar e markets liquidados, veja "Retenção e
liquidação de resultados") mais emitted_at. No canal do esporte chegam os
resultados de todos os jogos dele, sem precisar assinar cada jogo.
Limites: conexões simultâneas (max_ws_connections, por esporte quando o
ticket tem sport), canais por conexão (max_ws_subscriptions) e taxa de
mensagens recebidas.
6. Reconexão e reconciliação
A entrega via WebSocket é best effort (at-most-once): mensagens podem ser perdidas em quedas de rede. Por isso:
sequencepor evento (e por esporte no feed ao vivo) é monotônica. Se chegarsequence > última + 1, houve perda: descarte o estado local e refaça o snapshot REST (ou reassine o canal para receber um novo snapshot). Mensagens comsequence <= últimasão duplicadas/antigas: ignore.- Reconexão com exponential backoff + jitter (ex.: base 1 s, fator 2, teto 30 s, jitter aleatório de 0–100 % do atraso). Nunca reconecte em loop apertado.
- Cada reconexão exige um novo ticket (tickets são de uso único e expiram). Se o access token expirou, peça um novo token antes.
- Após reconectar: reassine os canais; cada assinatura entrega um novo
odds.snapshot/live.snapshot. - Detecte sessões zumbis: se não houver
pusher:pong/atividade dentro do intervalo de heartbeat, feche e reconecte. - Revogação de credencial ou suspensão da conta derruba sessões ativas
(
pusher:error4009, em poucos segundos) e impede novas conexões (401/403no token, na API e no/ws/authorize): não insista em reconectar.
delay = min(30s, 1s * 2^tentativa); sleep(random(0, delay))
Referência rápida
Endpoints (/api/v1)
| Método | Rota | Scope |
|---|---|---|
| GET | /sports |
events:read |
| GET | /leagues?sport_id= |
events:read |
| GET | /events/live |
events:read |
| GET | /events/upcoming?from=&to= |
events:read |
| GET | /events/{id} |
events:read |
| GET | /events/{id}/odds?bookmaker=&market= |
odds:read |
| GET | /events/{id}/results |
results:read |
| GET | /results?date=YYYY-MM-DD |
results:read |
| GET | /usage |
usage:read |
| POST | /ws/authorize |
ws:connect |
Além do scope, o plano do cliente precisa habilitar a funcionalidade
(events, odds, results, websocket) e, quando configurado, o esporte.
Filtros
| Parâmetro | Onde | Formato |
|---|---|---|
sport |
eventos, resultados | slug (soccer) |
sport_id |
/leagues |
inteiro |
league_id |
eventos, resultados | inteiro |
from, to |
/events/upcoming |
ISO 8601 UTC; janela máx. 7 dias |
bookmaker, market |
/events/{id}/odds |
lista separada por vírgula |
date |
/results |
YYYY-MM-DD (UTC), dentro da retenção |
Parâmetros desconhecidos retornam 400 bad_request; valores inválidos
retornam 422 validation_failed.
Paginação
Cursor opaco: per_page (1–100, padrão 50) e cursor. Continue enquanto
meta.pagination.next_cursor não for null. Não construa cursores manualmente.
Erros
{ "error": { "code": "rate_limited", "message": "Too many requests.", "details": {} },
"request_id": "01J9..." }
| HTTP | code |
Quando |
|---|---|---|
| 400 | bad_request |
Parâmetro não permitido / requisição malformada |
| 401 | unauthenticated |
Token ausente, inválido, expirado ou revogado |
| 403 | forbidden |
Scope ausente, conta suspensa, funcionalidade fora do plano |
| 404 | not_found |
Recurso inexistente ou fora do seu alcance |
| 405 | method_not_allowed |
Método HTTP não suportado na rota |
| 409 | conflict |
Conflito de estado (reservado; nenhuma rota atual retorna) |
| 422 | validation_failed |
Valor de parâmetro inválido (details por campo) |
| 429 | rate_limited |
Limite excedido — respeite Retry-After |
| 500 | internal_error |
Erro inesperado — informe o request_id |
| 503 | service_unavailable |
Indisponibilidade temporária — retry com backoff |
Use code (não message) na sua lógica. Informe o request_id ao suporte.
Limites e cabeçalhos
Limites por minuto, hora, dia e mês (janelas fixas do calendário UTC), por
cliente — somando todas as suas credenciais e endpoints. Requisições rejeitadas
(429) não consomem cota. Consulte GET /usage.
| Cabeçalho | Significado |
|---|---|
X-RateLimit-Limit |
Limite da janela mais restritiva aplicada |
X-RateLimit-Remaining |
Requisições restantes nessa janela |
X-RateLimit-Reset |
Unix epoch (s) em que a janela reinicia |
Retry-After |
Segundos a aguardar (em 429 e, quando aplicável, 503) |
X-Request-Id |
ID de correlação (pode ser enviado por você) |
Atualização, latência e stale
- O BestsOddsApi coleta da origem de forma centralizada; sua requisição nunca aciona o fornecedor. Intervalos de coleta são alvos configuráveis (ex.: eventos ao vivo 3–5 s, odds ao vivo 2–5 s conforme quota da origem, pré-jogo 15–60 s) — não são SLA.
meta.source_updated_at: última atualização na origem.meta.stale: true: a origem está indisponível/atrasada e você recebeu o último snapshot persistido. Decida na sua aplicação se o dado ainda serve.- Listas de eventos (
/events/live,/events/upcoming):stalereflete a última coleta bem-sucedida de cada esporte coberto pela consulta (mais antiga que 3× a cadência alvo ⇒true);meta.source_updated_até o horário mais antigo entre essas coletas.
Retenção e liquidação de resultados
Cada resultado traz markets: o resultado de cada seleção dos mercados
cobertos (won, lost, void, half_won, half_lost), um item por linha
que teve odds no jogo, para fechar bilhetes:
{"market": "asian_handicap", "line": "-0.750", "selections": {"home": "half_won", "away": "half_lost"}, "others": null}
Lista dos mercados liquidados e regras: Cobertura, seção 4.
Resultados finalizados ficam disponíveis por cinco dias após finished_at
(campo purge_after). Depois disso são removidos e GET /events/{id}/results
retorna 404; GET /results?date= aceita apenas datas dentro da janela.
Armazene do seu lado o que precisar guardar por mais tempo.
Política de versão
- A versão maior está no caminho:
/api/v1. - Dentro da v1 podem surgir mudanças compatíveis: novos campos, endpoints,
filtros e valores de enum (ex.: novos
status; trate desconhecidos comounknown). Ignore campos que não conhece. - Mudanças incompatíveis só em nova versão maior (
/api/v2), com aviso prévio e período de convivência. - Campos novos podem aparecer em
connection.*de/ws/authorizee nas mensagens WebSocket; ignore os que não conhece.