Guides9 min read

Sneaker price tracker in Python: watch StockX asks by size

A Python script that watches StockX lowest asks for specific sneakers and sizes, logs every snapshot to SQLite and flags drops below your target, for about 288 credits a month.

November 13, 2026Victor Lourme
On this page

By the end of this tutorial you will have a sneaker price tracker in Python that watches the StockX lowest ask for specific shoes in specific sizes, logs every snapshot to SQLite, and prints an alert when a price drops below your target. It is for developers and resellers who want a working tracker before they spend time on a UI. It uses Packet's cached StockX catalog, which returns every size of up to 20 products in one request for 0.4 credits. A watchlist of 20 pairs checked every hour fits inside the Free plan.

All prices below come from real calls made on November 13, 2026. Packet is an independent service and is not affiliated with StockX. If you want the full list of StockX endpoints first, the StockX API overview covers live versus cached data and sales history.

Find the StockX product IDs for your watchlist

The prices endpoint takes StockX product UUIDs, so you look each shoe up once and save the ID. The cached StockX search endpoint accepts a name, brand or SKU. Searching by SKU is the most precise, but it is not exact. Here is what came back for DD1391-100, the white and black "Panda" Dunk Low:

title sku weekly_orders lowest_ask
Nike Dunk Low Retro White Black Panda DD1391-100 124 60
Nike Dunk Low Retro White Black DD1391-100 0 0
Nike Dunk Low Retro White Black (2021) Welcome Offer DD1391-100 0 0
Nike Dunk Low Medium Curry DD1390-100 17 61
Nike Dunk Low Championship Court Purple DD1391-104 16 74

Three different products share the exact SKU, and the search also returns neighbouring SKUs from the same family. The Lost and Found Jordan 1 (DZ5485-612) behaves the same way: the main product has 82 weekly orders, and there is also a "(Size 8.5)" listing and a "Jordan ReStockX" bundle whose sku field is DZ5485-612/DX2454-106/DN3707-100/840606-192. So the lookup keeps results whose SKU list contains your code exactly, then picks the one with the most weekly orders:

import os
import time
import requests

API = "https://api.pckt.sh"
HEADERS = {"Authorization": f"Bearer {os.environ['PCKT_API_KEY']}"}


def find_product(sku):
    r = requests.get(
        f"{API}/v1/catalog/stockx/search",
        headers=HEADERS,
        params={"query": sku},
        timeout=30,
    )
    r.raise_for_status()
    matches = [p for p in r.json() if sku in p["sku"].split("/")]
    return max(matches, key=lambda p: p["weekly_orders"])


for sku in ["DD1391-100", "DZ5485-612"]:
    p = find_product(sku)
    print(sku, p["id"], p["title"], p["weekly_orders"], "orders/wk")
    time.sleep(1.1)
DD1391-100 5e6a1e57-1c7d-435a-82bd-5666a13560fe Nike Dunk Low Retro White Black Panda 124 orders/wk
DZ5485-612 9a4d44f9-4b16-4abc-ba58-c0db340ee791 Jordan 1 Retro High OG Chicago Lost and Found 82 orders/wk

Splitting on / matters because some products store two codes. The black and white adidas Samba OG is listed with sku BZ0057/B75806. I found it with a name search for adidas Samba OG, where it came second with 158 weekly orders and ID b5c56f04-a074-4d5c-8229-82e8179b318f. If no result matches, max() raises a ValueError. That is what you want for a one-off setup script.

Read every size in one request

The cached StockX prices endpoint takes up to 20 comma-separated UUIDs in ids and returns a flat JSON array. This is the minimal tracker: one request, a filter, a target per shoe.

import os
import requests

API = "https://api.pckt.sh"
HEADERS = {"Authorization": f"Bearer {os.environ['PCKT_API_KEY']}"}

WATCHLIST = {
    "5e6a1e57-1c7d-435a-82bd-5666a13560fe": {"name": "Nike Dunk Low Panda", "size": "9", "target": 75},
    "9a4d44f9-4b16-4abc-ba58-c0db340ee791": {"name": "Jordan 1 Lost and Found", "size": "9", "target": 240},
    "b5c56f04-a074-4d5c-8229-82e8179b318f": {"name": "adidas Samba OG", "size": "8", "target": 60},
}

r = requests.get(
    f"{API}/v1/catalog/stockx/prices",
    headers=HEADERS,
    params={"ids": ",".join(WATCHLIST)},
    timeout=30,
)
r.raise_for_status()

for row in r.json():
    item = WATCHLIST[row["stockx_product_id"]]
    if row["type"].upper() != "STANDARD" or row["variant"]["base_size"] != item["size"]:
        continue
    ask = row["lowest_ask"]
    flag = "BELOW TARGET" if 0 < ask <= item["target"] else ""
    print(f'{item["name"]:<26} US M {item["size"]:<4} ask ${ask:<4} bid ${row["highest_bid"]:<4} '
          f'{row["number_of_asks"]} asks  {flag}')
