Guides

Build StubHub and SeatGeek Price Alerts in Python

Get Slack alerts when StubHub, SeatGeek, Vivid Seats or Ticketmaster prices fall under your target or a section sells down. Working Python code.

Last updated

The cheapest pair in Vivid Seats section 110 for the Eagles at Sphere, Las Vegas, on December 11, 2026, all-in per ticket:

  • $413 on October 14
  • $388 on October 16
  • $383 on October 17
  • $403 on October 18

Replayed through the script below, a rule for a pair in section 110 at $390 or less alerts twice: at $388, and again when it fell to $383. By October 18 the cheapest pair was back over $400.

The script below watches StubHub, SeatGeek, Vivid Seats, Ticketmaster and the other marketplaces Tickets.dev covers. Run it every 15 minutes and it alerts when a price drops under your target, new tickets are listed in your section, or a section sells down.

Run it

Save alerts.py below and try --sample first. It replays real captures of section 110 for the Eagles show, with no key and no credits. Then get an API key and run it live:

Run it
pip install requests
python alerts.py --sample                # real saved captures, no key needed
export TICKETS_DEV_KEY=your_api_key
export SLACK_WEBHOOK_URL=https://hooks.slack.com/services/...   # optional
python alerts.py
Output of --sample
$ python alerts.py --sample
vividseats at 2026-10-14T15:04:38Z: 18 listings, first snapshot: the baseline
vividseats at 2026-10-16T03:04:46Z: 18 listings
  ALERT Pair in section 110 at $390 or less: vividseats section 110 row 36, 2 tickets, 388.00 USD each, all-in
vividseats at 2026-10-17T15:10:41Z: 18 listings
  ALERT Pair in section 110 at $390 or less: vividseats section 110 row 36, 2 tickets, 383.00 USD each, all-in
vividseats at 2026-10-18T15:10:29Z: 17 listings
stubhub at 2026-10-16T05:05:00Z: 9 listings, first snapshot: the baseline
stubhub at 2026-10-16T05:06:51Z: 9 listings
stubhub at 2026-10-16T05:34:58Z: 7 listings
stubhub at 2026-10-16T05:35:00Z: 6 listings

The StubHub captures were taken minutes apart on October 16. Section 110 had 25 tickets, and two captures two seconds apart returned 20 and 19, each missing different listings. No alert fired, because a listing only counts as gone once it's missing from two captures in a row (see comparing with the last two).

The code below watches the first upcoming show the search finds, or the one you click, on up to three of its marketplaces. Set the price in RULES to what you'd pay for that show.

matched eventsSearching…

Searching the catalog…

alerts.py · The Eagles, Dec 11 (example)
"""Ticket price and inventory alerts, one pass per run.

    python alerts.py           # capture, compare with earlier snapshots, notify
    python alerts.py --sample  # replay real saved captures: no key, no credits

Run it on a schedule. Each pass captures every URL in EVENT_URLS once,
compares each capture with the last two good ones from the same marketplace,
and posts what matches RULES to Slack (or prints it, without SLACK_WEBHOOK_URL).
A listing only counts as gone once it's missing from two captures in a row:
two captures minutes apart don't always return exactly the same listings.
"""
import json
import os
import sqlite3
import sys
import time

import requests

# The show to watch: one event page per marketplace, from the sources[].url of
# GET /v1/events (never billed). Each URL is one capture per run.
EVENT_URLS = [
    "https://www.stubhub.com/the-eagles-las-vegas-tickets-12-11-2026/event/161551904",
    "https://seatgeek.com/eagles-tickets/las-vegas-nevada-sphere-2026-12-11-8-30-pm/concert/18355052",
    "https://www.vividseats.com/the-eagles-tickets-las-vegas-sphere-at-the-venetian-resort-12-11-2026--concerts-adult-contemporary/production/7256971",
]

