Search Products
POST https://api.firmly.work/api/v1/discovery/search
Search for products across merchant catalogs using keyword-based queries. Use filters to narrow results by domain, price, availability, and variant attributes. See Search Behavior for how queries and filters are matched.
The endpoint accepts both POST and GET. Send filtered searches as POST with query, filters, page_size, and cursor in the JSON body. The GET path reads only query, page_size, and cursor from the query string — it does not accept a filters parameter. The next_page URL returned for pagination is a ready-to-call GET link.
Authentication
This endpoint supports three authentication methods. Any one of them is sufficient; pick the one that matches your integration shape.
-
x-firmly-authorization(string) — Device access token from Browser Session (client-side) or a server-to-server secret (backend). Required for Browser Session and Server-to-Server. -
x-firmly-device-id(string) — Required with Server-to-Server — the device ID of the client this request is on behalf of. Maximum 256 characters; allowed characters area-z,A-Z,0-9,-,_. -
x-firmly-app-id(string) — Required for App ID auth. The application identifier provided by Firmly.
For client-side applications (browser, mobile). The token is the device JWT minted by Browser Session Authentication.
curl -X POST https://api.firmly.work/api/v1/discovery/search \-H "x-firmly-authorization: YOUR_ACCESS_TOKEN" \-H "Content-Type: application/json" \-d '{"query": "shoes"}'
For trusted server-to-server clients that don’t need a per-device session. Pass your App ID in the x-firmly-app-id header. No device token required.
curl -X POST https://api.firmly.work/api/v1/discovery/search \-H "x-firmly-app-id: YOUR_APP_ID" \-H "Content-Type: application/json" \-d '{"query": "shoes"}'
For backend services acting on behalf of devices. See Server-to-Server Authentication.
curl -X POST https://api.firmly.work/api/v1/discovery/search \-H "x-firmly-authorization: YOUR_S2S_SECRET" \-H "x-firmly-device-id: DEVICE_ID" \-H "Content-Type: application/json" \-d '{"query": "shoes"}'
Request Body
-
query(string) — Search keyword(s). Optional iffilters(or acursorfrom a prior page) is provided — at least one ofqueryorfiltersis required on the first page. A request with neither returns400 BadRequest. -
filters(object) — Optional filters to narrow search results.Filter Object
filters.domains(string[]) — Array of merchant domains to search (e.g.,["staging.luma.gift", "merchant2.example"]). When omitted, all merchants are searched.filters.min_price(integer) — Minimum price filter in cents (e.g.,100000for $1,000.00,9999for $99.99). Must be an integer.filters.max_price(integer) — Maximum price filter in cents (e.g.,500000for $5,000.00). Must be an integer.filters.in_stock(boolean) — Filter to only show in-stock products. Set totrueto enable.filters.variants(array) — Array of variant filters. Each filter specifies an option name and allowed values. Available option names and values vary per merchant — read them fromfilter_optionsin the first-page response. See Search Behavior for which options can be filtered on and how values are matched.
Variant Filter Object
filters.variants[].option(string, required) — The variant option name (e.g.,"color","size","material").filters.variants[].values(string[], required) — Array of allowed values for this option (e.g.,["Blue", "Black"]). Matching is case-insensitive whole-value matching —"Blue"also matches a product’s"blue", but it does not match a different value like"Navy".
-
page_size(number, default20) — Number of results per page. Clamped to the range1–100. -
cursor(string) — Opaque pagination cursor. Omit for the first page; on subsequent requests pass the cursor carried in the previous response’snext_pageURL. The cursor also re-applies the first page’s filters, so you don’t resendfilterswhen paginating.
Response
-
products(array) — Array of product objects matching the search criteria. Returns empty array[]when no results found (HTTP 200).Product Object
base_sku(string) — Base SKU identifier for the producttitle(string) — Product titlebrand(string) — Brand name.nullwhen the merchant data has no brand.handle(string) — Product handle/slugdescription(string) — Product descriptionpdp_url(string) — Full URL to the product detail page on the merchant’s sitedomain(string) — Merchant domain (e.g.,merchant.example)domain_name(string) — Merchant display nameprice_range(object) — Price range for the product (accounts for variant pricing). All prices in USD cents.
Price Range Object
min(integer) — Minimum price across all variants in cents (e.g.,249999= $2,499.99 USD). Always an integer, never a string or decimal.max(integer) — Maximum price across all variants in cents. Always an integer, never a string or decimal.
has_available_variants(boolean) —trueif the product has at least one variant currently in stock. Always a boolean (true/false), never a string.images(array) — Array of product images
Image Object
url(string) — URL of the imagetype(string) — Image size variant (e.g.default,large,small,preview)
variants(array) — The product’s purchasable variants. This is where the value you need to add an item to a cart lives — each variant carries its ownadd_to_cart_ref, which is the required request body for Add Line Item. Pick a variant withavailable: true, then pass itsadd_to_cart_refthrough unchanged.
- `variant_option_values` (array) — Available variant options (sizes, colors, etc.). - `tags` (array) — Product tags. - `average_rating` (number) — Aggregated average review rating. `null` when the product has no reviews. - `total_reviews` (number) — Total review count. `null` when the product has no reviews.Variant object
handle(string, required) — Stable variant identifier.sku(string, required) — Stock-keeping unit for this variant. This is what a?variant=URL parameter is matched against.title(string, required) — Full variant title.display_name(string, required) — Short label for the variant (e.g.M / Orange).available(boolean) — Whether this variant is currently purchasable. Filter on this before adding to a cart — an unavailable variant is rejected at add-to-cart.add_to_cart_ref(object, required) — Opaque reference passed straight to Add Line Item. Don’t construct or modify it; echo back exactly what the catalog returned.price(object) — Variant price (Amount:numberin cents,value,currency,symbol).msrp(object) — Manufacturer’s suggested retail price (Amount).variant_option_list(array, required) — The option values that identify this variant, in order (e.g.["M", "Orange"]).variant_attributes(array) — Additional{ key, value }attributes the merchant exposes.images(array) — Variant-specific images.pdp_url(string) — Direct URL to this variant on the merchant’s site.requires_shipping(boolean) — Whether the variant needs physical shipping.
-
page_size(number) — Number of results returned on this page. -
filter_options(object) — Available filter facets for the current query, computed over the gated result pool — the set of matching products remaining after domain-authorization filtering (i.e. only merchants your account is authorized for). Returned on the first page only (absent when paginating with acursor).Filter Options
variant_options(object[]) — Variant facets:[{ option, values: [{ value, count }] }](e.g. Color, Size).domains(object[]) — Domain facets:[{ domain, count }]. There is nodomain_namekey here — unlike the product object, the facet’sdomainholds the merchant display name (e.g.Test Merchant), not the host.price_range(object) — Min/max price across the result pool, in cents:{ min, max }.nullwhen unavailable.in_stock_count(number) — Number of in-stock products in the result pool.total_count(number) — Total products in the gated result pool.
-
time_taken_ms(number) — Time taken to execute the search query in milliseconds -
next_page(string) — Full URL to fetch the next page of results. Use this URL directly with your authorization header.nullwhen there are no more results.
Request Example
curl --request POST \--url 'https://api.firmly.work/api/v1/discovery/search' \--header 'x-firmly-authorization: YOUR_ACCESS_TOKEN' \--header 'Content-Type: application/json' \--data '{"query": "leather sofa"}'
curl --request POST \--url 'https://api.firmly.work/api/v1/discovery/search' \--header 'x-firmly-authorization: YOUR_S2S_SECRET' \--header 'x-firmly-device-id: user-12345' \--header 'Content-Type: application/json' \--data '{"query": "leather sofa"}'
curl --request POST \--url 'https://api.firmly.work/api/v1/discovery/search' \--header 'x-firmly-authorization: YOUR_ACCESS_TOKEN' \--header 'Content-Type: application/json' \--data '{"query": "sofa","filters": {"domains": ["staging.luma.gift"],"min_price": 100000,"max_price": 500000,"in_stock": true,"variants": [{ "option": "color", "values": ["Brown", "Tan"] }]},"page_size": 10}'
curl --request POST \--url 'https://api.firmly.work/api/v1/discovery/search' \--header 'x-firmly-authorization: YOUR_ACCESS_TOKEN' \--header 'Content-Type: application/json' \--data '{"query": "sofa","filters": {"domains": ["merchant1.example", "merchant2.example", "merchant3.example"]},"page_size": 20}'
curl --request POST \--url 'https://api.firmly.work/api/v1/discovery/search' \--header 'x-firmly-authorization: YOUR_ACCESS_TOKEN' \--header 'Content-Type: application/json' \--data '{"query": "leather sofa","cursor": "<cursor_from_previous_response>","page_size": 20}'
Response Example
{"products": [{"base_sku": "12345","title": "Modern Leather Sofa","brand": "Test Merchant","handle": "modern-leather-sofa","description": "A beautiful modern leather sofa perfect for any living room.","pdp_url": "https://staging.luma.gift/products/modern-leather-sofa","domain": "staging.luma.gift","domain_name": "Test Merchant","price_range": {"min": 249999,"max": 319999},"has_available_variants": true,"images": [{"url": "https://cdn.staging.luma.gift/images/modern-leather-sofa.jpg","type": "default"}],"variants": [{"handle": "modern-leather-sofa-brown","sku": "12345-BROWN","title": "Modern Leather Sofa - Brown","display_name": "Brown","available": true,"add_to_cart_ref": { "variant_id": "12345-BROWN" },"price": { "currency": "USD", "value": 2499.99, "number": 249999, "symbol": "$" },"variant_option_list": ["Brown"]}],"variant_option_values": [],"tags": ["leather", "sofa", "modern", "living-room"],"average_rating": 4.6,"total_reviews": 212}],"page_size": 20,"time_taken_ms": 45,"next_page": "https://api.firmly.work/api/v1/discovery/search?query=leather+sofa&cursor=eyJjIjoiLi4uIn0&page_size=20","filter_options": {"variant_options": [{ "option": "color", "values": [{ "value": "Brown", "count": 14 }, { "value": "Tan", "count": 9 }] }],"domains": [{ "domain": "Test Merchant", "count": 23 }],"price_range": { "min": 199999, "max": 519999 },"in_stock_count": 18,"total_count": 23}}
{"products": [...],"page_size": 20,"time_taken_ms": 38,"next_page": null}
{"products": [],"page_size": 20,"time_taken_ms": 12,"next_page": null}
Usage Notes
Example: Search Implementation
// Search with filtersconst searchProducts = async (query, options = {}) => {const body = { query };// Build filters objectconst filters = {};if (options.domains) filters.domains = options.domains;if (options.minPrice) filters.min_price = options.minPrice;if (options.maxPrice) filters.max_price = options.maxPrice;if (options.inStock) filters.in_stock = true;if (options.variants) filters.variants = options.variants;if (Object.keys(filters).length > 0) {body.filters = filters;}if (options.pageSize) body.page_size = options.pageSize;if (options.cursor) body.cursor = options.cursor;const response = await fetch('https://api.firmly.work/api/v1/discovery/search',{method: 'POST',headers: {'x-firmly-authorization': authToken,'Content-Type': 'application/json'},body: JSON.stringify(body)});if (!response.ok) {const error = await response.json();throw new Error(error.error || 'Search failed');}return response.json();};// Usage - prices in cents (100000 = $1,000.00, 500000 = $5,000.00)const results = await searchProducts('leather sofa', {domains: ['staging.luma.gift', 'merchant2.example'],minPrice: 100000,maxPrice: 500000,inStock: true,variants: [{ option: 'color', values: ['Brown', 'Tan'] },{ option: 'size', values: ['Large', 'Sectional'] }],pageSize: 20});// Check if there are results (empty array is valid, not an error)if (results.products.length === 0) {console.log('No products found matching your criteria');} else {console.log(`Found ${results.products.length} products`);// Client-side sorting by price (low to high)const sortedByPrice = results.products.sort((a, b) => a.price_range.min - b.price_range.min);// Display price: convert cents to dollars.// toFixed(2) has no thousands separators, so 249999 -> "$2499.99".// For grouped output ("$2,499.99") use toLocaleString instead.const displayPrice = (cents) => `$${(cents / 100).toFixed(2)}`;console.log(displayPrice(results.products[0].price_range.min)); // e.g., "$2499.99"// Grouped: `$${(cents / 100).toLocaleString('en-US', { minimumFractionDigits: 2 })}` -> "$2,499.99"}// Pagination - use cursor from previous responseif (results.next_page) {// Extract cursor from next_page URL or use it directlyconst nextPageUrl = new URL(results.next_page);const cursor = nextPageUrl.searchParams.get('cursor');const nextResults = await searchProducts('leather sofa', {cursor,pageSize: 20});console.log(`Loaded ${nextResults.products.length} more products`);}
import requestsfrom urllib.parse import urlparse, parse_qsdef search_products(query, *, domains=None, min_price=None, max_price=None,in_stock=False, variants=None, page_size=None, cursor=None):"""Search the discovery index with filters and pagination."""body = {'query': query}# Build filters objectfilters = {}if domains: filters['domains'] = domainsif min_price: filters['min_price'] = min_priceif max_price: filters['max_price'] = max_priceif in_stock: filters['in_stock'] = Trueif variants: filters['variants'] = variantsif filters:body['filters'] = filtersif page_size: body['page_size'] = page_sizeif cursor: body['cursor'] = cursorresponse = requests.post('https://api.firmly.work/api/v1/discovery/search',headers={'x-firmly-authorization': auth_token,'Content-Type': 'application/json',},json=body,)response.raise_for_status()return response.json()# Usage - prices in cents (100000 = $1,000.00, 500000 = $5,000.00)results = search_products('leather sofa',domains=['staging.luma.gift', 'merchant2.example'],min_price=100000,max_price=500000,in_stock=True,variants=[{'option': 'color', 'values': ['Brown', 'Tan']},{'option': 'size', 'values': ['Large', 'Sectional']},],page_size=20,)# Check if there are results (empty array is valid, not an error)if not results['products']:print('No products found matching your criteria')else:print(f"Found {len(results['products'])} products")# Client-side sorting by price (low to high)sorted_by_price = sorted(results['products'], key=lambda p: p['price_range']['min'])# Display price: convert cents to dollars.# :.2f has no thousands separators, so 249999 -> "$2499.99".# For grouped output ("$2,499.99") use :,.2f instead.def display_price(cents):return f"${cents / 100:.2f}"print(display_price(results['products'][0]['price_range']['min'])) # e.g., "$2499.99"# Grouped: f"${cents / 100:,.2f}" -> "$2,499.99"# Pagination - use cursor from previous responseif results.get('next_page'):# Extract cursor from next_page URL or use it directlyparsed = urlparse(results['next_page'])cursor = parse_qs(parsed.query).get('cursor', [None])[0]next_results = search_products('leather sofa', cursor=cursor, page_size=20)print(f"Loaded {len(next_results['products'])} more products")
Search Behavior
How the endpoint matches queries and filters.
Search matches keywords, not intent. Your query is compared with the words the merchant publishes — title, brand, SKU, handle, tags, and category. So running shoes finds results, while something to wear for a marathon usually will not — it helps to send the words you would expect in a product name. Results are ordered by best match; there is no sort parameter, so you may want to re-sort on your side.
A SKU or handle works as a query. These are matched as a whole string rather than split into words, so ABC-123 finds that exact product, not everything containing abc or 123. It works best sent on its own.
In-stock products get a little extra priority. The lift is blended into the relevance score rather than applied on top, so a strong out-of-stock match still outranks a weak in-stock one, and nothing is removed. For a hard cut, use filters.in_stock.
Filters narrow the search. A result has to satisfy both your query and your filters: sofa with color: Brown returns only brown sofas — the filter cannot add a product that does not match sofa, and the query cannot add one that is not brown. Relevance is judged among the products that qualify, so your top result is the best brown sofa rather than the best sofa. Send filters with the request rather than filtering a response yourself, so relevance is judged over the products you actually want. If a filtered search comes back empty, adjust the query before relaxing the filters. A request with filters and no query returns every matching product in a stable order, not ranked by relevance.
Filter values are matched against the merchant’s own. Values are compared literally, with no synonyms or unit conversion, so Navy will not match Blue. Matching is case-insensitive and whole-value: blue matches Blue, though not Light Blue. Within one option values are OR; across options they are AND. Merchants each name things their own way, so it is safest to read option names and values from filter_options and pass them back unchanged. Those counts describe the current query’s matches, not the whole catalog.
Any declared option can be filtered on — there is no fixed list. size is the one special case, covering shoe, clothing, shirt, and accessory sizing together. An option the merchant has not declared matches nothing, so the search comes back empty rather than unfiltered.
Prices and stock come from a catalog snapshot. The index refreshes on a schedule rather than reading the merchant live, so price and availability can lag a little. It is best to treat results as a shortlist and confirm through the Catalog API before showing a final price or adding to cart.
An empty result is not an error. When nothing matches well enough, you receive HTTP 200 with an empty products list rather than an unrelated product.
Error Responses
Errors return a JSON body with code, error, and description. Program against the error value — descriptions are human-readable and may change.
400 — BadRequest
One of: (a) the request query field is missing or empty; (b) server-to-server path: x-firmly-device-id header is missing, exceeds 256 characters, or contains characters outside a-z, A-Z, 0-9, -, _. The description is specific to the failing case.
{ "code": 400, "error": "BadRequest", "description": "Request Syntax is not correct." }
400 — MissingAuthHeader
Device-auth path: x-firmly-authorization header is missing or empty.
{ "code": 400, "error": "MissingAuthHeader", "description": "x-firmly-authorization header is missing or invalid." }
400 — InvalidToken
Device-auth path: the token is not a valid JWT structure.
{ "code": 400, "error": "InvalidToken", "description": "Jwt token is invalid." }
400 — InvalidAPIToken
Server-to-server path: the API token is missing, invalid, or has been revoked.
{ "code": 400, "error": "InvalidAPIToken", "description": "API token is invalid." }
400 — ErrorMissingAppId
App-id-auth path: x-firmly-app-id header is missing or empty.
{ "code": 400, "error": "ErrorMissingAppId", "description": "x-firmly-app-id header is missing or invalid." }
400 — ErrorInvalidAppId
App-id-auth path: the supplied App ID is not recognized, or exceeds 512 characters.
{ "code": 400, "error": "ErrorInvalidAppId", "description": "App ID is invalid." }
401 — Unauthorized
The Server-to-Server credentials were rejected, or no authentication method (Browser Session, App ID, or Server-to-Server) accepted the request.
{ "code": 401, "error": "Unauthorized", "description": "Caller is not authorized to make this call" }
401 — InvalidJWTToken
Device-auth path: JWT signature could not be verified, or required claims are missing.
{ "code": 401, "error": "InvalidJWTToken", "description": "The JWT token is invalid." }
404 — PartnerNotFound
Device-auth path: the appid claim on the JWT does not map to a known partner.
{ "code": 404, "error": "PartnerNotFound", "description": "Partner could not be found" }
400 — InvalidInputBody
Request body failed schema validation (e.g. a filter field has the wrong type). The description includes the failing field path.
{ "code": 400, "error": "InvalidInputBody", "description": "The body is not processable" }
503 — StoreUnavailable
The search backend or a downstream dependency was unavailable.
{ "code": 503, "error": "StoreUnavailable", "description": "Store temporarily unavailable. Please try again later." }