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.

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.

  • 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 — 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, 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")

​​ 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." }