Nike Dunk Low Panda        US M 9    ask $78   bid $65   75 asks
Jordan 1 Lost and Found    US M 9    ask $254  bid $220  84 asks
adidas Samba OG            US M 8    ask $55   bid $46   75 asks  BELOW TARGET

The filter exists because the response is not one row per size. It is one row per size per fulfilment type. For the Panda Dunk in US M 9, the API returned four rows:

type number_of_asks number_of_bids lowest_ask highest_bid
STANDARD 75 13 78 65
EXPRESS_SHIPPED 24 0 86 0
EXPRESS_EXPEDITED 7 0 86 0
EXPRESS_NEXTDAY 7 0 86 0

A few things in this data will break a naive tracker:

  • Bids only appear on STANDARD rows. Every express row I saw had number_of_bids: 0 and highest_bid: 0.
  • Express asks can be far above standard. The Lost and Found in US M 4.5 had a standard ask of $170 and an EXPRESS_SHIPPED ask of $525. If you take the minimum across all rows you are usually fine. If you take the first row or the last row, you can be off by hundreds of dollars.
  • Not every size has every type. Panda sizes 3.5 to 5.5 only had a STANDARD row. The Lost and Found in 3.5 had STANDARD and EXPRESS_SHIPPED only.
  • lowest_ask: 0 means no asks, not a free shoe. The Panda in 4.5 had 0 asks and 6 bids. That is why the code checks 0 < ask.
  • Type casing differs from the docs example. The docs page shows "type": "standard", while the live responses returned STANDARD. Comparing with .upper() handles both.
  • Prices are whole dollars in the market currency. The default market is US.

The edge sizes are also where the spread is widest. These are all STANDARD rows for the Panda from the same response:

US M size asks bids lowest_ask highest_bid spread
3.5 2 6 155 40 115
4.5 0 6 0 49 n/a
7 30 8 83 63 20
9 75 13 78 65 13
9.5 104 16 70 59 11

With only 2 asks in a size, one seller relisting can move the lowest ask a lot. Storing number_of_asks next to the price lets you ignore drops in thin sizes later.

Store each check and alert on drops

A tracker needs memory. The version below writes each matching row to SQLite and alerts in three cases: no asks, ask at or below target, or ask lower than the last stored value. It sends up to 20 IDs per request and waits 1.1 seconds between requests. The primary key includes updated_at, so if the cache has not refreshed since your last run, the insert is ignored and no alert fires twice.

import os
import sqlite3
import time
import requests

API = "https://api.pckt.sh"
HEADERS = {"Authorization": f"Bearer {os.environ['PCKT_API_KEY']}"}

WATCHLIST = {
    "5e6a1e57-1c7d-435a-82bd-5666a13560fe": {"name": "Nike Dunk Low Panda", "size": "9", "target": 75},
    "9a4d44f9-4b16-4abc-ba58-c0db340ee791": {"name": "Jordan 1 Lost and Found", "size": "9", "target": 240},
    "b5c56f04-a074-4d5c-8229-82e8179b318f": {"name": "adidas Samba OG", "size": "8", "target": 60},
}

db = sqlite3.connect("prices.db")
db.execute("""
    CREATE TABLE IF NOT EXISTS prices (
        product_id TEXT, variant_id TEXT, size TEXT,
        lowest_ask INTEGER, highest_bid INTEGER, number_of_asks INTEGER,
        updated_at TEXT,
        PRIMARY KEY (product_id, size, updated_at)
    )
""")


def fetch_prices(ids):
    rows = []
    for i in range(0, len(ids), 20):
        r = requests.get(
            f"{API}/v1/catalog/stockx/prices",
            headers=HEADERS,
            params={"ids": ",".join(ids[i:i + 20])},
            timeout=30,
        )
        r.raise_for_status()
        rows.extend(r.json())
        time.sleep(1.1)
    return rows


def last_ask(product_id, size):
    hit = db.execute(
        "SELECT lowest_ask FROM prices WHERE product_id = ? AND size = ? "
        "ORDER BY updated_at DESC LIMIT 1",
        (product_id, size),
    ).fetchone()
    return hit[0] if hit else None


for row in fetch_prices(list(WATCHLIST)):
    item = WATCHLIST[row["stockx_product_id"]]
    size = row["variant"]["base_size"]
    if row["type"].upper() != "STANDARD" or size != item["size"]:
        continue

    ask = row["lowest_ask"]
    previous = last_ask(row["stockx_product_id"], size)
    cur = db.execute(
        "INSERT OR IGNORE INTO prices VALUES (?, ?, ?, ?, ?, ?, ?)",
        (row["stockx_product_id"], row["stockx_variant_id"], size, ask,
         row["highest_bid"], row["number_of_asks"], row["updated_at"]),
    )
    db.commit()
    if cur.rowcount == 0:
        continue  # same snapshot as the last run

    if ask == 0:
        print(f'{item["name"]} size {size}: no asks')
    elif ask <= item["target"]:
        print(f'ALERT {item["name"]} size {size}: ${ask} (target ${item["target"]})')
    elif previous and ask < previous:
        print(f'{item["name"]} size {size}: dropped ${previous} -> ${ask}')

