Events Performers Venues
Ticket Data & Analytics Ticket Data API Event Ticket Datasets
Sales Dataset Listings Dataset
Pricing Documentation Articles
Zach Bryan Sat, Oct 3 · Gillette Stadium, Foxborough, MA
Zach Bryan Fri, Oct 2 · Gillette Stadium, Foxborough, MA
TBD at Los Angeles Dodgers: NLDS (… Sat, Oct 3 · UNIQLO Field at Dodger Stadium, Los Angeles,…
New York Yankees at Tampa Bay Rays… Sat, Oct 3 · Tropicana Field, St. Petersburg, FL
Philadelphia Phillies at Atlanta B… Thu, Oct 1 · Truist Park, Atlanta, GA
Boston Red Sox at New York Yankees… Thu, Oct 1 · Yankee Stadium, Bronx, NY
Pittsburgh Steelers at Cleveland B… Thu, Oct 1 · Huntington Bank Field, Cleveland, OH
Arizona Cardinals at New York Gian… Sun, Oct 4 · MetLife Stadium, East Rutherford, NJ
Denver Broncos at San Francisco 49… Sun, Oct 4 · Levi's Stadium, Santa Clara, CA
Indianapolis Colts at Washington C… Sun, Oct 4 · Tottenham Hotspur Stadium, London, Gre, GB
View All Events

Build a Concert Ticket Price Alert in Python (With Discord)

Watch one zone of a concert and get a Discord message when its get-in price falls below your threshold. About 190 lines of Python, on a 5-minute schedule.

what you will buildterminal
$ python alert.py
Katseye · American Airlines Center, Dallas TX · 2026-11-11
zone Upper · get-in $298.60 · threshold $300.00
last scan 2026-09-30 23:30 UTC

alert sent to Discord

The price of a resale ticket changes many times before a concert. You do not need to check it by hand. This tutorial builds a short Python script that watches one zone of one concert with the SeatData API and its SDK. When the price falls below your threshold, the script sends a message to a Discord channel.

The script reads the get-in price of the zone. The get-in price is the lowest active listing price at the time of the last scan. It is the price of a listing and not the price of a sale. A listing can sell or change before you act, so read each message as a reason to look.

The finished script does four things on each run:

  • It finds the concert and reads its listings.
  • It compares the get-in price of your zone with your threshold.
  • It sends one Discord message when the price is below the threshold.
  • It remembers the message, so the next run does not send it again.

What you need

Assumed knowledge. You can run a Python script in a terminal and set an environment variable.

  • Python 3.8 or newer.
  • A SeatData API key. New accounts start with a $10 credit, and no card is needed to start. Create an account, then generate a key in the dashboard. The key is shown once, so store it when you see it.
  • The SDK, version 1.2.0 or newer. The getting started guide has the setup steps in detail.
  • A Discord server where you can manage webhooks. Your own server is sufficient. Step 4 creates the webhook.
shellbash
$ pip install "seatdata-sdk>=1.2.0"
$ export SEATDATA_API_KEY="your-64-character-key"
$ export DISCORD_WEBHOOK_URL="https://discord.com/api/webhooks/your-id/your-token"

Every script that calls the API reads the key from the SEATDATA_API_KEY environment variable. The scripts that send a message read the DISCORD_WEBHOOK_URL variable. Keep both values out of your source files and out of version control.

Step 1Find the event

Every price call needs an event id. Search by name and month to get one. The name can be a performer or a team.

01_search.pyPython
import os

from seatdata import SeatDataClient

client = SeatDataClient(api_key=os.environ["SEATDATA_API_KEY"])

events = client.search_events(
    event_name="Katseye",
    event_date="2026-11",
    historical=True,  # keeps the search working after the event date
    limit=20,
)
for event in events:
    print(event["event_id"], event["event_date"], event["venue_city"], "|", event["venue_name"])
Outputcaptured Sep 30, 2026
1358008 2026-11-01 Hamilton | TD Coliseum (Formerly Hamilton Coliseum)
1358011 2026-11-03 Detroit | Little Caesars Arena
1358009 2026-11-05 Chicago | United Center
1358021 2026-11-07 Minneapolis | Target Center
1358015 2026-11-10 Austin | Moody Center
1358017 2026-11-11 Dallas | American Airlines Center
1358020 2026-11-14 Las Vegas | Garden Arena at MGM Grand Hotel
1358014 2026-11-17 Seattle | Climate Pledge Arena
1358022 2026-11-19 Oakland | Oakland Arena
1358023 2026-11-21 Los Angeles | Crypto.com Arena
1366929 2026-11-22 Los Angeles | Crypto.com Arena
1358013 2026-11-24 Phoenix | Mortgage Matchup Center
1358012 2026-11-27 Ciudad de México | Pabellón Oeste del Palacio de Deportes
1370778 2026-11-28 Mexico City | Palacio de los Deportes Mexico

A tour plays many cities, and some cities get two nights. Check the city and the date before you copy the id. The date filter matches a part of the date, so 2026-11 finds every show in November 2026. The search reference lists every filter.

Step 2Read the listings

SeatData stores each listing of an event as a row. The listings endpoint returns every stored row of the event and the time of the last scan.

02_listings.pyPython
import json
import os
from datetime import datetime, timezone

from seatdata import SeatDataClient

EVENT_ID = 1358017  # the event_id from step 1
ZONE = "Upper"

client = SeatDataClient(api_key=os.environ["SEATDATA_API_KEY"])
data = client.get_listings(event_id=EVENT_ID)

rows = data["listings"]
# the response also holds the listings that the last scan did not find
active = [row for row in rows if row["active"] == 1]
scanned = datetime.fromtimestamp(data["last_refresh_timestamp"], timezone.utc)
print("last scan", scanned.strftime("%Y-%m-%d %H:%M UTC"))
print(f"{len(rows):,} rows, {len(active):,} active")
print("zones:", ", ".join(sorted({row["zone"] for row in active if row["zone"]})))

# the cheapest active listing of the zone sets its get-in price
in_zone = [row for row in active if row["zone"] == ZONE and row["price"] > 0]
print(json.dumps(min(in_zone, key=lambda row: row["price"]), indent=2))
Outputcaptured Sep 30, 2026
last scan 2026-09-30 23:30 UTC
25,575 rows, 788 active
zones: Admiral Suite, Flagship Suite, Floor, Lower, Middle, Platinum Suite, Upper
{
  "listing_id": 14054659248,
  "active": 1,
  "zone": "Upper",
  "section": "320",
  "row": "K",
  "quantity_start": 1,
  "quantity": 1,
  "price": 298.6
}

The script prints the time of the last scan in UTC, the number of rows, the zone names, and the cheapest active listing of the zone Upper. Most rows of a busy event are old, so each script of this tutorial selects the rows with active set to 1.

The response holds two more keys. last_refresh_timestamp is the time of the last scan in Unix seconds, or null. has_refreshed is 1 when the call cost a pull.

Read a listing record

Each row describes one listing in eight fields. The alert reads three of them: active, zone, and price.

one listingJSON
{
  "listing_id": 14054659248,
  "active": 1,
  "zone": "Upper",
  "section": "320",
  "row": "K",
  "quantity_start": 1,
  "quantity": 1,
  "price": 298.6
}
listing_idinteger
The id of the listing.
activeinteger
1 when the last scan found the listing. 0 when it did not.
zonestring
The zone of the seats. The script matches the name exactly. The field is empty when the listing has no zone.
sectionstring
The section of the seats.
rowstring
The row of the seats.
quantity_startinteger or null
The number of tickets when SeatData first saw the listing.
quantityinteger or null
The number of tickets when SeatData last saw the listing.
pricenumber
The price per ticket when SeatData last saw the listing. Fees can come on top. Prices are in US dollars.

The lowest listing of a zone can be a single ticket. If you need 2 or more tickets from one listing, set MIN_QUANTITY = 2 in the full script.

Step 3Check one zone

Select the zone that you want to watch, and set a threshold. The threshold is the listing price below which you want a message. The script computes the get-in price of each zone, which is the lowest price among its active listings.

03_check.pyPython
import os
import sys

from seatdata import SeatDataClient

EVENT_ID = 1358017
ZONE = "Upper"
THRESHOLD = 300.00  # US dollars


def money(value):
    return f"${value:,.2f}"


client = SeatDataClient(api_key=os.environ["SEATDATA_API_KEY"])
rows = client.get_listings(event_id=EVENT_ID)["listings"]

# the get-in price of a zone is the lowest price among its active listings
zones = {}
for row in rows:
    if row["active"] == 1 and row["price"] > 0 and row["zone"]:
        zones[row["zone"]] = min(row["price"], zones.get(row["zone"], row["price"]))
if ZONE not in zones:
    sys.exit(f"Zone '{ZONE}' has no active listing. Zones: {', '.join(sorted(zones))}")

for name, price in sorted(zones.items(), key=lambda item: item[1]):
    mark = "  <- the zone to watch" if name == ZONE else ""
    print(f"{name:<16}{money(price):>10}{mark}")

verdict = "below" if zones[ZONE] < THRESHOLD else "not below"
print()
print(f"{ZONE}: {money(zones[ZONE])} is {verdict} the threshold of {money(THRESHOLD)}")
Outputcaptured Sep 30, 2026
Upper              $298.60  <- the zone to watch
Middle             $342.83
Flagship Suite     $436.28
Lower              $437.62
Platinum Suite     $479.91
Floor              $519.14
Admiral Suite    $1,664.16

Upper: $298.60 is below the threshold of $300.00

The list shows every zone, from the lowest get-in price to the highest. Set a threshold that the price can reach.

A zone name with a typing error does not fail silently. The script stops and prints the zones that have active listings, so you can copy the correct name.

Step 4Send a Discord message

A webhook is a URL that accepts messages for one channel. It needs no Discord application and no login. Create one in Discord:

  1. Open Server Settings, and select Integrations.
  2. Select Create Webhook.
  3. Give the webhook a name, and select the channel for the messages.
  4. Select Copy Webhook URL, and store the URL in the DISCORD_WEBHOOK_URL environment variable.

You need the permission to manage webhooks on the server. The Discord guide to webhooks shows each step.

The standard library of Python sends the message. The request is a POST with a small JSON body.

04_webhook.pyPython
import json
import os
import urllib.request

# The URL is a secret. Anyone who has it can post to the channel.
WEBHOOK_URL = os.environ["DISCORD_WEBHOOK_URL"]

request = urllib.request.Request(
    WEBHOOK_URL,
    data=json.dumps({"content": "Test message from the price alert script."}).encode("utf-8"),
    # Discord rejects the default User-Agent of urllib
    headers={"Content-Type": "application/json", "User-Agent": "price-alert/1.0"},
    method="POST",
)
with urllib.request.urlopen(request, timeout=10) as response:
    print("Discord answered with status", response.status)
Outputcaptured Sep 30, 2026
Discord answered with status 204

Status 204 means that Discord accepted the message and sends no content back. Do not remove the User-Agent header. Discord rejects a request that carries the default header of urllib, and the answer is then status 403.

Step 5Alert once

A script that runs every 5 minutes must not send the same news on each run. The full script keeps a small state file, alert_state.json, and follows one rule with three parts:

  • Armed. When the price is below the threshold, the script sends one message. After that, it is not armed.
  • Not armed. The script sends nothing, also when the price falls further.
  • Armed again. When the price rises to the threshold plus 2%, the script is armed again. The next price below the threshold sends a new message.

The margin of 2% has a reason. The get-in price of a zone can move by small amounts from one scan to the next. Without a margin, a price that sits at the threshold crosses it again and again, and each fall sends a message. The constant REARM_MARGIN sets the margin.

This is the channel after the capture: the test message of step 4, and below it the alert of the first run.

The test message and the alert message of the script in a Discord channel
The test message and the alert in Discord, captured September 30, 2026.

The second run came a moment later. It found the same price and sent nothing:

second runtext
$ python alert.py
Katseye · American Airlines Center, Dallas TX · 2026-11-11
zone Upper · get-in $298.60 · threshold $300.00
last scan 2026-09-30 23:30 UTC

no message: an alert is already out. The script arms again at $306.00.

The script saves the state only after Discord accepted the message. When the send fails, the state does not change, and the next run tries again. When you change the event, the zone, MIN_QUANTITY, or the threshold, the script starts armed.

Step 6Schedule it

Schedule the script every 5 minutes with cron. Put both secrets in a file that only your user can read, and load that file in the cron line.

crontabtext
# alert.env holds two lines:
#   export SEATDATA_API_KEY="your-64-character-key"
#   export DISCORD_WEBHOOK_URL="https://discord.com/api/webhooks/your-id/your-token"
# chmod 600 alert.env
# Cron does not use your shell setup. Replace /usr/bin/python3 with the output of:
#   python3 -c "import sys; print(sys.executable)"
*/5 * * * * cd /home/you/alert && . ./alert.env && /usr/bin/python3 alert.py >> alert.log 2>&1

The cron line changes to the directory of the script first, because the state file is in the working directory. Each run adds its output to alert.log.

An error gives one line in the log, and the script ends with exit code 1. The script prints no traceback, and it never prints the key or the webhook URL. After the concert, the script prints that the event is over and sends nothing. Remove the cron line then.

What a run costs

One run of the script makes two API calls, and one call to Discord at most.

CallSDK methodCost
Searchsearch_eventsFree
Listingsget_listings1 pull for the first call, then 1 pull per new scan.

The first call for an event costs a pull. After that, a run costs a pull only when SeatData scanned the event again since your last paid pull. The other runs are free. So the schedule sets the most that the script can spend: a run every 5 minutes makes at most 288 pulls per day. The number of scans sets the real count. The price of a pull depends on your plan. See API pricing for the current rates.

Full script

The complete alert in one file. To watch a different concert, change the constants at the top. The last lines of the file catch each error that the script does not expect, so that cron logs one line.

alert.pyPython · 191 lines
"""Send a Discord message when the get-in price of one zone falls below a threshold.

Usage:
    export SEATDATA_API_KEY="your-64-character-key"
    export DISCORD_WEBHOOK_URL="https://discord.com/api/webhooks/your-id/your-token"
    python alert.py
"""
import json
import os
import sys
import urllib.error
import urllib.request
from datetime import date, datetime, timezone

try:
    from seatdata import SeatDataClient, SeatDataError
except ImportError:
    sys.exit('The SDK is missing. Run: pip install "seatdata-sdk>=1.2.0"')

PERFORMER = "Katseye"
CITY = "Dallas"
EVENT_DATE = "2026-11-11"  # the tour has many dates, and the date selects one
ZONE = "Upper"
MIN_QUANTITY = 1  # 2 watches only the listings with 2 or more tickets
THRESHOLD = 300.00  # US dollars
REARM_MARGIN = 0.02  # after an alert, the price must reach the threshold plus 2%
STATE_PATH = "alert_state.json"  # in the working directory


def find_event(client):
    # historical=True keeps the search working after the event date
    events = client.search_events(
        event_name=PERFORMER, venue_city=CITY, event_date=EVENT_DATE, historical=True, limit=1
    )
    if not events:
        sys.exit("No event found. Check PERFORMER, CITY, and EVENT_DATE.")
    return events[0]


def zone_price(listings):
    """Return the get-in price of ZONE: the lowest price among its active listings."""
    prices = {}
    for listing in listings:
        # the response also holds the listings that the last scan did not find
        if listing["active"] != 1 or listing["price"] <= 0:
            continue
        # quantity can be null, so MIN_QUANTITY = 1 does not read it
        if MIN_QUANTITY > 1 and (listing["quantity"] or 0) < MIN_QUANTITY:
            continue
        prices.setdefault(listing["zone"], []).append(listing["price"])
    if ZONE not in prices:
        zones = ", ".join(sorted(zone for zone in prices if zone)) or "none"
        sys.exit(f"Zone '{ZONE}' has no active listing. Zones with active listings: {zones}")
    return min(prices[ZONE])


def scan_time(timestamp):
    """Format the Unix time of the last scan in UTC."""
    if timestamp is None:
        return "unknown"
    return datetime.fromtimestamp(timestamp, timezone.utc).strftime("%Y-%m-%d %H:%M UTC")


def rearm_price(threshold, margin):
    return round(threshold * (1 + margin), 2)


def decide(armed, price, threshold, margin):
    """Return (send, armed) for one price reading.

    An armed script sends a message when the price is below the threshold. Then it waits.
    It arms again when the price reaches the threshold plus the margin.
    """
    if armed:
        send = price < threshold
        return send, not send
    return False, price >= rearm_price(threshold, margin)


def load_armed(event_id):
    """Return True when the script is armed. A missing file means armed."""
    try:
        with open(STATE_PATH) as handle:
            state = json.load(handle)
        # a state for another event, zone, quantity, or threshold does not count
        watch = (state["event_id"], state["zone"], state["min_quantity"], state["threshold"])
        if watch != (event_id, ZONE, MIN_QUANTITY, THRESHOLD):
            return True
        return bool(state["armed"])
    except FileNotFoundError:
        return True
    except (OSError, ValueError, KeyError, TypeError):
        sys.exit(f"State file error: cannot read {STATE_PATH}. Delete the file to start again.")


def save_state(event_id, armed, price, scanned):
    state = {
        "event_id": event_id, "zone": ZONE, "min_quantity": MIN_QUANTITY, "threshold": THRESHOLD,
        "armed": armed, "price": price, "scanned": scanned,
    }
    with open(STATE_PATH, "w") as handle:
        json.dump(state, handle, indent=2)


def send_discord(webhook_url, text):
    """Post one message to the Discord webhook."""
    request = urllib.request.Request(
        webhook_url,
        data=json.dumps({"content": text}).encode("utf-8"),
        # Discord rejects the default User-Agent of urllib
        headers={"Content-Type": "application/json", "User-Agent": "price-alert/1.0"},
        method="POST",
    )
    try:
        with urllib.request.urlopen(request, timeout=10):
            pass
    except urllib.error.HTTPError as error:
        sys.exit(f"Discord error: HTTP {error.code}")
    except OSError:
        sys.exit("Discord error: no connection")


def money(value):
    return f"${value:,.2f}"


def main():
    api_key = os.environ.get("SEATDATA_API_KEY", "").strip()
    webhook_url = os.environ.get("DISCORD_WEBHOOK_URL", "").strip()
    if not api_key:
        sys.exit("Set the SEATDATA_API_KEY environment variable first.")
    if not webhook_url:
        sys.exit("Set the DISCORD_WEBHOOK_URL environment variable first.")
    if not webhook_url.startswith("https://"):
        sys.exit("DISCORD_WEBHOOK_URL must start with https://")

    try:
        # one call returns every stored listing of the event, so allow 60 seconds
        client = SeatDataClient(api_key=api_key, timeout=60)
        event = find_event(client)
        if date.fromisoformat(event["event_date"]) < date.today():
            print(f'The event took place on {event["event_date"]}. Nothing to watch.')
            return
        # read the state before the listings call, so a damaged file costs no pull
        armed = load_armed(event["event_id"])
        data = client.get_listings(event_id=event["event_id"])
    except (SeatDataError, ValueError) as error:
        sys.exit(f"SeatData error: {error}")
    if "last_refresh_timestamp" not in data:
        sys.exit('Update the SDK. Run: pip install -U "seatdata-sdk>=1.2.0"')
    if not data["listings"]:
        sys.exit("No listings for this event yet.")
    price = zone_price(data["listings"])
    send, armed_now = decide(armed, price, THRESHOLD, REARM_MARGIN)

    seen = scan_time(data["last_refresh_timestamp"])
    if send:
        send_discord(webhook_url, (
            f'{event["event_name"]}, {event["venue_city"]}, {event["event_date"]}\n'
            f"Zone {ZONE}: the get-in price is {money(price)}. "
            f"Your threshold is {money(THRESHOLD)}.\n"
            f"Last scan: {seen}. A listing can sell or change at any time."
        ))
    if armed_now != armed:
        # the state changes only after Discord accepted the message
        try:
            save_state(event["event_id"], armed_now, price, data["last_refresh_timestamp"])
        except OSError:
            sys.exit(f"State file error: cannot write {STATE_PATH}. The next run repeats this step.")

    venue = f'{event["venue_name"]}, {event["venue_city"]} {event["venue_state"]}'
    rearm = money(rearm_price(THRESHOLD, REARM_MARGIN))
    print(f'{event["event_name"]} · {venue} · {event["event_date"]}')
    print(f"zone {ZONE} · get-in {money(price)} · threshold {money(THRESHOLD)}")
    print(f"last scan {seen}")
    print()
    if send:
        print("alert sent to Discord")
    elif armed_now and not armed:
        print(f"armed again: the price reached {rearm} or more")
    elif armed_now:
        print("no alert: the price is not below the threshold")
    else:
        print(f"no message: an alert is already out. The script arms again at {rearm}.")


if __name__ == "__main__":
    try:
        main()
    except Exception as error:  # cron logs one line, never a traceback
        sys.exit(f"Unexpected error: {type(error).__name__}")

Troubleshooting

The full script reports each SDK error as one line that starts with SeatData error. The step scripts show the error class.

ValueErroron start
The key is not 64 characters long. Copy it again from the dashboard.
SeatDataAuthErrorHTTP 401
The key is wrong or was revoked. Generate a new key.
SeatDataPaymentErrorHTTP 402
The balance does not cover the pull. Add funds or choose a plan.
SeatDataRateLimitErrorHTTP 429
Too many requests. The error carries retry_after, the number of seconds to wait.
The SDK is missingscript message
Python cannot import the SDK. Run the pip install line of "What you need" in the environment that runs the script.
Update the SDKscript message
The SDK is older than 1.2.0, and its listings call returns no scan time. Run the pip install line of the message.
No event foundscript message
The search matched nothing. Check the spelling of PERFORMER and CITY, and the date in EVENT_DATE.
Zone 'Upper' has no active listingscript message
The name has a typing error, or the zone had nothing on sale at the last scan. Compare the name with the list in the message.
Discord error: HTTP 404script message
Discord does not know the webhook. Copy the URL again, or create a new webhook.
State file errorscript message
The script cannot read or write alert_state.json. After a read error, delete the file, and the script starts armed. After a write error, check that your user can write to the directory.

Next steps

The alert watches one zone of one concert. The price history of the same event shows whether a fall is unusual. The first tutorial of this series builds a tracker for the price history of one event.

FAQ

What is a get-in price?
The get-in price is the lowest active listing price at the time of the last scan. A listing can sell or change before you act.
How much does the alert cost to run?
The first call for an event costs one pull. After that, a run costs one pull only when SeatData scanned the event again since your last paid pull. The other runs are free. Search is free.
Can I watch more than one zone?
Yes. One listings call holds every zone of the event, so more zones need no extra pull.
Do I need a Discord application?
No. A webhook URL is sufficient. You create the webhook in the settings of your server.
Does the script work after the concert?
It finds the event, prints that the event is over, and exits. It sends no message.
  • ticket prices
  • API
  • Python
  • Tutorial
  • Discord

Start building with SeatData today.

$10 credit on signup. No card needed to start.