Docs
Firmly Agentic Commerce
Set theme to dark (⇧+D)

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 are a-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 if filters (or a cursor from a prior page) is provided — at least one of query or filters is required on the first page. A request with neither returns 400 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., 100000 for $1,000.00, 9999 for $99.99). Must be an integer.
    • filters.max_price (integer) — Maximum price filter in cents (e.g., 500000 for $5,000.00). Must be an integer.
    • filters.in_stock (boolean) — Filter to only show in-stock products. Set to true to 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 from filter_options in 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, default 20) — Number of results per page. Clamped to the range 1–100.

  • cursor (string) — Opaque pagination cursor. Omit for the first page; on subsequent requests pass the cursor carried in the previous response’s next_page URL. The cursor also re-applies the first page’s filters, so you don’t resend filters when 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 product
    • title (string) — Product title
    • brand (string) — Brand name. null when the merchant data has no brand.
    • handle (string) — Product handle/slug
    • description (string) — Product description
    • pdp_url (string) — Full URL to the product detail page on the merchant’s site
    • domain (string) — Merchant domain (e.g., merchant.example)
    • domain_name (string) — Merchant display name
    • price_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) — true if 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 image
    • type (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 own add_to_cart_ref, which is the required request body for Add Line Item. Pick a variant with available: true, then pass its add_to_cart_ref through unchanged.
    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: number in 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.
    - `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.

  • 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 a cursor).

    Filter Options
    • variant_options (object[]) — Variant facets: [{ option, values: [{ value, count }] }] (e.g. Color, Size).
    • domains (object[]) — Domain facets: [{ domain, count }]. There is no domain_name key here — unlike the product object, the facet’s domain holds 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 }. null when 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. null when 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 filters
const searchProducts = async (query, options = {}) => {
const body = { query };
// Build filters object
const 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 response
if (results.next_page) {
// Extract cursor from next_page URL or use it directly
const 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 requests
from urllib.parse import urlparse, parse_qs
def 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 object
filters = {}
if domains: filters['domains'] = domains
if min_price: filters['min_price'] = min_price
if max_price: filters['max_price'] = max_price
if in_stock: filters['in_stock'] = True
if variants: filters['variants'] = variants
if filters:
body['filters'] = filters
if page_size: body['page_size'] = page_size
if cursor: body['cursor'] = cursor
response = 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 response
if results.get('next_page'):
# Extract cursor from next_page URL or use it directly
parsed = 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." }