# A listing must pass every filter in a rule (sections, rows, quantity), then
# the rule's trigger decides whether to alert. Leave a filter out to match any.
# Set your own section and price: sections as each marketplace spells them,
# prices all-in per ticket.
RULES = [
    {"name": "Pair at $250 or less", "quantity": 2, "max_price": 250},
    {"name": "Tickets down 20% or more", "tickets_drop_pct": 20},
    # Also available: {"name": "...", "sections": ["110"], "new_listings": True}.
    # Some marketplaces relist under new ids, so new-listing alerts can be noisy.
]

# The rules --sample replays, for the saved Eagles captures in sample_runs().
SAMPLE_RULES = [
    {"name": "Pair in section 110 at $390 or less",
     "sections": ["110"], "quantity": 2, "max_price": 390},
    {"name": "Section 110 tickets down 20% or more",
     "sections": ["110"], "tickets_drop_pct": 20},
]

SAMPLE = "--sample" in sys.argv
API_KEY = "" if SAMPLE else os.environ["TICKETS_DEV_KEY"]
SLACK_WEBHOOK_URL = os.environ.get("SLACK_WEBHOOK_URL")

# On a host like Railway, set these instead of editing the lists above:
# EVENT_URLS, one URL per line or comma-separated; RULES_JSON, the rules as a
# JSON list; ALERTS_DB, where to keep the database (on a persistent volume).
if os.environ.get("EVENT_URLS"):
    EVENT_URLS = [u.strip() for u in os.environ["EVENT_URLS"].replace(",", "\n").splitlines() if u.strip()]
if os.environ.get("RULES_JSON"):
    RULES = json.loads(os.environ["RULES_JSON"])
DB_PATH = os.environ.get("ALERTS_DB", "alerts.db")

SCHEMA = """
create table if not exists snapshots (
  id integer primary key,
  event_url text not null,
  captured_at text not null,
  body text not null
);
create table if not exists sent (
  key text primary key,
  price real,
  sent_at text not null
);
"""


class CaptureFailed(Exception):
    pass


def capture(url):
    """One capture, retrying what the API marks as retryable: 429 rate_limited
    and the capture failures that are ours, not yours."""
    wait = 0
    for attempt in range(3):
        time.sleep(wait)
        try:
            response = requests.get(
                "https://api.tickets.dev/v1/capture",
                params={"url": url},
                headers={"x-api-key": API_KEY},
                timeout=120,
            )
        except requests.RequestException as error:
            failure, wait = type(error).__name__, 10
            continue
        if response.ok:
            return response.json()
        try:
            error = response.json()["error"]
        except (ValueError, KeyError):
            error = {"code": f"HTTP {response.status_code}"}
        if not error.get("retryable"):
            raise CaptureFailed(error["code"])
        retry_after = response.headers.get("Retry-After", "")
        failure, wait = error["code"], int(retry_after) if retry_after.isdigit() else 10
    raise CaptureFailed(failure)


def listing_key(listing):
    """Listing ids are the marketplace's own: unique within one marketplace,
    so only compare a marketplace with itself. Where a marketplace publishes
    no id, fall back to where the seats are, which is approximate."""
    if listing["listingId"]:
        return listing["listingId"]
    return "|".join([listing["section"], listing["row"],
                     listing["seats"] or str(listing["totalPrice"])])


def sellable(listing):
    quantities = listing["sellableQuantities"].replace(" ", "").split(",")
    return {int(q) for q in quantities if q.isdigit()} or {listing["quantity"]}


def matches(rule, listing):
    folded = lambda values: {v.strip().casefold() for v in values}
    if "sections" in rule and listing["section"].strip().casefold() not in folded(rule["sections"]):
        return False
    if "rows" in rule and listing["row"].strip().casefold() not in folded(rule["rows"]):
        return False
    if "quantity" in rule and rule["quantity"] not in sellable(listing):
        return False
    return True


