Price intelligence infrastructure for developers
PricesAPI developer documentation
Product Search API — Search by product name or retailer URL and get matched products with available merchant offers inline.
Runnable code: guided examples or the Playground.
Base URL
https://api.pricesapi.io/api/v1Also served at https://api.buywisely.com.au/api/v1 — both hostnames resolve to the same API.
Get your first result
One call returns matched products with their merchant offers inline, and costs 10 credits.
const params = new URLSearchParams({ q: 'Sony WH-1000XM5', market: 'us', limit: '3' });
const response = await fetch(
`https://api.pricesapi.io/api/v1/products/search?${params}`,
{
headers: { Authorization: 'Bearer your_api_key' },
signal: AbortSignal.timeout(95_000),
}
);
const body = await response.json();
if (!response.ok) {
throw new Error(`${body.error?.code ?? response.status}: ${body.error?.message ?? 'Request failed'}`);
}
const { data: { products } } = body;
for (const product of products) {
if (product.offers.length === 0) {
console.log(`${product.title} — no offers`);
continue;
}
for (const offer of product.offers) {
console.log({
product_id: product.pid,
product: offer.product_title ?? product.title,
seller: offer.seller,
item_price: offer.price,
currency: offer.currency,
stock: offer.stock_status ?? 'Not provided',
delivery: offer.delivery_info ?? 'Not provided',
});
}
}Read seller item prices from offers[], alongside stock and delivery text. A listed price is not a checkout total or a guarantee of availability. There is no price field on a product: the card’s headline_price can sit above every offer. A first, uncached call runs discovery and offer collection, so give it a generous timeout — how long a call takes, what a call costs.
Building a comparison? Keep each product and market separate, check the seller's variant and condition, and label delivery as unknown when it is missing. With a known product ID, use Snapshot's comparison guide for normalized availability and observation timestamps.
What is live, and what may change
Product Search is available now. Schedules, Async Bulk Search, Product Snapshot, and Price History are in public Beta for every valid API account. Retained-history depth varies by product and market. Product Watches are coming next and are not publicly available yet.
Discover → Snapshot → History → Monitor → Deliver
Use PricesAPI data to build price comparisons and history views. Coverage varies by product and market; your application handles analysis and pricing decisions.
Pricing models, included allowances, and usage limits for all Beta features may change before general availability. Existing Product Search pricing and credit allowances remain unchanged unless separately announced.
Authentication
Send Authorization: Bearer <your_api_key> on every request. The header form keeps your key out of server logs, browser history and referrer leaks.
curl "https://api.pricesapi.io/api/v1/products/search?q=Sony+WH-1000XM5&market=us" \
-H "Authorization: Bearer your_api_key"Keys start with pricesapi_. Never commit one or ship it client-side. A ?api_key= query parameter works but lands in access logs, browser history and Referer headers.
What a call costs
Search Products costs 10 credits per response with at least one merchant offer and Async Bulk Search costs 10 credits per item submitted. Product Snapshot costs 1 credit per product, Product Snapshot Batch costs 1 credit per product returned, Product Clusters costs 1 credit per page returned and Price History costs 1 credit per day with recorded offers. Catalogue lookup and Schedules cost nothing.
| Operation | Credits | Charged per | What changes the charge |
|---|---|---|---|
| Search Products | 10 | per response with at least one merchant offer | Per response with at least one merchant offer. Cache hits follow the same rule. Results with no products or no merchant offers, and failed requests, cost nothing. |
| Async Bulk Search | 10 | per item submitted | Per item, reserved when the job is submitted — the same as a Search, because it is the same work. Items with no products or no merchant offers, failed items and cancelled-before-start items are refunded; checking status, reading results and cancelling cost nothing. |
| Product Snapshot | 1 | per product | Per product read by its ID. A retained read, priced at a tenth of a Search. Not-found products and failed requests cost nothing. |
| Product Snapshot Batch | 1 | per product returned | Per product RETURNED, never per call — the same price as a single Product Snapshot, because it is the same retained read. Items the batch could not find, and failed requests, cost nothing. |
| Product Clusters | 1 | per page returned | Per page returned, never per member: one credit buys as many members as you ask for, up to the limit of 100 per page. The same price as a single Product Snapshot, because it is the same retained read. A page with no members, and failed requests, cost nothing. |
| Price History | 1 | per day with recorded offers | One credit for each day in your requested window with at least one recorded offer. A 365-day window with offers on 18 days costs 18 credits. Your balance must initially cover all 365 requested days; credits for days without offers are returned. Catalog totals do not guarantee History availability or quote the charge for your selected window. Following the next-page cursor costs nothing more. Empty windows and failed requests cost nothing. |
| Catalogue lookup | 0 | per lookup | Optional discovery of PricesAPI IDs by product or seller name. Free, so that finding an ID never costs a Search. |
| Schedules | 0 | per call | Creating, listing, pausing and deleting schedules is free, and a background refresh uses no credit. A later customer-initiated Search follows normal Search billing. |
Operations that cost 0 credits keep working if your credits run out, but are still subject to endpoint rate limits. Searches with no products or no merchant offers, and empty History windows, are free. Known-product Snapshots with zero offers still bill. Not-found products and failed requests are free. Reservations may require a larger balance upfront; see each operation above.
Plans & Rate Limits
What each plan includes, in credits — the one balance every operation draws on. Personal credits are a one-time trial; paid-plan credits reset with the Stripe billing period.
Schedule slots. A daily or weekly schedule uses one slot, and pausing one frees it. Personal’s figure is a ceiling: free accounts draw from a single shared pool, so a new account may reach fewer. Paid plans have the slots to themselves. Schedules is in Beta and these limits may change.
You're billed 10 credits only when a Search Products response includes at least one merchant offer. Cache hits follow the same rule. Empty results (data.products: []), products with no merchant offers across the response, and 4xx/5xx responses do not use a credit. Async Bulk Search uses the same charge per successful item with at least one merchant offer. Snapshot, Batch, Clusters and History use the product, page or observed-day units in the table above. Known-product Snapshots with zero offers still bill. Not-found products, empty History windows and failed requests cost nothing; reservations may require a larger balance upfront.
| Plan | Price | Credits included | Which buys | Rate limit | Schedule slots | |
|---|---|---|---|---|---|---|
| Personal | $0 | 3,000 credits, one-time | 300 Searches or 3,000 single-product Snapshots | 6 req/min | Up to 25 Schedule slots, shared | Start free |
| DeveloperPopular | $49/month | 30,000 credits / month | 3,000 Searches or 30,000 single-product Snapshots · 1.6¢ a Search | 20 req/min | 300 Schedule slots | Create account to subscribe |
| Business | $199/month | 180,000 credits / month | 18,000 Searches or 180,000 single-product Snapshots · 1.1¢ a Search | 40 req/min | 1,500 Schedule slots | Create account to subscribe |
| Enterprise | Custom | Volume set with you | Priced on your volume | 60 req/min | Schedule volume set with you | Contact sales |
Request rate limits, per account per minute
API keys on the same account share these limits. Each operation below has its own bucket: retained reads do not spend the Search limit.
| Operation | Requests per minute |
|---|---|
Search Products — GET /api/v1/products/search | 6 Personal, 20 Developer, 40 Business, 60 Enterprise |
Product Catalog Lookup — GET /api/v1/products/catalog | 30 |
Product Snapshot — GET /api/v1/products | 60 |
Price History — GET /api/v1/products/history | 60 |
| Async Bulk Search management — submit, status, results and cancel | 30, shared across all four together |
Over the limit: 429 RATE_LIMIT_EXCEEDED with Retry-After in seconds. Price History carries two more limits that are not per-minute.
Search ProductsRecommended
GET /api/v1/products/searchPass a retailer URL or a plain product name. Returns up to limit candidates, each carrying its merchant offers inline as offers[] plus an offerCount. There is no separate offers request.
This endpoint is synchronous. A cold call runs discovery and offer collection inline — allow a generous read timeout, see how long a call takes.
Query Parameters — GET /api/v1/products/search
| Parameter | Type | Accepts | Out of range |
|---|---|---|---|
| q | string | Required. Retailer URL or product text. | 400 MISSING_QUERY |
| market | string | Required, never inferred. Lower-case two-letter code — gb, not uk. country is a permanent synonym; market wins if both are sent. | 400 INVALID_MARKET, supported list in the body. Markets we cover. |
| limit | integer | Maximum candidates returned, default 3. Allowed range by plan: 1–3 on Personal, 1–10 on Developer, 1–60 on Business and Enterprise. | Ask for more than your plan allows and you get your plan's maximum, announced: meta.limit_capped true, meta.limit_max gives the applied maximum. Zero, negative values, or non-plain integers (2.9, 3abc, +3, 03, empty): 400 INVALID_LIMIT naming the range. |
| offers_limit | integer | Maximum offers per product in offers[]. 1–60, default 15, max: 60. Returns the first offers in collected order, which does not guarantee the cheapest offers; offerCount is the returned length. | 400 INVALID_OFFERS_LIMIT naming the range for out-of-range values or the same invalid integer formats. Invalid values are rejected, not clamped. A valid smaller limit can shorten offers[]; the returned count does not establish total seller coverage. |
| max_age | integer | Optional. Maximum acceptable stored-data age in seconds, using non-negative decimal digits. Applies when scheduled serving or fallback is enabled; ignored otherwise. An explicit value prevents stale scheduled fallback. | 400 INVALID_MAX_AGE when enabled and the value is invalid, such as -1, 1.5, 10s or empty. |
| no_cache | string | Optional. 1, true or yes (case-insensitive) bypass retained cache and scheduled-store reads. Results still write to caches; concurrent requests can share an in-flight scrape. | Other values behave as false. A truthy value prevents stale scheduled fallback. |
A plain query string, spaces as +. Escape a retailer URL passed as q — let curl do it:
curl -G "https://api.pricesapi.io/api/v1/products/search" \
-H "Authorization: Bearer your_api_key" \
--data-urlencode "q=https://www.amazon.com/dp/B08N5WRWNW" -d "market=us"Example Request
# One call returns candidates with offers[] inline. Allow a generous cold-call timeout.
curl "https://api.pricesapi.io/api/v1/products/search?q=macbook+pro&market=us&limit=3&offers_limit=15" \
-H "Authorization: Bearer your_api_key" --max-time 95 --fail-with-bodyResponse Fields
The response is { success, data: { query, market, products }, meta }. Each entry in data.products carries:
| Field | Type | Notes |
|---|---|---|
| pid | integer | PricesAPI product-record ID. Reuse it with the same market for later reads. |
| title, image, source | string | Product name, thumbnail, primary retailer. image is an https URL or a self-contained data:image/... URI — an opaque source for <img src> — and null when there was no thumbnail. |
| headline_price, headline_currency | number, string | The card’s price and its ISO currency. Not a minimum and not necessarily an offer: it can exceed every entry in offers[] (the example below: headline 329.99 over a 248 offer from the same seller) and can describe a different variant. Quote from offers[]. |
| condition_label | string | The card’s condition text, verbatim: New, Used, Pre-owned, Refurbished, Open Box or Renewed. Usually null. Describes the card, not the sellers in offers[]. |
| rating, reviews | number | Rating and review count reported on the shopping card, not per seller offer. |
| offerCount | integer | Number of merchant offers in offers[]. |
| offers[] | array | Merchant offers, inline: seller, seller_url, price, currency, product_title, stock_status, delivery_info, url. Rating, reviews and condition describe the shopping card, not each seller offer. |
Before comparing prices, check the product title and seller listing to confirm the model and variant. A product-record ID does not confirm that every seller is offering the same variant.
Rating scope: the response does not identify the underlying review sources or confirm their variant scope. Counts expanded from abbreviations may be approximate.
Candidates also include position, catalog_id, cluster_id, multi_store, delivery, tags, and nearby_distance_km.
Response Metadata
The top-level meta object describes how the response was produced.
| Field | Type | Notes |
|---|---|---|
| latency_ms | number | Time spent serving this request: tens of seconds cold, small on a cache hit. No sub-second promise — size timeouts off the cold path. |
| cache_source | string | 'redis' (hot tier, fastest), 'db' (durable tier), 'miss' (cold path: discovery and offer collection ran), 'coalesced' (shared an in-flight scrape), or 'scheduled' (served from the scheduled store). |
| cache_age_s | number | Age of the cached result in seconds. Present only on a 'db' hit. |
| scraped_at | string | ISO 8601 time the returned data was scraped. Always present for scheduled-store responses. May be omitted on other sources, including older cached data without a known capture time. |
| next_refresh_at | string | ISO 8601 time the next scheduled cycle is due. Present only for scheduled-store responses. It can be in the past when overdue; it does not promise when a refresh will finish. |
| served_stale | boolean | true when a failed or degraded live scrape fell back to scheduled data. Absent for an explicit max_age or truthy no_cache. |
| degraded | boolean | true when the offers stage failed but candidates returned with empty offers[] — empty and partial results. |
| limit_capped, limit_max | boolean, number | Present only when you asked GET /api/v1/products/search for more than your plan's maximum and were served that maximum: limit_capped true, limit_max the maximum that applied. Absent otherwise; every other limit, including offers_limit, refuses out of range with a 400 naming it. |
Use scraped_at to assess data age when present. A missing timestamp means the capture time is unknown, not now; latency_ms measures request execution time, not data age. Search freshness controls do not refresh a known-product Snapshot or guarantee prices at merchant checkout.
meta also carries per-request diagnostics we may add to or change: raw_count, gid_bearing_count, discover_ms, products_ms and blocked. Do not build on them.
Example Response
{
"success": true,
"data": {
"query": "Sony WH-1000XM5",
"market": "us",
"products": [
{
"position": 1,
"pid": 100569554,
"product_ref": { "id": "100569554", "id_type": "pricesapi", "market": "us" },
"cluster_id": "11795796123145720151",
"catalog_id": "17755277695162489284",
"title": "Sony WH-1000XM5 Wireless Noise Cancelling Headphones",
"image": "https://encrypted-tbn0.gstatic.com/shopping?q=tbn:...",
"headline_price": 329.99,
"headline_currency": "USD",
"condition_label": null,
"source": "Best Buy",
"multi_store": true,
"rating": 4.8,
"reviews": 12400,
"delivery": "Free delivery",
"tags": [],
"nearby_distance_km": null,
"offerCount": 3,
"offers": [
{
"seller": "Best Buy",
"seller_url": "https://www.bestbuy.com",
"price": 248,
"currency": "USD",
"product_title": "Sony WH-1000XM5 Wireless Headphones",
"stock_status": "In stock",
"delivery_info": "Free delivery",
"url": "https://www.bestbuy.com/product/.../sku/6505727"
},
{
"seller": "Sony",
"seller_url": "https://electronics.sony.com",
"price": 249.99,
"currency": "USD",
"product_title": "WH-1000XM5 Noise Cancelling Headphones",
"stock_status": "In stock",
"delivery_info": "Free delivery",
"url": "https://electronics.sony.com/.../p/wh1000xm5-b"
},
{
"seller": "Target",
"seller_url": "https://www.target.com",
"price": 249.99,
"currency": "USD",
"product_title": "Sony WH-1000XM5 Wireless ANC Headphones",
"stock_status": "In stock",
"delivery_info": null,
"url": "https://www.target.com/p/sony-wh-1000xm5-.../A-86314264"
}
]
}
]
},
"meta": {
"latency_ms": 41822,
"raw_count": 38,
"gid_bearing_count": 33,
"cache_source": "miss",
"discover_ms": 12640,
"products_ms": 29182
}
}An illustrative cold call: cache_source "miss", ~42 s. A repeat returns the cached result with "redis" (or "db" plus cache_age_s) and its own latency_ms.
Public Beta
Product Catalog Lookup
Product Snapshot and Price History both need an exact product ID. GET /api/v1/products/catalog finds matching products by product or seller name and returns their IDs with a retained-data summary. Choose the product that matches your intended model and variant.
It returns no prices and no offers, never runs Search or a scraper, and never consumes a credit, so it works at zero credits. Its own limit is 30 requests per minute per account; responses are Cache-Control: private, no-store.
Already have a catalog ID?
Go directly to Product Snapshot for the latest retained offers, or Price History for a date range. Use id_type=catalog for a catalog ID, or id_type=pricesapi for a PricesAPI product ID. Both require an explicit market. History does not require a Snapshot request first.
Replace the example ID catalogue-123 with your catalog ID and set its market. These are paid reads: Snapshot costs 1 credit; the seven-day History example reserves 7 credits and refunds days without recorded offers.
Snapshot: latest retained offers
curl "https://api.pricesapi.io/api/v1/products?id=catalogue-123&id_type=catalog&market=au" \
-H "Authorization: Bearer your_api_key"History: offers in a date range
curl "https://api.pricesapi.io/api/v1/products/history?id=catalogue-123&id_type=catalog&market=au&from=2026-09-10&to=2026-09-16" \
-H "Authorization: Bearer your_api_key"A cluster ID identifies a group, not one product: use Product Clusters to list its members. The free Catalog Lookup below accepts product or seller text, not IDs.
Find products by name
| Query | Value |
|---|---|
q | Product name, 2–100 characters. |
seller | Seller name, 2–100 characters. Instead of q, or with it. |
market | Required, never inferred. Lower-case two-letter code. |
limit | An integer from 1 to 20 and defaults to 10. 01, 1.0, +1 and empty are 400 INVALID_LIMIT, not reinterpreted. |
cursor | Opaque; expires 15 minutes after it is issued, and is bound to the q, seller, market, limit and account that made it. Change any: 400 INVALID_CURSOR. |
At least one of q or seller is required. Neither is 400 INVALID_QUERY; an unknown query parameter is 400 INVALID_PARAMETER, not ignored.
curl "https://api.pricesapi.io/api/v1/products/catalog?q=anko+7.5l+air+fryer&market=au" \
-H "Authorization: Bearer your_api_key"{
"products": [
{
"id": "104751632",
"id_type": "pricesapi",
"market": "au",
"title": "Anko 7.5L Air Fryer",
"image_url": "https://encrypted-tbn0.gstatic.com/shopping?q=tbn...",
"retained": {
"offer_count": 152,
"days_with_offers": 48,
"first_date_with_offers": "2026-05-24",
"last_date_with_offers": "2026-09-16"
},
"sellers": [
{ "id": "49", "name": "Kmart", "first_date_with_offers": "2026-05-24", "last_date_with_offers": "2026-09-16", "days_with_offers": 48 },
{ "id": "102", "name": "Target", "first_date_with_offers": "2026-05-24", "last_date_with_offers": "2026-09-16", "days_with_offers": 48 }
],
"dates_with_offers": ["2026-09-16", "2026-09-15", "2026-09-13"]
}
],
"page": { "limit": 10, "returned": 1, "next_cursor": null },
"market_catalog": {
"market": "au",
"products": 9928046,
"products_at_most": 9928046,
"catalog_products": 10109016,
"measured_on": "2026-09-17"
}
}Pass a selected product's id and the same market to Price History or Product Snapshot with id_type=pricesapi.
curl "https://api.pricesapi.io/api/v1/products/history?id=104751632&id_type=pricesapi&market=au&from=2026-09-10&to=2026-09-16" \
-H "Authorization: Bearer your_api_key"This seven-day History request needs 7 credits available upfront. The final charge is 1 credit per day with recorded offers; unused credits are refunded. Follow page.next_cursor with the same parameters to read the remaining pages at no additional charge. Catalog totals are not a quote for this date window or a guarantee that History will return offers.
What the response tells you
Catalog counts retained new-condition offer records with a recorded price. History uses different inclusion and counting rules, so its results and counts can differ. Use the History response to describe the records it serves.
| Output | Meaning |
|---|---|
retained.offer_count | Offers held, not lookups. |
retained.days_with_offers | Distinct days those offers fall on. |
retained.first_date_with_offers, retained.last_date_with_offers | Earliest and latest UTC dates counted in this Catalog summary, not the limits of all History records. |
sellers[] | Named sellers in the available detail period, most days-with-offers first, each with its own dates. sellers[].id = offers[].seller_id. |
dates_with_offers[] | The latest available dates from the detail period, at most 30, most recent first. Not the complete set. |
| No qualifying Catalog offers | All four retained fields zero or null together, both arrays empty. A real answer, not an error. |
market_catalog.products | Titled products counted for this market. 0 is counted and empty; null is not counted, never zero. |
market_catalog.products_at_most | Always a number: equals products when counted, otherwise a proven ceiling. |
market_catalog.catalog_products, measured_on | The whole catalogue across every market, and the date all three were measured. Refreshed periodically, not live. |
Seller and date details cover a narrower period than the retained totals. Either array can be empty when older records exist or the details are unavailable. Empty arrays alone do not mean there is no History.
An empty products array with HTTP 200 means the query returned no matches. Check market_catalog.products: a positive count means the market has products, but does not guarantee a match for your query. Zero means none were counted at measured_on. If the count is null, use products_at_most as an upper bound, not an exact count. These market-level counts are refreshed periodically; product availability and history depth vary by market.
Search by seller
seller works for retailers of any size — seller=kmart and seller=ebay are ordinary paged reads. A seller-only query returns that seller's products most recently observed first. Seller-filtered discovery uses the current UTC month and the previous two months; this does not limit the returned retained totals to those months. Adding q narrows to titles that also match.
curl "https://api.pricesapi.io/api/v1/products/catalog?q=air+fryer&seller=Kmart&market=au" \
-H "Authorization: Bearer your_api_key"A seller term so generic it names a great many sellers — two characters match tens of thousands — is a 400 SELLER_SCAN_LIMIT_EXCEEDED naming the limit and telling you to use a more specific seller name. No Retry-After: it fails identically on retry.
Paging
Page with page.next_cursor until it is null; paging stops after 100 index positions per query. A short page can still carry a cursor: candidates whose identity cannot be confirmed in the requested market are dropped rather than failing the page.
Beta commercial terms: Pricing models, included allowances, and usage limits for all Beta features may change before general availability. Existing Product Search pricing and credit allowances remain unchanged unless separately announced.
Public Beta · every valid API account
Product Snapshot
GET /api/v1/products reads a known product and its latest stored seller prices. It does not run a new Search or refresh retailer pages. Check the response's timestamps before treating prices as current; available offers vary by product and market. Snapshot has its own rate limit.
1 credit per product read by ID; a not-found product or a failed read costs nothing.
| Query | Value |
|---|---|
id | Required. PricesAPI or Google catalog ID. |
id_type | Required. pricesapi or catalog. Never inferred. |
market | Required. Lower-case two-letter code. |
offers_limit | 0–60, default 60. 0 returns an empty offers array. Outside: 400 INVALID_OFFERS_LIMIT, refused not clamped. |
seller_domains | Repeatable, up to 10 hostname-only domains. Filters on each offer's seller_domain, before the retained ceiling and offers_limit; the response then carries seller_domains_filter with requested and matched domains. |
curl "https://api.pricesapi.io/api/v1/products?id=123456&id_type=pricesapi&market=gb&offers_limit=20&seller_domains=amazon.co.uk&seller_domains=argos.co.uk" \
-H "Authorization: Bearer your_api_key"Read this before you parse
Branch on body.error: every failure carries { success: false, error: { code, message } }.
| Search | Snapshot |
|---|---|
success / data / meta wrapper | No wrapper — product, observation, offers, offers_page at the top level |
pid | product.id, a string not an integer |
image | product.image_url |
offerCount | offers_page.returned |
product_title | title, per offer |
stock_status | availability, seller wording in availability_raw |
| — | seller_id, seller_domain, observed_at — Snapshot only |
| — | product.specs — Snapshot only. Search does not return it: read a candidate's product_ref here. |
Product attributes
product.specs is the product's attribute table as observed by our scrapers: an array of { name, value } strings, such as { "name": "Storage Capacity", "value": "128 GB, 256 GB" }. It is always present, and null when we hold no attribute table for the product — never an empty array.
- It describes the product family, not one variant or offer. A phone's “Colour” can list every colour and its “Storage Capacity” every capacity, so never read a value as the attribute of the item a seller is pricing.
- Names and values are display strings, not normalised units, and the set of names varies by category. Match names loosely and show values as text.
Build a seller-price comparison
Compare offers for one exact product and market. Show the item price, availability and observation time together; the lowest number alone is not a buying recommendation.
| In your app | Use the response this way |
|---|---|
| Item price | offers[].price excludes delivery. There is no numeric shipping, tax breakdown or checkout total. Do not label it “delivered price”. |
| Currency | currency is assigned from the requested market, not verified from each merchant's page. Keep markets separate; matching currency labels alone do not verify comparable prices. |
| Stock and age | Filter on availability === 'in_stock' if you only want offers recorded as in stock. Show observed_at and check observation.stale; null means unknown. Stored stock is not a live availability check. |
| Delivery | Display delivery_info as seller text. A null value means “not provided”, not free delivery. A country-level market does not confirm delivery to a buyer's address. |
| Variant and condition | Check each offer's title and product url for size, model and condition. Snapshot does not publish an offer condition field, so you cannot reliably filter to new-only offers. Do not treat a shared product ID as proof of identical seller listings, and do not use product.specs to tell variants apart: it describes the whole product family. |
| Comparison coverage | If offers_page.truncated is true or observation.state is partial, label the comparison as incomplete. Even a complete response covers only stored offers, not every seller in the market. |
Tracking changes or comparing against RRP? See Build a price-change report for same-pack comparisons, baseline examples and promotion limits.
Offers are sorted, not raw
| Behaviour | Detail |
|---|---|
| Order | Availability rank — in_stock, preorder, backorder, unknown, out_of_stock — then price ascending inside each class. Presentation order, not a search rank. |
| Truncation | offers_limit drops the worst-ranked end and sets offers_page.truncated, so offers[0] is the same offer at 1 as at 20. |
| No domain match | A known product returns 200 with empty offers. |
| Unknown ID | 404. Rerun Product Search with the original query or retailer URL, pick a candidate, and retain its PricesAPI ID with the market. |
| Catalog vs cluster ID | id_type=catalog takes a Google catalog ID (gid), one product variant in one market, on this same route. The cluster ID (gpcid) is a different namespace: id_type=cluster on /api/v1/product-clusters. The wrong one is a 404, not a silent substitution. |
curl "https://api.pricesapi.io/api/v1/products?id=catalogue-123&id_type=catalog&market=gb" \
-H "Authorization: Bearer your_api_key"Read a catalogue in one request
POST /api/v1/products/batch takes up to 100 known product identities. It preserves input order and reports an unavailable mapping as a per-item PRODUCT_NOT_FOUND. No Search, no scraper.
1 credit per product returned, never per call; misses and failed requests cost nothing.
| Rule | Detail |
|---|---|
| Reserve, then refund | The full requested count is reserved, misses refunded at once — your account must cover every item you ask for, so 25 items need 25 credits even if only 3 exist, else 403 INSUFFICIENT_CREDITS. |
| Duplicates | Charged once each; send an ID twice, get it twice. |
| Rate limit | 100 requested items per minute per account, shared across its API keys. A 25-item call counts as 25 items. |
items | 1 to 100, else 400 INVALID_BATCH. Never truncated. |
Per-item offers_limit | A JSON number, not a string: "3" is a 400 INVALID_OFFERS_LIMIT carrying its input_index. |
| Response | Bare: results and summary at the top level, each found entry adding input_index and status. |
curl -X POST "https://api.pricesapi.io/api/v1/products/batch" -H "Authorization: Bearer your_api_key" -H "Content-Type: application/json" --data '{"items":[{"id":"123456","id_type":"pricesapi","market":"gb","offers_limit":10,"seller_domains":["amazon.co.uk"]},{"id":"catalogue-123","id_type":"catalog","market":"gb","offers_limit":10}]}'Product clusters are plural
GET /api/v1/product-clusters pages the known products of one Google product-cluster ID; limit is 1–100, default 100, outside it 400 INVALID_CLUSTER_LIMIT. Deterministic membership: not Search ranking, not one auto-selected product, not guaranteed-exhaustive variants. Bare 200 — cluster, products, page at the top level, products being members, not Search's data.products candidates.
1 credit per page returned, never per member — one page holds as many members as limit asks for. An empty page, a rejected parameter, a 404 and a failed read cost nothing.
curl "https://api.pricesapi.io/api/v1/product-clusters?id=cluster-123&id_type=cluster&market=gb&limit=100" \
-H "Authorization: Bearer your_api_key"Beta commercial terms: Pricing models, included allowances, and usage limits for all Beta features may change before general availability. Existing Product Search pricing and credit allowances remain unchanged unless separately announced.
Public Beta
Price History
Read a product's recorded seller prices without rerunning Search. Each offer includes the seller, price, source page and recording time. Pass the product ID, ID type and market together, as you do for Snapshot.
Breaking change: group_by and summary are removed, range is now window, and each offer carries recorded_at where it carried observed_at. Old field to new field: Migration guide.
History is available across supported markets, but depth varies by product and market. Australian products currently have the deepest coverage. A valid request can return fewer observations than you expect, or an empty response with HTTP 200. An empty response means no prices were returned for that product and window, not a zero price.
curl "https://api.pricesapi.io/api/v1/products/history?id=123456&id_type=pricesapi&market=gb&from=2026-06-01&to=2026-06-07&limit=250" \
-H "Authorization: Bearer your_api_key"Replace the example ID and market with a product from Catalogue. This seven-day window needs 7 credits available before the first request; the final charge is 1 credit per day with recorded offers. The illustrative response below has prices on one day, so it costs 1 credit and refunds 6 credits. Following an issued next-page cursor adds no charge.
{
"product": { "id": "123456", "id_type": "pricesapi", "market": "gb", "currency": "GBP" },
"window": {
"from": "2026-06-01",
"to": "2026-06-07",
"as_of": "2026-09-17T00:00:00.000Z",
"days_requested": 7,
"days_with_offers": 1,
"times_with_offers": 1,
"offer_count": 1,
"first_recorded_at": "2026-06-02T03:14:07.120Z",
"last_recorded_at": "2026-06-02T03:14:07.120Z"
},
"offers": [
{
"recorded_at": "2026-06-02T03:14:07.120Z",
"seller_id": "4412",
"seller": "Argos",
"seller_domain": "argos.co.uk",
"price": 229.99,
"currency": "GBP",
"seller_product_url": "https://www.argos.co.uk/product/4412",
"product_title": "Example Headphones 2025 Model",
"stock_status": "In stock",
"delivery_info": "Free delivery over 50"
}
],
"page": { "limit": 250, "returned": 1, "next_cursor": null }
}Query parameters
| Parameter | Accepts | Out of range |
|---|---|---|
| id | Required. A PricesAPI or Google catalog ID. | n/a |
| id_type | Required. pricesapi, or catalog for a Google catalog ID (see Product Snapshot). Only have a name or URL? Use Product Search and retain its Product ID and market. | n/a |
| market | Required. Lower-case market code. | n/a |
| from, to | Inclusive UTC dates spanning at most 1,500 days. to not later than today, UTC. | 400 INVALID_DATE_RANGE |
| limit | 1 to 1,000. Default 250. | 400 INVALID_LIMIT naming the range, never a shortened page. |
| cursor | Opaque page.next_cursor, up to 3,072 characters. Copy it unchanged and keep every other parameter the same. Repeat while it is not null. | 409 CURSOR_EXPIRED 24 hours after that cursor was issued, if the ID now resolves to a different product, or if data-visibility rules change. Older formats may expire before 24 hours. A restart is a new billable read; do not restart automatically. |
If a read is interrupted
Copy the opaque cursor unchanged and repeat the original REST query's id, id_type, market, from, to and limit exactly. If limit was omitted, keep it omitted even when the response's page.limit is 250.
- You saved a page: save its offers and
page.next_cursortogether before fetching the next page. Resume with that cursor and the same query parameters. Continuation requests add no charge. A null cursor means you have reached the end. - A continuation response was lost: keep the cursor used for that request. You can retry it without another History charge while it remains valid. Do not append the same page twice; commit its offers and next cursor together. This is not an immutable export: late-arriving or removed records can change the rows returned.
- The first response was lost: without a saved response or issued cursor, do not assume a retry is free. A timeout does not establish whether the server completed the read. Check your usage before deciding whether to make another billable first-page request.
- You receive
409 CURSOR_EXPIRED: stop. Older pagination formats may expire before their normal 24-hour lifetime. Do not restart automatically. Starting again is optional and is a new billable first-page read with a new reservation. Keep partial results visibly incomplete unless you explicitly start a new walk. If you choose to restart, start without a cursor and discard the previous walk's offers; do not mix the two walks.
Response fields
A 200 returns product, window, offers and page at the top level — no success, no data wrapper, unwrapped like Product Snapshot. Only observations that exist are returned. Missing dates or periods are omitted, never zero, and never fill-forward.
| Field | What it means |
|---|---|
| window | The whole requested window, not the page you hold. Pinned on page 1; continuations echo it unchanged. |
| window.days_with_offers | Days inside the window carrying at least one offer. The billing key. |
| window.offer_count | The count at the instant page 1 was served, not a running total. Late-arriving records can increase the rows returned; removed records can reduce them. These changes do not by themselves expire a cursor. A null page.next_cursor ends the walk; the loaded row count need not equal this initial count. |
| offers[].seller_id | Group a per-retailer series on it. Stable across pages and products, and the same id the free Product Catalog Lookup publishes as sellers[].id. |
| offers[].price | Excludes delivery. |
| offers[].delivery_info | The seller's delivery text, not a delivery cost. Null means no delivery information is available, not free delivery. |
| offers[].seller_product_url | The offer page this price was read from, not the seller's home page. |
| offers[].product_title, .stock_status, .delivery_info | The seller's own words. Null at or before 2025-06-30T23:59:59.999Z. |
| Order and nulls | Offers ascend by recorded_at. recorded_at, seller_id, price and currency are always present; every other field may be null, meaning absent. |
Build a price-change report
History supplies observations; your app chooses the comparison and alert rules. Load every page for one product and market before summarising.
- Compare the same item. Check the seller listing for model, pack size and condition. Keep each
seller_idandseller_product_urlseparate; a shared product ID or similar title does not confirm an identical pack. If the URL or historical variant evidence is missing, leave the match unconfirmed. - Name the baseline. A previous observed price and a recommended retail price (RRP) answer different questions. RRP is not returned by the API: supply a dated, same-pack reference in your app. Compare the same currency and price basis; item prices exclude delivery.
- Show when offers were recorded.
recorded_atis when PricesAPI recorded the offer, not when it fetched the listing or the seller changed the price. Missing days remain unknown. Equal prices at two retailers do not establish who changed first or whether one deliberately matched the other.
Two calculations, two different labels
Illustrative amounts in AUD for a confirmed identical pack:
- Earlier recorded price A$100, later A$103: 3% above the previous observation —
(103 - 100) / 100 × 100. - Supplied RRP A$100, observed A$80: 20% below supplied RRP —
(100 - 80) / 100 × 100.
A baseline must be known and greater than zero. Neither calculation identifies a promotion, member price or permanent price increase.
The response has no structured RRP, membership or promotion fields. Verify eligibility separately before comparing special offers; a price rise may be a promotion ending. Repeated observations do not prove the price held between checks.
For recurring reports, Schedules refresh product queries, not exact-product price alerts. Your app still reads the results and handles thresholds, recipients and delivery.
Limits
| Limit | What happens |
|---|---|
| Two requests in flight per account | A third is refused with 429 RATE_LIMIT_EXCEEDED and Retry-After far under the 60 per minute limit — the trap for a page drawing several sparklines at once. Read in sequence, or two at a time. |
| 50,000 observation rows scanned per window | A denser window is refused with 400 INVALID_DATE_RANGE asking for a narrower range, so a window small by its dates can still be refused on a densely observed product. Split it by date. |
What a window costs
One credit per day with recorded offers — exactly the window.days_with_offers the response publishes, so a 365-day window over a product observed on 18 days costs 18, not 365. An empty window, a failed request and every cursor page cost nothing: the window is charged once, on page 1. Your available balance must first cover every requested day. For example, the 365-day request needs 365 available credits; 347 are returned if only 18 days have offers. Otherwise the API returns 403 INSUFFICIENT_CREDITS with the required reservation based on days_requested.
The free Catalogue lookup reports retained.days_with_offers across all stored history, not just your requested window. Use it to understand coverage, not as an exact price quote for a date range.
This retained-data read does not rerun Search or call a scraper, and it is available to every valid API account.
Beta commercial terms: Pricing models, included allowances, and usage limits for all Beta features may change before general availability. Existing Product Search pricing and credit allowances remain unchanged unless separately announced.
Async Bulk SearchBeta
POST /api/v1/search/jobsSubmit an ordered list of Search requests, poll the job, then page through the results in original input order. Each item behaves exactly like Search Products: global market coverage, cache controls, ranked candidates and inline merchant offers.
This public Beta is available to every valid API account. Use the bearer API key for the account that submitted the job; job reads and cancellation are caller-scoped. Questions? Email support@pricesapi.io.
Credits are reserved atomically. Submission reserves 10 credits for each item or rejects the whole job without creating partial work. A successful result with at least one merchant offer keeps its reserved credits. Items with no products or no merchant offers, failed items, and cancelled-before-start items refund their reservations. Job management requests use no additional credit.
Complete request sequence
curl -X POST "https://api.pricesapi.io/api/v1/search/jobs" \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: YOUR_UNIQUE_OPERATION_KEY" \
--data '{
"searches": [
{ "q": "Sony WH-1000XM5", "market": "us", "limit": 3 },
{ "q": "Kindle Paperwhite", "market": "gb", "offers_limit": 10 }
]
}'1. Submit a job
| Field | JSON type | Contract |
|---|---|---|
q | string | Required. Trimmed product name or retailer URL; 1–400 UTF-8 bytes and no control characters. |
market | string | Required global market code, case-insensitive, returned lowercase. There is no default. |
limit | integer | Candidate products per item. Optional, defaults to 3. Accepts 1–3 on Personal, 1–10 on Developer, and 1–60 on Business or Enterprise. Above your plan’s maximum returns 400 INVALID_ITEM; the job is refused, not shortened. |
offers_limit | integer | Optional, 1 to 60. Defaults to 15. |
max_age | integer | Optional non-negative JSON safe integer: the oldest cached or scheduled data this item accepts, in seconds. |
no_cache | boolean | Optional. true requires a live Search and bypasses cache reads. |
| Job rule | Contract |
|---|---|
searches | The configured Beta ceiling is currently 100 items per job, and the array must hold at least one item. An empty or oversized array returns 400 INVALID_ITEM_COUNT naming the range; a long job is never silently truncated. |
Body | Strict JSON, 100 KB maximum. Only a searches array at the root, only the fields above per item. Numeric strings and boolean strings are invalid. |
Out-of-range item field | Refused, never quietly reduced: 400 INVALID_ITEM naming the item index and the range. The whole job is refused on the first bad item. |
Idempotency-Key | Required, 1–255 characters. Generate and persist one unique key for each logical job. Reuse it only for retries of the same normalized, ordered request; use a new key for every new or changed job. A valid replay returns the original job with replayed: true and never reserves credits twice; reusing the key with a different request returns 409 IDEMPOTENCY_CONFLICT. |
2. Poll status
GET /api/v1/search/jobs/:id counts items by processing state and credit state. The reserved, consumed and refunded fields are item counts, not credit amounts. For example, one pending item has reserved: 1 while reserving 10 credits. The job is finished when pending + running equals zero; there is intentionally no separate aggregate job state. Poll about once every two seconds with jitter, back off toward ten, and honor Retry-After on 429 and 503.
expires_at is the job's execution cutoff. No pending item starts after it: unfinished pending items are cancelled and refunded, while an item already running may still complete. Status and results remain readable after the cutoff in the current Beta, but they are not durable storage; collect results promptly.
| Item state | Credit state | Meaning |
|---|---|---|
pending | reserved | Accepted and waiting to start; response is null. |
running | reserved | A worker is executing the Search; response is null. |
succeeded | consumed | Search returned at least one merchant offer, in the normal Search success envelope. |
empty | refunded | Search returned no products or products with no merchant offers, in the normal Search success envelope. |
failed | refunded | Search or worker execution failed; response carries the Search failure envelope and a machine-readable error_code. |
cancelled | refunded | The item was cancelled before it started; response is null. |
3. Page through results
GET /api/v1/search/jobs/:id/results pages items in input order, including pending and running ones, so partial progress can be inspected while work continues. Item states are live rather than a snapshot: after pending + running reaches zero, make a fresh pass from page 1 to collect the final outcomes.
| Parameter | Accepts | Notes |
|---|---|---|
limit | 1 to 200, default 50 | The page size of this read, not the per-item limit above. Outside the range returns 400 INVALID_LIMIT rather than clamping. |
cursor | the exact opaque next_cursor value | A null next_cursor marks the final page. Pagination order and cursor boundaries are stable. |
4. Request cancellation
DELETE /api/v1/search/jobs/:id is idempotent: pending items are cancelled and refunded exactly once, running items may still complete, terminal items are unchanged. It requests cancellation; it does not delete the job.
Errors
| Status | Code | When |
|---|---|---|
400 | INVALID_ITEM / INVALID_ITEM_COUNT | A field or the item count breaks the contract above. |
400 | INVALID_JSON | Malformed JSON body. |
413 | PAYLOAD_TOO_LARGE | Request body over 100 KB. |
409 | IDEMPOTENCY_CONFLICT | The key was reused with a different request. |
403 | INSUFFICIENT_CREDITS | The submission is unaffordable. |
404 | JOB_NOT_FOUND | Missing and foreign jobs alike. |
429 | RATE_LIMIT_EXCEEDED | Rate limited; honor Retry-After. |
503 | BULK_SEARCH_UNAVAILABLE | Temporary dependency failure; Retry-After: 5. |
503 | BULK_SEARCH_ADMISSION_PAUSED | Controlled drain; Retry-After: 60. Existing jobs stay readable and cancellable. |
SchedulesBeta
Recurring catalogue discovery for selected queries. Add terms from the dashboard or the management API; call Search Products when you need the products and offers.
Schedules Beta
Weekly and daily cadences are available globally across supported markets. Sub-daily cadences are not available yet.
Create and manage a Schedule
Add a product query for recurring discovery, then check its activity from your app. These examples use the management API only; they do not fetch product prices.
Replace your_api_key before running a request. Run each JavaScript snippet separately in a Node.js 18+ .mjs file. Nothing runs when you view or copy an example. See the full OpenAPI reference for request fields, response schemas and errors.
1. Add a query and check the result
Send each term as an object with its own market. Set frequency_minutes to 1440 for daily discovery or 10080 for weekly. This request creates a Schedule if a slot is available.
curl --include -X POST "https://api.pricesapi.io/api/v1/schedules" \
-H "Authorization: Bearer your_api_key" \
-H "Content-Type: application/json" \
--data '{"terms":[{"term":"Sony WH-1000XM5","market":"au","frequency_minutes":1440}]}'Check data.results for every term, even after HTTP 200. added and exists include the Schedule id; save it for reads and updates. data.summary counts each outcome.
cap_reached: no slot was available. Check your allocation before trying again.invalid: fix the item using itsreason. A bare string has no market and is not enrolled.rate_limited: that item was not attempted. Resubmit only those items; this is different from an HTTP 429.
2. Read its activity
Replace 123 with an ID returned for your account. The response is under data.schedule. Creating a Schedule does not mean its first discovery has finished.
curl --include "https://api.pricesapi.io/api/v1/schedules/123" \
-H "Authorization: Bearer your_api_key"activity describes the latest discovery attempt; cadence_health reports whether the Schedule is keeping up with its cadence. Neither is the observation time of every seller price. discovery_complete does not guarantee fresh prices.
To get products and offers, make a separate Search Products request for the term and market when your app needs them. That request follows the normal Search credit rules.
3. Pause or resume
The request below pauses your Schedule. Send {"paused":false} to resume instead. Resuming rechecks your slot cap and can return HTTP 409. To change cadence, send {"frequency_minutes":10080} for weekly.
curl --include -X PATCH "https://api.pricesapi.io/api/v1/schedules/123" \
-H "Authorization: Bearer your_api_key" \
-H "Content-Type: application/json" \
--data '{"paused":true}'When each schedule last ran
Every schedule carries an activity object for its current catalogue-discovery binding — the latest attempt only.
| Field | Meaning |
|---|---|
| activity.state | awaiting_first_discovery, discovery_complete, or discovery_issue. |
| activity.attempted_at | UTC instant of the latest attempt; null before the first. |
| activity.discovered_product_count | Products that attempt discovered. Zero is valid; null means unavailable. |
{
"activity": {
"state": "discovery_complete",
"attempted_at": "2026-08-30T03:15:00.000Z",
"discovered_product_count": 47
}
}Manage your Schedules
The public management API is caller-scoped.
| Operation | Request |
|---|---|
| List schedules | GET /api/v1/schedules |
| Add schedules | POST /api/v1/schedules |
| Search history | GET /api/v1/schedules/history?days=7 |
| Read status | GET /api/v1/schedules/:id |
| Pause or resume | PATCH /api/v1/schedules/:id |
| Remove schedule | DELETE /api/v1/schedules/:id |
| Read Schedule settings | GET /api/v1/schedules/settings |
| Update Schedule settings | PATCH /api/v1/schedules/settings |
curl "https://api.pricesapi.io/api/v1/schedules" \
-H "Authorization: Bearer your_api_key"Discovery analytics
| On | Parameter | Series returned |
|---|---|---|
GET /api/v1/schedules | activity_days accepts 7, 30, or 90 and defaults to 7. Anything else returns INVALID_ACTIVITY_DAYS. | meta.activity_series: one UTC point per day with completed, issues, products_returned. |
GET /api/v1/schedules/history | Its existing days window. | meta.demand_series: daily requests, successful_responses, errors for that caller and market. |
Analytics metadata is optional: a failed supplemental analytics read leaves the field omitted while the schedules or history rows still return.
Billing: Schedules management calls are free of API credits. Background refreshes use no API credits. A later customer-initiated non-empty Search follows the standard API credit rules.
Automatic enrolment: auto_enrol is true on a fresh account, so each live Search that returns products enrols its term and market at your default cadence (daily unless you set weekly), within your slot cap. PATCH /api/v1/schedules/settings with {"auto_enrol": false} turns it off; existing schedules are untouched.
How your term becomes a schedule
Unique on (market, normalised term): trimmed, slug taken if it is a URL, lower-cased, whitespace collapsed. A create echoes what you sent; list, detail and Search history return the normalised term — key your map on the schedule id, not the string you posted.
A URL with a descriptive slug normalises to that slug and behaves like a text term. One without — an Amazon /dp/ or eBay /itm/ link — is accepted and holds a slot, but normalises to the raw lower-cased URL, which is not a searchable term. Post the product name instead.
What each field means
| Field | What it means |
|---|---|
| origin | How it was created: bulk if the create body carried two or more terms, manual if exactly one. Nothing to do with Async Bulk Search. |
| status | Latest discovery activity plus whether the schedule is paused. recovering means the last attempt ended in an issue; the next runs as usual. |
| rate_limited | Per item on a create, and not a 429: never attempted, because the 20-second request budget ran out or a chunk was interrupted. Re-post it. |
| data.truncated | On GET /api/v1/schedules/history: the over-fetch window behind the query filled up, so more groups may exist beyond the ones next_cursor walks. |
| cap_reached | Personal slots are one fleet-wide shared pool, 25 at present. A full pool returns per-item cap_reached even while meta.slots_available shows headroom on your own account, so a batch sized from meta.slots_available alone can come back entirely cap_reached. |
How long a call takes
/products/search is synchronous: one call runs discovery and offer collection inline. No job id to poll, no webhook. meta.cache_source names the path it took — Response Metadata. A 'miss' duration varies by query and upstream conditions.
Set a generous read timeout. In an interactive UI, show a loading state or warm the cache first.
Markets we cover
market is required and has no default: without it you get 400 INVALID_MARKET, never a quiet search of Australia. Global coverage across the lower-case codes below; product and merchant availability varies by product and market. Use gb for the United Kingdom — uk returns 400 INVALID_MARKET.
Need a market not listed?
An unsupported market code returns 400 INVALID_MARKET, not an empty result. The Search error includes the supported codes. A supported market can still return no matches for a particular query. Email us; we prioritise coverage by customer demand.
Example with Market Parameter
# Search by product name, get US results
curl "https://api.pricesapi.io/api/v1/products/search?q=MacBook+Pro+M5+14-inch&market=us" \
-H "Authorization: Bearer your_api_key" --max-time 95Empty and partial results
An HTTP 200 response from GET /api/v1/products/search can contain no products or products without merchant offers. Handle both before calculating prices or sending stock alerts. Neither result uses API credits; Search is charged only when at least one merchant offer is returned. The retained-data reads have their own empty shapes — Product Snapshot.
Empty result
No matched products were returned: HTTP 200 with an empty data.products, not a 404. This does not establish that the product does not exist or is out of stock.
{
"success": true,
"data": { "query": "asdfghjkl", "market": "au", "products": [] },
"meta": { "latency_ms": 31204, "raw_count": 0, "gid_bearing_count": 0, "cache_source": "miss" }
}Products without offers
Products were found, but no merchant offers were returned. Check the returned offers[] arrays: the meta.degraded flag is optional. The example below includes it, but other zero-offer responses may not. A later retry may return offers, but is not guaranteed to. Keep any previous prices with their original observation times; do not replace them with zero prices or treat this response as an out-of-stock signal.
{
"success": true,
"data": {
"query": "Sony WH-1000XM5",
"market": "au",
"products": [
{ "position": 1, "pid": 100569554, "title": "Sony WH-1000XM5", "headline_price": 399,
"headline_currency": "AUD", "source": "JB Hi-Fi", "offerCount": 0, "offers": [] }
]
},
"meta": { "latency_ms": 38110, "raw_count": 21, "gid_bearing_count": 18,
"cache_source": "miss", "degraded": true }
}Error codes
Every failure returns { success: false, error: { code, message } } under a standard HTTP status. Branch on error.code.
Authentication Errors
| Code | Error | Description |
|---|---|---|
| 401 | MISSING_API_KEY | API key not provided in request |
| 401 | INVALID_API_KEY_FORMAT | API key doesn't start with pricesapi_ prefix |
| 401 | INVALID_API_KEY | API key not found in database |
| 403 | SUBSCRIPTION_CANCELLED | Your subscription has been cancelled |
| 403 | CREDITS_EXCEEDED | API credit allowance used up. The body carries details.credits_used, details.credits_included and details.resets_at — null when the allowance does not renew, as on the free evaluation credits. |
| 403 | MONTHLY_LIMIT_EXCEEDED | Monthly credit cap reached during the atomic usage check |
| 429 | RATE_LIMIT_EXCEEDED | A per-minute or History in-flight limit was hit. Search also uses this code for repeated over-quota requests: read the credit details and pause if credit is exhausted. |
Request Errors
| Code | Error | Description |
|---|---|---|
| 400 | MISSING_QUERY | Search query parameter 'q' is required |
| 400 | INVALID_LIMIT | limit outside its operation's range. Search: zero or non-integer (above your plan's maximum is served at that maximum). Product Catalog Lookup 1–20, Price History 1–1,000, Async Bulk results paging 1–200. The message names the range. |
| 400 | INVALID_OFFERS_LIMIT | offers_limit outside 1–60 on Search, or 0–60 on Product Snapshot, or not a plain integer. The message names the range. |
| 400 | INVALID_MARKET | Unsupported market code; the body returns the supported list. Use gb, not uk — Markets we cover. |
| 400 | INVALID_MARKET | market was not supplied. There is no default. |
| 400 | INVALID_PARAMETER | An unaccepted query parameter: refused, not ignored. The message names it, its replacement if retired (retailer/retailers → seller_domains, frequency → frequency_minutes), and every parameter accepted. |
| 404 | NOT_FOUND | Requested endpoint does not exist |
| 410 | ENDPOINT_GONE | Endpoint permanently removed. /products/:id/offers is gone; /products/search returns offers inline — Migration guide. |
| 400 | INVALID_PARAMETER | A parameter Price History does not accept. The removed group_by always lands here, checked before every other validation — migration guide. |
| 409 | CURSOR_EXPIRED | A Price History cursor at least 24 hours old, resolving to a different product, or invalidated by changed data-visibility rules. Older pagination formats may expire before their normal 24-hour lifetime. Stop; do not restart automatically. Starting again is optional and is a new billable first-page read with a new reservation. Keep partial results visibly incomplete. If you explicitly choose to restart, start without a cursor and discard the previous walk's offers; do not mix the two walks. |
Search service errors
These Search 503 responses mean the scraper is temporarily unavailable. They use no credit. Wait at least the Retry-After delay in seconds before another attempt; the delay does not guarantee recovery.
| Code | Error | Description |
|---|---|---|
| 503 | SCRAPER_UNAVAILABLE | Scraper backend temporarily unavailable. |
| 503 | SCRAPER_BUSY | Scraper at capacity. |
| 503 | SCRAPER_CIRCUIT_OPEN | Circuit breaker open after repeated upstream failures. |
Server Errors
Do not automatically replay a request after a server fault. Email support@pricesapi.io with the request time and error code, never your API key.
| Code | Error | Description |
|---|---|---|
| 500 | SEARCH_FAILED | The search failed on our side. |
| 500 | DB_ERROR | We could not read our own store. |
| 500 | INTERNAL_ERROR | An internal operation failed, including a fault while checking your key. |
Before retrying
Read the HTTP status, error code, quota details and any Retry-After header. A rate limit and an exhausted credit balance need different actions.
- Exhausted credit: pause until credit is available. Search returns
403 CREDITS_EXCEEDEDorMONTHLY_LIMIT_EXCEEDED; retained reads use403 INSUFFICIENT_CREDITS. Repeated over-quota Search calls can instead return429 RATE_LIMIT_EXCEEDEDwith quota details. Do not retry that refusal onRetry-Afteralone. - Rate-only
429 RATE_LIMIT_EXCEEDED: wait at least the header's delay in seconds. Share pacing across workers; History also limits concurrent reads. 503: check the operation's error code. It can mean scraper pressure or an unavailable dependency. Honor a valid delay; otherwise use bounded backoff. Stop at a deadline instead of retrying indefinitely.- A transport timeout leaves the outcome unknown. A new Search can bill again. For Async Bulk Search, recover the existing job or replay the same payload with its original idempotency key.
Error Response Format
{
"success": false,
"error": {
"code": "INVALID_API_KEY",
"message": "API key is invalid",
"details": "The provided API key was not found"
}
}Code examples
One call returns candidates with their offers. Full examples:
const API_KEY = 'your_api_key';
const BASE_URL = 'https://api.pricesapi.io/api/v1';
async function searchProducts(query, market) {
// limit's maximum is your plan's: Personal 3, Developer 10, Business 60.
const params = new URLSearchParams({ q: query, market, limit: '3' });
// Cold calls run product discovery and offer collection — allow a generous timeout.
const response = await fetch(
`${BASE_URL}/products/search?${params}`,
{
headers: { Authorization: `Bearer ${API_KEY}` },
signal: AbortSignal.timeout(95_000),
}
);
const body = await response.json();
if (!response.ok) {
const retryAfter = response.headers.get('Retry-After');
throw new Error(`${body.error?.code ?? response.status}: ${body.error?.message ?? 'Request failed'}${retryAfter ? '; Retry-After: ' + retryAfter : ''}`);
}
return body;
}
// One call returns candidates WITH offers inline — there is no second /offers call.
searchProducts('laptop', 'us').then(({ data, meta }) => {
const products = data.products;
console.log(`Found ${products.length} products (cache: ${meta.cache_source})`);
if (products.length > 0) {
const top = products[0];
console.log(`First candidate: ${top.title}`);
console.log(`${top.offerCount} merchant offers:`);
for (const offer of top.offers) {
console.log(` ${offer.seller} — ${offer.price} ${offer.currency} (${offer.stock_status})`);
}
}
});MCP serverBeta
Point an MCP client that sends custom HTTP headers at https://mcp.pricesapi.io/mcp with your existing API key. Same operations and response shapes as REST, with tighter list bounds so one tool result cannot fill an agent’s context. Size against each tool’s own bound, not the REST maximum.
| Tool | What it does |
|---|---|
search_products | A term or retailer URL in one market. offers_limit caps at 20 here, 60 on REST. |
find_catalog_products | A product or seller name → the IDs the other tools need. Start here with a name and no ID; runs no scrape. |
get_product_snapshot | One known product and its latest retained offers, by ID and market. Cannot search by name. |
batch_get_product_snapshots | Up to 25 exact products (100 on REST), input order kept, misses reported per item. |
list_product_cluster_members | Pages retained products for one Google product-cluster ID and market. Membership, not ranking. |
get_price_history | Sparse observed prices for one exact product and market; missing periods stay absent. |
list_supported_countries | The canonical market codes. |
list_schedules, get_schedule, create_schedules, update_schedule, remove_schedule | Recurring searches and cadence health. create_schedules takes 100 terms here, 1,000 on REST. |
submit_search_job, get_search_job, list_search_job_results, cancel_search_job | Durable one-off batches. One request each; never polls for you. |
Connect a client
Keys vary by client; the values do not:
{
"mcpServers": {
"pricesapi": {
"url": "https://mcp.pricesapi.io/mcp",
"headers": {
"Authorization": "Bearer your_api_key"
}
}
}
}Credentials belong in the Authorization header, never in the endpoint URL or in tool arguments. OAuth is not available yet, so the client must set custom HTTP headers.
Billing
A tool costs exactly what the REST operation behind it costs, and is charged on the same unit — what a call costs. Each search_products call makes one Search Products request, and a response with at least one merchant offer uses 10 credits, including a cache hit; a result with no products or no merchant offers, or an error, uses 0 credits.
| Tool | REST operation | credits |
|---|---|---|
search_products | Search Products | 10 |
submit_search_job | Async Bulk Search | 10 |
get_product_snapshot | Product Snapshot | 1 |
batch_get_product_snapshots | Product Snapshot Batch | 1 |
list_product_cluster_members | Product Clusters | 1 |
get_price_history | Price History | 1 |
find_catalog_products | Catalogue lookup | 0 |
list_schedulesget_schedulecreate_schedulesupdate_scheduleremove_schedule | Schedules | 0 |
list_supported_countries | No priced operation behind it | 0 |
get_search_joblist_search_job_resultscancel_search_job | Job management; the items were reserved at submit | 0 |
A batch reserves before it reads, so your account must cover every item it asks for, not only the ones that exist. Retained product reads do not call a scraper; a cold search can take several minutes while PricesAPI discovers products.
This MCP Beta currently exposes 16 tools.
Migrating from the old API
Only for integrations built before the product API was consolidated into GET /api/v1/products/search. Five breaking changes below, plus one for Price History.
Action required: callers of the old /offers endpoint
GET /api/v1/products/:id/offers returns 410 ENDPOINT_GONE. Read the inline offers[] from /products/search. Email support.
Removed endpoints
| Old endpoint | Status | Replacement |
|---|---|---|
| GET /products/:id/offers | 410 ENDPOINT_GONE | Offers are inline on each /products/search candidate. |
| GET /products/lookup | Removed | Use /products/search. |
| GET /products/lookup/bulk | Removed | Use /products/search (one query per call). |
| GET /search?q= | Superseded | Use /products/search. |
| GET /shopping/search | Superseded | Use /products/search. |
Breaking changes
| What changed | What you do |
|---|---|
| The two-step flow is gone. | One /products/search call returns candidates with offers[] and offerCount inline. |
limit max 10 → your plan's maximum. | Above your plan's maximum is served at that maximum, with meta.limit_capped and meta.limit_max. |
| Per-offer fields updated. | Read product_title, stock_status, delivery_info beside seller, seller_url, price, currency, url. shipping is removed (always 0, never a real cost); condition is on the product as condition_label; rating and reviews stay on the candidate. |
| Latency is synchronous. | Use a generous read timeout — how long a call takes. |
503 SCRAPER_UNAVAILABLE / SCRAPER_BUSY / SCRAPER_CIRCUIT_OPEN. | Sleep for Retry-After, then retry — error codes. |
Replacing the /offers call
One call, offers inline, auth on the Authorization header:
// NEW — one call, offers inline. limit's maximum is your plan's (Personal 3,
// Developer 10, Business 60). Allow a generous cold-call timeout.
const response = await fetch(
`${BASE_URL}/products/search?q=${encodeURIComponent(query)}&market=us&limit=3`,
{ headers: { Authorization: `Bearer ${API_KEY}` }, signal: AbortSignal.timeout(95_000) }
);
const body = await response.json();
if (!response.ok) {
const retryAfter = response.headers.get('Retry-After');
throw new Error(`${body.error?.code ?? response.status}: ${body.error?.message ?? 'Request failed'}${retryAfter ? '; Retry-After: ' + retryAfter : ''}`);
}
const { data, meta } = body;
const top = data.products[0];
// No 2nd request — offers are already here, with stock + delivery inline.
for (const offer of top?.offers ?? []) {
console.log(offer.seller, offer.price, offer.currency, offer.stock_status);
}
console.log('cache:', meta.cache_source);Price History: observed offers replace day and month rollups
Action required: callers of GET /api/v1/products/history
A breaking change to a released Public Beta route; no legacy shape. group_by returns 400 INVALID_PARAMETER, checked before every other validation, so a caller that still sends it fails every call. Everything the rollups carried is derivable from offers[]; recipe below. Email support.
Old field → new field
| Old | New |
|---|---|
| group_by=day|month | Removed. Returns 400 INVALID_PARAMETER. Aggregate offers[] in your application. |
| prices[] | offers[] — one record per observed offer, not per period. |
| prices[].date / .month | offers[].recorded_at, a UTC instant. Its first 10 characters are the UTC date. |
| range | window. requested_days → days_requested; observed_days → days_with_offers (the billing key); observation_count → times_with_offers; offer_count unchanged. missing_days is removed: it equals days_requested minus days_with_offers. |
| range.available_from / .available_to | window.first_recorded_at / window.last_recorded_at — instants now, not dates, and null when nothing was recorded. |
| summary | Removed entirely. first_observed_price, latest_observed_price, lowest_observed_price, highest_observed_price, change and change_percent are all derivable from offers[]. |
| PAGINATION_NOT_SUPPORTED | Gone — Price History is paginated now. It takes its own limit (1–1,000, default 250) and cursor, and returns page.next_cursor on the response. |
| offers[].merchant / .merchant_domain | offers[].seller / offers[].seller_domain — the word seller_id and Search already use. |
| offers[].condition | Removed. Every condition is still counted and returned. |
| offers[].shipping | Removed. offers[].price excludes delivery. Use delivery_info for the seller's delivery text, not a numeric delivery cost. Null does not mean free delivery. |
| — | New on every offer: seller_id (group a per-retailer series on it), plus nullable seller_product_url, product_title, stock_status, delivery_info. product_title, stock_status and delivery_info are null at or before 2025-06-30T23:59:59.999Z; from 2025-07-01 they carry the seller’s own wording when available. |
Your numbers will not match exactly, and that is expected. The rollups counted condition = 'new' only; offers[] returns every condition, so window.days_with_offers and the first and last instants can be larger. condition is no longer published, so the old figures cannot be reproduced.
Build a daily summary client-side
Not a naive min/max: one closing price per seller (its last observation that day, lowest price if two tied on the instant), then low, median and high across sellers. This uses the old aggregation method, but cannot reproduce its new-condition-only results. Load all pages for one product, market and window before summarising. Each response has one currency; do not combine products or currencies into the same summary.
// Summarise one product/market/window after loading every page.
// The rollups counted condition === 'new' only. History publishes EVERY
// condition and no longer publishes the field, so results can differ from
// the old rollups. Keep products and currencies separate.
const median = (values) => {
const sorted = [...values].sort((a, b) => a - b);
const middle = Math.floor(sorted.length / 2);
return sorted.length % 2 === 1
? sorted[middle]
: (sorted[middle - 1] + sorted[middle]) / 2;
};
function dailyRollup(offers) {
const days = new Map();
for (const offer of offers) {
const date = offer.recorded_at.slice(0, 10); // UTC date
if (!days.has(date)) days.set(date, []);
days.get(date).push(offer);
}
return [...days.keys()].sort().map((date) => {
const rows = days.get(date);
// One "close" per seller: its LAST observation that day, lowest price if tied.
const closes = [...new Set(rows.map((r) => r.seller_id))].map((sellerId) => {
const mine = rows.filter((r) => r.seller_id === sellerId);
const latest = mine.reduce((a, b) => (b.recorded_at > a.recorded_at ? b : a));
return Math.min(
...mine.filter((r) => r.recorded_at === latest.recorded_at).map((r) => r.price),
);
});
return {
date,
lowest_price: Math.min(...closes),
median_merchant_price: median(closes),
highest_price: Math.max(...closes),
merchant_count: closes.length,
offer_count: rows.length,
};
});
}For months, group on recorded_at.slice(0, 7). The monthly row mixed two series: median_merchant_price was the median of the daily medians of seller closing prices, while lowest_price and highest_price came from all observations that month. Use the daily medians above for the monthly median, but keep the raw offers for monthly extremes; daily closing-price extremes can miss intraday prices. The removed condition filter means this method still cannot recreate the old values exactly.
Paging the whole window
limit accepts 1 to 1,000. Walk page.next_cursor until it is null, repeating every other parameter unchanged: The first request reserves credits for the requested days; issued continuation pages cost no additional credits. This seven-day example reserves up to seven credits. Replace the example ID and dates first.
// NEW — no group_by. Page until next_cursor is null.
async function allOffers(params) {
const offers = [];
let cursor = null;
let window = null;
do {
const url = new URL('/api/v1/products/history', BASE_URL);
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
if (cursor) url.searchParams.set('cursor', cursor);
const res = await fetch(url, { headers: { Authorization: `Bearer ${API_KEY}` } });
if (res.status === 409) {
throw new Error(
'CURSOR_EXPIRED — stop; do not restart automatically. ' +
'Older pagination formats may expire before their normal 24-hour lifetime. ' +
'Starting again is optional and is a new billable first-page read with a new reservation. ' +
'Keep partial results visibly incomplete. If you explicitly choose to restart, omit the cursor ' +
'and discard offers from the previous walk; do not mix the two walks.'
);
}
const body = await res.json();
if (!res.ok) {
throw new Error((body.error?.code || res.status) + ': ' +
(body.error?.message || 'History request failed'));
}
window ??= body.window; // pinned on page 1, echoed unchanged after
offers.push(...body.offers);
cursor = body.page.next_cursor;
} while (cursor);
return { window, offers };
}
const { window, offers } = await allOffers({
id: '123456', id_type: 'pricesapi', market: 'gb',
from: '2026-09-14', to: '2026-09-20', limit: '1000',
});
console.log(window.offer_count, offers.length);
console.log(dailyRollup(offers));A cursor expires after 24 hours, if the ID resolves to a different product, or if data-visibility rules change. These cases return 409 CURSOR_EXPIRED. Older pagination formats may expire before their normal 24-hour lifetime. Stop; do not restart automatically. Starting again is optional and is a new billable first-page read with a new reservation. Keep partial results visibly incomplete. If you explicitly choose to restart, start without a cursor and discard the previous walk's offers; do not mix the two walks. Late-arriving or removed records can change the total rows received without expiring the cursor. Coverage stays pinned to the first page, so receiving all pages is not an immutable export. A window too dense to read inside its budget returns 400 INVALID_DATE_RANGE asking for a narrower range, which can be far less than the 1,500-day maximum.