Skip to Content

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ónPermalink for this section

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_key

Parámetros de consultaPermalink for this section

ParámetroTipoPredeterminadoDescripción
channelstringopportunitiesQué 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.
sportstringtodosFiltrar por deporte(s), separados por comas (p. ej. basketball, football, ice_hockey)
sportsbookstringpermitidos por el planFiltrar por sportsbook(s), separados por comas
leaguestringtodasFiltrar por liga(s), separadas por comas
event_idstringtodosFiltrar por ID(s) de evento, separados por comas
marketstringtodosFiltrar por tipo(s) de mercado, separados por comas (p. ej. moneyline, point_spread, total_points, player_points)
min_evnumber2.0Porcentaje mínimo de EV para eventos de oportunidades +EV
min_profitnumber0.5Porcentaje mínimo de beneficio para eventos de arbitraje únicamente (no se aplica al filtrado de low-hold)
statestring—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_keystring—API key (alternativa a la autenticación por cabecera para EventSource del navegador)

Opciones de canalPermalink for this section

CanalEventos entregadosCaso de uso
oddssnapshot, odds:update, odds:removed, heartbeatSeguimiento de movimientos de cuotas
opportunitiessnapshot, ev:detected/expired, arb:detected/expired, middles:detected/expired, low_hold:detected/expired, heartbeatAlertas sobre oportunidades
gamestategamestate:snapshot, gamestate:update, gamestate:final, heartbeatMarcadores 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.
allTodos los tipos de eventosImagen 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 convenienciaPermalink for this section

RutaEquivalente 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 SSEPermalink for this section

connectedPermalink for this section

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}
CampoTipoDescripción
stream_idstringIdentificador único del stream
channelstringEco del canal solicitado (odds, opportunities o all)
filtersobjectEco de los filtros activos
reconnectedbooleantrue 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
resumedbooleanPresente 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_countnumberCon resumed: true — cuántos eventos almacenados en búfer se reenvían
fallback_reasonstringCon 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
trialobject | undefinedPresente si el usuario está en una prueba de streaming. Contiene active, expires_at, remaining_hours, max_streams

snapshotPermalink for this section

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}
CampoTipoDescripción
oddsarrayArray de objetos Odds completos (consulta el endpoint de Odds para todos los campos)
countnumberNúmero de cuotas en este fragmento
totalnumberNúmero total de cuotas que coinciden con los filtros
offsetnumberDesplazamiento de este fragmento dentro del resultado completo
has_morebooleantrue si siguen más fragmentos snapshot

snapshot:completePermalink for this section

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:updatePermalink for this section

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

CampoTipoDescripción
idstringID único de la cuota — coincide con el id del snapshot inicial
odds_americannumberCuota americana actualizada (p. ej. -150)
odds_decimalnumberCuota decimal actualizada (p. ej. 1.667)
odds_probabilitynumberProbabilidad implícita actualizada (p. ej. 0.6)
linenumber | nullLínea/handicap actualizada (p. ej. -3.5), o null para moneyline
is_livebooleanIndica si el evento está actualmente en vivo
timestampstringHora 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:

CampoTipoDescripción
oddsarrayArray de objetos OddsDelta (compactos — solo campos dinámicos)
countnumberNúmero de cuotas en este fragmento
bookstringSportsbook que ha cambiado (p. ej. "draftkings")
partialbooleantrue si siguen más fragmentos para este lote de actualización

ev:detectedPermalink for this section

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 id y reemplaza la versión guardada con cada elemento de :detected.
  • Alerta solo sobre un id que no hayas visto. Un id conocido es una actualización, no una oportunidad nueva. Cuenta como vistos los ids de los fragmentos de snapshot y olvida un id cuando *:expired lo 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:

CampoTipoDescripción
opportunitiesarrayOportunidades nuevas o actualizadas. Haz upsert de cada una por id
countnumberNúmero de elementos en opportunities
typestringev, arbitrage, middles o low_hold

ev:expiredPermalink for this section

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:detectedPermalink for this section

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:expiredPermalink for this section

Una oportunidad de arbitraje detectada anteriormente ya no está disponible.