def price_of(listing):
    """All-in per ticket, or None when the capture didn't have one."""
    price = listing["totalPrice"]
    return price if isinstance(price, (int, float)) and price > 0 else None


def describe(listing, snapshot):
    price = price_of(listing)
    amount = f"{price:,.2f} {snapshot['currency']} each, all-in" if price else "no price"
    return (f"{snapshot['source']} section {listing['section']} row {listing['row']}, "
            f"{listing['quantity']} tickets, {amount}")


def still_listed(snapshot, earlier):
    """The listings to count as on sale at this snapshot: everything in it, plus
    anything from the capture before that's missing only from this one."""
    keep = {listing_key(l): l for l in snapshot["listings"]}
    if earlier is not None:
        for listing in earlier["listings"]:
            keep.setdefault(listing_key(listing), listing)
    return keep


def check(rule, previous, earlier, snapshot):
    """Alerts for one rule, as (dedupe key, price, message). previous and
    earlier are the last two good snapshots, newest first (either can be None)."""
    now = [l for l in snapshot["listings"] if matches(rule, l)]
    scope = f"{rule['name']}|{snapshot['source']}"
    found = []
    if "max_price" in rule:
        # One alert per rule and marketplace: the cheapest matching listing.
        priced = [l for l in now if price_of(l) and price_of(l) <= rule["max_price"]]
        if priced:
            best = min(priced, key=price_of)
            found.append((scope, price_of(best), f"{rule['name']}: {describe(best, snapshot)}"))
    if previous is None:
        return found  # the first snapshot is the baseline: nothing to diff yet
    seen = {listing_key(l) for s in (previous, earlier) if s for l in s["listings"]}
    if rule.get("new_listings"):
        for listing in now:
            if listing_key(listing) not in seen:
                found.append((f"{scope}|{listing_key(listing)}", None,
                              f"{rule['name']}: {describe(listing, snapshot)}"))
    if "tickets_drop_pct" in rule:
        # Gone means missing from this capture and the one before it. "Then" is the
        # larger of the last two counts, so a listing relisted under a new id isn't
        # counted twice.
        tickets = lambda listings: sum(l["quantity"] for l in listings if matches(rule, l))
        old = max(tickets(s["listings"]) for s in (previous, earlier) if s)
        new = tickets(still_listed(snapshot, previous).values())
        if old and (old - new) * 100 / old >= rule["tickets_drop_pct"]:
            found.append((f"{scope}|{snapshot['capturedAt']}", None,
                          f"{rule['name']}: {snapshot['source']} went from {old} to "
                          f"{new} tickets between {previous['capturedAt']} and "
                          f"{snapshot['capturedAt']}. Sold, delisted or repriced: "
                          "the listings don't say which."))
    return found


def already_sent(db, key, price):
    row = db.execute("select price from sent where key = ?", (key,)).fetchone()
    if row is None:
        return False
    # A price alert repeats only when the price falls below the last one sent.
    return price is None or row[0] is None or price >= row[0]


def notify(message, url):
    if SAMPLE or not SLACK_WEBHOOK_URL:
        print(f"  ALERT {message}")
        return True
    response = requests.post(
        SLACK_WEBHOOK_URL, json={"text": f"{message}\n{url}"}, timeout=10
    )
    return response.ok


