On the wire · 04:56:59 UTC Live 567 · Prematch 14,380 · Markets 56,915 Vol. I · No. 5 · Friday 13 November 2026 wss://pinnfeed.io/v1/stream
PinnFeed
Guía · Bot de Discord · Node

Crea un bot de picks para Discord con cuotas de Pinnacle en vivo

Actualizado el · PinnFeed · 7 min de lectura

Este bot vigila los precios de Pinnacle en el WebSocket de PinnFeed y publica un embed en un canal de Discord cada vez que un mercado hace steam: su cuota baja un 5% o más en dos minutos en un mercado con una apuesta máxima de al menos 100. Es un solo archivo de unas 90 líneas. No necesita librería de Discord ni cuenta de bot, porque publica mediante un webhook entrante del canal.

Es el gemelo en Discord del bot de picks para Telegram. La regla de detección es la que explica la guía de detección de steam. Es una herramienta educativa para mayores de 18 años, no pronósticos.

Paso 1 — Crea el webhook#

  1. En tu servidor de Discord, abre Ajustes del servidor → Integraciones → Webhooks y elige Nuevo webhook.
  2. Ponle nombre, elige el canal donde publicará y usa Copiar URL del webhook.
  3. Trata esa URL como una contraseña: quien la tenga puede publicar en el canal. Si se filtra, borra el webhook y crea otro.

Paso 2 — Configura el entorno#

export PINNFEED_KEY="ps_live_..."                          # plan Builder (WebSocket)
export DISCORD_WEBHOOK="https://discord.com/api/webhooks/..." # del paso 1
npm install ws

Si dejas DISCORD_WEBHOOK sin definir, el bot funciona en modo de prueba: imprime cada alerta en vez de publicarla.

El stream necesita Builder (99 €/mes) o superior. Las claves Free y Hobby reciben 403 plan_lacks_ws en /v1/stream; consulta los precios.

Paso 3 — El bot#

Guárdalo como discord-picks-bot.mjs:

// discord-picks-bot.mjs: post Pinnacle steam moves to a Discord channel through an incoming webhook.
// npm install ws    Node 18+ (global fetch)
// env: PINNFEED_KEY (Builder or higher), DISCORD_WEBHOOK (channel webhook URL; unset = dry run)
import WebSocket from 'ws';

const WS_URL = process.env.PINNFEED_WS || 'wss://pinnfeed.io/v1/stream';
const { PINNFEED_KEY, DISCORD_WEBHOOK } = process.env;
const MIN_DROP = Number(process.env.MIN_DROP || 0.05);    // 5% shortening...
const WINDOW_MS = Number(process.env.WINDOW_MS || 120000); // ...inside 2 minutes
const MIN_STAKE = Number(process.env.MIN_STAKE || 100);   // skip thin markets
const SIDES = new Set(['home', 'away', 'draw', 'over', 'under']);
const events = new Map();
const highs = new Map();   // "marketId|outcome" -> [{t, p}]
const sent = new Map();    // "marketId|outcome" -> last alert time
const queue = [];          // embeds waiting to be posted
let busy = false;

const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

// One worker drains the queue, so a slow or rate-limited webhook never blocks the stream.
async function drain() {
  if (busy) return;
  busy = true;
  while (queue.length) {
    const embed = queue.shift();
    if (!DISCORD_WEBHOOK) { console.log('[dry run]', embed.title, '|', embed.description.replace(/\n/g, ' | ')); continue; }
    for (let attempt = 0; attempt < 3; attempt++) {
      const r = await fetch(DISCORD_WEBHOOK, {
        method: 'POST', headers: { 'content-type': 'application/json' },
        body: JSON.stringify({ embeds: [embed], allowed_mentions: { parse: [] } }),
      }).catch((e) => ({ ok: false, status: 0, text: async () => e.message }));
      if (r.status === 429) { const b = await r.json().catch(() => ({})); await sleep(1000 * (b.retry_after ?? 1)); continue; }
      if (!r.ok) console.error('discord', r.status, await r.text());
      break;
    }
  }
  busy = false;
}

function post(embed) {
  queue.push(embed);
  if (queue.length > 50) queue.shift(); // an alert from minutes ago is no longer news
  drain();
}

function check(m) {
  const ev = events.get(m.eventId);
  if (!ev || (m.maxStake ?? 0) < MIN_STAKE) return;
  const now = Date.now();
  for (const o of m.outcomes) {
    if (!SIDES.has(o.name)) continue;
    const k = `${m.id}|${o.name}`;
    const h = (highs.get(k) || []).filter((x) => now - x.t < WINDOW_MS);
    h.push({ t: now, p: o.decimal });
    highs.set(k, h);
    const high = Math.max(...h.map((x) => x.p));
    const drop = 1 - o.decimal / high;
    if (drop < MIN_DROP || now - (sent.get(k) || 0) < WINDOW_MS) continue;
    sent.set(k, now);
    const pick = o.name === 'home' ? ev.home : o.name === 'away' ? ev.away : o.name;
    const line = o.points == null ? '' : m.type === 'total' ? ` ${o.points}` : ` ${o.points > 0 ? '+' : ''}${o.points}`;
    post({
      title: `📉 ${ev.home} v ${ev.away}`,
      description: `**${pick}** (${m.type}${line})\nPinnacle ${high.toFixed(2)} → ${o.decimal.toFixed(2)} (−${(drop * 100).toFixed(1)}%)\n` +
        `Fair now ${o.fair.toFixed(2)} · max stake ${m.maxStake}`,
      color: 0xe8590c,
      footer: { text: `${ev.sport.toUpperCase()} · ${ev.league?.name ?? ''} · 18+ · not a tip` },
      timestamp: new Date(now).toISOString(),
    });
  }
}

