BestsOddsApi

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):

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 }
}

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:

  1. Com o Bearer token (scope ws:connect; o plano precisa incluir websocket), peça um ticket (TTL ~45 s). Recomendado: uma conexão por esporte, enviando sport — 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_subscriptions canais (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": { "...": "..." }
    }
    
  2. 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): use wsPath: connection.ws_path (wss://HOST/t/{ticket}/app/{APP_KEY}).

    Rejeições no handshake são respostas HTTP simples (sem upgrade): 401 ticket ausente/inválido/expirado/reutilizado, 403 ticket revogado ou origem não permitida, 429 limite de conexões simultâneas (max_ws_connections) atingido, 503 indisponível.

  3. Aguarde pusher:connection_established.

  4. Assine o canal do evento trocando {eventId} em channel_template: {"event":"pusher:subscribe","data":{"channel":"private-customer.{customerId}.event.{eventId}.odds"}}. O campo auth é opcional e ignorado — o gateway autoriza pelo ticket (cliente e plano); você não pode forjar o customerId. Em pusher-js use channelAuthorization: { customHandler: (p, cb) => cb(null, { auth: '' }) }. Sucesso: pusher_internal:subscription_succeeded; falha: pusher:subscription_error com status 403 (canal de outro cliente, evento de esporte fora do seu plano, evento inexistente/expurgado), 429 (limite de canais) ou 503.

  5. Logo após a assinatura chega odds.snapshot só 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": "..." }]
    }
    

    odds tem o mesmo formato de GET /events/{id}/odds. Depois chegam deltas odds.changed: uma mensagem por jogo e por atualização, com todos os mercados que mudaram. Aplique só os com sequence maior que a do snapshot (sequence: null = o stream ainda não emitiu nada; aplique qualquer delta). Cada item de markets traz 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
    }
    
  6. 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.

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:

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

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