Checking a price on one store is easy. Checking the same product on Amazon, Walmart and eBay every day, and getting the price you'd actually pay, is where it gets fiddly: every store has its own way of showing you a number that isn't the one you want.
This post builds a small tracker in Python for one real product (Sony's WH-1000XM5 headphones, black). It:
- reads the lowest new price on Amazon, Walmart and eBay,
- stores every reading in SQLite,
- tells you when a store hits a new low or drops under your target.
Along the way it deals with three traps I hit while building it: Amazon's buy box showing a used offer, eBay's cheapest results being spare parts, and shipping that changes depending on where the request comes from.
Full disclosure: I'm building API.SH, which is what fetches the store data here. Every account gets 1,000 free credits a month, enough to run this tracker daily for several products. Every code block below was run before publishing, and the output under it is what it printed.
Setup
pip install apish-sdk
export APISH_API_KEY="ash_live_..."
The SDK needs Python 3.9 or newer. Get a key from the dashboard after signing up.
Trap 1: Amazon's price isn't always a new item
The obvious move is to read the product page and take its price:
from apish import Apish
apish = Apish() # reads APISH_API_KEY
p = apish.amazon.product(url="B09XS7JWHH")
print(p["title"][:45], "|", p["price"], "|", p["seller"])
offers = apish.amazon.offers(asin="B09XS7JWHH", condition="new")
for o in offers["offers"][:3]:
print(o["buy_box"], o["condition"], o["price_value"], o["sold_by"])
Sony WH-1000XM5 Premium Noise Cancelling Wire | $176.40 | Adorama
True Used - Like New 176.4 Adorama
False New 263.95 Video & Audio Center - Same Day Shipping
False New 263.95 Video & Audio Center - Same Day Shipping
$176.40 looks like a great price, but it's the buy box, and today the buy box is a used, like new offer. The cheapest new one is $263.95. If you track the product page price, your chart jumps between new and used prices whenever Amazon moves the buy box.
The offers endpoint lists every seller's offer, the same as Amazon's "Other sellers" panel, with a condition on each one. Note that the buy box offer is pinned to the top even when you ask for condition="new", so filter on the condition field yourself as well. It's also a lighter call than the full product page, which matters when you run it every day.
Trap 2: eBay's cheapest results are spare parts
res = apish.ebay.search(q="sony wh-1000xm5", condition="new", buying="buy_it_now", sort="price_asc")
for item in res["items"][:4]:
print(item["price_value"], item["title"][:60])
42.99 Sony Headphones WH-1000XM5 XM5 Headband Hinge Swivel Replace
44.99 For SONY WH-1000XM5 Wireless Noise Canceling Bluetooth Headp
8.98 Lot Sony Headphones WH-1000XM5 XM5 Headband Wire Board Conne
6.99 Sony WH-1000XM5 Headphones 16-Pin Wire Clip Connector OEM fo
Sort by price on eBay and you get hinges, earpads and connectors, all "new" and all matching the model name. Two fixes: a price floor (min_price) well below any real deal but above the accessories, and eBay's own excluded-words filter (exclude) for the parts vocabulary.
You'll also notice the results aren't in strict price order, and that price_value is the item price alone: shipping comes as text in shipping ("Free delivery", "+$14.99 delivery"). The tracker parses it and adds it, so it compares the price you'd actually pay.
Trap 3: shipping depends on where you are
eBay quotes shipping to the visitor's location, so the same listing can show free shipping on one request and a few dollars on the next. When I ran the same search twice, the cheapest listing showed "+$5.00 delivery" and then "+$3.00 delivery". The eBay search endpoint takes a zip parameter, which pins the shipping quotes to one ship-to ZIP code. Use your own, so the landed price is what you'd pay.
The three price functions
prices.py:
import re
from apish import Apish
apish = Apish() # reads APISH_API_KEY
def money(text):
"""'+$14.99 delivery' -> 14.99, 'Free delivery' -> 0.0"""
m = re.search(r"\$([\d,]+\.?\d*)", text or "")
return float(m.group(1).replace(",", "")) if m else 0.0
def amazon_price(asin):
res = apish.amazon.offers(asin=asin, condition="new")
new = [o for o in res["offers"] if o["condition"] == "New" and o["price_value"]]
best = min(new, key=lambda o: o["price_value"])
return best["price_value"], best["sold_by"], res.meta.credits_used
def walmart_price(item_id):
res = apish.walmart.product(url=item_id)
new = [o for o in res["offers"] if o["is_new"] and o["price"]]
best = min(new, key=lambda o: o["price"])
seller = (res.get("seller_info") or {}).get("name")
return best["price"], seller, res.meta.credits_used
def ebay_price(query, exclude, min_price, zip_code="10001"):
res = apish.call("/ebay/v1/search", {
"q": query, "condition": "new", "buying": "buy_it_now", "location": "us",
"sort": "price_asc", "min_price": min_price, "exclude": exclude,
"zip": zip_code, # shipping on each card is quoted to this ZIP
})
landed = [(i["price_value"] + money(i["shipping"]), i) for i in res["items"] if i["price_value"]]
total, item = min(landed, key=lambda x: x[0])
return round(total, 2), item["url"], res.meta.credits_used
A few notes:
-
Walmart has the same new-versus-not problem as Amazon: the Walmart product endpoint returns its
offersby condition (new, open box and so on), each withis_new. You can pass the item id from the product URL (walmart.com/ip/.../386006068) asurl. -
eBay uses
apish.call()with the route, which passes anything the endpoint accepts. I wrote this against an SDK release whose typedapish.ebay.search()didn't haveexcludeandzipyet; fromapish-sdk0.3.0 it does, so either works. - Each function returns
credits_usedfrom the response, so you can see what a run costs.
A first check:
from prices import amazon_price, walmart_price, ebay_price
print("Amazon ", amazon_price("B09XS7JWHH"))
print("Walmart", walmart_price("386006068"))
print("eBay ", ebay_price("sony wh-1000xm5 headphones",
exclude="hinge repair earpads replacement parts case wf-1000xm5",
min_price=120))
Amazon (263.95, 'Video & Audio Center - Same Day Shipping', 1)
Walmart (198, 'Focus Camera', 1)
eBay (149.99, 'https://www.ebay.com/itm/377441133552', 2)
eBay has no product ID shared across sellers the way Amazon has an ASIN, so the eBay number really means "the cheapest new listing that matches these words". Today's winner is titled WH-1000XM5SA, not the black model the other two rows track. That's why the function returns the listing URL: you check it before you buy.
Finding the same product on another store
You might hope to match products automatically by barcode. Walmart gives you the UPC, and Amazon's search accepts a UPC as the query:
from apish import Apish
apish = Apish()
wm = apish.walmart.product(url="386006068")
print(wm["name"], "| UPC", wm["upc"], "| model", wm["model"])
hits = apish.amazon.search(q=wm["upc"])
for p in hits["products"][:3]:
print(" ", p["asin"], p["sponsored"], p["title"][:70])
Sony WH-1000XM5 The Best Wireless Noise Canceling Headphones, Black | UPC 027242923232 | model WH1000XM5/B
B0CFSD4TSQ False Sony WH-1000XM5 Noise Canceling Wireless Headphones - 30hr Battery Lif
B0F7SQMJHP False Sony WH-1000XM5 Wireless Industry Leading Noise Canceling Headphones w
B0F3PT1VBL True Sony WH-1000XM6 The Best Noise Cancelling Wireless Headphones Midnight
None of those is the listing we're tracking (B09XS7JWHH). The first is an "International Version, No Warranty" listing, the third is the next model, and it's a sponsored ad. Barcode search is a good way to find candidates, but for a tracker you'll want to pick the IDs once by hand and keep them in a watchlist.
The tracker
track.py keeps a watchlist, stores every reading, and alerts on two things: a new lowest price for that store, and the first time a store goes under your target.
import sqlite3
from datetime import datetime, timezone
from prices import amazon_price, walmart_price, ebay_price
WATCHLIST = [
{
"name": "Sony WH-1000XM5 (black)",
"target": 250.00,
"amazon": "B09XS7JWHH",
"walmart": "386006068",
"ebay": {
"query": "sony wh-1000xm5 headphones",
"exclude": "hinge repair earpads replacement parts case wf-1000xm5",
"min_price": 120,
},
},
]
db = sqlite3.connect("prices.db")
db.execute("""create table if not exists prices (
checked_at text, product text, store text, price real, detail text)""")
def check(product):
readings = {}
for store, fetch, arg in [
("amazon", amazon_price, product["amazon"]),
("walmart", walmart_price, product["walmart"]),
("ebay", lambda q: ebay_price(**q), product["ebay"]),
]:
try:
price, seller, credits = fetch(arg)
except Exception as e: # one store failing shouldn't stop the others
print(f" {store:8} failed: {e}")
continue
readings[store] = (price, seller)
print(f" {store:8} ${price:>8.2f} {seller} ({credits} cr)")
return readings
def previous_low(product, store):
row = db.execute("select min(price) from prices where product = ? and store = ?",
(product, store)).fetchone()
return row[0]
now = datetime.now(timezone.utc).isoformat(timespec="seconds")
for p in WATCHLIST:
print(p["name"])
readings = check(p)
alerts = []
for store, (price, seller) in readings.items():
low = previous_low(p["name"], store)
if low is not None and price < low:
alerts.append(f"{store} is at a new low: ${price:.2f} (was ${low:.2f})")
if price <= p["target"] and (low is None or low > p["target"]):
alerts.append(f"{store} is under your target: ${price:.2f} <= ${p['target']:.2f}")
db.execute("insert into prices values (?, ?, ?, ?, ?)", (now, p["name"], store, price, seller))
db.commit()
if readings:
store, (price, _) = min(readings.items(), key=lambda kv: kv[1][0])
print(f" cheapest: {store} at ${price:.2f}")
for a in alerts:
print(" ALERT:", a)
First run, with an empty database:
Sony WH-1000XM5 (black)
amazon $ 263.95 Video & Audio Center - Same Day Shipping (1 cr)
walmart $ 198.00 Focus Camera (1 cr)
ebay $ 149.99 https://www.ebay.com/itm/377441133552 (2 cr)
cheapest: ebay at $149.99
ALERT: walmart is under your target: $198.00 <= $250.00
ALERT: ebay is under your target: $149.99 <= $250.00
Second run, prices unchanged, so no alerts:
Sony WH-1000XM5 (black)
amazon $ 263.95 Video & Audio Center - Same Day Shipping (1 cr)
walmart $ 198.00 Focus Camera (1 cr)
ebay $ 149.99 https://www.ebay.com/itm/377441133552 (2 cr)
cheapest: ebay at $149.99
The target alert fires only when a store crosses the target (its previous low was above it), so you aren't told the same thing every morning. The new-low alert fires whenever a store beats its own record.
To send the alerts somewhere useful, replace the print(" ALERT:", a) line with a Slack or Discord webhook, an email, or a push service.
Run it every day
A cron entry is all it takes:
0 8 * * * cd ~/price-tracker && APISH_API_KEY=ash_live_... python3 track.py >> track.log 2>&1
Two things to know when you schedule it:
-
Caching. Responses are cached for 24 hours, and a repeat call with the same parameters returns the stored copy (it costs the same as a live call). Once a day that never matters. If you check several times a day, pass
options={"fresh": True}to each call so you get a live price. - What it costs. At the credits shown in the output above, one product is 4 credits per run, so 1,000 free credits a month cover about 8 products checked daily. Calls that fail on our side aren't charged. Current prices are on the pricing page.
Where to take it next
-
Price history charts:
select checked_at, store, price from pricesstraight into pandas or a spreadsheet. - More stores: the same pattern works for Target, Best Buy, Costco and others in the catalog; each needs its own "what is the real new price" rule.
- No code: there's a ready-made n8n template that tracks competitor prices and reviews on Amazon, Walmart and eBay, logs them to Google Sheets and posts changes to Slack.
The store pages for each API are at api.sh/amazon-api, api.sh/walmart-api and api.sh/ebay-api.
What's the worst price trap you've hit on a store? I'd like to collect them.
Top comments (0)