function connect(delay = 1000) {
  const ws = new WebSocket(WS_URL, { headers: { 'x-api-key': PINNFEED_KEY }, perMessageDeflate: true });
  ws.on('open', () => {
    delay = 1000;
    ws.send(JSON.stringify({ type: 'subscribe', sports: ['soccer', 'tennis', 'basketball'], markets: ['moneyline', 'spread', 'total'], main: true, specials: false }));
  });
  ws.on('message', (raw) => {
    const msg = JSON.parse(raw);
    if (msg.type === 'ping') return ws.send('{"type":"pong"}');
    if (msg.type === 'snapshot') msg.events.forEach((e) => events.set(e.id, e));
    if (msg.type === 'delta') for (const c of msg.changes) {
      if (c.kind === 'event') c.op === 'upsert' ? events.set(c.event.id, c.event) : events.delete(c.id);
      if (c.kind === 'market' && c.op === 'upsert') check(c.market);
    }
  });
  ws.on('close', () => setTimeout(() => connect(Math.min(delay * 2, 30000)), delay));
  ws.on('error', (e) => console.error('ws error:', e.message));
}
connect();

Qué hace, en orden:

  • Se suscribe a las líneas principales (moneyline, hándicap, total) de fútbol, tenis y baloncesto. Cambia sports por cualquiera de los 13 deportes de tu plan; el protocolo WebSocket (en inglés) lista todos los filtros.
  • Guarda una ventana de 2 minutos por mercado y resultado, y marca una caída de MIN_DROP respecto al máximo de la ventana. Los mercados por debajo de MIN_STAKE se ignoran, porque un movimiento con un límite bajo dice poco.
  • Encola la publicación. Un único proceso envía los embeds al webhook, así un webhook lento o limitado nunca bloquea el stream. Si hay más de 50 alertas esperando, se descarta la más antigua.
  • Respeta los 429. Discord responde a las ráfagas con 429 y un valor retry_after en segundos en el cuerpo JSON. El bot espera ese tiempo y reintenta, hasta tres intentos.
  • Sin menciones. allowed_mentions: { parse: [] } impide que un nombre de equipo o jugador mencione a alguien en el canal.

Paso 4 — Ejecútalo y pruébalo#

node discord-picks-bot.mjs

En modo de prueba, una alerta se imprime así:

[dry run] 📉 Udinese v Fiorentina | **Udinese** (spread +0.25) | Pinnacle 1.88 → 1.70 (−9.6%) | Fair now 1.75 · max stake 150

En Discord la misma alerta es un embed: el partido como título, el lado y el movimiento de cuota en el cuerpo, la cuota justa sin margen y la apuesta máxima en la última línea, y deporte, liga y la nota «18+ · not a tip» en el pie.

Ajusta el volumen con variables de entorno; por ejemplo, MIN_DROP=0.08 para menos alertas y más fuertes, o MIN_STAKE=500 para quedarte solo con mercados más profundos.

Paso 5 — Mantenlo en marcha#

  • Ejecútalo con systemd, pm2 o un contenedor que se reinicie si falla. El bot se reconecta solo al WebSocket con backoff y reconstruye su lista de eventos desde el nuevo snapshot.
  • Basta un webhook por canal. Si quieres fútbol y tenis en canales distintos, ejecuta dos copias con otros sports y webhooks; una clave Builder permite 3 conexiones WebSocket.
  • Lleva el historial con honestidad: registra cada alerta y mídela con el valor de la línea de cierre, no con el porcentaje de aciertos.
  • Para ver qué publicar y cómo moderar un servidor, consulta el caso de uso de servidores de Discord.

Preguntas frecuentes#

¿Qué plan de PinnFeed necesita el bot de Discord?#

Builder (99 €/mes) o superior, porque lee el WebSocket. Mira la página del precio del WebSocket en vivo + prepartido para comparar. Los planes de pago tienen 3 días de prueba gratis; se pide tarjeta y se cobra tras el día 3 salvo que canceles.

¿Puedo usarlo en el plan Free?#

Tal cual, no. Free es solo REST con 30 segundos de retraso. Para un bot más lento en Free o Hobby, haz polling a /v1/drops una vez por minuto y publica el resultado en el mismo webhook; consulta la documentación REST (en inglés).

¿Necesito una cuenta de bot de Discord o discord.js?#

No. Un webhook entrante acepta un POST JSON simple, así que el bot solo necesita fetch. Usa una cuenta de bot con discord.js solo si quieres comandos de barra o respuestas.

¿Cómo evito saturar el canal?#

Sube MIN_DROP o MIN_STAKE, reduce sports y mantén el enfriamiento por resultado (una alerta por lado de mercado cada WINDOW_MS). El límite de la cola descarta alertas viejas en lugar de publicarlas tarde.

Las normas sobre pronósticos y publicidad del juego cambian según el país (en España, por ejemplo, la publicidad del juego está regulada), y Discord tiene sus propias normas de comunidad. Revisa las normas de donde estéis tú y tus miembros, mantén el servidor solo para mayores de 18 años y nunca presentes las alertas como ganancias seguras.

El plan WebSocket: Builder, 99 €/mes.

Cuotas de Pinnacle en tiempo real por un único WebSocket: los 13 deportes, todos los mercados, cuotas justas sin margen y cuotas de cierre. 3 días gratis y cancelas con un clic.

Form K-1 · Free API key

Get your free API key.

One field. No password, no card: enter your email, click the link we send, and your key is on screen.

By continuing you agree to the Terms and Privacy Policy.

One free key per email. Link valid for 20 minutes.