event: arb:expired data: {"expired":["61c501b83ce932d1"],"count":1,"type":"arbitrage"}

middles:detectedPermalink for this section

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:expiredPermalink for this section

Una oportunidad de middle detectada anteriormente ya no está disponible.

event: middles:expired data: {"expired":["middle_abc123"],"count":1,"type":"middles"}

low_hold:detectedPermalink for this section

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:expiredPermalink for this section

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:snapshotPermalink for this section

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:updatePermalink for this section

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:finalPermalink for this section

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:lockedPermalink for this section

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:removedPermalink for this section

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"}
CampoTipoDescripción
idsstring[]IDs de cuotas a eliminar del estado local
countnumberNúmero de cuotas eliminadas
bookstringSportsbook que eliminó las cuotas

heartbeatPermalink for this section

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_requiredPermalink for this section

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

displacedPermalink for this section

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.

errorPermalink for this section

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ónPermalink for this section

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:

  1. Reanudación (connected con resumed: true) — los eventos odds que 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. Un snapshot:complete con mode: "resume" marca el paso a datos en vivo.
  2. Resincronización completa (connected con resumed: false y un fallback_reason) — el servidor no pudo reenviarlos. Llega un snapshot completo nuevo y su snapshot:complete lleva mode: "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ódigoPermalink for this section

// 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 concurrentesPermalink for this section

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.

PlanMá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 displaced con reconnect: false y 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 streamsPermalink for this section

  • 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 maxStreams por 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 connected incluye tu stream_id para hacer seguimiento

Manejo de erroresPermalink for this section

Errores a nivel de streamPermalink for this section

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ónPermalink for this section

Estos cierran la conexión. Manéjalos en onerror:

Código de errorEstado HTTPDescripciónResolución
too_many_streams429Demasiados streams concurrentesCierra los streams no utilizados
tier_restricted403El streaming no está disponible en tu planAñade el complemento WebSocket
invalid_api_key401API key ausente o inválidaVerifica tu API key
validation_error400Parámetros de filtro inválidosRevisa los parámetros de consulta

Buenas prácticasPermalink for this section

  1. Usa el canal adecuado — channel=odds solo para cuotas, channel=opportunities solo para oportunidades, channel=all para todo
  2. Usa filtros para reducir el ancho de banda — Pasa los parámetros sport, league, sportsbook, market y event_id para acotar los datos
  3. Establece umbrales — Usa min_ev y min_profit para filtrar oportunidades de bajo valor del lado del servidor
  4. Espera a snapshot:complete — Esto indica que se han enviado todos los datos iniciales. Oculta los estados de carga después de recibirlo
  5. Maneja odds:removed — Elimina las cuotas del estado local cuando las recibas para evitar mostrar datos obsoletos
  6. Maneja la reconexión con elegancia — EventSource se reconecta automáticamente, pero restablece el estado local cuando recibas un nuevo evento snapshot
  7. Procesa las actualizaciones de forma asíncrona — No bloquees el manejador de eventos; encola las actualizaciones para procesamiento en segundo plano
  8. Monitoriza los heartbeats — Si no llega ningún heartbeat en 60 segundos, considera la conexión inactiva y reconéctate
  9. Cierra los streams no utilizados — Cada stream abierto cuenta contra tu límite concurrente
  10. Usa Last-Event-ID — Permite al servidor reproducir los eventos perdidos tras una reconexión
  11. Haz upsert de las oportunidades por id — Un elemento de :detected puede ser una actualización de una oportunidad que ya tienes. Alerta solo sobre un id que no hayas visto y olvida los ids que enumera *:expired

Migración: Deltas SSE compactosPermalink for this section

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:

  1. Almacena las cuotas del snapshot en un mapa local indexado por id. El evento snapshot sigue enviando objetos Odds completos con todos los campos. Una fila de odds:update con un id que no está en el mapa es en sí un objeto Odds completo — añádela al mapa.

  2. Fusiona los deltas de odds:update por id en 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.

  3. No accedas a campos estáticos en los objetos delta. Campos como event_id, market_type, selection, home_team y sportsbook no 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 relacionadosPermalink for this section

Last updated on