def process(db, url, snapshot):
    """Compare one capture with the last two good ones and send what's new."""
    if not snapshot["listings"] and not snapshot["note"]:
        # No listings and no notice saying why (sold out, canceled, not on
        # sale) looks like a partial page, not a sell-out. Keep the old
        # baseline and let the next capture decide.
        print(f"{snapshot['source']}: empty capture, ignored")
        return
    rows = db.execute(
        "select body from snapshots where event_url = ? order by id desc limit 2",
        (url,),
    ).fetchall()
    previous, earlier = ([json.loads(r[0]) for r in rows] + [None, None])[:2]
    print(f"{snapshot['source']} at {snapshot['capturedAt']}: "
          f"{len(snapshot['listings'])} listings"
          + ("" if previous else ", first snapshot: the baseline"))

    delivered = True
    for rule in SAMPLE_RULES if SAMPLE else RULES:
        for key, price, message in check(rule, previous, earlier, snapshot):
            if already_sent(db, key, price):
                continue
            if notify(message, snapshot["sourceUrl"]):
                db.execute(
                    "insert into sent (key, price, sent_at) values (?, ?, datetime()) "
                    "on conflict (key) do update set price = excluded.price, "
                    "sent_at = excluded.sent_at",
                    (key, price),
                )
                db.commit()
            else:
                delivered = False
    if not delivered:
        # Keep the old baseline so the next run finds the same changes and
        # retries what didn't send. What did send is in the sent table already.
        print("  some alerts failed to send, will retry next run")
        return
    db.execute(
        "insert into snapshots (event_url, captured_at, body) values (?, ?, ?)",
        (url, snapshot["capturedAt"], json.dumps(snapshot)),
    )
    # Only the last two are ever read. A capture can be 300 KB, so keeping
    # every one would add tens of MB a day.
    db.execute(
        "delete from snapshots where event_url = ? and id not in "
        "(select id from snapshots where event_url = ? order by id desc limit 2)",
        (url, url),
    )
    db.commit()


def main():
    db = sqlite3.connect(":memory:" if SAMPLE else DB_PATH)
    db.executescript(SCHEMA)
    if SAMPLE:
        for snapshot in sample_runs():
            process(db, snapshot["sourceUrl"], snapshot)
        return
    if not EVENT_URLS:
        raise SystemExit("Set EVENT_URLS to the event pages to watch: the sources[].url "
                         "of GET https://api.tickets.dev/v1/events?query=<show>.")
    for url in EVENT_URLS:
        try:
            snapshot = capture(url)
        except CaptureFailed as error:
            # Not an empty snapshot: nothing is stored and nothing "disappears".
            print(f"capture failed, will retry next run: {url} ({error})")
            continue
        process(db, url, snapshot)


