Products (v2)
POST /v2/products. Legacy JSON-body product search.
POST https://api.pricehero.ai/v2/products
Legacy. Prefer v3 catalog search for new integrations.
Transitional endpoint: same catalog logic as v1, but parameters are sent as a JSON body instead of the query string.
Request
curl -X POST https://api.pricehero.ai/v2/products \
-H "Authorization: Bearer ph_test_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"search": "Louis Vuitton Speedy",
"category": "handbags,watches",
"attribute": "product",
"model": "model_abc,model_def",
"style": "style_xyz",
"page": 0,
"page_size": 20
}'Body parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
search | string | ; | Free-text search |
attribute | string | product | Which taxonomy node rows to return: product, model, style, brand, category, family, case, or dial. Drives search filtering. Invalid values fall back to product. |
page | number | 0 | Page index (0-based) |
page_size | number | 20 | Page size (max 100) |
sort | string | popularity_desc | popularity_desc, popularity_asc, name_asc, name_desc. Listings only, a search is ordered by relevance. |
tags | string | ; | Comma-separated tag ids, OR: the row must carry at least one. Unknown tags return 400. |
exclude_tags | string | ; | Comma-separated tag ids to drop. A row carrying any of them is excluded. |
exclude_<field> | string | ; | Same as any facet key, negated. "exclude_model": "model_a,model_b" drops those models. |
Optional attribute facets
Use top-level JSON properties to narrow results. Catalog attributes (brand, model, style, family, case, dial, …) follow the same OR within a field, AND across fields rules as v3 catalog search.
category. Comma-separated category names or IDs that scope the search. Omit to use the defaulthandbags,watches.
Other attribute keys depend on the category (e.g. handbags: brand, model, style, family; watches often add case, dial). Values are comma-separated entity ids where multiple ids mean OR within that field.
{
"category": "handbags,watches",
"brand": "brand_R7xVn3LpQ2sK8mTzH5YwE9aU4FbG1JdC6uN",
"model": "model_abc,model_def",
"style": "style_xyz",
"case": "case_aaa,case_bbb",
"dial": "dial_xyz"
}Your API key must allow v2 (or v1). A 403 is returned if the key cannot access v2.
Response
Same envelope and product shape as v1: success, data, search, results, total_results, page, total_pages, stats. See Products (v1) for a representative JSON example.
Credits: the catalog credit per successful billable response, plus the pricing module only on product-scope requests (attribute=product). Non-product scopes (model, brand, style family, case, style, …) return a max market estimate but are currently billed the catalog credit only. See Credits.
Filtering by tags
A tag is curated knowledge about an item that is not in its text; material:exotic is the
first one. It exists because no text rule can decide it: "Crocodile Embossed Leather" is
calfskin and "Python Print" is canvas, so searching for "crocodile" is not the same question as
"is this a CITES-regulated skin".
Tags are a closed vocabulary. Fetch a category to see what it offers, in available_tags;
an unrecognised tag returns 400 rather than silently matching nothing. That matters most when
excluding: a mistyped exclusion that quietly matched nothing would return more rows than you
asked for.
"available_tags": [
{ "id": "material:exotic", "name": "Exotic material (CITES)", "group": "material" }
]A flat list, ordered by group, then by display order within a group. group repeats the
namespace already in the id, so it is there for building sections without parsing ids: group by it
when you want headings, ignore it when you just need to know whether a tag is offered.
Need a tag that does not exist? Ask for it: write to mailys@pricehero.ai with the filtering decision it would drive. The vocabulary stays small on purpose and each request is assessed, on two questions: is it knowledge a text search cannot decide, and can it be applied consistently to the whole catalogue? A tag that is only sometimes right is worse than no tag, because a filter that quietly misses rows looks exactly like one that works.
Tags inherit down and aggregate up. A product carries its style's tags, and a model carries a tag only when every one of its styles does. So excluding exotic material hides the models that are only ever exotic, while keeping a Birkin that also comes in Togo, whose exotic styles are then filtered one step later.
That rule is built for exclusion, so tags= is narrower than it may look when listing models:
with attribute=model it returns models that are entirely exotic, not every model that offers an
exotic style. For the second question list styles instead (attribute=style&tags=material:exotic);
a style either carries the tag or it does not. Products are unaffected: a product is one style in
different sizes or colours, so it carries its style's tags directly.
{
"category": "handbags",
"attribute": "product",
"brand": "brand_9YJxjSHS5UK1jFmA2dThev",
"exclude_tags": "material:exotic",
"sort": "name_asc"
}Send "attribute": "category" to see a category's available_tags.
Sorting
sort follows a <field>_<direction> grammar.
| value | meaning |
|---|---|
popularity_desc | default: most popular first |
popularity_asc | least popular first |
name_asc | A→Z |
name_desc | Z→A |
name is the listed row's own name: model name when listing models, style name when listing
styles, brand name when listing brands. Products have no single name, so theirs is composed
brand + model + style; the category is deliberately excluded, or every handbag would sort
under "Handbags".
Sorting applies to listings, not searches. A request with search / query is ordered by
relevance regardless, and the response reports sort_applied so you can tell which ordering you
actually got rather than assuming your request was honoured.
Sorting is case-insensitive and accent-folding, so Épi sorts with Epi and apple with
Apple. Nothing else is normalised: a leading article counts (The Row sorts under T), and a
name starting with punctuation or a digit sorts before the letters.
Market estimates and condition grades
market_estimates gives an estimated resale value per condition grade, for one region and one
marketplace source. There is one object per (source, region) pair; google_shopping, ebay,
the_realreal, fashionphile, vestiaire_collective and others, so a variant typically carries
several.
| grade | condition | meaning |
|---|---|---|
A | Excellent | Minimal to no visible wear. Looks nearly new, no significant flaws. |
B | Great | Light wear, still very good. Minor marks, slight fading or light scratches. |
C | Good | Moderate wear. Visible marks or fading, fully functional and presentable. |
D | Fair | Significant wear. Noticeable marks, stains or damage, but still usable. |
Values are in currency (lowercase ISO, e.g. usd) and descend from A to D. They use the same grade
scale the vision endpoints return when assessing an item's condition, so an estimate can be read
directly against an identified item's grade. Any grade may be null when there is not enough
signal for that condition tier.
region is one of NA, EU-UK, EU-IE.
Pricing data and historical estimates
pricing_data and historical_estimates are both arrays of { "region": "...", "url": "..." } pointing at per-variant CSV files, one entry
per region.
pricing_data: the individual marketplace listings the estimate is derived from. Columns:link,variant_name,vendor,price,currency,date. This is the evidence behind a number, not the number itself.historical_estimates: the same variant's estimate history for that region, so you can show how a value has moved over time. Displaying it is permitted; retaining it is not — it may not power stored history, monitoring, or change alerts. See Usage Policy.
These URLs expire after one hour. The files live in a private bucket; what you receive is a time-limited signed link generated for your request, not a permanent address.
- Fetch them promptly, and do not store or share the URL: it will stop working, and a stored one is a credential with an hour of life left in it.
- To read a file again later, request the resource again and use the fresh URL. The raw address
without a signature returns
403. - Signing is fail-closed: if a URL cannot be signed it is omitted from the array rather than returned unsigned. So the array can be shorter than the number of regions, or empty.