Build a Discord picks bot on live Pinnacle odds
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#
- In your Discord server, open Server Settings → Integrations → Webhooks and choose New Webhook.
- Name it, pick the channel it should post to, and use Copy Webhook URL.
- 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
sportsto 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_DROPfrom the window high. Markets belowMIN_STAKEare 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
429and aretry_aftervalue 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,pm2or 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
sportsand 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.