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.
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
STANDARDrows. Every express row I saw hadnumber_of_bids: 0andhighest_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_SHIPPEDask 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
STANDARDrow. The Lost and Found in 3.5 hadSTANDARDandEXPRESS_SHIPPEDonly. lowest_ask: 0means no asks, not a free shoe. The Panda in 4.5 had 0 asks and 6 bids. That is why the code checks0 < ask.- Type casing differs from the docs example. The docs page shows
"type": "standard", while the live responses returnedSTANDARD. 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 426with{"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 andraise_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_atkey 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.
Victor Lourme
Working at pckt.sh