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/v1

Also 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.

javascript
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.

OperationCreditsCharged perWhat changes the charge
Search Products10per response with at least one merchant offerPer 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 Search10per item submittedPer 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 Snapshot1per productPer 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 Batch1per product returnedPer 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 Clusters1per page returnedPer 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 History1per day with recorded offersOne 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 lookup0per lookupOptional discovery of PricesAPI IDs by product or seller name. Free, so that finding an ID never costs a Search.
Schedules0per callCreating, 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.

PlanPriceCredits includedWhich buysRate limitSchedule slots
Personal$03,000 credits, one-time300 Searches or 3,000 single-product Snapshots6 req/minUp to 25 Schedule slots, sharedStart free
DeveloperPopular$49/month30,000 credits / month3,000 Searches or 30,000 single-product Snapshots · 1.6¢ a Search20 req/min300 Schedule slotsCreate account to subscribe
Business$199/month180,000 credits / month18,000 Searches or 180,000 single-product Snapshots · 1.1¢ a Search40 req/min1,500 Schedule slotsCreate account to subscribe
EnterpriseCustomVolume set with youPriced on your volume60 req/minSchedule volume set with youContact 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.

OperationRequests per minute
Search Products — GET /api/v1/products/search6 Personal, 20 Developer, 40 Business, 60 Enterprise
Product Catalog Lookup — GET /api/v1/products/catalog30
Product Snapshot — GET /api/v1/products60
Price History — GET /api/v1/products/history60
Async Bulk Search management — submit, status, results and cancel30, 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.

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

bash
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

bash
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

QueryValue
qProduct name, 2–100 characters.
sellerSeller name, 2–100 characters. Instead of q, or with it.
marketRequired, never inferred. Lower-case two-letter code.
limitAn integer from 1 to 20 and defaults to 10. 01, 1.0, +1 and empty are 400 INVALID_LIMIT, not reinterpreted.
cursorOpaque; 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.

bash
curl "https://api.pricesapi.io/api/v1/products/catalog?q=anko+7.5l+air+fryer&market=au" \
  -H "Authorization: Bearer your_api_key"
json
{
  "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.

bash
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.

OutputMeaning
retained.offer_countOffers held, not lookups.
retained.days_with_offersDistinct days those offers fall on.
retained.first_date_with_offers, retained.last_date_with_offersEarliest 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 offersAll four retained fields zero or null together, both arrays empty. A real answer, not an error.
market_catalog.productsTitled products counted for this market. 0 is counted and empty; null is not counted, never zero.
market_catalog.products_at_mostAlways a number: equals products when counted, otherwise a proven ceiling.
market_catalog.catalog_products, measured_onThe 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.

bash
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.

QueryValue
idRequired. PricesAPI or Google catalog ID.
id_typeRequired. pricesapi or catalog. Never inferred.
marketRequired. Lower-case two-letter code.
offers_limit0–60, default 60. 0 returns an empty offers array. Outside: 400 INVALID_OFFERS_LIMIT, refused not clamped.
seller_domainsRepeatable, 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.
bash
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 } }.

SearchSnapshot
success / data / meta wrapperNo wrapper — product, observation, offers, offers_page at the top level
pidproduct.id, a string not an integer
imageproduct.image_url
offerCountoffers_page.returned
product_titletitle, per offer
stock_statusavailability, 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 appUse the response this way
Item priceoffers[].price excludes delivery. There is no numeric shipping, tax breakdown or checkout total. Do not label it “delivered price”.
Currencycurrency 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 ageFilter 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.
DeliveryDisplay 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 conditionCheck 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 coverageIf 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

BehaviourDetail
OrderAvailability rank — in_stock, preorder, backorder, unknown, out_of_stock — then price ascending inside each class. Presentation order, not a search rank.
Truncationoffers_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 matchA known product returns 200 with empty offers.
Unknown ID404. Rerun Product Search with the original query or retailer URL, pick a candidate, and retain its PricesAPI ID with the market.
Catalog vs cluster IDid_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.
bash
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.

RuleDetail
Reserve, then refundThe 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.
DuplicatesCharged once each; send an ID twice, get it twice.
Rate limit100 requested items per minute per account, shared across its API keys. A 25-item call counts as 25 items.
items1 to 100, else 400 INVALID_BATCH. Never truncated.
Per-item offers_limitA JSON number, not a string: "3" is a 400 INVALID_OFFERS_LIMIT carrying its input_index.
ResponseBare: results and summary at the top level, each found entry adding input_index and status.
bash
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.

bash
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.

bash
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.

