pnclFEED Free key

Quickstart notes

Post drop alerts to Discord from your Python listener

The drop listener already parses every alert. Add a webhook POST and a per-match cooldown, and your Discord channel shows each move.

The drop listener from the Python quickstart already does the hard part: one open request, a data line per batch, each alert parsed with its sport, match, outcome, old price, new price and fair price. Turning it into a Discord channel takes one more function: format the alert, POST it to a webhook, and do not let the POST slow the stream down.

What you need before the code

Two things beyond the listener itself. First, a plan that pushes drops: the pricing page shows drops start with the $99-a-month push plan and are included in the $149 and $229 plans, checked 2026-10-24. Without one, the stream answers with a plan_lacks_sse control message instead of alerts. Second, a Discord webhook URL, which lives in the channel's settings under Integrations. Keep it in an environment variable like your API key.

The poster

The change to the listener is small. Where the quickstart prints each alert, call a notify function instead:

import json, os, time, requests

BASE = os.environ["ODDS_BASE_URL"]
KEY = os.environ["ODDS_API_KEY"]
HOOK = os.environ["DISCORD_WEBHOOK_URL"]

s = requests.Session()
s.headers["x-portal-apikey"] = KEY
last_sent = {}

def notify(a):
    key = (a["home"], a["outcome"])
    if time.time() - last_sent.get(key, 0) < 300:
        return  # same match and outcome within 5 minutes
    last_sent[key] = time.time()
    pct = (a["from_price"] - a["to_price"]) / a["from_price"] * 100
    text = (f'{a["sport"]} | {a["home"]} | {a["outcome"]}: '
            f'{a["from_price"]} -> {a["to_price"]} ({pct:.1f}%), '
            f'fair {a["nvp"]}')
    try:
        requests.post(HOOK, json={"content": text}, timeout=10)
    except requests.RequestException:
        pass  # the stream matters more than one message

def listen():
    r = s.get(BASE + "/odds-drop", params={"min_drop": 5},
              stream=True, timeout=90)
    r.raise_for_status()
    for raw in r.iter_lines():
        if not raw.startswith(b"data:"):
            continue
        msg = json.loads(raw[5:])
        if isinstance(msg, dict):
            continue  # control frame, not alerts
        for a in msg:
            notify(a)

The percentage is the same arithmetic the feed measures: the fall divided by the old price. The fair price comes attached to the alert, so the message carries what the market thinks the side is worth, not just where it moved.

Keep the channel readable

A webhook that fires on every alert becomes wallpaper within a day. Two guards keep it worth reading. Let the stream do the coarse filter: min_drop on the connection sets the floor, 5 in the documented example, and anything below it never reaches your code. Then let the cooldown do the fine one: five minutes per match and outcome, as above, so a price sliding in steps produces one message, not six.

Resist mentions and embeds at the start. Plain text lines scan fastest, and a channel that pings only when you decide it should stays trusted.

When Discord says no

Discord rate-limits webhooks and answers bursts with its own 429. The poster above treats a failed send as a lost message, which is the right default for an alert you will not act on five minutes later. If you need every alert delivered, queue them and drain the queue slowly; do not retry inside the read loop, where a slow webhook stalls the stream.

The listener was already the whole integration. The webhook is the print statement, upgraded.