On the wire · 04:49:18 UTC Live 550 · Prematch 14,382 · Markets 56,971 Vol. I · No. 5 · Friday 13 November 2026 wss://pinnfeed.io/v1/stream
PinnFeed
Guide · Discord bot · Node

Build a Discord picks bot on live Pinnacle odds

Updated · PinnFeed · 7 min read

This bot watches Pinnacle prices on the PinnFeed WebSocket and posts an embed to a Discord channel whenever a market steams: its price shortens by 5% or more within two minutes in a market with a max stake of at least 100. It is one file of about 90 lines. It needs no Discord library and no bot account, because it posts through a channel incoming webhook.

It is the Discord twin of the Telegram picks bot. The detection rule is the one explained in the steam detection guide. This is educational tooling for adults (18+), not betting tips.

Step 1 — Create the webhook#

  1. In your Discord server, open Server Settings → Integrations → Webhooks and choose New Webhook.
  2. Name it, pick the channel it should post to, and use Copy Webhook URL.
  3. Treat that URL like a password: anyone who has it can post to the channel. If it leaks, delete the webhook and create a new one.

Step 2 — Set the environment#

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

Leave DISCORD_WEBHOOK unset to run in dry-run mode: the bot prints each alert instead of posting it.

The stream needs Builder (€99/month) or higher. Free and Hobby keys get 403 plan_lacks_ws on /v1/stream; see pricing.

Step 3 — The bot#

Save as 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();

What it does, in order:

  • Subscribe to soccer, tennis and basketball main lines (moneyline, spread, total). Change sports to any of the 13 sports on your plan; the WebSocket protocol lists every filter.
  • Keep a 2-minute price window per market and outcome, and flag a drop of MIN_DROP from the window high. Markets below MIN_STAKE are skipped, because a move on a thin limit means little.
  • Queue the post. A single worker sends embeds to the webhook, so a slow or rate-limited webhook never blocks the stream. If more than 50 alerts are waiting, the oldest is dropped.
  • Honour 429s. Discord answers bursts with 429 and a retry_after value in seconds in the JSON body. The worker waits that long and retries, up to three attempts.
  • No pings. allowed_mentions: { parse: [] } stops a team or player name from mentioning anyone in the channel.

Step 4 — Run and test#

node discord-picks-bot.mjs

In dry-run mode an alert prints like this:

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

In Discord the same alert is an embed: the match as the title, the side and price move in the body, the no-vig fair price and max stake on the last line, and sport, league and an "18+ · not a tip" note in the footer.

Tune the volume with environment variables, for example MIN_DROP=0.08 for fewer, stronger alerts, or MIN_STAKE=500 to keep only deeper markets.

Step 5 — Keep it running#

  • Run it under systemd, pm2 or a container with restart on failure. The bot reconnects to the WebSocket with backoff by itself and rebuilds its event list from the new snapshot.
  • One webhook per channel is enough. If you want soccer and tennis in separate channels, run two copies with different sports and webhooks; a Builder key allows 3 WebSocket connections.
  • Track the record honestly: log each alert and measure it with closing line value instead of win rate.
  • For a server-level view of what to post and how to moderate it, see use case: Discord servers.

Questions people ask#

Which PinnFeed plan does the Discord bot need?#

Builder (€99/month) or higher, because it reads the WebSocket. Builder is the lowest price on monthly or annual billing we found for a live + prematch Pinnacle odds WebSocket (Nov 2026). Paid plans have a 3-day free trial; a card is required and it is charged after day 3 unless you cancel.

Can I run it on the Free plan?#

Not as written. Free is REST only with a 30-second delay. For a slower bot on Free or Hobby, poll /v1/drops once a minute and post the results to the same webhook; see the REST docs.

Do I need a Discord bot account or discord.js?#

No. An incoming webhook accepts a plain JSON POST, so the bot only needs fetch. Use a full bot account with discord.js only if you want slash commands or replies.

How do I stop the channel from being flooded?#

Raise MIN_DROP or MIN_STAKE, narrow sports, and keep the per-outcome cooldown (one alert per market side per WINDOW_MS). The queue cap drops stale alerts instead of posting them late.

Is it legal to post picks in a Discord server?#

Rules on betting tips and gambling promotion vary by country, and Discord has its own community guidelines. Check the rules where you and your members are, keep the server 18+, and never present alerts as guaranteed wins.

The WebSocket plan: Builder, €99/mo.

Real-time Pinnacle odds pushed over one WebSocket, all 13 sports, every market, no-vig fair odds and closing lines. 3 days free, cancel in one click.

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.