Headless search: what the response carries, and the URL your front end composes
A headless front end has no theme to render a result: it receives data and builds the page itself. So it needs to know exactly what search returns, and what it will have to fetch elsewhere. Here is the Heurix response field by field, with real calls, and the limits an integrator runs into on day one.
The responses shown here come from the engine at the version served in production on 13 September 2026, on a three-product catalog indexed for this article: two variants of the same screw and one nut. Product values and API messages are shown as the API returns them, in French.
One call, the whole product
By default, each result carries the complete product as you indexed it. This is deliberate: the client never has to make a second call to render a result card. For a headless front end, that is one network round trip fewer per search.
curl -X POST https://api.heurix.fr/v1/index/boutique/search \
-H "Authorization: Bearer hxp_your_public_key" \
-H "Content-Type: application/json" \
-d '{"q": "vis inox m8", "limit": 1, "exclude_description": true}'
{
"query": "vis inox m8",
"tokens": [
"vis",
"inox",
"m8"
],
"total": 3,
"offset": 0,
"limit": 1,
"hits": [
{
"product": {
"id": "44556677889901",
"platform_id": 44556677889901,
"produit_id": "8801",
"handle": "vis-tete-hexagonale-inox-a2",
"ref": "VIS-M8X20-A2",
"name": "Vis tête hexagonale M8x20 inox A2",
"price": 12.9,
"compare_at_price": 15.9,
"stock": true,
"category": "Visserie",
"url": "https://boutique.example/products/vis-tete-hexagonale-inox-a2",
"image": "https://cdn.example/vis-m8x20.jpg"
},
"score": 54.8,
"in_stock": true,
"matched": [
"terme 'vis' (depuis 'vis', x1.00)",
"terme 'vis-m8x20-a2' (depuis 'vis', x0.22)",
"terme 'inox' (depuis 'inox', x1.00)",
"annotation #DIAM_M8",
"annotation #DIAM_M8_INOX",
"annotation #FAM_VIS",
"annotation #MAT_INOX"
],
"pinned": false,
"buried": false
}
],
"fallback": false
}
The indexed product also had a margin field. It is not in the response: a public key never receives it. Here is where each field comes from.
| Field | Where it comes from |
|---|---|
product.* | Your last indexing call, as sent. Nothing is read back from your platform at search time. |
product.price | The price field the calling key serves: price by default. |
product.margin | Removed for a public key, served to a server key. |
scorematched | Computed by this search: relevance, and its readable reasons. |
in_stock | Derived from your stock field. |
pinnedburied | Query rules set in the console. |
fallback | true when hits holds your featured products because nothing matched; total then stays at 0. |
facetsprice_filterfilters_unknownsuggested_categoryhighlighted_bundle | Present only when they have something to say. Their absence is the normal case. |
What the response does not carry, unless you indexed it: the product page URL, the image, variants and their options, the currency, real-time stock and price. A price changed in your back office only exists for search after your next indexing call. Payload size is controlled with exclude_description: on a real catalog, the description accounts for more than 80% of a product's weight in the response.
The engine never composes a URL; the front end does
In the response above, url is present because it was indexed. The second variant of the same screw has none, and the engine does not make one up: it knows neither your domain, nor your sales channel, nor your routes.
That is what makes the response usable without a theme. Index the stable part, a handle or a slug, and compose the path in the front end:
const link = p.url || "/products/" + encodeURIComponent(p.handle);
A relative path stays correct on the production domain, on localhost and on a preview domain. It is the rule our own Shopify storefront script applies: a password-protected store, the state of every merchant preparing to open, does not provide a public product URL, and the path is rebuilt from the handle.
ids_only: when your platform hydrates its own product data
If your front end already reads its products elsewhere, from a CMS, a PIM or your platform's API, ask for the order only:
{"q": "inox m8", "ids_only": true}
{"total": 3, "ids": ["44556677889903", "44556677889901", "44556677889902"]}
id_field returns another field instead of id: the identifier your platform expects, a handle, a slug.
{"q": "inox m8", "ids_only": true, "id_field": "handle"}
{"total": 3, "ids": ["ecrou-hexagonal-inox-a2", "vis-tete-hexagonale-inox-a2", "vis-tete-hexagonale-inox-a2"]}
Three identifiers, two distinct. A catalog indexed per variant returns the product handle once per matching variant: deduplicate before hydrating, keeping the first occurrence to preserve the order. A product without the requested field is dropped, and counted:
{"q": "inox m8", "ids_only": true, "id_field": "url"}
{"total": 3, "ids": ["https://boutique.example/products/vis-tete-hexagonale-inox-a2"], "ignores": 2, "champ_manquant": "url"}
What ids_only leaves out
The light response carries only total and ids. Measured on the same catalog:
- Requested facets are missing, with no warning. The same query in full mode returns
"facets": {"FAM": {"FAM_VIS": 2, "FAM_ECROU": 1}}. - Zero results returns
{"total": 0, "ids": []}. Full mode returns yourfeaturedproducts with"fallback": true. price_filter,filters_unknownandmatcheddo not appear either.
If your results page shows facets, full mode with exclude_description remains the call to make.
The public key, and what allowed_origins imposes
A headless front end calls from the browser, with a public hxp_ key created by your server key. It searches, browses a catalog and sends events, nothing else: a public key that tries to index gets a 403. Its quota is that of the server key that created it.
curl -X POST https://api.heurix.fr/v1/keys/public \
-H "Authorization: Bearer YOUR_SERVER_KEY" \
-H "Content-Type: application/json" \
-d '{"allowed_origins": "https://www.boutique.example, boutique.example, localhost:3000"}'
{"key": "hxp_...", "allowed_origins": "www.boutique.example,boutique.example,localhost", "scope": "search, browse, events"}
The comparison is on the host alone: scheme and port are stripped, both when stored and on every request. Three consequences, measured:
- localhost is accepted on every port. Declared as
localhost:3000, it also lets anhttp://localhost:5173origin through. - No wildcard.
*.boutique.exampleis rejected with a 422, and so is*. An empty field is the only way to allow every origin. - Each preview domain is a distinct host, so it is rejected.
Origin: https://boutique-git-panier-acme.vercel.app
HTTP 403
{"detail": "Origine 'boutique-git-panier-acme.vercel.app' non autorisée pour cette clé publique. Domaines autorisés : www.boutique.example, boutique.example, localhost."}
Vercel and Netlify create one domain per deployment, which the list cannot enumerate. Today, a preview environment has a single way out: a public key with no origin restriction, usable from any site for as long as it exists.
Server-side rendering: today, two options, neither satisfactory
Server-side rendering calls from Node, not from a browser: the request carries no Origin header, and a restricted public key rejects it.
HTTP 403
{"detail": "Origine 'absente' non autorisée pour cette clé publique. Domaines autorisés : www.boutique.example, boutique.example, localhost."}
Two options exist, and each has a cost.
- A public key with no origin restriction. The server calls it without trouble. But the restriction is gone for everyone: if that key is also served to the browser, any site can use it, on your quota.
- The server key. It needs no
Origin, but it sees the whole catalog: the margin comes out in the response, and the price configured on a public key does not apply. Anything your render passes to the browser for hydration carries those fields.
The same product, for the same query, depending on the key:
// server key
{
"id": "44556677889903",
"platform_id": 44556677889903,
"produit_id": "8802",
"handle": "ecrou-hexagonal-inox-a2",
"ref": "ECR-M8-A2",
"name": "Écrou hexagonal M8 inox A2",
"price": 6.2,
"margin": 2.0,
"stock": true,
"category": "Visserie",
"featured": true
}
// public key
{
"id": "44556677889903",
"platform_id": 44556677889903,
"produit_id": "8802",
"handle": "ecrou-hexagonal-inox-a2",
"ref": "ECR-M8-A2",
"name": "Écrou hexagonal M8 inox A2",
"price": 6.2,
"stock": true,
"category": "Visserie",
"featured": true
}
Neither is satisfactory.
The widget or the API
heurix-search.js is a ready-to-drop search bar. Heurix.searchBox() requires a DOM element (containerId), injects its styles and draws its own list. It reads name, ref, price and compare_at_price, so it needs full mode, and accepts renderItem, onSelect and resultHref for rendering and navigation. It suits a browser-rendered page that wants autocomplete without writing it. It does not run in a server render, it is not a React or Vue component, and no npm package is published.
A front end with its own components calls the API directly:
const r = await fetch("https://api.heurix.fr/v1/index/boutique/search", {
method: "POST",
headers: {
"Authorization": "Bearer hxp_your_public_key",
"Content-Type": "application/json",
},
body: JSON.stringify({ q, limit: 24, exclude_description: true }),
});
const { total, hits, facets, fallback } = await r.json();
A misnamed field in the body is rejected with a 422 rather than ignored: {"query": "écrou"} does not return the whole catalog, the response names the extra field.
Next steps
The three integration levels and the details of ids_only are in the documentation, creating and revoking public keys in Authentication, and the full parameter list in Search.