API Referencev3

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

ParameterTypeDefaultDescription
querystring;Free-text search
category_idstring;Optional category slug or id to scope the search and validate taxonomy_node for that category’s allowed entity types.
filtersarray;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_nodestringproductDocument 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.
pageinteger0Page index (0-based)
page_sizeinteger20Page size (server-capped, default max 100)
sortstringpopularity_descpopularity_desc, popularity_asc, name_asc, name_desc. Listings only. The response reports sort_applied.
includesstring[]["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’s ids list appears on the product’s attributes for that field (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 for brand and one for model means the product must match both a chosen brand and a chosen model (ids must match that attribute’s entity type on the product).
  • field uses the same strings as product attributes (brand, model, family, style, etc.). Each id in ids must 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 field tags.

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.

valuemeaning
popularity_descdefault: most popular first
popularity_ascleast popular first
name_ascA→Z
name_descZ→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.

gradeconditionmeaning
AExcellentMinimal to no visible wear. Looks nearly new, no significant flaws.
BGreatLight wear, still very good. Minor marks, slight fading or light scratches.
CGoodModerate wear. Visible marks or fading, fully functional and presentable.
DFairSignificant 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.

Search Catalog — Price Hero Docs