Stream Unificado
GET /api/v1/stream — Atualizações em tempo real de odds e oportunidades via Server-Sent Events (SSE).
Requer Add-on WebSocket ($99/mês) em qualquer plano pago, ou Enterprise (incluído). O plano gratuito não oferece suporte a streaming.
Autenticação
Passe sua API key via cabeçalho ou parâmetro de consulta:
# Cabeçalho (recomendado para uso server-side)
curl -H "X-API-Key: sk_live_your_key" \
https://api.sharpapi.io/api/v1/stream
# Parâmetro de consulta (obrigatório para EventSource no navegador)
https://api.sharpapi.io/api/v1/stream?api_key=sk_live_your_keyParâmetros de Consulta
| Parâmetro | Tipo | Padrão | Descrição |
|---|---|---|---|
channel | string | opportunities | O que transmitir: odds, opportunities, gamestate (apenas Enterprise) ou all. Também aceita o alias plural channels. Uma lista separada por vírgulas com mais de um canal colapsa para all — veja a nota abaixo. |
sport | string | todos | Filtrar por esporte(s), separados por vírgula (ex.: basketball, football, ice_hockey) |
sportsbook | string | permitidos pelo plano | Filtrar por sportsbook(s), separados por vírgula |
league | string | todas | Filtrar por liga(s), separadas por vírgula |
event_id | string | todos | Filtrar por ID(s) de evento, separados por vírgula |
market | string | todos | Filtrar por tipo(s) de mercado, separados por vírgula (ex.: moneyline, point_spread, total_points, player_points) |
min_ev | number | 2.0 | Porcentagem mínima de EV para eventos de oportunidade +EV |
min_profit | number | 0.5 | Porcentagem mínima de lucro apenas para eventos de arbitragem (não se aplica à filtragem de low-hold) |
state | string | — | Código de estado dos EUA apenas para roteamento de deep links; não filtra nem altera odds. Aceita os 50 códigos de estados dos EUA e dc. Um código válido adiciona ?state= aos deep links de odds. Códigos omitidos, vazios ou não suportados não adicionam sufixo, então o redirecionamento aplica seu próprio padrão pa. Códigos não suportados emitem filter_warning após connected; a conexão é estabelecida. |
api_key | string | — | API key (alternativa à autenticação por cabeçalho para EventSource no navegador) |
Opções de Channel
| Channel | Eventos Entregues | Caso de Uso |
|---|---|---|
odds | snapshot, odds:update, odds:removed, heartbeat | Acompanhar movimentações de odds |
opportunities | snapshot, ev:detected/expired, arb:detected/expired, middles:detected/expired, low_hold:detected/expired, heartbeat | Alertar sobre oportunidades |
gamestate | gamestate:snapshot, gamestate:update, gamestate:final, heartbeat | Placares ao vivo, períodos, cronômetros e dados situacionais por evento. Cada gamestate:update reemite o slate completo do momento — no SSE não existe gamestate:removed (veja gamestate:update). Apenas plano Enterprise. Veja Live Game State para o catálogo completo de campos. |
all | Todos os tipos de evento | Visão completa em tempo real |
Uma assinatura por stream SSE. Para manter paridade com a API WebSocket, o endpoint aceita tanto channel quanto o plural channels, e ambos toleram um valor separado por vírgulas. Mas uma conexão SSE mantém uma única assinatura: se você passar mais de um canal válido (ex.: ?channels=odds,opportunities), a requisição colapsa para channel=all em vez de retornar um erro. Um único valor (?channel=odds) transmite apenas aquele canal. Para assinar de forma seletiva um subconjunto específico de canais, use a API WebSocket, que oferece filtragem multicanal real com channels= em uma única conexão.
Rotas de Conveniência
| Rota | Equivalente A |
|---|---|
GET /api/v1/stream/odds | /api/v1/stream?channel=odds |
GET /api/v1/stream/opportunities | /api/v1/stream?channel=opportunities |
GET /api/v1/stream/gamestate | /api/v1/stream?channel=gamestate |
GET /api/v1/stream/all | /api/v1/stream?channel=all |
GET /api/v1/stream/events/:eventId | /api/v1/stream?channel=odds&event_id=:eventId |
Tipos de Evento SSE
connected
Enviado imediatamente quando o stream é estabelecido.
event: connected
data: {"stream_id":"stream_1704960637000","channel":"all","filters":{"sportsbook":null,"sport":["basketball"],"league":["nba"],"event":null,"market":null},"reconnected":false}| Campo | Tipo | Descrição |
|---|---|---|
stream_id | string | Identificador único do stream |
channel | string | Eco do channel solicitado (odds, opportunities ou all) |
filters | object | Eco dos filtros ativos |
reconnected | boolean | true se esta for uma reconexão via Last-Event-ID — também num resume bem-sucedido, então não é o sinal para limpar o estado |
resumed | boolean | Presente quando você reconectou com um Last-Event-ID. true → o servidor está reenviando os eventos odds que você perdeu (com replayed_count) em vez de enviar um snapshot; se não conseguir concluir o reenvio, um snapshot completo vem mesmo assim, rotulado com mode: "full_resync" no snapshot:complete. false → o servidor não conseguiu retomar; um snapshot completo é enviado e fallback_reason diz por quê |
replayed_count | number | Com resumed: true — quantos eventos em buffer são reenviados |
fallback_reason | string | Com resumed: false — por que o resume recorreu ao snapshot (por exemplo seq_too_old, process_restarted, filter_changed, channel_unsupported). Informativo; a recuperação é a mesma: aceite o snapshot completo. Lista completa de valores |
trial | object | undefined | Presente se o usuário estiver em um trial de streaming. Contém active, expires_at, remaining_hours, max_streams |
snapshot
Despejo completo de dados enviado após connected. Contém todas as odds ou oportunidades atuais que correspondem aos seus filtros. Conjuntos grandes são divididos em múltiplos eventos snapshot (até 1000 itens cada).
Cada objeto de odds no snapshot contém todos os campos — esta é a forma completa de Odds que seu cliente deve armazenar localmente. Eventos odds:update subsequentes enviam apenas os campos alterados (veja abaixo).
Nos channels opportunities e all, os fragmentos de oportunidades trazem seu array sob o tipo de oportunidade em vez de odds — ev, arbitrage, middles ou low_hold — com os mesmos campos count, total, offset e has_more. Armazene esses itens também por id: um ev:detected posterior (ou arb:/middles:/low_hold:detected) com o mesmo id é uma atualização de um deles.
event: snapshot
id: evt_00001
data: {"odds":[{"id":"123456","sportsbook":"draftkings","event_id":"nba_phosuns_phi76ers_2026-02-08","sport":"basketball","league":"nba","home_team":"PHI 76ers","away_team":"PHO Suns","market_type":"moneyline","selection":"PHO Suns","selection_type":"away","odds_american":-155,"odds_decimal":1.645,"odds_probability":0.608,"line":null,"event_start_time":"2026-02-08T19:00:00Z","is_live":false,"timestamp":"2026-02-08T18:47:20Z","deep_link":"https://sportsbook.draftkings.com/event/..."}],"count":1000,"total":3200,"offset":0,"has_more":true}| Campo | Tipo | Descrição |
|---|---|---|
odds | array | Array de objetos Odds completos (veja endpoint Odds para todos os campos) |
count | number | Número de odds neste fragmento |
total | number | Número total de odds que correspondem aos filtros |
offset | number | Offset deste fragmento no resultado completo |
has_more | boolean | true se mais fragmentos snapshot virão a seguir |
snapshot:complete
Sinaliza que todos os snapshots iniciais foram enviados. Seguro para ocultar estados de carregamento após recebê-lo.
event: snapshot:complete
id: evt_00005
data: {"status":"ready","books":["draftkings","fanduel"],"total_odds":3200}odds:update
Disparado quando as odds mudam para um sportsbook. Enviado apenas nos channels odds ou all.
Payload delta compacto. Eventos delta contêm apenas campos que podem mudar entre atualizações — id, odds_american, odds_decimal, odds_probability, line, is_live e timestamp. Campos estáticos como sportsbook, sport, league, home_team, away_team, market_type, selection, deep_link e event_start_time não são incluídos nos deltas. Mescle cada delta no seu mapa local de odds por id usando os objetos completos recebidos no snapshot inicial. Algumas linhas chegam completas em vez de como delta: uma linha com um id que você ainda não tem é enviada completa, com todos os campos de Odds, no mesmo evento odds:update — armazene-a como está em vez de descartá-la. Veja Migração: Deltas SSE Compactos abaixo.
event: odds:update
id: evt_00042
data: {"odds":[{"id":"123456","odds_american":-150,"odds_decimal":1.667,"odds_probability":0.6,"line":null,"is_live":false,"timestamp":"2026-02-08T18:47:38Z"}],"count":1,"book":"draftkings","partial":false}Campos do objeto delta (OddsDelta):
| Campo | Tipo | Descrição |
|---|---|---|
id | string | ID único da odd — corresponde ao id do snapshot inicial |
odds_american | number | Odds americanas atualizadas (ex.: -150) |
odds_decimal | number | Odds decimais atualizadas (ex.: 1.667) |
odds_probability | number | Probabilidade implícita atualizada (ex.: 0.6) |
line | number | null | Linha/spread atualizada (ex.: -3.5), ou null para moneyline |
is_live | boolean | Se o evento está atualmente ao vivo |
timestamp | string | Horário ISO 8601 em que a SharpAPI atualizou esta odd pela última vez através do seu pipeline — avança a cada ciclo de ingestão. É um sinal de frescor / liveness do feed; não é quando o preço mudou pela última vez. Veja Entendendo o campo timestamp. |
Campos do envelope:
| Campo | Tipo | Descrição |
|---|---|---|
odds | array | Array de objetos OddsDelta (compactos — apenas campos dinâmicos) |
count | number | Número de odds neste fragmento |
book | string | Sportsbook que sofreu alteração (ex.: "draftkings") |
partial | boolean | true se mais fragmentos virão para este lote de atualização |
ev:detected
Uma nova oportunidade de valor esperado positivo, ou uma versão atualizada de uma já enviada (mesmo id). Enviado apenas nos channels opportunities ou all.
:detected significa nova ou atualizada — faça upsert por id. Desde 2026-09-26, o stream SSE reenvia uma oportunidade cujo conteúdo mudou sob o mesmo id — um novo preço, EV%, probabilidade justa, odds das pernas, is_suspended ou quality_tier — no evento normal ev:detected, arb:detected, middles:detected ou low_hold:detected, como a API WebSocket faz. Antes dessa data, o stream SSE entregava essas atualizações de forma irregular — algumas mudanças de conteúdo chegavam ao cliente sob o mesmo id, a maioria não —, de modo que um cliente que ignorava um id repetido podia manter uma versão desatualizada até a oportunidade expirar ou o cliente se reconectar.
- Armazene as oportunidades por
ide substitua a versão armazenada a cada item de:detected. - Alerte apenas sobre um
idque você ainda não viu. Umidconhecido é uma atualização, não uma nova oportunidade. Conte como vistos os ids dos fragmentos desnapshote esqueça umidquando*:expiredo listar.
Eventos *:expired e o formato do payload não mudam.
event: ev:detected
data: {"opportunities":[{"id":"a1b2c3d4e5f6","game_id":"nba_phosuns_phi76ers_2026-02-08","ev_percentage":4.35,"odds_american":-105,"odds_decimal":1.952,"no_vig_odds":-101,"selection":"PHO Suns -3.5","market":"point_spread","line":-3.5,"sportsbook":"draftkings","game":"PHO Suns @ PHI 76ers","sport":"basketball","league":"nba","home_team":"PHI 76ers","away_team":"PHO Suns","start_time":"2026-02-08T19:00:00.000Z","is_live":false,"confidence_score":72,"kelly_percent":3.8,"book_count":4,"detected_at":"2026-02-08T18:47:20.000Z"}],"count":1,"type":"ev"}Os quatro eventos :detected compartilham este envelope:
| Campo | Tipo | Descrição |
|---|---|---|
opportunities | array | Oportunidades novas ou atualizadas. Faça upsert de cada uma por id |
count | number | Número de itens em opportunities |
type | string | ev, arbitrage, middles ou low_hold |
ev:expired
Uma oportunidade +EV detectada anteriormente não está mais disponível. expired lista os ids a remover; count e type são como em ev:detected.
event: ev:expired
data: {"expired":["a1b2c3d4e5f6"],"count":1,"type":"ev"}arb:detected
Uma nova oportunidade de arbitragem, ou uma versão atualizada de uma já enviada (mesmo id). Enviado apenas nos channels opportunities ou all.
event: arb:detected
data: {"opportunities":[{"id":"61c501b83ce932d1","event_id":"nba_phosuns_phi76ers_2026-02-08","event_name":"PHO Suns @ PHI 76ers","sport":"basketball","league":"nba","market_type":"moneyline","line":null,"profit_percent":2.8,"implied_total":97.2,"is_live":false,"legs":[{"sportsbook":"draftkings","selection":"PHO Suns","odds_american":150,"odds_decimal":2.5,"implied_probability":0.4,"stake_percent":41.4},{"sportsbook":"fanduel","selection":"PHI 76ers","odds_american":-130,"odds_decimal":1.769,"implied_probability":0.5652,"stake_percent":58.6}],"detected_at":"2026-02-08T18:47:21.000Z"}],"count":1,"type":"arbitrage"}arb:expired
Uma oportunidade de arbitragem detectada anteriormente não está mais disponível.
event: arb:expired
data: {"expired":["61c501b83ce932d1"],"count":1,"type":"arbitrage"}middles:detected
Uma nova oportunidade de middle, ou uma versão atualizada de uma já enviada (mesmo id). Enviado apenas nos channels opportunities ou all.
event: middles:detected
data: {"opportunities":[{"id":"middle_abc123","event_id":"nba_phosuns_phi76ers_2026-02-08","event_name":"PHO Suns @ PHI 76ers","sport":"basketball","league":"nba","market_type":"player_points","side1":{"book":"draftkings","selection":"Over 22.5","line":22.5,"odds":{"american":-110,"decimal":1.909,"probability":0.5238,"fair_probability":0.51},"stake_percent":50,"odds_age_seconds":2.1,"deep_link":null},"side2":{"book":"fanduel","selection":"Under 23.5","line":23.5,"odds":{"american":-105,"decimal":1.952,"probability":0.5122,"fair_probability":0.49},"stake_percent":50,"odds_age_seconds":1.5,"deep_link":null},"middle_size":1,"middle_numbers":[23],"middle_probability":0.12,"expected_value":3.5,"quality_score":85,"detected_at":"2026-02-08T18:47:22.000Z"}],"count":1,"type":"middles"}middles:expired
Uma oportunidade de middle detectada anteriormente não está mais disponível.
event: middles:expired
data: {"expired":["middle_abc123"],"count":1,"type":"middles"}low_hold:detected
Uma nova oportunidade de low-hold, ou uma versão atualizada de uma já enviada (mesmo id). Enviado apenas nos channels opportunities ou all.
event: low_hold:detected
data: {"opportunities":[{"id":"lowhold_abc123","event_id":"nba_phosuns_phi76ers_2026-02-08","event_name":"PHO Suns @ PHI 76ers","sport":"basketball","league":"nba","market_type":"moneyline","line":null,"home_team":"PHI 76ers","away_team":"PHO Suns","start_time":"2026-02-08T19:00:00.000Z","hold_percentage":1.2,"is_live":false,"all_books":["draftkings","fanduel"],"side1":{"selection":"PHO Suns","books":["draftkings"],"line":null,"odds":{"american":-108,"decimal":1.926,"implied_probability":0.5192,"fair_probability":0.5096},"deep_links":{"draftkings":"https://sportsbook.draftkings.com/event/..."}},"side2":{"selection":"PHI 76ers","books":["fanduel"],"line":null,"odds":{"american":110,"decimal":2.1,"implied_probability":0.4762,"fair_probability":0.4904},"deep_links":{"fanduel":"https://sportsbook.fanduel.com/event/..."}},"detected_at":"2026-02-08T18:47:22.000Z"}],"count":1,"type":"low_hold"}low_hold:expired
Uma oportunidade de low-hold detectada anteriormente não está mais disponível.
event: low_hold:expired
data: {"expired":["lowhold_abc123"],"count":1,"type":"low_hold"}gamestate:snapshot
Slate ao vivo completo do momento, enviado uma única vez após connected no
channel gamestate (ou all). O payload é uma lista plana de linhas de
evento — cada linha tem o mesmo formato de um evento REST de
Live Game State, mais o event_id.
event: gamestate:snapshot
data: {"data":[{"event_id":"atp_jdoe_rroe_2026-07-03","home_team":"J. Doe","away_team":"R. Roe","sport":"tennis","league":"atp","home_score":9,"away_score":10,"game_period":"S3","is_live":true,"primary_book":"draftkings","book_count":4}]}gamestate:update
Disparado a cada ciclo de atualização do gamestate. Enviado apenas nos
channels gamestate ou all.
Reemissão do slate completo — não é um delta. Diferente de odds:update,
cada gamestate:update por SSE carrega o slate ao vivo completo que
corresponde aos seus filtros. Não existe evento gamestate:removed no
SSE — um evento finalizado continua no payload como sua
linha status: "final" durante a
janela de carry e
depois deixa de aparecer. Substitua seu gamestate local por inteiro a cada
gamestate:update; não faça merge como delta, ou eventos encerrados ficarão
no seu estado para sempre. Um caso limite: com filtros sport/league
definidos, um ciclo que não casa com nenhum evento não envia
nenhum gamestate:update (apenas streams sem filtro recebem o payload
vazio {"data": []}), então o último evento encerrado nunca é substituído —
se os heartbeats continuam mas as atualizações param, trate o slate como
possivelmente vazio e expire as linhas não atualizadas. Se você precisa de
entrega incremental — atualizações de linhas alteradas mais listas explícitas
de ids gamestate:removed — use a API WebSocket
em vez disso; os formatos dos campos estão na
referência de Live Game State.
event: gamestate:update
data: {"data":[{"event_id":"atp_jdoe_rroe_2026-07-03","home_team":"J. Doe","away_team":"R. Roe","sport":"tennis","league":"atp","home_score":10,"away_score":10,"game_period":"S3","is_live":true,"primary_book":"draftkings","book_count":4}]}O channel gamestate também não é retomável no SSE: o replay por
Last-Event-ID cobre apenas o channel odds. Uma reconexão de gamestate
sempre reinicializa com um gamestate:snapshot novo — trate cada reconexão
como um começo do zero: limpe o gamestate local e reconstrua a partir do novo
snapshot. Note que os frames de gamestate (e os heartbeats) não carregam
linha id: de SSE, então um EventSource simples no channel gamestate
reconecta sem Last-Event-ID — o connected dele não traz nem resumed nem
fallback_reason. Essas chaves aparecem apenas quando a reconexão de fato
apresenta um Last-Event-ID (por exemplo no channel all, onde eventos de
odds definem o cursor de retomada, ou com um header definido à mão): o
servidor então confirma com resumed: false e
fallback_reason: "channel_unsupported".
gamestate:final
Enviado uma vez quando um evento termina, com sua linha terminal
(status: "final", placar final, completed_at). Mesmo envelope
{"data": [...]}, mesmo acesso e mesmos filtros sport/league do
gamestate:update, sem linha id:. A linha também continua em cada
gamestate:update durante a janela de carry, então este frame é um aviso,
não a única entrega. Pode se repetir ocasionalmente —
deduplique por (event_id, completed_at). Veja
Avisos de evento finalizado.
event: gamestate:final
data: {"data":[{"event_id":"atp_jdoe_rroe_2026-07-03","home_team":"J. Doe","away_team":"R. Roe","sport":"tennis","league":"atp","sets_home":1,"sets_away":2,"score_type":"sets","winner":"away","status":"final","completed_at":"2026-07-03T19:42:10Z","is_live":false,"stale":false}]}odds:locked
Disparado quando um mercado é suspenso/fechado (ex.: após um gol, durante um movimento de linha ou em um bloqueio no fim do jogo) — o preço fica congelado, mas a seleção não é mais apostável. Carrega o subconjunto suspenso do delta atual, com o mesmo formato de payload de odds:update, com is_active: false. Enviado apenas nos channels odds ou all.
É um sinal de bloqueio dedicado e é complementar — as mesmas linhas também chegam em odds:update com is_active: false, então clientes que já leem is_active não precisam assinar odds:locked separadamente. Use quando quiser um sinal de bloqueio dedicado sem fazer parse de cada odds:update.
event: odds:locked
id: 3f9c2a1b:12851
data: {"odds":[{"id":"123456","odds_american":-2500,"is_live":true,"is_active":false,"timestamp":"2026-02-08T18:47:38Z"}],"count":1,"book":"pinnacle","partial":false}Um mercado que reabre emite um odds:update normal com is_active: true (e um preço novo). Mercados que uma casa remove por completo chegam por odds:removed.
O envelope é o mesmo de odds:update, incluindo o campo replay: frames odds:locked reenviados durante uma retomada carregam "replay": true, exatamente como frames odds:update e odds:removed reenviados.
odds:removed
Odds removidas por um sportsbook (ex.: mercado retirado, evento liquidado). Enviado apenas nos channels odds ou all.
event: odds:removed
id: evt_00051
data: {"ids":["123456","789012"],"count":2,"book":"draftkings"}| Campo | Tipo | Descrição |
|---|---|---|
ids | string[] | IDs de odds a serem removidos do estado local |
count | number | Número de odds removidas |
book | string | Sportsbook que removeu as odds |
heartbeat
Keep-alive enviado a cada 30 segundos. Se você não receber um heartbeat dentro de 60 segundos, a conexão pode estar obsoleta.
event: heartbeat
data: {"timestamp":"2026-01-26T02:11:07.846Z"}resync_required
Enviado quando deltas ao vivo foram pulados porque seu cliente consumiu devagar demais (veja a política de consumidor lento). Entregue assim que a contrapressão passa, antes do próximo evento de dados — seu estado local está agora incompleto. Busque /api/v1/odds novamente para o escopo dos seus filtros, ou reconecte para um snapshot novo.
event: resync_required
data: {"reason":"backpressure","message":"Deltas were dropped due to slow consumption. Request /api/v1/odds for a full snapshot or reconnect."}displaced
Enviado como evento final quando uma conexão mais nova com a mesma API key assume o slot do stream (a mais nova vence — veja Uma conexão, muitos tópicos); o stream é fechado em seguida. reconnect: false é determinante: não reconecte automaticamente, ou você vai derrubar sua própria sessão mais nova em loop.
event: displaced
data: {"code":"too_many_streams","message":"Displaced by a newer connection on the same API key (newer-wins). This stream was closed because another session took the single per-key stream slot.","reconnect":false,"hint":"Run one stream per key. To stream from multiple processes concurrently, request a maxStreams increase for your key or mint a separate key per process at https://sharpapi.io/dashboard.","docs":"https://docs.sharpapi.io/streaming/single-connection"}O hint é o texto literal do servidor. O aumento a que ele se refere é um override maxStreams por chave, vendido como add-on pago em qualquer plano pago — veja Limites de Streams Concorrentes.
error
Erro recuperável no stream. A conexão permanece aberta.
event: error
data: {"code":"upstream_error","message":"Temporary issue fetching DraftKings data. Will retry."}Reconexão
SSE oferece suporte a reconexão automática via cabeçalho Last-Event-ID. No canal odds o servidor resolve um de dois resultados, e ele sempre diz qual:
- Resume (
connectedcomresumed: true) — os eventosoddsque você perdeu são reenviados a partir de uma janela best-effort, cada um marcado com"replay": true; nenhum snapshot é enviado. Mantenha seu estado local — os deltas reenviados o deixam atualizado. Umsnapshot:completecommode: "resume"marca a passagem para dados ao vivo. - Ressincronização completa (
connectedcomresumed: falsee umfallback_reason) — o servidor não conseguiu reenviar. Um novo snapshot completo chega, e seusnapshot:completetrazmode: "full_resync".
resumed: true significa que a repetição começou, não que terminou: se depois chegarem chunks de snapshot, era o caminho 2 no fim — limpe seu estado local no primeiro desses chunks. Se nenhum chunk de snapshot chegar porque nada mais corresponde aos seus filtros, limpe-o quando snapshot:complete informar mode: "full_resync". Só eventos odds carregam linhas id: — reconexões em gamestate, opportunities e all sempre seguem o caminho do snapshot completo.
// Navegadores lidam com isso automaticamente com EventSource.
// Para clientes personalizados, defina o cabeçalho na reconexão:
const headers = {
'X-API-Key': 'YOUR_KEY',
'Last-Event-ID': 'evt_00042'
};Limpe seu estado local só quando um snapshot completo for chegar: num connected sem resumed: true; no primeiro chunk de snapshot depois de um resumed: true; ou, quando um snapshot completo depois de um resumed: true não traz nenhum chunk de snapshot, no snapshot:complete que informa mode: "full_resync". Não limpe em toda reconexão nem em reconnected: true — ele também é true num resume bem-sucedido, e lá nenhum snapshot chega para você mesclar os deltas. Se você não limpar antes de um snapshot completo de verdade, odds obsoletas da sessão anterior se misturarão com dados novos.
O EventSource do navegador lida com Last-Event-ID e a reconexão automaticamente. Nenhum código extra é necessário para a reconexão em si, mas você deve cuidar da limpeza de estado no lado do cliente.
Exemplos de Código
Browser
// Mapa local de odds — indexado pelo ID da odd, armazena objetos Odds completos do snapshot.
// Eventos delta são mesclados neste mapa pelo ID.
const oddsMap = new Map();
let resuming = false; // definido por `connected` com `resumed: true`
// Oportunidades por tipo, cada uma indexada por `id`. Um item de `:detected` é uma
// oportunidade nova OU uma versão atualizada de uma que você já tem (mesmo `id`).
const oppsByType = { ev: new Map(), arbitrage: new Map(), middles: new Map(), low_hold: new Map() };
// Upsert por `id`. Retorna true apenas na primeira vez que um `id` aparece —
// alerte nisso, não em cada item de `:detected`.
function upsertOpp(type, opp) {
const isNew = !oppsByType[type].has(opp.id);
oppsByType[type].set(opp.id, opp); // substitui a versão que você tinha
return isNew;
}
const eventSource = new EventSource(
'https://api.sharpapi.io/api/v1/stream?channel=all&league=nba&api_key=YOUR_KEY'
);
eventSource.addEventListener('connected', (e) => {
const { stream_id, channel, resumed } = JSON.parse(e.data);
// Mantenha as odds locais só num resume (`resumed: true`): aí os deltas perdidos são
// reenviados em vez de um snapshot. Qualquer outra conexão é seguida de um snapshot
// completo — limpe agora. Não use `reconnected`: ele também é `true` num resume.
resuming = resumed === true;
if (!resuming) oddsMap.clear();
// Oportunidades nunca são retomadas: cada conexão as reenvia no `snapshot`.
for (const map of Object.values(oppsByType)) map.clear();
console.log(`Stream ${stream_id} connected (${channel})`);
});
eventSource.addEventListener('snapshot', (e) => {
const data = JSON.parse(e.data);
// Um snapshot depois de `resumed: true` significa que o servidor desistiu do resume:
// este snapshot substitui seu estado.
if (resuming) {
oddsMap.clear();
resuming = false;
}
// Fragmentos de oportunidades trazem `ev` / `arbitrage` / `middles` / `low_hold` em vez de `odds`
for (const type of Object.keys(oppsByType)) {
for (const opp of data[type] ?? []) oppsByType[type].set(opp.id, opp);
}
if (!data.odds) return;
// Armazena objetos Odds completos indexados por ID
for (const odd of data.odds) {
oddsMap.set(odd.id, odd);
}
console.log(`Snapshot chunk: ${data.count} odds (${oddsMap.size}/${data.total} total)`);
});
eventSource.addEventListener('snapshot:complete', (e) => {
const { mode } = JSON.parse(e.data); // "resume", "full_resync" ou ausente numa primeira conexão
// Um snapshot completo pode chegar sem nenhum chunk de `snapshot` — nada mais
// corresponde aos seus filtros —, então a limpeza no handler de `snapshot` nunca rodou.
// A checagem de `resuming` evita apagar um snapshot que você acabou de guardar.
if (mode === 'full_resync' && resuming) oddsMap.clear();
resuming = false;
console.log(`Snapshot complete: ${oddsMap.size} odds loaded`);
});
eventSource.addEventListener('odds:update', (e) => {
const { odds, book } = JSON.parse(e.data);
// Mescla deltas compactos no estado local — apenas campos dinâmicos são enviados
for (const delta of odds) {
const existing = oddsMap.get(delta.id);
if (existing) {
Object.assign(existing, delta); // Mescla campos alterados
} else {
// Um `id` que você ainda não tem chega como linha completa — guarde como está
oddsMap.set(delta.id, delta);
}
}
console.log(`${book}: ${odds.length} odds updated`);
});
eventSource.addEventListener('odds:removed', (e) => {
const { ids, book } = JSON.parse(e.data);
for (const id of ids) {
oddsMap.delete(id);
}
console.log(`${book}: ${ids.length} odds removed`);
});
// Cada item de `:detected` é salvo com upsert; só um `id` visto pela primeira vez é registrado como novo.
eventSource.addEventListener('ev:detected', (e) => {
const { opportunities: opps } = JSON.parse(e.data);
opps.forEach(opp => {
if (upsertOpp('ev', opp)) console.log(`+EV: ${opp.selection} at ${opp.ev_percentage}%`);
});
});
eventSource.addEventListener('arb:detected', (e) => {
const { opportunities: arbs } = JSON.parse(e.data);
arbs.forEach(arb => {
if (upsertOpp('arbitrage', arb)) console.log(`Arb: ${arb.profit_percent}% profit`);
});
});
eventSource.addEventListener('middles:detected', (e) => {
const { opportunities: middles } = JSON.parse(e.data);
middles.forEach(m => {
if (upsertOpp('middles', m)) console.log(`Middle: ${m.event_name} — EV ${m.expected_value}%`);
});
});
eventSource.addEventListener('low_hold:detected', (e) => {
const { opportunities: holds } = JSON.parse(e.data);
holds.forEach(h => {
if (upsertOpp('low_hold', h)) console.log(`Low hold: ${h.hold_percentage}%`);
});
});
// `:expired` lista os ids que sumiram. Esqueça-os, para que um `id` que
// volte depois conte como novo de novo.
for (const prefix of ['ev', 'arb', 'middles', 'low_hold']) {
eventSource.addEventListener(`${prefix}:expired`, (e) => {
const { expired, type } = JSON.parse(e.data); // type: 'ev' | 'arbitrage' | 'middles' | 'low_hold'
for (const id of expired) oppsByType[type].delete(id);
});
}
eventSource.addEventListener('heartbeat', () => {
console.log('Connection alive');
});
eventSource.onerror = () => {
console.log('Connection lost, auto-reconnecting...');
};Limites de Streams Concorrentes
O limite é por chave de API e é compartilhado entre SSE e WebSocket. Não é por URL de conexão: uma segunda conexão com filtros diferentes não ganha um slot próprio.
| Plano | Streams concorrentes máximos por chave |
|---|---|
| Qualquer plano pago (streaming via add-on WebSocket, $99/mês) | 1 |
Qualquer plano pago com Streams Concorrentes Adicionais provisionados (um override maxStreams por chave) | O número provisionado na chave — continua sendo 1 até o override ser concedido; entre em contato com vendas |
Abrir um segundo stream na mesma chave não retorna erro — ele desloca o primeiro. A nova conexão sempre é aceita e a antiga é fechada (“a mais nova vence”).
O lado deslocado é avisado explicitamente:
- SSE — um evento final
displacedcomreconnect: falsee, em seguida, o encerramento - WebSocket — código de fechamento
4001 displaced by newer session
Um cliente deslocado não deve reconectar automaticamente. O slot agora pertence à sessão mais nova, então reconectar a expulsaria imediatamente e criaria um loop de reconexão.
No SSE isso exige uma ação explícita: um EventSource nativo reconecta sozinho após qualquer encerramento pelo servidor, e reconnect: false no payload do evento não impede isso — esse campo é uma orientação para o seu código, não uma instrução para o navegador. Chame eventSource.close() dentro do seu handler displaced.
429 too_many_streams ainda é retornado, mas apenas quando uma chave que JÁ tem acesso a streaming resolve para zero slots — um override explícito maxStreams: 0. Uma chave sem acesso a streaming nem chega ao limitador: é recusada antes com 403 tier_restricted. Em um plano pago normal ocorre deslocamento, não um 429.
Gerenciando Streams
- São contadas conexões abertas por chave de API, não URLs únicas — veja Uma conexão, muitos tópicos para cobrir muitos esportes, ligas e casas em um único socket
- O limite é coordenado entre instâncias da API — a posse do slot fica em estado compartilhado, não por processo — então distribuir conexões por vários hosts próprios não é uma forma admitida de contorná-lo
- Para streams realmente paralelos, solicite um aumento de
maxStreamspor chave (um add-on pago em qualquer tier pago) ou crie uma chave separada por processo - Fechar a conexão HTTP (ou chamar
eventSource.close()) libera o slot imediatamente - Use filtros mais amplos em menos streams ao invés de muitos streams restritos
- O payload do evento
connectedinclui seustream_idpara rastreamento
Tratamento de Erros
Erros de Nível de Stream
Erros enviados como eventos SSE são recuperáveis — a conexão permanece aberta:
event: error
data: {"code":"upstream_error","message":"Temporary issue fetching data. Will retry."}Erros de Nível de Conexão
Estes encerram a conexão. Trate-os no onerror:
| Código de Erro | Status HTTP | Descrição | Resolução |
|---|---|---|---|
too_many_streams | 429 | Muitos streams concorrentes | Feche streams não utilizados |
tier_restricted | 403 | Streaming não disponível no seu plano | Adicione o add-on WebSocket |
invalid_api_key | 401 | API key ausente ou inválida | Verifique sua API key |
validation_error | 400 | Parâmetros de filtro inválidos | Verifique os parâmetros de consulta |
Boas Práticas
- Use o channel correto —
channel=oddsapenas para odds,channel=opportunitiesapenas para oportunidades,channel=allpara tudo - Use filtros para reduzir o consumo de banda — Passe os parâmetros
sport,league,sportsbook,marketeevent_idpara restringir os dados - Defina limiares — Use
min_evemin_profitpara filtrar oportunidades de baixo valor no servidor - Aguarde por
snapshot:complete— Isso sinaliza que todos os dados iniciais foram enviados. Oculte os estados de carregamento após recebê-lo - Trate
odds:removed— Remova odds do estado local quando recebidas para evitar exibir dados obsoletos - Trate a reconexão de forma elegante — O
EventSourcese reconecta automaticamente, mas redefina o estado local quando receber um novo eventosnapshot - Processe atualizações de forma assíncrona — Não bloqueie o handler de eventos; enfileire as atualizações para processamento em segundo plano
- Monitore os heartbeats — Se nenhum heartbeat chegar em 60 segundos, considere a conexão obsoleta e reconecte
- Feche streams não utilizados — Cada stream aberto conta contra seu limite concorrente
- Use
Last-Event-ID— Permite que o servidor reproduza eventos perdidos após uma reconexão - Faça upsert das oportunidades por
id— Um item de:detectedpode ser uma atualização de uma oportunidade que você já tem. Alerte apenas sobre umidque você ainda não viu e esqueça os ids listados em*:expired
Migração: Deltas SSE Compactos
Mudança incompatível para consumidores de odds:update SSE. O evento odds:update agora envia objetos OddsDelta compactos contendo apenas campos dinâmicos (id, odds_american, odds_decimal, odds_probability, line, is_live, timestamp). Campos estáticos como sportsbook, sport, league, home_team, away_team, market_type, selection, deep_link e event_start_time são enviados apenas no evento snapshot inicial. A exceção é uma linha com um id que você não recebeu antes — um mercado que abriu ou reabriu no meio do stream — que é enviada completa, com todos os campos de Odds, no mesmo evento odds:update.
Por quê: O payload anterior enviava o objeto Odds completo a cada alteração, gerando ~170 KB/s por conexão. O delta compacto reduz o consumo de banda em ~5x, enviando apenas os 6-7 campos que realmente mudaram.
O que alterar no seu cliente:
-
Armazene as odds do snapshot em um mapa local indexado por
id. O eventosnapshotainda envia objetosOddscompletos com todos os campos. Uma linha deodds:updatecom umidque não está no mapa é ela mesma um objetoOddscompleto — adicione-a ao mapa. -
Mescle os deltas de
odds:updateporidao invés de tratá-los como objetos independentes. Cada delta contém apenas os campos que podem mudar — procure o objeto completo no seu mapa local e aplique a atualização. -
Não acesse campos estáticos em objetos delta. Campos como
event_id,market_type,selection,home_teamesportsbooknão estão presentes nos deltas. Leia-os do seu mapa local em vez disso.
Antes (incorreto — acessando campos não presentes no delta):
eventSource.addEventListener('odds:update', (e) => {
const { odds } = JSON.parse(e.data);
for (const o of odds) {
// ❌ o.event_id, o.market_type, o.selection são undefined nos deltas
console.log(`${o.event_id} ${o.market_type}: ${o.selection} → ${o.odds_american}`);
}
});Depois (correto — mescla no estado local):
eventSource.addEventListener('odds:update', (e) => {
const { odds } = JSON.parse(e.data);
for (const delta of odds) {
const full = oddsMap.get(delta.id);
if (full) {
Object.assign(full, delta); // Mescla campos alterados
// ✅ full.event_id, full.market_type, full.selection ainda estão disponíveis
console.log(`${full.event_id} ${full.market_type}: ${full.selection} → ${full.odds_american}`);
} else {
// Um `id` que você ainda não tem chega como linha completa — guarde como está
oddsMap.set(delta.id, delta);
}
}
});Endpoints Relacionados
- Oportunidades +EV - Endpoint REST para dados de EV (transmitido via
ev:detected) - Oportunidades de Arbitragem - Endpoint REST para arbs (transmitido via
arb:detected) - Oportunidades de Low Hold - Endpoint REST para low hold (transmitido via
low_hold:detected) - Resumo de Middles - Estatísticas agregadas de middles para polling de dashboard
- WebSocket API - Alternativa bidirecional ao SSE