def sample_runs():
    """Real saved captures of the Eagles at Sphere, Las Vegas, Dec 11, 2026,
    trimmed to section 110 and the fields this script reads. Vivid Seats over
    four days, and StubHub in one half hour, when captures minutes apart
    didn't return the same listings."""
    VS = "https://www.vividseats.com/the-eagles-tickets-las-vegas-sphere-at-the-venetian-resort-12-11-2026--concerts-adult-contemporary/production/7256971"
    SH = "https://www.stubhub.com/the-eagles-las-vegas-tickets-12-11-2026/event/161551904"
    # (listing id, row, quantity, sellable quantities, all-in price per ticket)
    vs = {
        "2026-10-14T15:04:38Z": [("VB17446211114", "34", 2, "2", 413), ("VB17508470500", "28", 2, "2", 423), ("VB17477880051", "36", 2, "2", 426), ("VB17508470065", "27", 2, "2", 436), ("VB17446384957", "36", 4, "2,4", 436), ("VB16889394915", "24", 3, "1,3", 443), ("VB17445826661", "35", 8, "1,2,3,4,5,6,8", 443), ("VB17520348890", "33", 4, "2,4", 444), ("VB17446179990", "34", 8, "1,2,3,4,5,6,8", 447), ("VB16889394913", "25", 2, "2", 457), ("VB17520348516", "32", 4, "2,4", 457), ("VB17508470491", "26", 2, "2", 461), ("VB17520350229", "31", 4, "2,4", 470), ("VB17445553236", "23", 8, "1,2,3,4,5,6,8", 549), ("VB17446648587", "24", 4, "2,4", 702), ("VB17446661335", "28", 5, "1,2,3,5", 702), ("VB17445579836", "26", 4, "2,4", 702), ("VB16738688658", "20", 4, "1,2,3,4", 2199)],
        "2026-10-16T03:04:46Z": [("VB17550216158", "36", 2, "2", 388), ("VB17550216391", "35", 4, "2,4", 415), ("VB17550777861", "27", 2, "2", 418), ("VB17550777547", "34", 2, "2", 422), ("VB17550777222", "33", 4, "2,4", 424), ("VB17550778096", "36", 4, "2,4", 424), ("VB17550776776", "35", 8, "1,2,3,4,5,6,8", 431), ("VB17550777572", "34", 8, "1,2,3,4,5,6,8", 435), ("VB17550776263", "32", 4, "2,4", 437), ("VB17550778560", "26", 2, "2", 442), ("VB16889394915", "24", 3, "1,3", 443), ("VB17550778401", "31", 4, "2,4", 450), ("VB16889394913", "25", 2, "2", 457), ("VB17445553236", "23", 8, "1,2,3,4,5,6,8", 549), ("VB17446661335", "28", 5, "1,2,3,5", 702), ("VB17446648587", "24", 4, "2,4", 702), ("VB17445579836", "26", 4, "2,4", 702), ("VB16738688658", "20", 4, "1,2,3,4", 2199)],
        "2026-10-17T15:10:41Z": [("VB17559480588", "36", 2, "2", 383), ("VB17559480683", "35", 4, "2,4", 410), ("VB17550777861", "27", 2, "2", 418), ("VB17550777547", "34", 2, "2", 422), ("VB17550777222", "33", 4, "2,4", 424), ("VB17550778096", "36", 4, "2,4", 424), ("VB17550776776", "35", 8, "1,2,3,4,5,6,8", 431), ("VB17550777572", "34", 8, "1,2,3,4,5,6,8", 435), ("VB17550776263", "32", 4, "2,4", 437), ("VB17550778560", "26", 2, "2", 442), ("VB16889394915", "24", 3, "1,3", 443), ("VB17550778401", "31", 4, "2,4", 450), ("VB16889394913", "25", 2, "2", 457), ("VB17445553236", "23", 8, "1,2,3,4,5,6,8", 549), ("VB17446648587", "24", 4, "2,4", 702), ("VB17446661335", "28", 5, "1,2,3,5", 702), ("VB17445579836", "26", 4, "2,4", 702), ("VB16738688658", "20", 4, "1,2,3,4", 2199)],
        "2026-10-18T15:10:29Z": [("VB17559480588", "36", 2, "2", 403), ("VB17550777861", "27", 2, "2", 418), ("VB17550777547", "34", 2, "2", 422), ("VB17550777222", "33", 4, "2,4", 424), ("VB17550778096", "36", 4, "2,4", 424), ("VB17550776776", "35", 8, "1,2,3,4,5,6,8", 431), ("VB17550777572", "34", 8, "1,2,3,4,5,6,8", 435), ("VB17550776263", "32", 4, "2,4", 437), ("VB17550778560", "26", 2, "2", 442), ("VB16889394915", "24", 3, "1,3", 443), ("VB17550778401", "31", 4, "2,4", 450), ("VB16889394913", "25", 2, "2", 457), ("VB17445553236", "23", 8, "1,2,3,4,5,6,8", 549), ("VB17446648587", "24", 4, "2,4", 702), ("VB17446661335", "28", 5, "1,2,3,5", 702), ("VB17445579836", "26", 4, "2,4", 702), ("VB16738688658", "20", 4, "1,2,3,4", 2199)],
    }
    sh_all = [("13393273250", "36", 2, "2", 410.58), ("14275456839", "27", 2, "2", 421.41), ("14378936820", "26", 2, "2", 446.52), ("13785298384", "24", 3, "1,3", 456.81), ("13785298382", "25", 2, "2", 470.77), ("13375105514", "23", 8, "1,2,3,4,5,6,8", 554.29), ("14343971595", "16", 2, "2", 839.75), ("13711192541", "21", 2, "2", 875.95), ("13711192539", "23", 2, "2", 875.95)]
    without = lambda *ids: [l for l in sh_all if l[0] not in ids]
    sh = {
        "2026-10-16T05:05:00Z": sh_all,
        "2026-10-16T05:06:51Z": sh_all,
        "2026-10-16T05:34:58Z": without("13785298384", "14343971595"),
        "2026-10-16T05:35:00Z": without("14343971595", "13711192541", "13711192539"),
    }

    def snapshot(source, url, captured_at, rows):
        return {"source": source, "currency": "USD", "note": "", "sourceUrl": url, "capturedAt": captured_at,
                "listings": [{"listingId": i, "section": "110", "row": r, "quantity": q, "sellableQuantities": sq,
                              "totalPrice": price, "seats": ""} for i, r, q, sq, price in rows]}

    return ([snapshot("vividseats", VS, t, rows) for t, rows in vs.items()]
            + [snapshot("stubhub", SH, t, rows) for t, rows in sh.items()])

