TrackPin API · v1
The TrackPin API
The TrackPin API returns amazon.com prices judged against what each product normally sells for. Base URL https://trackpin.co/api/v1. JSON only. Money is integer cents (USD). Timestamps are milliseconds since the Unix epoch. v1 changes are additive only; see the changelog.
Early access.
Endpoints
- GET/v1/product/{asin}Reference
Price, normal price, verdict, listing claim and full history for one product.
- GET/v1/dealsReference
Products priced below their normal price, with the website's filters. Includes recently ended deals.
- GET/v1/searchReference
Find products by title, ASIN or amazon.com link.
- GET/v1/usageReference
Your plan and today's usage. Calls to it don't count against your quota.
- POST/v1/keys/rotateReference
Swap your key for a new one. The old key stops working at once.
https://trackpin.co/api/v1x-api-key: pk_…Free keys are sent straight away by email; paid keys are ready as soon as you pay. Keep yours on your server, for example in an environment variable:
export TRACKPIN_API_KEY=pk_…Make a request
Fetch one product by its ASIN, here the Instant Pot Duo Plus 6 Qt pressure cooker (B01NBKTPTS), with your key in the
x-api-keyheader.Read
verdict.labelandprice.reference_centsverdict.labelis one ofgreat,good,normal,above,steadyorearly.price.reference_centsis the normal price it's measured against. The Product reference describes every field.
curl https://trackpin.co/api/v1/product/B01NBKTPTS \
-H "x-api-key: $TRACKPIN_API_KEY"const res = await fetch("https://trackpin.co/api/v1/product/B01NBKTPTS", {
headers: { "x-api-key": process.env.TRACKPIN_API_KEY },
});
const data = await res.json();
if (!res.ok) throw new Error(`TrackPin ${res.status}: ${data.error}`);
console.log(data.verdict.label, data.price.reference_cents);import os
import requests
r = requests.get(
"https://trackpin.co/api/v1/product/B01NBKTPTS",
headers={"x-api-key": os.environ["TRACKPIN_API_KEY"]},
timeout=10,
)
r.raise_for_status()
data = r.json()
print(data["verdict"]["label"], data["price"]["reference_cents"])Send your key in the x-api-key header, or as Authorization: Bearer pk_…. Keys start with pk_. We store only a hash of your key, so we can't show it again. If you lose it, request a new one.
Keep keys on your server. The API doesn't send CORS headers, so a web page on another site can't call it directly. To see a key's plan and today's usage without spending a request, use Check your key.
Subscribed on RapidAPI? Call the API through RapidAPI with your X-RapidAPI-Key; you don't need a pk_key. RapidAPI applies your plan's quota and shows your usage. The per-minute ceilings and live-check limits on this page still apply, by RapidAPI plan: BASIC gets the Free limits (no live checks), PRO the Pro limits, ULTRA and MEGA the Business limits.
curl https://trackpin.co/api/v1/usage \
-H "x-api-key: $TRACKPIN_API_KEY"
curl https://trackpin.co/api/v1/usage \
-H "Authorization: Bearer $TRACKPIN_API_KEY"Each key has a daily quota, counted per UTC day and reset at 00:00 UTC.
| Plan | Requests per day |
|---|---|
| Free | 100 |
| Pro | 20,000 |
| Business | 250,000 |
| Enterprise | More than 250,000 |
Per-minute ceilings
Each key also has per-minute ceilings, counted over a moving 60-second window: one for all calls to product, deals and search, and lower ones for search and deals on their own.
| Plan | All calls | Search | Deals |
|---|---|---|---|
| Free | 60 | 10 | 30 |
| Pro | 300 | 20 | 60 |
| Business | 1,200 | 20 | 60 |
| Enterprise | Custom | ||
A call over a ceiling gets 429 rate limit exceeded with a Retry-After header: wait that many seconds, then retry. It isn't counted against your daily quota. /v1/usage doesn't count toward these ceilings but has its own, 60 a minute.
Metered responses carry three headers, so you can pace your calls without guessing:
X-RateLimit-LimitYour daily quota.X-RateLimit-RemainingRequests left today.X-RateLimit-ResetISO time of the next reset (00:00 UTC).
Every authenticated call to product, deals or search counts against the daily quota, including ones that return 404, because quota is counted before the request runs. A call turned away by a per-minute ceiling doesn't count. /v1/usage is free. Over the daily quota, calls return 429 daily quota exceeded until the reset.
A product call with live=1 counts as 5 requests, or 1 when the live check fails or can't run, and live checks have a daily cap per key. See Live checks. A product call for a product we don't track yet counts as 1, also when it starts tracking it. See New products.
Content-Type: application/json
X-RateLimit-Limit: 20000
X-RateLimit-Remaining: 18760
X-RateLimit-Reset: 2026-11-12T00:00:00.000ZAll error bodies are {"error": string}. A 429 over the daily quota adds limit and used. A 429 over a per-minute ceiling adds limit (the ceiling), window_s and retry_after_s, and comes with a Retry-After header.
- 401
missing api keyNo key sent.
- 401
invalid api keyUnknown or revoked key.
- 403
live checks need a paid planlive=1on the product endpoint with a key on a plan without live checks. Not counted against your quota. - 404
not foundWe don't track this ASIN and couldn't start tracking it:
tracking.statussays why. See New products. - 405
method not allowedThe data endpoints are GET only. The
Allowheader lists the methods an endpoint answers: GET, HEAD and OPTIONS (POST and OPTIONS for/v1/keys/rotate). - 429
rate limit exceededMore than your plan's per-minute ceiling; wait
Retry-Afterseconds. Not charged. The body addslimit,window_sandretry_after_s. - 429
daily quota exceededOver today's quota. The body adds
limitandused. - 500
internal errorSomething failed on our side, not in your request.
- 503
temporarily unavailableOur database can't be reached right now. Retry after the number of seconds in the
Retry-Afterheader.
Some endpoints add their own errors: Search returns 400 for a query that's too short and 504 when a title search takes too long. Each reference page lists them.
{
"error": "missing api key"
}{
"error": "daily quota exceeded",
"limit": 100,
"used": 101
}Retry-After: 7
{
"error": "rate limit exceeded",
"limit": 300,
"window_s": 60,
"retry_after_s": 7
}Concepts
It's price.reference_cents in every response. price.discount_pct compares the current price with it: positive means below normal, negative means above. It's never measured against yesterday's price or against the crossed-out price on the listing.
Why the lowest price that lasted: a price seen for only an hour shouldn't define normal, and neither should an inflated price nobody paid. Because the window is the last 45 days, the normal price follows genuine long-term price changes.
{
"asin": "B01NBKTPTS",
"price": {
"current_cents": 9498,
"reference_cents": 9498,
"discount_pct": 0,
"is_deal": false,
"observed_at": 1790785935486,
"changed_at": 1790064174666,
"checked_at": 1794355114364,
"source": "stored"
}
}verdict.label is the same verdict the website shows, and verdict.sentence the same plain-English sentence. It compares the current price with the normal price:
| Verdict | When |
|---|---|
Great price label: great | 20% or more below normal |
Good price label: good | 5% to 20% below normal |
Normal price label: normal | Within 5% of normal |
Above normal label: above | 5% or more above normal |
Steady price label: steady | One price the whole time, over at least 14 days |
Too early to tell label: early | Less than 14 days of history, or no normal price yet |
If we haven't seen the price for more than 3 days, verdict.stale is true and the verdict may be out of date. Prices under $1, or more than 90% below normal, are treated as likely listing errors: verdict.suspect is true, and verdict.label still shows the verdict the numbers give.
Verdicts describe the price against its normal price. They aren't advice to buy or to wait.
{
"asin": "B01NBKTPTS",
"verdict": {
"label": "early",
"text": "Too early to tell",
"pct_vs_normal": null,
"evidence": "none",
"stale": true,
"suspect": false,
"steady_since": null,
"sentence": "Too early to tell. We haven't worked out its normal price yet. Not on sale at our last check (17h ago). Last price seen Sep 30."
}
}verdict.evidence says how much history backs the verdict, counted in days tracked:
Strong
strong60+ days tracked.
Moderate
moderate30 to 59 days.
Thin
thin14 to 29 days. The website marks the verdict as provisional.
None
noneUnder 14 days. No verdict: the label is
early.
A stale price caps evidence at Thin. stats.history_days, stats.price_changes and stats.distinct_prices give you the underlying numbers.
{
"asin": "B01NBKTPTS",
"verdict": {
"label": "early",
"evidence": "none",
"stale": true
},
"stats": {
"history_days": 99,
"price_changes": 17,
"distinct_prices": 7
}
}history[] is the price as steps, oldest first. Each step has price_cents, from (when we first saw that price) and to (when we last saw it).
Draw it as a step chart and don't interpolate: between one step's to and the next step's from, the price changed at a moment we didn't observe.
It covers everything we hold for the product, and our oldest history starts on Jun 21, 2026. stats.distinct_prices of 1 means the price hasn't moved since we started tracking it. Pass history=0 to leave the steps out when you only need the verdict.
{
"asin": "B01NBKTPTS",
"history": [
{
"price_cents": 15967,
"from": 1789236147916,
"to": 1789661870487
},
{
"price_cents": 14749,
"from": 1789663675256,
"to": 1790037958180
},
{
"price_cents": 9498,
"from": 1790064174666,
"to": 1790785935486
}
]
}
// 199 earlier steps not shownMany listings show a crossed-out List or Typical price. listing_claim reports it, with the discount the listing claims and the real discount against the normal price.
- listing discount
- = 1 − price ÷ crossed-out price
- real discount
- = 1 − price ÷ normal price
holds_upHolds upThe real discount is at least half the listing's.smallerSmaller than shownBelow normal, but by less than half of what the listing shows.not_below_normalNot below normalThe real discount is under 2%.uncheckedNot checked yetUnder 30 days of history, or the price is stale.
listing_claim is null when the listing shows no crossed-out price, or when the listing price differs from the latest price we recorded, so the claim and the verdict always describe the same price.
The crossed-out price is the reference shown on the listing. We don't know how it was set; we only compare it with prices we've observed.
{
"asin": "B0CWB3TF7X",
"price": {
"current_cents": 11999,
"reference_cents": 11999,
"discount_pct": 0
},
"listing_claim": {
"label": "List:",
"listing_reference_cents": 13999,
"claimed_pct": 14,
"real_pct": 0,
"class": "not_below_normal",
"observed_at": 1794411379405
}
}price.observed_at is when we last saw the current price. price.changed_at is when it became the current price, and price.checked_at is the latest check of the product we have on record. When price.checked_at is later than price.observed_at, the price wasn't on the listing at that check: the product was out of stock or had no offer. verdict.stale is true after 72 hours. Refresh frequency varies by product.
When you show a price, show when it was checked next to it, the way TrackPin does. Prices are observations and can be out of date. To read a price at request time instead, use a live check.
{
"asin": "B01NBKTPTS",
"price": {
"current_cents": 9498,
"observed_at": 1790785935486,
"changed_at": 1790064174666,
"checked_at": 1794355114364
},
"verdict": {
"stale": true
},
"last_seen": 1794355115607
}With live=1, the product endpoint checks the price on amazon.com at request time instead of returning the last price we recorded. Live checks are available on Pro and Business.
- Cost5 requests from your daily quota. When the live check fails or can't run, the call counts as 1.
- Daily capLive checks per key per UTC day: 4,000 on Pro and 20,000 on Business. Only checks that come back
okcount. Past the cap you get the stored data withlive.statusunavailableandlive.reasonlimit, and the call counts as 1. - TimeAbout 1–2 seconds in our tests.
live_timeout_mssets how long to wait, from 1,000 to 15,000 ms (default 8,000), counted from when we receive the request. - Result
price.sourceislive,price.current_centsis the price the check found andprice.observed_atis when amazon.com answered (or a later sighting of the same price we already held). The verdict, listing claim, stats and history describe that price. - HistoryThe reading is recorded into the price history (
live.recorded), so later responses include it.
If the live check fails or can't run, you still get HTTP 200 with the stored data: price.source is stored, and live.status and live.reason say what happened. Read price.source before presenting a price as checked at request time.
okThe check read the listing. If the listing showed no single price,live.price_centsis null andpricekeeps the stored price.failed · timeoutThe check didn't finish withinlive_timeout_ms.failed · upstreamamazon.com didn't answer the check normally.failed · not_listedamazon.com's answer didn't include this product.failed · errorSomething failed on our side.unavailable · busyToo many live checks were running, for your key or overall.unavailable · pausedLive checks are paused for a while.unavailable · disabledLive checks are switched off.unavailable · limitToday's quota had room for this call but not for the 5 requests of a live check, or your key has used today's live checks (4,000 on Pro and 20,000 on Business).
A key on a plan without live checks gets 403 live checks need a paid plan, and the call isn't counted. live.shared is true when the reading wasn't made for your request: amazon.com was read within the last 60 seconds, by another request's live check or by our regular tracking. HEAD requests never run a live check.
curl -G https://trackpin.co/api/v1/product/B01NBKTPTS \
-d live=1 \
-H "x-api-key: $TRACKPIN_API_KEY"const params = new URLSearchParams({ live: "1" });
const res = await fetch(`https://trackpin.co/api/v1/product/B01NBKTPTS?${params}`, {
headers: { "x-api-key": process.env.TRACKPIN_API_KEY },
});
const data = await res.json();
if (!res.ok) throw new Error(`TrackPin ${res.status}: ${data.error}`);
console.log(data.price.source, data.price.current_cents, data.live.status, data.live.reason);import os
import requests
r = requests.get(
"https://trackpin.co/api/v1/product/B01NBKTPTS",
params={"live": 1},
headers={"x-api-key": os.environ["TRACKPIN_API_KEY"]},
timeout=10,
)
r.raise_for_status()
data = r.json()
print(data["price"]["source"], data["price"]["current_cents"], data["live"]["status"], data["live"]["reason"])Ask for a product we don't track yet and we check it on amazon.com in the same request. If amazon.com lists it, we start tracking it and you get a 200 with today's price and a tracking object. This works on every plan, without live=1.
- Cost1 request, whatever the outcome. Also with
live=1on a plan that has live checks; on other planslive=1is still a 403 and nothing is checked. - Result
tracking.startedis true andprice.sourceislive. For a product we held no prices for,historyholds the one price we have and the verdict isearly: we judge a price once we have about 14 days of history (tracking.verdict_after_days). A product we had prices for keeps its history. When amazon.com shows no single price,price.current_centsis null.live.recordedsays whether the reading went into the price history.image, rating andcategoryfollow after our first full check, in about 25 minutes (tracking.first_scan_eta_s). - Not started404
not foundwithtracking.statussaying why, and aRetry-Afterheader when asking again later may work. When we hold older prices for the product, you get a 200 with them andtrackinginstead. If the check read a price but adding the product failed, you get a 200 with that price andtracking.startedfalse. - Opt out
discover=0skips the check: a product we don't track gets 404not foundstraight away, withouttracking. - TimingThe check has
live_timeout_ms(default 8 seconds); if it doesn't answer in time,tracking.statusisfailedwithtracking.reasontimeout. Requests for the same product at the same moment share one check: each waits at most its ownlive_timeout_ms, and the check runs within the first request's.tracking.newis true for the one that added the product.
startedWe track the product now: amazon.com listed it and it was added to the products we check.live.recordedsays whether the reading went into its price history (a reading without a price doesn't).tracking.newis true for the request that added it.not_listed · not_listedamazon.com's answer didn't include this product, so there's nothing to track. Asking again within 24 hours gets the same answer without a new check.failed · timeout | upstream | errorThe check didn't get a reading in time, amazon.com didn't answer normally, or adding the product failed on our side. Try again afterretry_after_s.unavailable · busy | pausedThe check couldn't run just now: too many checks for your key or overall, or checks (or adding new products) are paused for a while. Try again afterretry_after_s.blocked · blockedWe don't track this product.disabled · disabledChecks on amazon.com are switched off for now.
The check is a live check: your key's live-check speed limits apply, but the daily live-check cap doesn't, and it doesn't count toward it. HEAD requests never check.
{
"error": "not found",
"tracking": {
"started": false,
"new": false,
"status": "unavailable",
"reason": "busy",
"since": null,
"source": "api",
"first_scan_eta_s": null,
"verdict_after_days": null,
"retry_after_s": 15
}
}Resources
Link to a verdict
Any page can link to a TrackPin verdict with https://trackpin.co/check?q={url}, where {url} is a URL-encoded amazon.com product link, a short link or an ASIN. The bookmarklet on Check prices from Amazon does exactly this for the page you're on.
Chrome extension
Shows the same verdict on amazon.com product pages, for shoppers. It needs no key and doesn't use your quota. It reads only the product ID from the page.
OpenAPI
/api/v1/openapi.json is an OpenAPI 3.1 description generated from the same definitions as these pages. Import it into your HTTP client or code generator.
https://trackpin.co/check?q={url}
// e.g. https://trackpin.co/check?q=https%3A%2F%2Fwww.amazon.com%2Fdp%2FB01NBKTPTSjavascript:(()=>{location.href='https://trackpin.co/check?q='+encodeURIComponent(location.href)})()