On the wire · 04:57:03 UTC Live 567 · Prematch 14,380 · Markets 56,916 Vol. I · No. 5 · Friday 13 November 2026 wss://pinnfeed.io/v1/stream
PinnFeed
Guide · Bot Discord · Node

Créez un bot de pronostics Discord sur les cotes Pinnacle en direct

Mis à jour le · PinnFeed · 7 min de lecture

Ce bot surveille les prix de Pinnacle sur le WebSocket de PinnFeed et publie un embed dans un salon Discord chaque fois qu'un marché fait du steam : sa cote baisse de 5 % ou plus en deux minutes, sur un marché dont la mise maximale est d'au moins 100. C'est un seul fichier d'environ 90 lignes. Il n'a besoin ni de bibliothèque Discord ni de compte bot, car il publie via un webhook entrant du salon.

C'est le jumeau Discord du bot de pronostics Telegram. La règle de détection est celle expliquée dans le guide de détection du steam. C'est un outil pédagogique réservé aux plus de 18 ans, pas des conseils de paris.

Étape 1 — Créez le webhook#

  1. Dans votre serveur Discord, ouvrez Paramètres du serveur → Intégrations → Webhooks et choisissez Nouveau webhook.
  2. Donnez-lui un nom, choisissez le salon où il publiera et utilisez Copier l'URL du webhook.
  3. Traitez cette URL comme un mot de passe : quiconque la possède peut publier dans le salon. Si elle fuit, supprimez le webhook et créez-en un autre.

Étape 2 — Configurez l'environnement#

export PINNFEED_KEY="ps_live_..."                          # offre Builder (WebSocket)
export DISCORD_WEBHOOK="https://discord.com/api/webhooks/..." # de l'étape 1
npm install ws

Si DISCORD_WEBHOOK n'est pas défini, le bot tourne en mode test : il affiche chaque alerte au lieu de la publier.

Le flux nécessite Builder (99 €/mois) ou supérieur. Les clés Free et Hobby reçoivent 403 plan_lacks_ws sur /v1/stream ; voir les tarifs.

Étape 3 — Le bot#

Enregistrez-le sous 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();

Ce qu'il fait, dans l'ordre :

  • Il s'abonne aux lignes principales (moneyline, handicap, total) du football, du tennis et du basket. Remplacez sports par l'un des 13 sports de votre offre ; le protocole WebSocket (en anglais) liste tous les filtres.
  • Il garde une fenêtre de 2 minutes par marché et par issue, et signale une baisse de MIN_DROP par rapport au plus haut de la fenêtre. Les marchés sous MIN_STAKE sont ignorés, car un mouvement sur une limite faible dit peu de chose.
  • Il met les publications en file. Un seul worker envoie les embeds au webhook, si bien qu'un webhook lent ou limité ne bloque jamais le flux. Au-delà de 50 alertes en attente, la plus ancienne est abandonnée.
  • Il respecte les 429. Discord répond aux rafales par un 429 et une valeur retry_after en secondes dans le corps JSON. Le bot attend ce délai et réessaie, jusqu'à trois tentatives.
  • Aucune mention. allowed_mentions: { parse: [] } empêche un nom d'équipe ou de joueur de mentionner quelqu'un dans le salon.

Étape 4 — Lancez-le et testez-le#

node discord-picks-bot.mjs

En mode test, une alerte s'affiche ainsi :

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

Dans Discord, la même alerte est un embed : le match en titre, le côté et le mouvement de cote dans le corps, la cote équitable sans marge et la mise maximale sur la dernière ligne, puis le sport, la ligue et la mention « 18+ · not a tip » en pied.

Réglez le volume avec des variables d'environnement, par exemple MIN_DROP=0.08 pour des alertes plus rares et plus fortes, ou MIN_STAKE=500 pour ne garder que les marchés plus profonds.

Étape 5 — Gardez-le en marche#

  • Lancez-le sous systemd, pm2 ou un conteneur qui redémarre en cas d'échec. Le bot se reconnecte seul au WebSocket avec backoff et reconstruit sa liste d'événements depuis le nouveau snapshot.
  • Un webhook par salon suffit. Pour le football et le tennis dans des salons séparés, lancez deux copies avec d'autres sports et webhooks ; une clé Builder permet 3 connexions WebSocket.
  • Tenez votre historique honnêtement : enregistrez chaque alerte et mesurez-la avec la closing line value plutôt qu'avec le taux de réussite.
  • Pour savoir quoi publier et comment modérer un serveur, voir le cas d'usage des serveurs Discord (en anglais).

Questions fréquentes#

Quelle offre PinnFeed faut-il pour le bot Discord ?#

Builder (99 €/mois) ou supérieur, car il lit le WebSocket. Pour comparer, voir la page sur le prix du WebSocket en direct et avant-match. Les offres payantes ont 3 jours d'essai gratuit ; une carte est requise et elle est débitée après le jour 3 sauf résiliation.

Puis-je le faire tourner avec l'offre Free ?#

Pas tel quel. Free est uniquement REST, avec 30 secondes de retard. Pour un bot plus lent sur Free ou Hobby, interrogez /v1/drops une fois par minute et publiez le résultat sur le même webhook ; voir la documentation REST (en anglais).

Faut-il un compte bot Discord ou discord.js ?#

Non. Un webhook entrant accepte un simple POST JSON, le bot n'a donc besoin que de fetch. Utilisez un compte bot avec discord.js seulement si vous voulez des commandes slash ou des réponses.

Comment éviter d'inonder le salon ?#

Augmentez MIN_DROP ou MIN_STAKE, réduisez sports et gardez le délai par issue (une alerte par côté de marché par WINDOW_MS). La limite de la file abandonne les vieilles alertes au lieu de les publier en retard.

Les règles sur les pronostics et la publicité pour les jeux d'argent varient selon les pays. En France, les jeux d'argent en ligne et leur publicité sont encadrés par l'Autorité nationale des jeux (ANJ), et la loi du 9 juin 2023 sur l'influence commerciale restreint la promotion des paris sportifs par les influenceurs. Discord a aussi ses propres règles de communauté. Vérifiez les règles là où vous et vos membres vous trouvez, réservez le serveur aux plus de 18 ans et ne présentez jamais une alerte comme un gain sûr. Ceci n'est pas un avis juridique.

Le plan WebSocket : Builder, 99 €/mois.

Les cotes Pinnacle en temps réel sur un seul WebSocket : les 13 sports, tous les marchés, cotes justes sans marge et cotes de clôture. 3 jours gratuits, résiliable en 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.