if __name__ == "__main__":
    main()

StubHub, SeatGeek and Ticketmaster price alerts

Put one event page per marketplace in EVENT_URLS. You get them all from one call to /v1/events, which is never billed, in sources[].url. Each marketplace is compared only with itself, since listing ids belong to the marketplace.

  • StubHub and TickPick show prices with fees included. Vivid Seats, SeatGeek and Ticketmaster itemize a fee. Rules compare totalPrice, the all-in price per ticket before sales tax, so $390 means the same on all of them.
  • Vivid Seats relisted most of its section 110 tickets under new listing ids between October 14 and 16. Price alerts don't care, but new-listing alerts on Vivid Seats can be noisy.
  • Ticketmaster kept its cheapest pair at $396.33 from October 14 to 18 while its listings went from 58 to 48. Only an inventory rule would have shown that.
  • SeatGeek's cheapest pair for the show fell from $426 to $397 on October 16, so a get-in rule at $400 would have fired there.

Section names differ between marketplaces, so list each spelling you want; the comparison guide shows how one floor section is named four ways.

Rules brokers use

A rule is a name, some filters (sections, rows, quantity) and one trigger. A listing has to pass every filter before the trigger is checked. These are the ones people pricing or buying a section tend to set up:

alerts.py · RULES
RULES = [
    # Someone listed under you: a pair in your section below your own price.
    {"name": "Undercut in section 110", "sections": ["110"],
     "quantity": 2, "max_price": 395},
    # Your section is selling down, or sellers are pulling tickets.
    {"name": "Section 110 down 20%", "sections": ["110"], "tickets_drop_pct": 20},
    # The get-in price: the cheapest pair anywhere in the venue.
    {"name": "Get-in pair at $380 or less", "quantity": 2, "max_price": 380},
    # Fresh supply in rows you buy or sell.
    {"name": "New listing, section 110 rows 20-25", "sections": ["110"],
     "rows": ["20", "21", "22", "23", "24", "25"], "new_listings": True},
]
TriggerFires whenNeeds earlier captures
max_priceThe cheapest matching listing is at or under your price, all-in. One alert per marketplace, and again only if the price falls further.No
tickets_drop_pctMatching tickets fell by at least this percent, counting a listing as gone once it's missing from two captures in a rowYes
new_listingsA matching listing wasn't in either of the last two capturesYes

quantity checks sellableQuantities, the purchase sizes the seller allows. Four tickets that sell as "2,4" can fill an order for two. Three that sell as "1,3" can't, so they never match a pair rule. A listing without an all-in price is skipped by price rules.

How it works

  1. ScheduleOne pass every N minutes
  2. CaptureEach marketplace, live
  3. StoreGood snapshots only
  4. CompareWith the last two
  5. MatchYour rules and filters
  6. NotifyOnce per change

1. Capture and store snapshots

Each run captures every event URL once and stores each good snapshot in SQLite, the whole response as JSON. It keeps the last two per URL, which is all a run compares with. A snapshot is good when the capture succeeded and returned listings, or returned no listings with a note saying why.

