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.
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. -
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 — inspect the variants on returned products to discover them.
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")
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." }