Custom fields: teach Trooply your catalog's vocabulary.
Every shop has fields the built-in schema doesn't cover. Apparel needs material and fit. Electronics need screen_size and refresh_rate. Beauty needs spf and ingredients. Rather than shoving these into metadata and hoping, declare them.
Why declare fields at all?
Technically, nothing stops you from indexing products with arbitrary metadata keys — they're stored and returned. What declaration buys you is three things Trooply can't do without it:
- Typed filtering. Once a field is declared,
/v1/searchaccepts it infilterswith exact, list-any-of, and numeric-range semantics. Undeclared keys are ignored by the filter layer, because we don't know whether to treat"42"as a string or a number. - Auto-generated facets.
GET /v1/products/facetsreads your declarations and emits a facet block per field with the unique values (for strings) or min/max (for numerics) the catalog actually contains. That's the backing data for your storefront's filter sidebar — no extra code. - A portal UI that reflects your taxonomy. Facets the merchant didn't declare don't render in the portal. Noise gets filtered at the boundary.
Declaring a field
Five-second call. The key is what you'll use in both metadata on indexed products and in the filters dict on search.
POST /v1/custom-fields
{
"key": "material",
"label": "Material",
"type": "string",
"filterable": true,
"facet": true
}
Three types are supported:
string— free text. Exact match by default, or a list of acceptable values.number— integer or float. Supportsgte/lte/gt/ltrange filters.boolean— true/false. Handy forin_stock,is_organic,is_new.
Reserved keys
A handful of field keys are reserved for built-in semantics: name, price, image_url, category, vendor, tags, badges, in_stock, stock_quantity, plus anything starting with _ (the underscore prefix is for internal breakdown fields). The API will 422 if you try to redeclare one of them — choose a different key.
Indexing values
Once declared, send the field in the product's metadata on index (or bulk index). That's it — no separate API:
POST /v1/products
{
"product_id": "SKU-4821",
"image_url": "https://cdn.shop.com/bags/marla.jpg",
"name": "Marla Tote",
"metadata": {
"category": "Handbags",
"price": 189,
"material": "vegan leather",
"fit": "oversized",
"screen_size": null
}
}
Null values are skipped at index time. A field can be present on some products and absent on others — the facet API just won't list that product under that facet's value counts.
Filtering on search
The filters dict on /v1/search/text, /v1/search/url, and /v1/search/fusion is a flat {field: value} mapping. The value shape dictates the filter semantics:
| Value shape | Means | Example |
|---|---|---|
| string / number / bool | Exact match | {"category": "Handbags"} |
| list | Any-of — at least one value must match | {"material": ["leather","vegan leather"]} |
{"gte": x, "lte": y} | Numeric range, each bound optional | {"price": {"gte": 50, "lte": 200}} |
You can mix shapes in one call:
POST /v1/search/text
{
"query": "tote",
"limit": 20,
"filters": {
"category": "Handbags",
"material": ["leather", "vegan leather"],
"price": {"gte": 50, "lte": 250},
"in_stock": true
}
}
Filtering happens before the vector search, not after. The candidate set is narrowed in Qdrant using the filter, and then we run CLIP similarity only on that subset. That's important for two reasons — it's faster at scale, and it prevents the classic bug where a 10-result search returns zero hits because the top-K organic candidates all happened to be out-of-stock.
A field you haven't declared is silently ignored by the filter layer. That's intentional — a merchant-supplied filter from a storefront form shouldn't crash the search call if the server doesn't know the key yet. But it also means typos go unnoticed. Tools that interact with the filter API should render the facet list from /v1/products/facets, not hard-code field names.
Facets: the free filter UI
GET /v1/products/facets returns one block per declared field with facet: true:
{
"facets": [
{
"key": "material", "label": "Material", "type": "string",
"values": [
{"value": "leather", "count": 128},
{"value": "vegan leather", "count": 57},
{"value": "canvas", "count": 22}
]
},
{
"key": "price", "label": "Price", "type": "number",
"min": 12, "max": 489
}
]
}
Render the string facets as check-lists, the number facets as range sliders, and pass the user's selections back as the filters dict. You get a functional faceted search experience without writing a filter backend.
Patterns that actually work
- Keep declarations stable. Rename a field and you have to re-index every product with the new key. If a field is wrong, delete-and-recreate costs less than live migration.
- Don't declare single-use fields. If a field applies to one SKU, it's not a facet, it's a product-specific tag. Leave it in
metadataundeclared. - Match your storefront vocabulary. If your PDPs say "Fabric", make the field
label"Fabric" — the facet API returns the label for the UI to use.
Up next
Once you can filter by whatever attribute matters, the next lever is communication: telling shoppers about a campaign, a shipping cutoff, a flash sale — inside the search result set itself. That's promo banners.