An event with nothing to sell (sold out, canceled, not yet on sale) is a normal 200 with an empty listings array and the reason in note. A failed capture is an error response with a code and is never billed. The script stores neither a failure nor an empty capture without a note, so neither can look like every listing disappearing.

2. Compare with the last two

Two captures minutes apart don't always return exactly the same listings. If the script compared only with the last capture, one short capture would read as tickets selling, and the next one as new listings appearing.

So each snapshot is compared with the last two good ones from the same marketplace. A listing counts as gone only when it's missing from this capture and the one before. It counts as new only when it wasn't in either of the last two. The inventory-drop rule compares with the larger of the last two counts, so tickets relisted under new ids aren't counted twice.

Listings are matched on the marketplace's own listingId. Where a marketplace publishes no id, the script falls back to section, row and seats, which is close but not exact. The first snapshot of an event is the baseline, so new-listing and drop rules start from the second. Price rules can fire straight away.

3. Send each alert once

The script posts each alert to a Slack incoming webhook set in SLACK_WEBHOOK_URL, or prints it without one. For email, SMS or your own app, replace notify, which returns whether the message was delivered.

alerts.py · notify
def notify(message, url):
    if SAMPLE or not SLACK_WEBHOOK_URL:
        print(f"  ALERT {message}")
        return True
    response = requests.post(
        SLACK_WEBHOOK_URL, json={"text": f"{message}\n{url}"}, timeout=10
    )
    return response.ok

A pair under your price stays under it on every run until it sells. So a price rule sends one alert per marketplace, for the cheapest matching listing, and again only when that price drops below the last one sent. That's why the sample alerts at $388 and $383 and nothing in between. Other alerts are recorded under the rule, the marketplace and the listing (or the capture, for a drop) and never sent twice.

If an alert fails to send, the script doesn't store that snapshot. The next run finds the same change against the same baseline and tries again, and the alerts that did go out aren't repeated.

Scheduling, retries and costs

The script does one pass and exits, so a scheduler runs it. On a server that keeps alerts.db between runs, one cron line does it:

crontab
*/15 * * * * cd /srv/ticket-alerts && TICKETS_DEV_KEY=tk_live_... SLACK_WEBHOOK_URL=https://hooks.slack.com/services/... python alerts.py >> alerts.log 2>&1
Hosted schedulers like GitHub Actions start each run on a fresh machine, so a local SQLite file is gone by the next run. There, keep the snapshots and sent alerts in a hosted database instead; the price tracking guide covers where to store them.

Retries happen at two levels. Within a run, a capture that fails with a retryable error is tried up to three times, waiting as long as a 429 rate_limited response's Retry-After header asks. Anything else, like event_not_found for a bad URL, is logged and left for the next run, and nothing is stored for it.

Every successful capture counts once against your plan, so your schedule sets the bill. For three marketplaces over 30 days:

  • 2,160 captures every hour, within the Developer plan's 10,000 a month
  • 8,640 captures every 15 minutes, within the Developer plan's 10,000 a month

Failed captures aren't billed. Like the tracker, you can capture less often far from the show and more often close to it. Plans and rates are on the pricing page.

What an alert can't tell you

A listing that disappears may have sold, been delisted, or been relisted at a new price under a new id, and the snapshots can't tell these apart. That's why the inventory-drop message says so instead of calling it a sale. A drop in tickets is still a useful signal; just don't read it as sales data. Our guide to ticket pricing data explains the difference.

Alerts are also only as fresh as your schedule. A listing that appears and sells between two captures never shows up, so capture more often when it matters.

Next steps

Try it on a live event

Paste any supported event URL and you get back every listing with its all-in price, in the same schema for every marketplace. Failed captures are never billed.

TicketmasterStubHubSeatGeekTickPickVivid Seats+10
Get API keyOpen the playground