How to Build a Restock Alert Bot With Proxies
Build a restock alert bot in Python: pull the in-stock signal, rotate an IP per poll, jitter checks 1-5 minutes, and fire one alert per restock. With costs.
A restock alert bot has one job: tell you within a few minutes that an item you want has gone from sold out to buyable, and tell you exactly once. The polling part is easy. The hard parts are reading stock status reliably, not getting your IP throttled after a day of checks, and not waking up to forty duplicate pings because a block page looked like "out of stock". This guide builds the whole poll-and-diff pipeline in Python, with SparkProxy rotating proxies or the Scraping API doing the fetching, and ends with what 1,000 checks actually cost.
The poll-and-diff loop
Every restock monitor, from a hobby script watching one graphics card to a reseller tool watching 400 SKUs, runs the same loop:
- Pick the next product that is due, based on its own jittered timer.
- Fetch it through a proxy, ideally a different exit IP from the last poll.
- Extract a stock signal and reduce it to IN, OUT or UNKNOWN.
- Compare with the last confirmed state stored for that item.
- If the item moved from a confirmed OUT to IN, record the event and send one alert.
- Schedule the next check 1 to 5 minutes out.
Nothing in that loop stores page history. You only keep the latest confirmed state per item plus a log of alerts sent, which is what makes this different from general change tracking. If you want field-level diffs, content hashes or 304 handling for whole pages, our guide to incremental web scraping and change detection covers that broader problem. A restock bot needs one bit per item and a memory of when it last flipped.
The data flow is small enough to fit in one SQLite file. Every Python block in this guide uses only the standard library plus requests, and each one compiles under Python 3.14.2 (we checked with py_compile; requests 2.34.2 was the current PyPI release on 2026-10-30).
import sqlite3
SCHEMA = """
CREATE TABLE IF NOT EXISTS items (
item_key TEXT PRIMARY KEY, -- url, or url#variant for one size
url TEXT NOT NULL,
signal TEXT NOT NULL, -- 'shopify_js' | 'jsonld' | 'button'
variant_id TEXT, -- only for shopify_js
country TEXT DEFAULT 'us'
);
CREATE TABLE IF NOT EXISTS item_state (
item_key TEXT PRIMARY KEY REFERENCES items(item_key),
state TEXT NOT NULL, -- last CONFIRMED state: 'IN' or 'OUT'
since TEXT NOT NULL, -- when that state was first seen
unknown_run INTEGER DEFAULT 0, -- consecutive UNKNOWN results
next_due REAL NOT NULL -- unix time of the next poll
);
CREATE TABLE IF NOT EXISTS alerts (
item_key TEXT NOT NULL,
out_since TEXT NOT NULL, -- identifies the sold-out period that ended
detected_at TEXT NOT NULL,
sent INTEGER DEFAULT 0,
UNIQUE (item_key, out_since)
);
"""
def open_db(path="restock.db"):
db = sqlite3.connect(path, isolation_level=None) # explicit transactions below
db.executescript(SCHEMA)
return db
The UNIQUE (item_key, out_since) constraint is the deduplication. One sold-out period can end only once, so it can only ever produce one alert row, however many workers or restarts touch it.
A watchlist that knows how to read each page
Store, alongside each URL, the method used to read it. Stores differ, and guessing at runtime wastes requests. A watchlist entry needs four fields: the URL, the signal type, an optional variant ID when you care about one size or colour, and the country the store should see you from.
WATCHLIST = [
# A Shopify store: the product JSON endpoint, watched at variant level
{"url": "https://shop.sparkproxy.io/products/trail-runner",
"signal": "shopify_js", "variant_id": "794864233", "country": "us"},
# A store that publishes Product JSON-LD
{"url": "https://store.sparkproxy.io/p/console-bundle-2tb",
"signal": "jsonld", "variant_id": None, "country": "gb"},
# A store with neither: read the add-to-cart button
{"url": "https://outlet.sparkproxy.io/item/88231",
"signal": "button", "variant_id": None, "country": "us"},
]
def seed(db, now):
for w in WATCHLIST:
key = w["url"] + ("#" + w["variant_id"] if w["variant_id"] else "")
db.execute("INSERT OR IGNORE INTO items VALUES (?,?,?,?,?)",
(key, w["url"], w["signal"], w["variant_id"], w["country"]))
db.execute("INSERT OR IGNORE INTO item_state(item_key, state, since, next_due) "
"VALUES (?, 'OUT', 'seed', ?)", (key, now))
Seeding every item as OUT looks like it could fire a false alert on the first poll if the item is already in stock. That is deliberate for a watchlist of sold-out items you are waiting on. If you add items that may already be in stock, seed them as 'IN' so only a future sell-out followed by a restock triggers.
Scraping at scale? Skip the blocks.
Fast, unblockable datacentre proxies with unlimited bandwidth.
Three places a page admits it has stock
Pick the signal in this order, because each step down is more fragile than the last.
| Signal | Where it lives | What "in stock" looks like | Breaks when |
|---|---|---|---|
| Hidden product API | A JSON endpoint the storefront itself calls | A boolean such as `"available": true` per variant | Rarely; it is a public contract the theme depends on |
| JSON-LD `offers.availability` | ` |
