Search Catalog
POST /v3/catalog/query. Search the Price Hero catalog (products and taxonomy entities).
POST https://api.pricehero.ai/v3/catalog/query
Legacy path: POST /api/v3/catalog/search (same behavior).
Search the catalog by text query with optional facet filters. You can search products (default) or a specific entity type (e.g. model, style, dial) using taxonomy_node; use category_id to scope which entity types are valid for that category (see the parameters table).
Request
curl -X POST https://api.pricehero.ai/v3/catalog/query \
-H "Authorization: Bearer ph_test_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"query": "Louis Vuitton Speedy",
"taxonomy_node": "product",
"page": 0,
"page_size": 20,
"includes": ["taxonomy", "pricing"]
}'Body parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
query | string | ; | Free-text search |
category_id | string | ; | Optional category slug or id to scope the search and validate taxonomy_node for that category’s allowed entity types. |
filters | array | ; | Facet filters: each item is { "field": "<attribute field>", "ids": ["id1", …] }. OR among ids for the same field; AND between different fields. Same catalog field / id strings as product attributes. Unknown or inapplicable facets yield no rows, not an error. |
taxonomy_node | string | product | Document kind to search: product, brand, model, style, family, case, dial, or category. If you pass a category_id filter, only types allowed for that category are accepted; otherwise the API returns 400 and may include allowed_taxonomy_nodes. |
page | integer | 0 | Page index (0-based) |
page_size | integer | 20 | Page size (server-capped, default max 100) |
sort | string | popularity_desc | popularity_desc, popularity_asc, name_asc, name_desc. Listings only. The response reports sort_applied. |
includes | string[] | ["taxonomy"] | taxonomy, pricing only (catalog is rejected with 400) |
filters (facet narrowing)
Optional array of objects: { "field": "<attribute field>", "ids": ["id1", "id2", …] }.
- OR (same
field): a product matches if any id in that object’sidslist appears on the product’sattributesfor thatfield(e.g. several brands → match if the product is any of those brands). - AND (different
fields): each object in the array must be satisfied. Example: one object forbrandand one formodelmeans the product must match both a chosen brand and a chosen model (ids must match that attribute’s entity type on the product). fielduses the same strings as product attributes (brand,model,family,style, etc.). Each id inidsmust appear on the product’s attributes for that field.- If a facet does not apply to the chosen category, you may get zero results (not an error).
Each object is { "field", "include", "exclude?" }:
include: values to match, OR within the list.exclude: values to drop. A row carrying any of them is excluded.field: a taxonomy field (brand,model,style, …) or the virtual fieldtags.
OR inside an object, AND across objects. One object listing several values matches any of them; repeating the field requires all of them:
[{ "field": "brand", "include": ["brand_a", "brand_b"] }]
[{ "field": "tags", "include": ["material:exotic"] }, { "field": "tags", "include": ["edition:limited"] }]The first returns rows from either brand. The second returns only rows carrying both tags.
Note that ANDing a single-valued field is unsatisfiable: a model has one brand, so two brand
objects return nothing.
Splitting an exclude list changes nothing, since exclusion already drops a row carrying any listed
value: exclude: ["a","b"] and two exclude objects return the same rows.
ids is a deprecated alias for include. It still works, but sending both is a 400, and a
response to a request using it carries a Deprecation: true header.
"filters": [
{ "field": "brand", "include": ["brand_abc", "brand_def"] },
{ "field": "tags", "exclude": ["material:exotic"] },
{ "field": "model", "exclude": ["model_xyz"] }
]Filtering versus searching
Use search to match words that appear in an item's text, and filters with
"field": "tags" to filter on curated knowledge about the item that its text does not carry.
"field": "keywords" is not supported and returns a 400. A keyword is by definition a word
already in the text, so matching one is what search does. Unlike a tag it cannot be validated:
a misspelled keywords value would match nothing and read as "there are no exotic items", which
is exactly the answer a vendor must never be given by accident. Every tag value is checked against
the vocabulary, so an unknown tag is a 400 rather than a silent empty result.
Response
Successful responses include includes, resolved_taxonomy (node + level), total_results, page, total_pages, credits_used, and data. level is derived from the category’s attribute hierarchy when a category filter is set, or from the first hit’s category when there are results.
page is 0-based, and total_pages is a count, so the last page is total_pages - 1. With total_results: 180 and page_size: 100 you get total_pages: 2: page 0 returns 100 rows and page 1 returns the remaining 80. Requesting page 2 returns an empty data array, not an error.
Each item has id, name, popularity, images, and thumbnails. attributes (objects with field, id, name) is included only when includes contains taxonomy. The variants array is included only when the row is a product (taxonomy_node === product); each variant includes fields only when includes contains taxonomy. With includes containing pricing, each variant includes a pricing object: market_estimates is null or an array of one object per catalog_market_estimates row for that variant (all regions and sources, e.g. google_shopping and ebay); historical_estimates and pricing_data are each null or an array of { "region": "…", "url": "…" }.
Non-product hits (e.g. a model row) return the display name in name (sourced from the row’s internal entity name when applicable) and do not include a variants field.
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, and it makes include narrower than it may look when you are
listing models: { "field": "tags", "include": ["material:exotic"] } with taxonomy_node: "model"
returns models that are entirely exotic, not every model that offers an exotic style. To ask the
second question, list styles instead. A style either carries the tag or it does not:
{ "taxonomy_node": "style", "filters": [{ "field": "tags", "include": ["material:exotic"] }] }Products are unaffected by the distinction: a product is one style in different sizes or colours, so it carries its style's tags directly.
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, for tracking how a value has moved over time.
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.
Credits
See Credits. You are charged only when this response includes at least one row in data (the results count for that response is greater than zero). The taxonomy include is billed as the catalog credit; adding pricing adds the pricing module credits only on product-scope requests (taxonomy_node === "product") when the request is billable. Any non-product scope (model, brand, style family, case, style, …) returns a max market estimate but is currently charged the catalog credit only.