json
{
  "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

ParameterAcceptsOut of range
idRequired. A PricesAPI or Google catalog ID.n/a
id_typeRequired. 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
marketRequired. Lower-case market code.n/a
from, toInclusive UTC dates spanning at most 1,500 days. to not later than today, UTC.400 INVALID_DATE_RANGE
limit1 to 1,000. Default 250.400 INVALID_LIMIT naming the range, never a shortened page.
cursorOpaque 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_cursor together 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.

FieldWhat it means
windowThe whole requested window, not the page you hold. Pinned on page 1; continuations echo it unchanged.
window.days_with_offersDays inside the window carrying at least one offer. The billing key.
window.offer_countThe 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_idGroup 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[].priceExcludes delivery.
offers[].delivery_infoThe seller's delivery text, not a delivery cost. Null means no delivery information is available, not free delivery.
offers[].seller_product_urlThe offer page this price was read from, not the seller's home page.
offers[].product_title, .stock_status, .delivery_infoThe seller's own words. Null at or before 2025-06-30T23:59:59.999Z.
Order and nullsOffers 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.

  1. Compare the same item. Check the seller listing for model, pack size and condition. Keep each seller_id and seller_product_url separate; 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.
  2. 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.
  3. Show when offers were recorded. recorded_at is 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

LimitWhat happens
Two requests in flight per accountA 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 windowA 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.

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 its reason. 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.

FieldMeaning
activity.stateawaiting_first_discovery, discovery_complete, or discovery_issue.
activity.attempted_atUTC instant of the latest attempt; null before the first.
activity.discovered_product_countProducts that attempt discovered. Zero is valid; null means unavailable.
json
{
  "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.

OperationRequest
List schedulesGET /api/v1/schedules
Add schedulesPOST /api/v1/schedules
Search historyGET /api/v1/schedules/history?days=7
Read statusGET /api/v1/schedules/:id
Pause or resumePATCH /api/v1/schedules/:id
Remove scheduleDELETE /api/v1/schedules/:id
Read Schedule settingsGET /api/v1/schedules/settings
Update Schedule settingsPATCH /api/v1/schedules/settings
curl "https://api.pricesapi.io/api/v1/schedules" \
  -H "Authorization: Bearer your_api_key"

Discovery analytics

OnParameterSeries returned
GET /api/v1/schedulesactivity_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/historyIts 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

FieldWhat it means
originHow it was created: bulk if the create body carried two or more terms, manual if exactly one. Nothing to do with Async Bulk Search.
statusLatest discovery activity plus whether the schedule is paused. recovering means the last attempt ended in an issue; the next runs as usual.
rate_limitedPer 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.truncatedOn 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_reachedPersonal 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.

🇺🇸
US
United States
🇦🇺
AU
Australia
🇬🇧
GB
United Kingdom
🇨🇦
CA
Canada
🇮🇪
IE
Ireland
🇳🇿
NZ
New Zealand
🇩🇪
DE
Germany
🇫🇷
FR
France
🇮🇹
IT
Italy
🇪🇸
ES
Spain
🇵🇹
PT
Portugal
🇳🇱
NL
Netherlands
🇧🇪
BE
Belgium
🇦🇹
AT
Austria
🇨🇭
CH
Switzerland
🇸🇪
SE
Sweden
🇳🇴
NO
Norway
🇩🇰
DK
Denmark
🇫🇮
FI
Finland
🇵🇱
PL
Poland
🇨🇿
CZ
Czechia
🇸🇰
SK
Slovakia
🇭🇺
HU
Hungary
🇷🇴
RO
Romania
🇬🇷
GR
Greece
🇺🇦
UA
Ukraine
🇹🇷
TR
Türkiye
🇮🇱
IL
Israel
🇦🇪
AE
UAE
🇸🇦
SA
Saudi Arabia
🇿🇦
ZA
South Africa
🇮🇳
IN
India
🇯🇵
JP
Japan
🇰🇷
KR
South Korea
🇹🇼
TW
Taiwan
🇭🇰
HK
Hong Kong
🇸🇬
SG
Singapore
🇲🇾
MY
Malaysia
🇹🇭
TH
Thailand
🇮🇩
ID
Indonesia
🇵🇭
PH
Philippines
🇻🇳
VN
Vietnam
🇲🇽
MX
Mexico
🇧🇷
BR
Brazil
🇦🇷
AR
Argentina
🇨🇱
CL
Chile
🇨🇴
CO
Colombia

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 95

Empty 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.

json
{
  "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.

json
{
  "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

CodeErrorDescription
401MISSING_API_KEYAPI key not provided in request
401INVALID_API_KEY_FORMATAPI key doesn't start with pricesapi_ prefix
401INVALID_API_KEYAPI key not found in database
403SUBSCRIPTION_CANCELLEDYour subscription has been cancelled
403CREDITS_EXCEEDEDAPI 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.
403MONTHLY_LIMIT_EXCEEDEDMonthly credit cap reached during the atomic usage check
429RATE_LIMIT_EXCEEDEDA 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

CodeErrorDescription
400MISSING_QUERYSearch query parameter 'q' is required
400INVALID_LIMITlimit 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.
400INVALID_OFFERS_LIMIToffers_limit outside 1–60 on Search, or 0–60 on Product Snapshot, or not a plain integer. The message names the range.
400INVALID_MARKETUnsupported market code; the body returns the supported list. Use gb, not uk — Markets we cover.
400INVALID_MARKETmarket was not supplied. There is no default.
400INVALID_PARAMETERAn 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.
404NOT_FOUNDRequested endpoint does not exist
410ENDPOINT_GONEEndpoint permanently removed. /products/:id/offers is gone; /products/search returns offers inline — Migration guide.
400INVALID_PARAMETERA parameter Price History does not accept. The removed group_by always lands here, checked before every other validation — migration guide.
409CURSOR_EXPIREDA 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.

CodeErrorDescription
503SCRAPER_UNAVAILABLEScraper backend temporarily unavailable.
503SCRAPER_BUSYScraper at capacity.
503SCRAPER_CIRCUIT_OPENCircuit 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.

CodeErrorDescription
500SEARCH_FAILEDThe search failed on our side.
500DB_ERRORWe could not read our own store.
500INTERNAL_ERRORAn 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_EXCEEDED or MONTHLY_LIMIT_EXCEEDED; retained reads use 403 INSUFFICIENT_CREDITS. Repeated over-quota Search calls can instead return 429 RATE_LIMIT_EXCEEDED with quota details. Do not retry that refusal on Retry-After alone.
  • 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

json
{
  "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.

ToolWhat it does
search_productsA term or retailer URL in one market. offers_limit caps at 20 here, 60 on REST.
find_catalog_productsA product or seller name → the IDs the other tools need. Start here with a name and no ID; runs no scrape.
get_product_snapshotOne known product and its latest retained offers, by ID and market. Cannot search by name.
batch_get_product_snapshotsUp to 25 exact products (100 on REST), input order kept, misses reported per item.
list_product_cluster_membersPages retained products for one Google product-cluster ID and market. Membership, not ranking.
get_price_historySparse observed prices for one exact product and market; missing periods stay absent.
list_supported_countriesThe canonical market codes.
list_schedules, get_schedule, create_schedules, update_schedule, remove_scheduleRecurring 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_jobDurable one-off batches. One request each; never polls for you.

Connect a client

Keys vary by client; the values do not:

json
{
  "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.

ToolREST operationcredits
search_productsSearch Products10
submit_search_jobAsync Bulk Search10
get_product_snapshotProduct Snapshot1
batch_get_product_snapshotsProduct Snapshot Batch1
list_product_cluster_membersProduct Clusters1
get_price_historyPrice History1
find_catalog_productsCatalogue lookup0
list_schedulesget_schedulecreate_schedulesupdate_scheduleremove_scheduleSchedules0
list_supported_countriesNo priced operation behind it0
get_search_joblist_search_job_resultscancel_search_jobJob management; the items were reserved at submit0

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 endpointStatusReplacement
GET /products/:id/offers410 ENDPOINT_GONEOffers are inline on each /products/search candidate.
GET /products/lookupRemovedUse /products/search.
GET /products/lookup/bulkRemovedUse /products/search (one query per call).
GET /search?q=SupersededUse /products/search.
GET /shopping/searchSupersededUse /products/search.

Breaking changes

What changedWhat 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

OldNew
group_by=day|monthRemoved. Returns 400 INVALID_PARAMETER. Aggregate offers[] in your application.
prices[]offers[] — one record per observed offer, not per period.
prices[].date / .monthoffers[].recorded_at, a UTC instant. Its first 10 characters are the UTC date.
rangewindow. 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_towindow.first_recorded_at / window.last_recorded_at — instants now, not dates, and null when nothing was recorded.
summaryRemoved entirely. first_observed_price, latest_observed_price, lowest_observed_price, highest_observed_price, change and change_percent are all derivable from offers[].
PAGINATION_NOT_SUPPORTEDGone — 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_domainoffers[].seller / offers[].seller_domain — the word seller_id and Search already use.
offers[].conditionRemoved. Every condition is still counted and returned.
offers[].shippingRemoved. 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.