Stream unificado
GET /api/v1/stream — Actualizaciones en tiempo real de cuotas y oportunidades mediante Server-Sent Events (SSE).
Requiere el complemento WebSocket (99 $/mes) en cualquier plan de pago, o Enterprise (incluido). El plan gratuito no admite streaming.
Autenticación
Pasa tu API key mediante cabecera o parámetro de consulta:
# Cabecera (recomendado para uso del lado del servidor)
curl -H "X-API-Key: sk_live_your_key" \
https://api.sharpapi.io/api/v1/stream
# Parámetro de consulta (obligatorio para EventSource del navegador)
https://api.sharpapi.io/api/v1/stream?api_key=sk_live_your_keyParámetros de consulta
| Parámetro | Tipo | Predeterminado | Descripción |
|---|---|---|---|
channel | string | opportunities | Qué transmitir: odds, opportunities, gamestate (solo Enterprise) o all. También acepta el alias plural channels. Una lista separada por comas con más de un canal colapsa a all — consulta la nota a continuación. |
sport | string | todos | Filtrar por deporte(s), separados por comas (p. ej. basketball, football, ice_hockey) |
sportsbook | string | permitidos por el plan | Filtrar por sportsbook(s), separados por comas |
league | string | todas | Filtrar por liga(s), separadas por comas |
event_id | string | todos | Filtrar por ID(s) de evento, separados por comas |
market | string | todos | Filtrar por tipo(s) de mercado, separados por comas (p. ej. moneyline, point_spread, total_points, player_points) |
min_ev | number | 2.0 | Porcentaje mínimo de EV para eventos de oportunidades +EV |
min_profit | number | 0.5 | Porcentaje mínimo de beneficio para eventos de arbitraje únicamente (no se aplica al filtrado de low-hold) |
state | string | — | Código de estado de EE. UU. solo para enrutar enlaces directos; no filtra ni modifica cuotas. Acepta los 50 códigos de estados de EE. UU. y dc. Un código válido añade ?state= a los enlaces de cuotas. Los códigos omitidos, vacíos o no admitidos no añaden sufijo, por lo que la redirección aplica su valor predeterminado pa. Los códigos no admitidos emiten filter_warning después de connected; la conexión se establece. |
api_key | string | — | API key (alternativa a la autenticación por cabecera para EventSource del navegador) |
Opciones de canal
| Canal | Eventos entregados | Caso de uso |
|---|---|---|
odds | snapshot, odds:update, odds:removed, heartbeat | Seguimiento de movimientos de cuotas |
opportunities | snapshot, ev:detected/expired, arb:detected/expired, middles:detected/expired, low_hold:detected/expired, heartbeat | Alertas sobre oportunidades |
gamestate | gamestate:snapshot, gamestate:update, gamestate:final, heartbeat | Marcadores en vivo, periodos, relojes y datos situacionales por evento. Cada gamestate:update reemite el slate completo del momento — en SSE no existe gamestate:removed (consulta gamestate:update). Solo plan Enterprise. Consulta Estado del juego en vivo para el catálogo completo de campos. |
all | Todos los tipos de eventos | Imagen completa en tiempo real |
Una suscripción por stream SSE. Por paridad con la API WebSocket, el endpoint acepta tanto channel como el plural channels, y ambos toleran un valor separado por comas. Pero una conexión SSE mantiene una única suscripción: si pasas más de un canal válido (p. ej. ?channels=odds,opportunities), la solicitud colapsa a channel=all en lugar de devolver un error. Un único valor (?channel=odds) transmite solo ese canal. Para suscribirte de forma selectiva a un subconjunto concreto de canales, usa la API WebSocket, que admite el filtrado multicanal real con channels= en una sola conexión.
Rutas de conveniencia
| Ruta | 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 eventos SSE
connected
Se envía inmediatamente cuando se establece el stream.
event: connected
data: {"stream_id":"stream_1704960637000","channel":"all","filters":{"sportsbook":null,"sport":["basketball"],"league":["nba"],"event":null,"market":null},"reconnected":false}| Campo | Tipo | Descripción |
|---|---|---|
stream_id | string | Identificador único del stream |
channel | string | Eco del canal solicitado (odds, opportunities o all) |
filters | object | Eco de los filtros activos |
reconnected | boolean | true si se trata de una reconexión mediante Last-Event-ID — también en una reanudación correcta, así que no es la señal para borrar el estado |
resumed | boolean | Presente cuando te reconectaste con un Last-Event-ID. true → el servidor está reenviando los eventos odds que te perdiste (con replayed_count) en lugar de enviar un snapshot; si no puede terminar el reenvío, al final sí llega un snapshot completo, etiquetado con mode: "full_resync" en snapshot:complete. false → el servidor no pudo reanudar; llega un snapshot completo y fallback_reason explica por qué |
replayed_count | number | Con resumed: true — cuántos eventos almacenados en búfer se reenvían |
fallback_reason | string | Con resumed: false — por qué la reanudación recurrió al snapshot (p. ej. seq_too_old, process_restarted, filter_changed, channel_unsupported). Informativo; la recuperación es la misma: acepta el snapshot completo. Lista completa de valores |
trial | object | undefined | Presente si el usuario está en una prueba de streaming. Contiene active, expires_at, remaining_hours, max_streams |
snapshot
Volcado completo de datos enviado tras connected. Contiene todas las cuotas u oportunidades actuales que coinciden con tus filtros. Los conjuntos de datos grandes se fragmentan en varios eventos snapshot (hasta 1000 elementos cada uno).
Cada objeto de cuotas en el snapshot contiene todos los campos — esta es la forma completa de Odds que tu cliente debe almacenar localmente. Los eventos odds:update posteriores envían solo los campos modificados (ver más abajo).
En los canales opportunities y all, los fragmentos de oportunidades llevan su array bajo el tipo de oportunidad en lugar de odds — ev, arbitrage, middles o low_hold — con los mismos campos count, total, offset y has_more. Guarda también esos elementos por id: un ev:detected posterior (o arb:/middles:/low_hold:detected) con el mismo id es una actualización de uno de ellos.
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 | Descripción |
|---|---|---|
odds | array | Array de objetos Odds completos (consulta el endpoint de Odds para todos los campos) |
count | number | Número de cuotas en este fragmento |
total | number | Número total de cuotas que coinciden con los filtros |
offset | number | Desplazamiento de este fragmento dentro del resultado completo |
has_more | boolean | true si siguen más fragmentos snapshot |
snapshot:complete
Indica que se han enviado todos los snapshots iniciales. Es seguro ocultar los estados de carga después de recibirlo.
event: snapshot:complete
id: evt_00005
data: {"status":"ready","books":["draftkings","fanduel"],"total_odds":3200}odds:update
Se dispara cuando cambian las cuotas de un sportsbook. Solo se envía en los canales odds o all.
Carga útil delta compacta. Los eventos delta contienen únicamente los campos que pueden cambiar entre actualizaciones — id, odds_american, odds_decimal, odds_probability, line, is_live y timestamp. Los campos estáticos como sportsbook, sport, league, home_team, away_team, market_type, selection, deep_link y event_start_time no se incluyen en los deltas. Fusiona cada delta en tu mapa local de cuotas por id usando los objetos completos recibidos en el snapshot inicial. Algunas filas llegan completas en lugar de como delta: una fila con un id que aún no tienes se envía completa, con todos los campos de Odds, en el mismo evento odds:update — guárdala tal cual en vez de descartarla. Consulta Migración: Deltas SSE compactos más abajo.
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 del objeto delta (OddsDelta):
| Campo | Tipo | Descripción |
|---|---|---|
id | string | ID único de la cuota — coincide con el id del snapshot inicial |
odds_american | number | Cuota americana actualizada (p. ej. -150) |
odds_decimal | number | Cuota decimal actualizada (p. ej. 1.667) |
odds_probability | number | Probabilidad implícita actualizada (p. ej. 0.6) |
line | number | null | Línea/handicap actualizada (p. ej. -3.5), o null para moneyline |
is_live | boolean | Indica si el evento está actualmente en vivo |
timestamp | string | Hora ISO 8601 en que SharpAPI refrescó por última vez esta cuota a través de su pipeline — avanza en cada ciclo de ingesta. Es una señal de frescura del feed / actividad; NO es cuándo cambió el precio por última vez. Consulta Entendiendo el campo timestamp. |
Campos del envoltorio:
| Campo | Tipo | Descripción |
|---|---|---|
odds | array | Array de objetos OddsDelta (compactos — solo campos dinámicos) |
count | number | Número de cuotas en este fragmento |
book | string | Sportsbook que ha cambiado (p. ej. "draftkings") |
partial | boolean | true si siguen más fragmentos para este lote de actualización |
ev:detected
Una nueva oportunidad de valor esperado positivo, o una versión actualizada de una ya enviada (mismo id). Solo se envía en los canales opportunities o all.
:detected significa nueva o actualizada — haz upsert por id. Desde el 2026-09-26, el stream SSE reenvía una oportunidad cuyo contenido cambió bajo el mismo id — un nuevo precio, EV%, probabilidad justa, cuotas de las patas, is_suspended o quality_tier — en el evento habitual ev:detected, arb:detected, middles:detected o low_hold:detected, como hace la API WebSocket. Antes de esa fecha, el stream SSE entregaba estas actualizaciones de forma irregular — algunos cambios de contenido sí llegaban al cliente bajo el mismo id, la mayoría no —, de modo que un cliente que ignoraba un id repetido podía conservar una versión desactualizada hasta que la oportunidad expiraba o el cliente se reconectaba.
- Guarda las oportunidades por
idy reemplaza la versión guardada con cada elemento de:detected. - Alerta solo sobre un
idque no hayas visto. Unidconocido es una actualización, no una oportunidad nueva. Cuenta como vistos los ids de los fragmentos desnapshoty olvida unidcuando*:expiredlo incluya.
Los eventos *:expired y el formato del payload no cambian.
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"}Los cuatro eventos :detected comparten este sobre:
| Campo | Tipo | Descripción |
|---|---|---|
opportunities | array | Oportunidades nuevas o actualizadas. Haz upsert de cada una por id |
count | number | Número de elementos en opportunities |
type | string | ev, arbitrage, middles o low_hold |
ev:expired
Una oportunidad +EV detectada anteriormente ya no está disponible. expired enumera los id que hay que eliminar; count y type son como en ev:detected.
event: ev:expired
data: {"expired":["a1b2c3d4e5f6"],"count":1,"type":"ev"}arb:detected
Una nueva oportunidad de arbitraje, o una versión actualizada de una ya enviada (mismo id). Solo se envía en los canales opportunities o 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
Una oportunidad de arbitraje detectada anteriormente ya no está disponible.
event: arb:expired
data: {"expired":["61c501b83ce932d1"],"count":1,"type":"arbitrage"}middles:detected
Una nueva oportunidad de middle, o una versión actualizada de una ya enviada (mismo id). Solo se envía en los canales opportunities o 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
Una oportunidad de middle detectada anteriormente ya no está disponible.
event: middles:expired
data: {"expired":["middle_abc123"],"count":1,"type":"middles"}low_hold:detected
Una nueva oportunidad de low-hold, o una versión actualizada de una ya enviada (mismo id). Solo se envía en los canales opportunities o 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
Una oportunidad de low-hold detectada anteriormente ya no está disponible.
event: low_hold:expired
data: {"expired":["lowhold_abc123"],"count":1,"type":"low_hold"}gamestate:snapshot
Slate en vivo completo del momento, enviado una sola vez tras connected en
el canal gamestate (o all). El payload es una lista plana de filas de
evento — cada fila tiene la misma forma que un evento REST de
Live Game State, más su 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
Se dispara en cada ciclo de actualización del gamestate. Solo se envía en los
canales gamestate o all.
Reemisión del slate completo — no es un delta. A diferencia de
odds:update, cada gamestate:update por SSE lleva el slate en vivo
completo que coincide con tus filtros. No existe un evento
gamestate:removed en SSE — un evento finalizado se queda en el payload como su
fila status: "final" durante la
ventana de carry y
después deja de aparecer. Reemplaza tu gamestate local por completo
en cada gamestate:update; no lo fusiones como un delta, o los eventos
terminados quedarán en tu estado para siempre. Un caso límite: con filtros
sport/league puestos, un ciclo que no coincide con ningún evento no envía
ningún gamestate:update (solo los streams sin filtrar reciben el payload
vacío {"data": []}), así que el último evento terminado nunca se reemplaza —
si los heartbeats continúan pero las actualizaciones se detienen, trata el
slate como posiblemente vacío y caduca las filas no refrescadas. Si necesitas
entrega incremental — actualizaciones de filas cambiadas más listas explícitas
de ids gamestate:removed — usa la API WebSocket
en su lugar; las formas de los campos están en la
referencia 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}]}El canal gamestate tampoco es reanudable por SSE: la reproducción con
Last-Event-ID cubre únicamente el canal odds. Una reconexión de gamestate
siempre vuelve a arrancar con un gamestate:snapshot nuevo — trata cada
reconexión como un arranque en frío: limpia el gamestate local y recompónlo
desde el nuevo snapshot. Ten en cuenta que los frames de gamestate (y los
heartbeats) no llevan línea id: de SSE, así que un EventSource simple en
el canal gamestate se reconecta sin Last-Event-ID — su evento connected
no lleva ni resumed ni fallback_reason. Esas claves aparecen solo cuando
la reconexión sí presenta un Last-Event-ID (p. ej. en el canal all, donde
los eventos de odds fijan el cursor de reanudación, o con una cabecera puesta
a mano): el servidor responde entonces con resumed: false y
fallback_reason: "channel_unsupported".
gamestate:final
Se envía una vez cuando un evento termina, con su fila terminal
(status: "final", marcador final, completed_at). Mismo envoltorio
{"data": [...]}, mismo acceso y mismos filtros sport/league que
gamestate:update, sin línea id:. La fila también sigue en cada
gamestate:update durante la ventana de carry, así que este frame es un
aviso, no la única entrega. Puede repetirse ocasionalmente —
deduplica por (event_id, completed_at). Consulta
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
Se dispara cuando un mercado queda suspendido/cerrado (p. ej. tras un gol, durante un movimiento de línea o por un bloqueo al final del partido) — el precio queda congelado pero la selección ya no es apostable. Lleva el subconjunto suspendido del delta actual, con la misma forma de payload que odds:update, con is_active: false. Solo se envía en los canales odds o all.
Es una señal de bloqueo dedicada. Es complementario — las mismas filas llegan también en odds:update con is_active: false, así que los clientes que ya leen is_active no necesitan suscribirse a odds:locked por separado. Úsalo cuando quieras una señal de bloqueo dedicada sin parsear 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}Un mercado que reabre emite un odds:update normal con is_active: true (y un precio nuevo). Los mercados que una casa retira por completo llegan por odds:removed.
El sobre es el mismo que el de odds:update, incluido el campo replay: los frames odds:locked reenviados durante una reanudación llevan "replay": true, igual que los frames odds:update y odds:removed reproducidos.
odds:removed
Cuotas eliminadas por un sportsbook (p. ej. mercado retirado, evento finalizado). Solo se envía en los canales odds o all.
event: odds:removed
id: evt_00051
data: {"ids":["123456","789012"],"count":2,"book":"draftkings"}| Campo | Tipo | Descripción |
|---|---|---|
ids | string[] | IDs de cuotas a eliminar del estado local |
count | number | Número de cuotas eliminadas |
book | string | Sportsbook que eliminó las cuotas |
heartbeat
Keep-alive enviado cada 30 segundos. Si no recibes un heartbeat en 60 segundos, la conexión podría estar inactiva.
event: heartbeat
data: {"timestamp":"2026-01-26T02:11:07.846Z"}resync_required
Se envía cuando se omitieron deltas en vivo porque tu cliente consumió demasiado despacio (consulta la política de consumidor lento). Se entrega en cuanto se libera la contrapresión, antes del siguiente evento de datos — tu estado local está ahora incompleto. Vuelve a pedir /api/v1/odds para tu ámbito de filtros, o reconéctate para obtener un snapshot nuevo.
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
Se envía como evento final cuando una conexión más nueva con la misma clave de API toma el slot del stream (gana la más nueva — consulta Una conexión, muchos temas); el stream se cierra a continuación. reconnect: false es determinante: no te reconectes automáticamente, o expulsarás tu propia sesión más nueva en bucle.
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"}El hint es la redacción literal del servidor. El aumento al que se refiere es un override maxStreams por clave, vendido como add-on de pago en cualquier plan de pago — consulta Límites de streams concurrentes.
error
Error recuperable en el stream. La conexión permanece abierta.
event: error
data: {"code":"upstream_error","message":"Temporary issue fetching DraftKings data. Will retry."}Reconexión
SSE admite reconexión automática mediante la cabecera Last-Event-ID. En el canal odds el servidor resuelve uno de dos resultados, y siempre te dice cuál:
- Reanudación (
connectedconresumed: true) — los eventosoddsque te perdiste se reenvían desde una ventana best-effort, cada uno marcado con"replay": true; no se envía ningún snapshot. Conserva tu estado local — los deltas reenviados lo ponen al día. Unsnapshot:completeconmode: "resume"marca el paso a datos en vivo. - Resincronización completa (
connectedconresumed: falsey unfallback_reason) — el servidor no pudo reenviarlos. Llega un snapshot completo nuevo y susnapshot:completellevamode: "full_resync".
resumed: true significa que la repetición empezó, no que terminó: si después llegan chunks de snapshot, era el camino 2 al final — borra tu estado local en el primero de esos chunks. Si no llega ningún chunk de snapshot porque ya nada coincide con tus filtros, bórralo cuando snapshot:complete informe mode: "full_resync". Solo los eventos odds llevan líneas id: — las reconexiones de gamestate, opportunities y all siempre toman el camino del snapshot completo.
// Los navegadores manejan esto automáticamente con EventSource.
// Para clientes personalizados, establece la cabecera al reconectar:
const headers = {
'X-API-Key': 'YOUR_KEY',
'Last-Event-ID': 'evt_00042'
};Borra tu estado local solo cuando vaya a llegar un snapshot completo: en un connected sin resumed: true; en el primer chunk de snapshot tras un resumed: true; o, cuando un snapshot completo tras un resumed: true no trae ningún chunk de snapshot, en el snapshot:complete que informa mode: "full_resync". No lo borres en cada reconexión ni con reconnected: true — también es true en una reanudación correcta, y ahí no llega ningún snapshot en el que fusionar los deltas. Si no borras antes de un snapshot completo real, las cuotas obsoletas de la sesión anterior se mezclarán con los datos nuevos.
El EventSource del navegador maneja Last-Event-ID y la reconexión automáticamente. No se necesita código adicional para la reconexión en sí, pero debes encargarte de borrar el estado en el lado del cliente.
Ejemplos de código
Browser
// Mapa local de cuotas — indexado por ID de cuota, almacena objetos Odds completos del snapshot.
// Los eventos delta se fusionan en este mapa por ID.
const oddsMap = new Map();
let resuming = false; // lo fija `connected` con `resumed: true`
// Oportunidades por tipo, cada una indexada por `id`. Un elemento de `:detected` es una
// oportunidad nueva O una versión actualizada de una que ya tienes (mismo `id`).
const oppsByType = { ev: new Map(), arbitrage: new Map(), middles: new Map(), low_hold: new Map() };
// Upsert por `id`. Devuelve true solo la primera vez que se ve un `id` —
// alerta con eso, no con cada elemento de `:detected`.
function upsertOpp(type, opp) {
const isNew = !oppsByType[type].has(opp.id);
oppsByType[type].set(opp.id, opp); // reemplaza la versión que tenías
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);
// Conserva las cuotas locales solo en una reanudación (`resumed: true`): entonces se
// reenvían los deltas perdidos en lugar de un snapshot. Cualquier otra conexión va
// seguida de un snapshot completo — borra ahora. No uses `reconnected`: también es
// `true` en una reanudación.
resuming = resumed === true;
if (!resuming) oddsMap.clear();
// Las oportunidades nunca se reanudan: cada conexión las reenvía en `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);
// Un snapshot tras `resumed: true` significa que el servidor abandonó la reanudación:
// este snapshot reemplaza tu estado.
if (resuming) {
oddsMap.clear();
resuming = false;
}
// Los fragmentos de oportunidades llevan `ev` / `arbitrage` / `middles` / `low_hold` en lugar 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;
// Almacenar 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" o ausente en una primera conexión
// Un snapshot completo puede llegar sin ningún chunk de `snapshot` — ya nada coincide
// con tus filtros —, así que el borrado del handler de `snapshot` nunca se ejecutó. La
// comprobación de `resuming` evita borrar un snapshot que acabas 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);
// Fusionar deltas compactos en el estado local — solo se envían campos dinámicos
for (const delta of odds) {
const existing = oddsMap.get(delta.id);
if (existing) {
Object.assign(existing, delta); // Fusionar campos modificados
} else {
// Un `id` que aún no tienes llega como fila completa — guárdalo tal cual
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 elemento de `:detected` se guarda con upsert; solo un `id` visto por primera vez se registra como nuevo.
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` enumera los ids que ya no están. Olvídalos, para que un `id` que
// vuelva más tarde cuente otra vez como nuevo.
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...');
};Límites de streams concurrentes
El límite es por clave de API y se comparte entre SSE y WebSocket. No es por URL de conexión: una segunda conexión con filtros distintos no obtiene su propio slot.
| Plan | Máximo de streams concurrentes por clave |
|---|---|
| Cualquier plan de pago (streaming mediante el complemento WebSocket, 99 $/mes) | 1 |
Cualquier plan de pago con Streams Concurrentes Adicionales aprovisionados (un override maxStreams por clave) | El número aprovisionado en la clave — sigue siendo 1 hasta que se conceda el override; contacta con ventas |
Abrir un segundo stream con la misma clave no devuelve un error — desplaza al primero. La nueva conexión siempre se establece y la anterior se cierra («gana la más reciente»).
Al lado desplazado se le informa explícitamente:
- SSE — un evento final
displacedconreconnect: falsey después el cierre del stream - WebSocket — código de cierre
4001 displaced by newer session
Un cliente desplazado no debe reconectarse automáticamente. El slot lo ocupa ahora la sesión más reciente, así que reconectar la expulsaría de inmediato y provocaría un bucle de reconexión.
En SSE esto exige una acción explícita: un EventSource nativo se reconecta por su cuenta tras cualquier cierre del servidor, y reconnect: false en la carga útil del evento no lo impide — ese campo es una indicación para tu código, no una instrucción para el navegador. Llama a eventSource.close() dentro de tu manejador displaced.
429 too_many_streams se sigue devolviendo, pero solo cuando una clave que YA tiene acceso al streaming se resuelve a cero slots — un override explícito maxStreams: 0. Una clave sin acceso al streaming no llega siquiera al limitador: se rechaza antes con 403 tier_restricted. En un plan de pago normal se produce desplazamiento, no un 429.
Gestión de streams
- Se cuentan conexiones abiertas por clave de API, no URLs únicas — consulta Una conexión, muchos temas para cubrir muchos deportes, ligas y casas en un solo socket
- El límite se coordina entre instancias de la API — la propiedad del slot se replica en un estado compartido, no por proceso — así que repartir las conexiones entre varios hosts propios no es una forma admitida de saltárselo
- Para streams realmente paralelos, solicita un aumento de
maxStreamspor clave (un add-on de pago en cualquier plan de pago) o crea una clave separada por proceso - Cerrar la conexión HTTP (o llamar a
eventSource.close()) libera el slot de inmediato - Usa filtros más amplios en menos streams en lugar de muchos streams reducidos
- La carga útil del evento
connectedincluye tustream_idpara hacer seguimiento
Manejo de errores
Errores a nivel de stream
Los errores enviados como eventos SSE son recuperables — la conexión permanece abierta:
event: error
data: {"code":"upstream_error","message":"Temporary issue fetching data. Will retry."}Errores a nivel de conexión
Estos cierran la conexión. Manéjalos en onerror:
| Código de error | Estado HTTP | Descripción | Resolución |
|---|---|---|---|
too_many_streams | 429 | Demasiados streams concurrentes | Cierra los streams no utilizados |
tier_restricted | 403 | El streaming no está disponible en tu plan | Añade el complemento WebSocket |
invalid_api_key | 401 | API key ausente o inválida | Verifica tu API key |
validation_error | 400 | Parámetros de filtro inválidos | Revisa los parámetros de consulta |
Buenas prácticas
- Usa el canal adecuado —
channel=oddssolo para cuotas,channel=opportunitiessolo para oportunidades,channel=allpara todo - Usa filtros para reducir el ancho de banda — Pasa los parámetros
sport,league,sportsbook,marketyevent_idpara acotar los datos - Establece umbrales — Usa
min_evymin_profitpara filtrar oportunidades de bajo valor del lado del servidor - Espera a
snapshot:complete— Esto indica que se han enviado todos los datos iniciales. Oculta los estados de carga después de recibirlo - Maneja
odds:removed— Elimina las cuotas del estado local cuando las recibas para evitar mostrar datos obsoletos - Maneja la reconexión con elegancia —
EventSourcese reconecta automáticamente, pero restablece el estado local cuando recibas un nuevo eventosnapshot - Procesa las actualizaciones de forma asíncrona — No bloquees el manejador de eventos; encola las actualizaciones para procesamiento en segundo plano
- Monitoriza los heartbeats — Si no llega ningún heartbeat en 60 segundos, considera la conexión inactiva y reconéctate
- Cierra los streams no utilizados — Cada stream abierto cuenta contra tu límite concurrente
- Usa
Last-Event-ID— Permite al servidor reproducir los eventos perdidos tras una reconexión - Haz upsert de las oportunidades por
id— Un elemento de:detectedpuede ser una actualización de una oportunidad que ya tienes. Alerta solo sobre unidque no hayas visto y olvida los ids que enumera*:expired
Migración: Deltas SSE compactos
Cambio incompatible para los consumidores SSE de odds:update. El evento odds:update ahora envía objetos OddsDelta compactos que solo contienen campos dinámicos (id, odds_american, odds_decimal, odds_probability, line, is_live, timestamp). Los campos estáticos como sportsbook, sport, league, home_team, away_team, market_type, selection, deep_link y event_start_time solo se envían en el evento snapshot inicial. La excepción es una fila con un id que no has recibido antes — un mercado que se abrió o volvió a abrirse a mitad del stream — que se envía completa, con todos los campos de Odds, en el mismo evento odds:update.
Por qué: La carga útil anterior enviaba el objeto Odds completo en cada cambio, generando ~170 KB/s por conexión. El delta compacto reduce el ancho de banda en ~5x, enviando únicamente los 6-7 campos que realmente cambiaron.
Qué cambiar en tu cliente:
-
Almacena las cuotas del snapshot en un mapa local indexado por
id. El eventosnapshotsigue enviando objetosOddscompletos con todos los campos. Una fila deodds:updatecon unidque no está en el mapa es en sí un objetoOddscompleto — añádela al mapa. -
Fusiona los deltas de
odds:updateporiden lugar de tratarlos como objetos independientes. Cada delta solo contiene los campos que pueden cambiar — busca el objeto completo en tu mapa local y aplica la actualización. -
No accedas a campos estáticos en los objetos delta. Campos como
event_id,market_type,selection,home_teamysportsbookno están presentes en los deltas. Léelos desde tu mapa local en su lugar.
Antes (incorrecto — accediendo a campos no presentes en el 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 son undefined en los deltas
console.log(`${o.event_id} ${o.market_type}: ${o.selection} → ${o.odds_american}`);
}
});Después (correcto — fusionar en el 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); // Fusionar campos modificados
// ✅ full.event_id, full.market_type, full.selection siguen disponibles
console.log(`${full.event_id} ${full.market_type}: ${full.selection} → ${full.odds_american}`);
} else {
// Un `id` que aún no tienes llega como fila completa — guárdalo tal cual
oddsMap.set(delta.id, delta);
}
}
});Endpoints relacionados
- Oportunidades +EV - Endpoint REST para datos de EV (transmitidos vía
ev:detected) - Oportunidades de arbitraje - Endpoint REST para arbitrajes (transmitidos vía
arb:detected) - Oportunidades de Low Hold - Endpoint REST para low hold (transmitidos vía
low_hold:detected) - Resumen de Middles - Estadísticas agregadas de middles para sondeo en dashboards
- API WebSocket - Alternativa bidireccional a SSE