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

Einen Discord-Picks-Bot mit Live-Quoten von Pinnacle bauen

Aktualisiert am · PinnFeed · 6 Min. Lesezeit

Dieser Bot beobachtet die Pinnacle-Preise im PinnFeed-WebSocket und postet ein Embed in einen Discord-Kanal, sobald ein Markt steamt: Die Quote fällt innerhalb von zwei Minuten um 5 % oder mehr, in einem Markt mit einem Maximaleinsatz von mindestens 100. Es ist eine einzige Datei mit rund 90 Zeilen. Sie braucht weder eine Discord-Bibliothek noch einen Bot-Account, weil sie über einen eingehenden Webhook des Kanals postet.

Er ist das Discord-Gegenstück zum Telegram-Picks-Bot. Die Erkennungsregel erklärt die Anleitung zur Steam-Erkennung. Das ist ein Lernwerkzeug für Erwachsene ab 18 Jahren, keine Wett-Tipps.

Schritt 1 — Den Webhook anlegen#

  1. Öffne in deinem Discord-Server Servereinstellungen → Integrationen → Webhooks und wähle Neuer Webhook.
  2. Gib ihm einen Namen, wähle den Kanal, in den er posten soll, und nutze Webhook-URL kopieren.
  3. Behandle diese URL wie ein Passwort: Wer sie hat, kann in den Kanal posten. Wenn sie öffentlich wird, lösche den Webhook und lege einen neuen an.

Schritt 2 — Die Umgebung einrichten#

export PINNFEED_KEY="ps_live_..."                          # Builder-Plan (WebSocket)
export DISCORD_WEBHOOK="https://discord.com/api/webhooks/..." # aus Schritt 1
npm install ws

Lässt du DISCORD_WEBHOOK leer, läuft der Bot im Testmodus: Er gibt jede Meldung aus, statt sie zu posten.

Der Stream braucht Builder (99 €/Monat) oder höher. Free- und Hobby-Schlüssel erhalten auf /v1/stream den Fehler 403 plan_lacks_ws; siehe Preise.

Schritt 3 — Der Bot#

Speichere ihn als 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();

Was er der Reihe nach tut:

  • Abonniert die Hauptlinien (Moneyline, Spread, Total) für Fußball, Tennis und Basketball. Ersetze sports durch eine der 13 Sportarten deines Plans; das WebSocket-Protokoll (auf Englisch) listet alle Filter.
  • Führt ein 2-Minuten-Fenster pro Markt und Ausgang und meldet einen Rückgang um MIN_DROP gegenüber dem Höchstwert im Fenster. Märkte unter MIN_STAKE werden übersprungen, weil eine Bewegung bei niedrigem Limit wenig aussagt.
  • Stellt Posts in eine Warteschlange. Ein einzelner Worker schickt die Embeds an den Webhook, sodass ein langsamer oder gedrosselter Webhook den Stream nie blockiert. Warten mehr als 50 Meldungen, fällt die älteste weg.
  • Beachtet 429. Discord beantwortet Bursts mit 429 und einem Wert retry_after in Sekunden im JSON-Body. Der Bot wartet so lange und versucht es erneut, höchstens dreimal.
  • Keine Pings. allowed_mentions: { parse: [] } verhindert, dass ein Team- oder Spielername jemanden im Kanal erwähnt.

Schritt 4 — Starten und testen#

node discord-picks-bot.mjs

Im Testmodus sieht eine Meldung so aus:

[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 ist dieselbe Meldung ein Embed: das Spiel als Titel, Seite und Quotenbewegung im Text, die faire Quote ohne Marge und der Maximaleinsatz in der letzten Zeile sowie Sportart, Liga und der Hinweis „18+ · not a tip" in der Fußzeile.

Steuere die Menge über Umgebungsvariablen, etwa MIN_DROP=0.08 für weniger, stärkere Meldungen oder MIN_STAKE=500 für nur tiefere Märkte.

Schritt 5 — Dauerhaft betreiben#

  • Betreibe ihn mit systemd, pm2 oder einem Container mit Neustart bei Fehlern. Der Bot verbindet sich mit Backoff selbst neu und baut seine Eventliste aus dem neuen Snapshot wieder auf.
  • Ein Webhook pro Kanal reicht. Willst du Fußball und Tennis in getrennten Kanälen, starte zwei Kopien mit anderen sports und Webhooks; ein Builder-Schlüssel erlaubt 3 WebSocket-Verbindungen.
  • Führe die Bilanz ehrlich: Protokolliere jede Meldung und miss sie mit dem Closing Line Value statt mit der Trefferquote.
  • Was man in einem Server postet und wie man ihn moderiert, beschreibt der Anwendungsfall Discord-Server (auf Englisch).

Häufige Fragen#

Welchen PinnFeed-Plan braucht der Discord-Bot?#

Builder (99 €/Monat) oder höher, weil er den WebSocket liest. Zum Vergleich siehe die Seite zum Preis des WebSockets für Live und Pre-Match. Bezahlte Pläne haben eine kostenlose Testphase von 3 Tagen; eine Karte ist nötig und wird nach Tag 3 belastet, sofern du nicht kündigst.

Läuft er im Free-Plan?#

So nicht. Free ist nur REST mit 30 Sekunden Verzögerung. Für einen langsameren Bot mit Free oder Hobby fragst du /v1/drops einmal pro Minute ab und postest das Ergebnis an denselben Webhook; siehe die REST-Dokumentation (auf Englisch).

Brauche ich einen Discord-Bot-Account oder discord.js?#

Nein. Ein eingehender Webhook nimmt einen einfachen JSON-POST an, der Bot braucht also nur fetch. Einen Bot-Account mit discord.js brauchst du nur für Slash-Befehle oder Antworten.

Wie verhindere ich, dass der Kanal überläuft?#

Erhöhe MIN_DROP oder MIN_STAKE, verkleinere sports und behalte die Sperrzeit pro Ausgang (eine Meldung pro Marktseite je WINDOW_MS). Das Limit der Warteschlange verwirft alte Meldungen, statt sie verspätet zu posten.

Ist es erlaubt, Picks in einem Discord-Server zu posten?#

Die Regeln für Wett-Tipps und Glücksspielwerbung unterscheiden sich von Land zu Land; in Deutschland regelt etwa der Glücksspielstaatsvertrag 2021 auch die Werbung für Sportwetten. Discord hat außerdem eigene Community-Richtlinien. Prüfe die Regeln dort, wo du und deine Mitglieder seid, halte den Server ab 18 und stelle Meldungen nie als sichere Gewinne dar. Das ist keine Rechtsberatung.

Der WebSocket-Plan: Builder, 99 €/Monat.

Pinnacle-Quoten in Echtzeit über einen einzigen WebSocket: alle 13 Sportarten, alle Märkte, faire Quoten ohne Marge und Schlussquoten. 3 Tage kostenlos, mit einem Klick kündbar.

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.