Given the snapshot above, the first run should print a single line, ALERT adidas Samba OG size 8: $55 (target $60), and a second run against the same snapshot should print nothing. Replace the print calls with a Discord or Slack webhook when you are happy with the logic.

The variant_id column is there for the next step. It is the StockX variant UUID for that size. For the Panda in US M 9 it is a2ea632e-b87a-46a1-b35d-739b8050fdf2.

Check an alert against recent sales

An ask below your target is only a good price if pairs have actually been selling above it. The cached sales endpoint returns individual sales and takes a variant_id to filter to one size. It costs 0.6 credits, so call it only when an alert fires, not on every run.

import os
import requests

API = "https://api.pckt.sh"
HEADERS = {"Authorization": f"Bearer {os.environ['PCKT_API_KEY']}"}

r = requests.get(
    f"{API}/v1/catalog/stockx/sales",
    headers=HEADERS,
    params={
        "id": "5e6a1e57-1c7d-435a-82bd-5666a13560fe",
        "variant_id": "a2ea632e-b87a-46a1-b35d-739b8050fdf2",
        "page_size": 10,
    },
    timeout=30,
)
r.raise_for_status()

for sale in r.json():
    print(sale["created_at"][:10], sale["order_type"], sale["amount"], sale["currency"])
2026-11-12 STANDARD 71 USD

I asked for 10 sales and got one. Your code should handle short or empty lists instead of assuming a full page. That one sale still tells you something: the last Panda in size 9 sold for $71, between the current bid ($65) and ask ($78). A target of $75 is realistic for this size. A target of $60 would probably never fire.

For a wider view across all sizes, the daily sales endpoint groups sales by day. Same id, page_size set to 7, also 0.6 credits:

date avg_amount orders
2026-11-12 80 17
2026-11-11 52 1
2026-11-10 74 19
2026-11-06 60 1
2026-11-05 89 19
2026-10-30 56 6
2026-10-29 72 20

Seven rows cover 15 calendar days here, because days with no rows are skipped (Oct 31 to Nov 4 and Nov 7 to 9). If you compute a "7-day average", filter by date and don't just take the first seven rows. Days with a single order ($52 and $60) also pull a naive average down, so weight by orders. Weighted by orders, these seven rows average about $77.

Running it on a schedule

Run the script from cron or a scheduled job. Two limits shape how often:

  • Rate limit. The Free plan allows 1 request per second. While researching this post I made two calls back to back and the second returned HTTP 426 with {"status":false,"message":"rate limit exceeded"}. The same status is used when you run out of credits, which is why the code sleeps 1.1 seconds between requests and raise_for_status() stops the run instead of saving bad data.
  • Freshness. Cached catalog data can be up to 24 hours old. All three snapshots I pulled were stamped between 00:01 and 00:03 UTC on November 13. Polling more often than the data changes only spends credits, and the updated_at key in the table shows you how often it really changes for your products. If you need the price right now, for example to confirm an alert before buying, the live checkout subtotal endpoint returns the final price with delivery and platform fees for 1 credit per call.

What it costs

A month is counted as 30 days, so hourly runs are 720 runs. Search calls happen once per product at setup.

Workload Endpoint Credits per request Requests per month Credits per month
Setup: look up 3 SKUs /v1/catalog/stockx/search 0.4 3 (once) 1.2
3 products, hourly /v1/catalog/stockx/prices 0.4 720 288
20 products, hourly /v1/catalog/stockx/prices 0.4 720 (1 per run) 288
20 products, every 4 hours /v1/catalog/stockx/prices 0.4 180 72
100 products, hourly /v1/catalog/stockx/prices 0.4 3,600 (5 per run) 1,440
30 alerts checked against sales /v1/catalog/stockx/sales 0.6 30 18
20 products, hourly, live instead /v1/stockx/product 1 14,400 14,400

Batching is the whole trick: 20 products cost the same as 3. A 20-pair hourly watchlist with sales checks comes to about 306 credits, inside the Free plan's 500 credits a month. New accounts can also earn up to 550 bonus credits. At 100 products hourly you need the Developer plan's 50,000 credits. Doing the same job with live per-product calls costs 50 times more. The pricing page lists every plan, and the credit calculator works out other schedules.

Where to take it next

The same pattern works for other marketplaces. The cached GOAT prices endpoint also takes up to 20 IDs for 0.4 credits and returns lowest_ask and instant_lowest_ask per size, so a second watchlist keyed on GOAT product IDs gives you a cross-market comparison for the same credit cost. Two things to watch: GOAT product IDs are integers, and GOAT rows carry a size string instead of StockX's variant.base_size. Normalise both into one table before comparing. For background on how Packet collects and replicates this data, see the post introducing the Packet scraping engine.

V

Victor Lourme

Working at pckt.sh