Documentation
API v1

Search API — Reference

Everything you need to index a catalog and run a first search in minutes. Base URL: https://api.heurix.fr

Access requires an API key. Sign up self-service to get one immediately, or contact us for custom needs.

Introduction

The Search API exposes the Heurix engine: a search engine built for technical catalogs, where queries and product records go through the same pipeline — normalization (case, accents), business synonyms, typo tolerance, then a rule cascade that turns text into structured annotations. That cascade is what lets m8x20 find a record stored as "M8 x 20 — A2".

The API is REST, JSON, HTTPS only. All responses are UTF-8 encoded.

Quickstart

Three calls are enough for a working first search.

1 Index your products (the catalog is created automatically on first send):

curl -X POST https://api.heurix.fr/v1/index/mycatalog/items \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "rulepack": "outillage",
    "items": [
      {"id": "V1", "name": "Hex head screw", "ref": "M8 x 20 — A2", "stock": 100},
      {"id": "V2", "name": "Hex head screw", "ref": "M8 x 30 — A2", "stock": 50}
    ]
  }'

2 Search — typos are welcome. Edit the fields to see the command change:

3 Read the response — every result explains why it matched:

{
  "query": "m8x20 stainless",
  "total": 2,
  "hits": [
    {
      "product": {"id": "V1", "name": "Hex head screw", "ref": "M8 x 20 — A2", "stock": 100},
      "score": 37.5,
      "in_stock": true,
      "matched": ["annotation #VIS_M8X20", "annotation #DIAM_M8", "annotation #LONG_20"]
    }
  ]
}

Authentication

Every call must carry your API key in the Authorization header:

Authorization: Bearer YOUR_KEY

A missing or malformed key returns 401; a key that's invalid or outside its scope returns 403.

Two types of keys

The choice isn't cosmetic: it determines what a third party could do if they got hold of your key.

TypePrefixScopeWhere to use it
Server key hx_ Full access: indexing, merchandising, statistics, billing Server-side only. Never in a web page.
Public key hxp_ Search, catalog browsing, conversion events — nothing else Browser, mobile apps, any context where the key is visible

A public key calling an endpoint outside its scope gets an explicit 403. It shares the quota of the server key that generated it — creating several therefore doesn't raise your limits.

Generating a public key

From your console (My account → API key), or via the API with your server key:

curl -X POST https://api.heurix.fr/v1/keys/public \
  -H "Authorization: Bearer YOUR_SERVER_KEY" \
  -H "Content-Type: application/json" \
  -d '{"allowed_origins": "mysite.com,www.mysite.com"}'

allowed_origins is optional but recommended: the key is then only accepted from the listed domains, making it useless if someone copies it onto another site. GET /v1/keys/public lists them, DELETE /v1/keys/public/{key} revokes one.

Existing keys remain server keys and keep working exactly as before — nothing breaks. But if you've placed one in your site's JavaScript (search bar, tracker, Browse widget), replace it with a public key: as it stands, any visitor can read it and reach your billing.

Request counting

Billing is based on a monthly request counter, simple by design:

Search1 search call = 1 request
Indexingnever counted against your request quota — update your catalog as often as needed, only the total number of indexed products is capped by plan
Federated search1 request per catalog queried
ManagementMost management calls are not counted — keys, accounts, subscription, merchandising priorities, analytics. Nine endpoints are the exception, listed just below
Ranking1 browse call = 1 Browse request, on a separate counter from search
Overage (Scale)Extra usage is billed at the rate shown on the pricing page — never a cutoff
Overage (other plans)A 10% grace margin absorbs a one-off spike; beyond that, the request is rejected with a clear 429 code

Management calls that are counted

These nine endpoints each count as one request. No general rule separates them from the other management calls — neither the HTTP verb nor the nature of the operation: that is why they are listed one by one rather than summarised in a sentence.

GET /v1/index/{catalog}/statsCatalog statistics
GET /v1/index/{catalog}/synonymsRead synonyms
PUT /v1/index/{catalog}/synonymsReplace synonyms
GET /v1/index/{catalog}/synonym-suggestionsSynonym suggestions
GET /v1/rulepacksList of rule packs
PUT /v1/index/{catalog}/configChange the active pack
POST /v1/index/{catalog}/custom-rulesCreate a custom rule
DELETE /v1/index/{catalog}/custom-rules/{id}Delete a custom rule
DELETE /v1/index/{catalog}/items/{id}Delete a product

Worth knowing if you sync a catalog from a store: deletion applies to one product and counts as one request. Removing 300 products one by one therefore consumes 300 requests of your monthly quota.

Your current usage is available anytime via GET /v1/usage.

Plans and limits

Every API key is tied to a plan that defines its monthly request volume, its indexable product count, and its number of catalogs. Feature details for each plan are on the pricing page; here are the raw limits:

PlanRequests / monthMax productsCatalogs
Trial (14 days)2,0002,0002
Starter — €19/month15,0008,0001
Growth — €49/month30,00025,0003
Scale — €139/month150,000 then billed50,000unlimited

Past the 14-day trial without subscribing to a paid plan, a key automatically reverts to trial limits until subscription — your catalogs and their configuration remain intact, simply less accessible in volume until you move to a paid plan.

Exceeding a limit returns a 429 code with the detail of the cause. The detail sentence comes out in French, as every error message doesplan and limit_type are what you branch on:

{
  "detail": "Plafond de requêtes du plan 'starter' dépassé (5512/5000, marge de 10% également dépassée). Passez à un plan supérieur pour continuer.",
  "plan": "starter",
  "limit_type": "requests",
  "upgrade_url": "https://heurix.fr/pricing.html"
}

Product structure

A product is a free-form JSON object. Four fields play a special role:

FieldTypeRole
idstring requiredUnique identifier within the catalog. Re-indexing the same id replaces the product (upsert).
refstringProduct reference (SKU, manufacturer code). Indexed, highest weight.
namestringProduct name. Indexed, high weight.
descriptionstringDescription. Indexed, standard weight.
stocknumber | bool | stringAvailability — used for sorting at equal relevance. 0, false, "out" = unavailable.
lat / lonnumber | stringPosition, in decimal degrees. Sent like any other field — no migration, no reindexing: a product that receives them on your next upload is geolocated the same day. Strings are accepted ("44.84" reads as 44.84); lat without lon locates nothing — a position is a pair. Used by radius search, never indexed as text.

Any other field (price, image, URL, category...) is stored as-is and returned in results, without being indexed. One field deserves a specific mention: compare_at_price — if present and greater than price, the demo widget automatically displays it as a struck-through price with the discount percentage, no extra configuration needed.

POST/v1/index/{catalog}/search-overrides

Query priorities

Pin or bury a product when a search contains a specific word — "if the search contains promo, this product appears first." Independent from Browse & Discovery, which ranks browse categories, not search queries.

curl -X POST https://api.heurix.fr/v1/index/mycatalog/search-overrides \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"query": "promo", "product_id": "sku-123", "action": "pin", "position": 1}'

Triggered by contiguous substring, not just an exact match: a rule set on "promo" triggers for "screw promo" or "big stainless promo", but not for "promotional" (a different word once normalized). A pin can surface a product that doesn't textually match the query — that's the point of this feature, not a bug.

FieldTypeDescription
querystringThe trigger, normalized like the rest of the engine (accents, case) before comparison.
product_idstringThe affected product.
actionstringpin (exact position, requires position) or bury (end of list, only applies to a product already present naturally).
positionintegerThe product's final rank, starting at 1. position: 3 places it 3rd; other products fill the remaining slots in their natural order. Two products requesting the same rank are placed side by side. A rank beyond the number of results puts the product at the end of the list.

GET /v1/index/{catalog}/search-overrides lists every rule for the catalog (or those for a specific query with ?query=...); DELETE /v1/index/{catalog}/search-overrides?query=...&product_id=... removes one. None of these three calls consume your quota — they're configuration actions, not search requests.

Facets and filters

A facet groups the annotations that share a common prefix — FORMAT_POCHE and FORMAT_BROCHE belong to the FORMAT group. The group is the prefix, not a declared name: the engine keeps the annotations starting with FORMAT_. Request a per-group count with facets, narrow results with filters:

curl -X POST https://api.heurix.fr/v1/index/mycatalog/search \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -d '{"q": "polar", "facets": ["FORMAT", "LANG"], "filters": ["FORMAT_POCHE"]}'
{
  "total": 17,
  "hits": [ ... ],
  "facets": {
    "FORMAT": {"FORMAT_POCHE": 17, "FORMAT_BROCHE": 9, "FORMAT_GF": 4},
    "LANG":   {"LANG_FR": 12, "LANG_EN": 5}
  }
}

Response taken from the engine, livres pack, on a 44-record catalog holding 30 crime novels — 12 pocket in French, 9 paperback in French, 5 pocket in English, 4 hardcover in French. The five annotation names above are the ones the pack actually emits, in French like every annotation: POCHE is pocket, BROCHE paperback, GF hardcover. The query is "polar" because that is the word this catalog stores; an English catalog would be searched in English.

Counting and filtering do not behave the same way, and that is the distinction to remember. Counting is disjunctive; filtering is cumulative. They answer two different questions.

  • Counting is disjunctive within a single group: filtering on FORMAT_POCHE doesn't make FORMAT_BROCHE disappear from the count. That's what lets you show "Paperback (9)" as an alternative option even once pocket is selected, exactly like a standard e-commerce filter sidebar. It answers: "how many would there be if I ticked this one?"
  • Across groups the filter does apply — which is why LANG shows 12 and 5 rather than 25 and 5: the languages counted are those of the pocket-format crime novels only. Disjunction holds inside the filtered group, not beyond it.
  • Filtering is cumulative everywhere — across different groups and within a single group alike. filters: ["FORMAT_POCHE", "FORMAT_BROCHE"] asks for products carrying both annotations, not one or the other: in a catalog where a book has a single format, that returns zero results, while the count still shows 17 and 9. Measured on the same catalog. It answers: "which ones satisfy everything that is ticked?"

So it isn't a contradiction, but it needs to be understood before wiring a checkbox sidebar: ticking two values of the same group empties the list while the counters still show positive numbers.

To express an OR, on Browse only: filters=langue:fr|en — the pipe separates alternatives within one business field, the one you supply at indexing time, the comma separates fields, which stay cumulative. See Browse. On the search side the pipe works for field filters toofilters: ["brand:Makita|Bosch"] returns either one. Annotations, however, stay cumulative with one another: ["FORMAT_POCHE", "FORMAT_BROCHE"] demands both, and no syntax asks for just one of them.

GET/v1/browse/{catalog}/{category}

Browse & Discovery

Ranks every product in a category with no query — for a listing page (category, department), not a search bar. Replaces a static sort with configurable ranking, enriched by your own manual priorities.

{category} is a field you supply at indexing time — exactly like name or stock, never derived from a rule pack. Add categories (a list — a product can belong to several categories at once, useful for listing both a category and its ancestors) or category (a single value) to your products:

Don't want to write the fetch call yourself? Download the ready-to-use snippet — a step-by-step guide with a full example is available on the blog.

curl -X POST https://api.heurix.fr/v1/index/mycatalog/items \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"items": [{"id": "sku-123", "name": "GDX 18V-285 Drill",
       "categories": ["power-tools", "drills-drivers"],
       "price": 139.90, "margin": 28.50, "stock": 8}]}'

curl "https://api.heurix.fr/v1/browse/mycatalog/drills-drivers?sort=price_asc" \
  -H "Authorization: Bearer YOUR_API_KEY"
ParameterTypeDescription
sortstringstock (default) · recent · alphabetical · price_asc / price_desc (reads the calling key's price field, price by default — see Per-key pricing) · margin (reads the margin field, highest first) · popular (see below)
filtersstring"field:value,field2:value2" — narrows on any free product attribute (brand, color…), in addition to the category. Any non-reserved text field automatically becomes filterable at indexing time, no configuration needed.
The comma is an AND, the pipe is an OR. brand:Makita|Bosch returns one or the other; brand:Makita,color:blue requires both. Two pairs on the same field stay cumulative — tags:promo,tags:clearance only returns products carrying both tags, which is meaningful on a multi-valued field.
facetsstring"field,field2" — returns a count per value for each requested field, disjunctive (filtering on one value of a field doesn't remove the field's other values from the count, same as on the search side).
limit / offsetintPagination, same as search.
in_stock_onlyboolExcludes out-of-stock products (default false), before facet counts are computed — a hidden product doesn't weigh into the counts.
langstringSame filter as search — see Search.
curl "https://api.heurix.fr/v1/browse/mycatalog/drills-drivers?filters=brand:Makita&facets=brand,color" \
  -H "Authorization: Bearer YOUR_API_KEY"

A product missing the requested field (no price for a price_asc sort, for example) is never excluded and never breaks the call — it simply lands at the end of the list, regardless of sort direction.

Consumes the Browse quota, on a separate counter from search — a category call never eats into your search volume.

Building a category page in the browser

browse accepts a public key (hxp_), so you can build a category page entirely in JavaScript, with no server proxy. That is what the demo shop does — its code is readable in demo-boutique.js.

// PUBLIC key (hxp_), restricted to your domains via allowed_origins.
// A server key here would be readable by any visitor.
const KEY = "hxp_your_public_key";
const CATALOG = "mycatalog";

fetch(`https://api.heurix.fr/v1/browse/${CATALOG}/drills-drivers?sort=popularity&limit=8`, {
  headers: { Authorization: "Bearer " + KEY },
})
  .then(r => r.ok ? r.json() : Promise.reject(new Error("HTTP " + r.status)))
  .then(d => {
    // browse returns hits: the product sits in h.product.
    document.getElementById("grid").innerHTML =
      (d.hits || []).map(h => renderCard(h.product)).join("");
  })
  .catch(e => {
    // Storefront message for the visitor, technical detail in the console.
    document.getElementById("grid").textContent = "Category temporarily unavailable.";
    console.error("Heurix browse:", e.message);
  });

d.total gives the category's reference count, useful for a counter or pagination. A 403 from a domain absent from allowed_origins carries an explicit message naming the allowed domains — a diagnostic aid, not a leak.

Sort by popularity (clicks + purchases)

sort=popular relies on events actually reported by the Heurix Tracker — a purchase counts for 5, a click on a search result for 1. A heavily-clicked but never-yet-purchased product therefore stays visible in the ranking rather than being fully absent until a sale is reported. With no tracking data at all, every product simply falls back to the default order — the call never fails.

Manual merchandising: pinning or burying a product

Independent from natural sort: a pinned product (pin) appears at the chosen position at the top of the list regardless of its stock, price, or popularity; a buried product (bury) is systematically pushed to the end. Useful for featuring a new arrival or discreetly hiding an end-of-life product without unindexing it.

curl -X POST https://api.heurix.fr/v1/browse/mycatalog/drills-drivers/overrides \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"product_id": "sku-123", "action": "pin", "position": 1}'

curl -X DELETE https://api.heurix.fr/v1/browse/mycatalog/drills-drivers/overrides/sku-123 \
  -H "Authorization: Bearer YOUR_API_KEY"

Doesn't consume your quota — it's a configuration action, not a request. A priority set on a product that later leaves the category simply becomes a no-op, not an error.

Boost or demote by attribute

Same principle as manual merchandising, but for every product sharing an attribute rather than one at a time — "boost the entire Makita brand" instead of pinning each reference individually. A boost or demotion by attribute positions the affected products as a group, sorted among themselves by the chosen sort; it's not a fixed position (see pin above for that).

curl -X POST https://api.heurix.fr/v1/browse/mycatalog/drills-drivers/attribute-rules \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"field": "brand", "value": "Makita", "action": "boost"}'

curl -X DELETE "https://api.heurix.fr/v1/browse/mycatalog/drills-drivers/attribute-rules?field=brand&value=Makita" \
  -H "Authorization: Bearer YOUR_API_KEY"

Priority order in case of conflict, strongest to weakest: pinned (per product) > buried (per product or per attribute) > boosted (per attribute) > natural sort. A demotion, whether set per product or per attribute, always wins over a boost — a product explicitly hidden should never be resurfaced by a broader boost rule that happens to also apply to it.

POST/v1/federated-search

Federated search

Queries multiple catalogs in a single call and merges results by relevance — useful for a multilingual site (one catalog per language) or multi-brand setup. Scoring weights are global to the engine, not per-catalog: scores stay comparable across catalogs, even with different rule packs.

Request body

ParameterTypeDescription
catalogsarray of string required1 to 10 catalog names to query.
qstringThe search query.
limit / offsetintegerPagination over the merged set.
filters / facetsarray of stringSame forms as on search — annotations and product fields, mixed. Filtering is applied catalog by catalog; facet counts are summed across catalogs, since the same value in two shops means the same thing.
lat / lon / radius_kmnumberSame rules and same bounds as on search, including the 200 km ceiling and the exclusion of products without a position.

Response

Same shape as a regular search, with a catalog field added to each hit to identify its source, and catalogs_searched / catalogs_not_found to spot an invalid catalog name.

Both diagnostic keys are per catalog here, because a field present in one catalog and absent from another is the normal case of a federation, not the exception. filters_unknown becomes an object — {"branches": [], "products": ["county"]}, catalogs with nothing to report listed with an empty array so you can read at a glance where your filter landed — and radius_no_positions a list of the catalogs holding no coordinates at all. Both stay absent when they have nothing to say.

A filter that designates nothing in one catalog removes every result from it, without removing that catalog from catalogs_searched. That is what these keys make readable: the response says it searched three catalogs and serves results from one, and you know why.

curl -X POST https://api.heurix.fr/v1/federated-search \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -d '{"catalogs": ["store-fr", "store-en"], "q": "red sweater"}'

Counting: 1 request per catalog actually queried — federating across 3 catalogs counts as 3 requests.

POST/v1/index/{catalog}/items

Indexing

Adds or updates products in batch (upsert on id). The catalog is created automatically if it doesn't exist. Optional featured field (boolean): marks a product as a candidate for the fallback shown when a search finds nothing — see Search, zero-result fallback.

Request body

ParameterTypeDescription
itemsarray required1 to 5,000 products per call. Every product must have an id.
rulepackstringRule pack to attach to the catalog — eleven available, see the full list (outillage, mode, industrie, etc.). Changing the pack re-indexes the catalog.

Response

{"indexed": 2, "catalog": {"catalog": "mycatalog", "products": 2, "terms": 11,
 "annotations": 9, "rulepack": "outillage", "synonym_groups": 0}}

Grouping results by family

On a technical catalog, a broad search often returns hundreds of products that differ only by one dimension. Measured on a 10,000-reference catalog: M8 stainless screw returns 6,582 results, the first of which are M8×30, M8×80, M8×35, M8×6.

Grouped, these 6,582 results become 52 families — "Hexagon stainless A2 screw, 265 products" — ranked by relevance.

curl -X POST https://api.heurix.fr/v1/index/mycatalog/search \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"q": "M8 stainless screw", "limit": 6, "group_by": "auto"}'
{
  "total": 6582,
  "familles": 52,
  "groupes": [
    {
      "famille": "vis inox hex",
      "etiquettes": ["FAM_VIS", "MAT_INOX", "TETE_HEX"],
      "produits": 265,
      "score": 60.0,
      "representant": { "id": "REF-001360", "name": "Hexagon screw M8x30 stainless A4" },
      "ids": ["REF-001360", "REF-001789"]
    }
  ]
}
ParameterEffect
group_by: "auto"Groups on the pack's standard tags — family, material, head type, color
group_by: "FAM,MAT"Groups on the prefixes you choose

Dimensions are deliberately excluded from the family key: they're what varies within a family. Including them would produce one family per product.

Families are ordered by the best score among their members, not by their size. A relevant but small family therefore ranks ahead of a large but marginal one.

Without group_by, the response is unchanged: your existing integrations see no difference.

In the JavaScript widget

Grouping is configured through a threshold, not a switch. A visitor typing a precise reference wants their product, not a family — grouping systematically would turn an exact search into a detour.

Heurix.searchBox({
  apiKey: "hxp_your_public_key",
  catalog: "mycatalog",
  containerId: "my-search",

  // Groups only past 50 results
  groupThreshold: 50,

  // Optional: what to do when a family is clicked.
  // By default, the search refines with the family's name.
  onSelectGroup: function (famille, requete) {
    window.location = "/search?q=" + encodeURIComponent(requete + " " + famille.famille);
  }
});

Three levels of integration

Integration time doesn't depend on Heurix but on what you're replacing. These three levels go from lightest to most complete — nothing requires starting with the last one.

LevelWhat you replaceOrder of magnitude
1 — Autocomplete Your search bar's dropdown menu 1 day
2 — Identifier list Which products, and in what order. Your template, your product pages, and your facets stay yours 3 to 5 days
3 — Full replacement The entire results page, with our facets and our analytics 2 to 3 weeks

Level 2 — the ids_only parameter

Heurix decides which products and in what order. Your platform renders its own product pages. The response contains only the essentials — for 50 results, a few hundred bytes instead of several dozen kilobytes.

curl -X POST https://api.heurix.fr/v1/index/mycatalog/search \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"q": "M8x20 stainless", "limit": 24, "ids_only": true}'

# {"total": 47, "ids": ["SKU-1234", "SKU-5678", ...]}

Ranking, pagination, and filters all apply exactly as on the full response. Only the shape changes.

Applying this order in Magento

On a category page, MySQL's FIELD() function forces a product collection into a list's exact order:

// Plugin on the product collection
$ids = $this->heurix->getOrderedIds($query);   // ids_only call

$collection->addIdFilter($ids);
$collection->getSelect()->order(
    new \Zend_Db_Expr('FIELD(e.entity_id,' . implode(',', $ids) . ')')
);

Something to watch for. Since Magento 2.4, the search results page no longer goes through a SQL collection: it runs through Elasticsearch or OpenSearch. A collection plugin therefore has no effect there, and a custom search adapter is needed instead. Category pages, however, stay on SQL collections — which is why the Ranking option integrates noticeably faster than Search.

Depending on your platform

Not all platforms accept an identifier list with the same ease. The gap is significant, and it drives your integration time far more than Heurix itself does.

PlatformHook pointNatureOrder of magnitude
PrestaShop 1.7+ ProductSearchProviderInterface Interface built for this. The core accepts an array of identifiers and hydrates product pages, facets, pagination, and sorting. 2 to 3 days
WooCommerce pre_get_posts with post__in and orderby => post__in Hijacking the WordPress query. Works, but it isn't an extension point designed for this. 3 to 4 days
Magento 2.4+ — category pages FIELD(e.entity_id, …) on the collection Works: the collection stays in SQL. 3 to 5 days
Magento 2.4+ — results page Custom search adapter Search runs through Elasticsearch: a collection plugin has no effect there. 2 to 3 weeks
Shopify Not verified to date. Write to us rather than follow an approximate recipe.

PrestaShop — official module

A ready-to-install module exists. It implements ProductSearchProviderInterface and requires no theme changes. It is in beta, provided on request: write to us to receive the folder used in step 1.

CompatibilityPrestaShop 8.x and 9.x. The 1.7 branch is not supported: the module refuses to install below 8.0.0
DependenciesNone — native cURL
What it replacesSearch results only. Theme, templates, and filters stay yours
FallbackPrestaShop's native search if Heurix doesn't respond
Installation
  1. Copy the heurixsearch folder into your store's modules/
  2. Back office → Modules → install "Heurix Search"
  3. Enter your server key (hx_) and your catalog's name
  4. Activate
One prerequisite to know about

PrestaShop identifies its products by an integer (id_product). Your Heurix catalog must therefore be indexed with these identifiers, not your internal references.

Concretely, the id field sent to Heurix must contain 1360, not REF-001360. You can keep your business reference in the ref field, which is indexed with the highest weight.

{
  "items": [
    {
      "id": "1360",                        // PrestaShop id_product
      "ref": "REF-001360",                 // your business reference
      "name": "Hexagon head screw M8x20 stainless A2",
      "price": 1.24,
      "stock": 2485
    }
  ]
}

If your identifiers aren't numeric, the module discards them and logs it in PrestaShop's logs — you'll then see "no results" even though Heurix found some. That's the symptom to recognize.

PrestaShop — the interface, if you'd rather use your own module

A module implementing ProductSearchProviderInterface is enough. It receives the query, calls Heurix with ids_only, and returns the identifiers. PrestaShop does the rest.

public function runQuery(ProductSearchContext $context, ProductSearchQuery $query)
{
    $ids = $this->heurix->rechercher($query->getSearchString());   // ids_only

    $resultat = new ProductSearchResult();
    // PrestaShop's core fills in the missing data
    $resultat->setProducts(array_map(fn($id) => ['id_product' => $id], $ids));
    $resultat->setTotalProductsCount(count($ids));
    return $resultat;
}

WooCommerce — one thing to know

The mechanism works, but WordPress keeps running its own search as long as the s parameter stays set. It needs to be cleared — which also empties the "Results for: …" label shown at the top of the page. Plan for a title filter to restore it.

add_action('pre_get_posts', function ($query) {
    if (!is_admin() && $query->is_main_query() && $query->is_search()) {
        $ids = heurix_rechercher(get_search_query());   // ids_only
        $query->set('post_type', 'product');
        $query->set('post__in', $ids ?: [0]);           // [0] = no results
        $query->set('orderby', 'post__in');             // preserve OUR order
        $query->set('s', '');                           // otherwise WP refilters
    }
});

Importing a file from the console

If you export your catalog as CSV or XML from an ERP or a PIM, no development is needed. The console detects the format, analyzes the file, and sends it for you.

Accepted formatsCSV and XML, detected automatically — a single drop button
Detection — CSVSeparator (;, ,, tab) and encoding, including Latin-1, common on French exports
Detection — XMLThe element that repeats once per product, recognized automatically; encoding read from the file itself. Namespaces (Google Shopping feeds, g:id, g:price) are handled with no configuration
MatchingSuggested from your column headers (CSV) or elements and attributes (XML), editable, with a live value preview
Numbers1,24 and 1 234,56 are understood; in XML, in stock / out of stock are recognized for stock
BatchingAutomatic, in batches of 5,000
Rule packRecommended from your file's content, before import

A check shows, before sending, how many rows would be kept — a wrong match is visible immediately rather than after the fact.

One decision to make before your first import

The identifier you index with must be the one you use everywhere else. It's the one decision that's hard to walk back.

Three systems rely on this identifier:

  • The conversion tracker. It records the product_id your site sends it. If you index by business reference — VIS-M8-INOX — but your site sends its platform's identifier — 1360 —, the two will never meet and your sales analytics will stay empty.
  • Platform modules. PrestaShop, WooCommerce, and Magento identify their products by an integer. The id_field parameter lets you keep your business reference as the identifier while still supplying that integer — see below.
  • The MCP server. It returns your identifiers as-is. A business reference is even more readable there for a conversational agent.

The most flexible solution: index with your business reference — it's what your buyers type, and it's heavily weighted in ranking — and add a platform_id field (a column in CSV, an element or attribute in XML) holding your platform's identifier. That way you have both.

Code Article;id_prestashop;Désignation;Prix HT;Qté dispo
VIS-M8-INOX;1360;Vis tête hexagonale M8x20 inox A2;1,24;2485

In the matching screen, map Code Article to Identifier and id_prestashop to Platform identifier. In XML, the principle is the same: map the corresponding element or attribute, whatever it's called in your feed.

Large catalogs: index in batches

A single call accepts 5,000 products at most. Beyond that, the response is a 422 telling you how many calls to split into.

Successive calls add to the same catalog: order doesn't matter, and nothing gets overwritten. A product that's already there gets updated, not duplicated.

# Split a 10,000-product catalog into two batches
python3 -c "
import json
produits = json.load(open('catalog.json'))['items']
for i in range(0, len(produits), 5000):
    with open(f'batch-{i//5000}.json', 'w') as f:
        json.dump({'rulepack': 'outillage', 'items': produits[i:i+5000]}, f)
"

# Then send each batch
for batch in batch-*.json; do
  curl -X POST https://api.heurix.fr/v1/index/mycatalog/items \
    -H "Authorization: Bearer YOUR_API_KEY" \
    -H "Content-Type: application/json" \
    --data-binary @$batch
done

Order of magnitude. Expect roughly one second per 10,000 products for indexing itself. The network upload usually takes longer than that.

Changing an existing catalog's rule pack

The pack is declared at indexing time, in the rulepack field. There's no endpoint to change it afterward, and that's not an oversight: annotations are computed at indexing time, not at search time. Changing the pack without re-indexing would leave your products with the old pack's annotations.

To change packs, re-import your catalog with the new pack name. Existing products are updated, nothing is lost.

Expect roughly 6 seconds for 10,000 products, a minute for 100,000. During that time, searches across your whole instance are queued — pick an off-peak hour for a large catalog.

curl -X POST https://api.heurix.fr/v1/index/mycatalog/items \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "rulepack": "mode",
    "items": [ ... your products ... ]
  }'
DELETE/v1/index/{catalog}/items/{id}

Deletion

Removes a product from the index and storage. Returns 404 if the id is unknown.

curl -X DELETE https://api.heurix.fr/v1/index/mycatalog/items/V1 \
  -H "Authorization: Bearer YOUR_API_KEY"
GETPUT/v1/index/{catalog}/synonyms

Synonyms

Business synonyms expand search: "screw" can find "bolt". GET reads the current groups, PUT replaces them entirely. Groups sharing a term are merged automatically.

curl -X PUT https://api.heurix.fr/v1/index/mycatalog/synonyms \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"groups": [["screw", "bolt"], ["nut", "ecrou"]]}'
GET/v1/index/{catalog}/synonym-suggestions

Synonym suggestions

Suggests matches for a term that returned nothing, drawn from your catalog's real vocabulary — the same edit-distance tolerance already used by search itself, never AI. Terms already found, numeric references (pairing two different products would be the worst possible false synonym), and words under 4 letters are excluded by design. Never creates anything automatically — you approve each candidate before it lands in the synonyms endpoint.

curl -X GET "https://api.heurix.fr/v1/index/mycatalog/synonym-suggestions?q=washr" \
  -H "Authorization: Bearer YOUR_API_KEY"

→ {"query": "washr", "suggestions": [
     {"jeton": "washr", "candidats": [
       {"terme": "washer", "distance": 1, "produits": 340}
     ]}
   ]}
GET/v1/index/{catalog}/stats

Statistics

Catalog state: number of products, indexed terms, annotations produced by the cascade, active rule pack, synonym groups.

GET/v1/usage

Usage

Request counter for the current month, for the calling key.

{"month": "2026-07", "requests": 1284}
GET/v1/rulepacks

Rule packs

Lists the packs available on your instance, with their level and rule counts. Eleven standard packs are provided, covering verticals where references are structured: hardware (fasteners, DIN/ISO standards, materials), automotive (Bosch/MANN/Valeo OEM references, engine type, mounting side), electrical (ratings, curves, cross-sections, harmonized designations), plumbing (15x21 threads, nominal diameters, materials), industry (bearings, standardized references), electronics, fashion (sizes, colors, materials), wine, books, finance, and sport (volumes in liters, standardized sizes, string patterns). Custom packs for your catalog can be designed as a ROI Simulator mission.

GET/v1/index/{catalog}/rulepack-suggestion

Automatic pack suggestion

Recommends the best-fitting pack based on your catalog's actual content, not the vertical declared at signup — a fashion catalog left on the hardware pack never finds out on its own otherwise. Annotates a sample of the catalog with every available pack and compares the resulting coverage. Never changes anything: the suggestion is returned, switching packs stays a deliberate action via the indexing endpoint. Doesn't count against quota — an account lookup, like /v1/usage.

curl -X GET https://api.heurix.fr/v1/index/mycatalog/rulepack-suggestion \
  -H "Authorization: Bearer YOUR_API_KEY"

→ {"classement": [
     {"pack": "mode", "annotations_distinctes": 42, "produits_annotes": 380, "couverture_pct": 95.0},
     {"pack": "outillage", "annotations_distinctes": 3, "produits_annotes": 12, "couverture_pct": 3.0}
   ], "recommande": "mode", "raison": "...",
   "marge": {"second": "outillage", "produits_annotes": 31.67,
             "annotations_distinctes": 14.0, "critere": "produits_annotes"}}

Pack names are engine constants, not display labels. The eleven packs are named after their rule files, in French, and pack, recommande, second and pack_actuel all carry those exact strings whatever language you call from: automobile, electricite, electronique, finance, industrie, livres, mode, outillage, plomberie, sport, vins. Above, "mode" is the fashion pack and "outillage" the hardware one — compare against those strings, not against their English names. Same for raison, a French sentence like every message the API writes.

By how much the winner wins, and when it doesn't

recommande can be null, and that is a result, not a failure: no pack recognises your references significantly, the pack already in place is the best one, the gap with it is too small to justify a reindex, or two packs are tied. raison says which of those applies. A client that displays the recommendation must handle that null value rather than treat it as a missing answer.

marge says by how much the winner leads its best competitor. second is not the runner-up in the ranking: it is the best pack that recognises anything at all — comparing against a pack that annotates nothing would be a division by zero. With no competitor, all three fields are null; a winner with no rival has no margin, which is not the same thing as a zero margin.

The ranking sorts on two criteria in this order — produits_annotes first, annotations_distinctes to break the tie — and both ratios are returned because neither is enough on its own. critere is the only authority on ties: the ratios are rounded for display, critere is computed on the integers. When it is null, the two packs annotate exactly the same thing and the sort separated nothing — the engine then refuses to recommend, and recommande is null too. No threshold is applied to the margin itself: you get the number, the decision stays yours.

Before indexing, on a plain sample

POST/v1/rulepacks/suggest

The same comparison, but before any import: you send a sample of products and get the same response object back, with no indexed catalog. That is what lets you pick a pack before the first import rather than after — fixing it afterwards means reimporting everything. At most 300 products are examined; anything beyond that in the body is ignored. An empty sample is rejected with a 422. Doesn't count against quota, and writes nothing: no catalog is created.

pack_actuel is always null in this response — there is no catalog yet, so no pack in place to compare against.

curl -X POST https://api.heurix.fr/v1/rulepacks/suggest \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"items": [{"id": "1", "ref": "VIS-M8X20", "name": "Vis tête hexagonale M8x20 inox A2"},
                 {"id": "2", "ref": "ECR-M8", "name": "Écrou hexagonal M8 zingué"}]}'

→ {"classement": [
     {"pack": "outillage", "annotations_distinctes": 9, "produits_annotes": 2, "couverture_pct": 100.0},
     {"pack": "plomberie", "annotations_distinctes": 1, "produits_annotes": 1, "couverture_pct": 50.0}
   ], "recommande": "outillage", "raison": "aucun pack n'est configuré",
   "pack_actuel": null, "echantillon": 2,
   "marge": {"second": "plomberie", "produits_annotes": 2.0,
             "annotations_distinctes": 9.0, "critere": "produits_annotes"}}
GETPOSTDELETE/v1/index/{catalog}/custom-rules

Custom Rules

Extends a catalog's active rule pack, without touching it — a Custom Rule belongs to a single catalog, never shared with another account. Two templates, no regex to write: keyword (a list of equivalent words all trigger the same label) and prefix_number (a prefix followed by a number becomes a label with the captured value, e.g. RAL recognizes "RAL 9010" and "RAL9010"). Immediate effect: every already-indexed product is re-annotated as soon as the rule is created. Up to 30 custom rules per catalog.

curl -X POST https://api.heurix.fr/v1/index/mycatalog/custom-rules \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"rule_type": "keyword", "label": "Anchor",
       "keywords": ["drywall anchor", "molly bolt", "toggle anchor"]}'

curl -X POST https://api.heurix.fr/v1/index/mycatalog/custom-rules \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"rule_type": "prefix_number", "label": "RAL color", "prefix": "RAL"}'

Manageable both via API and directly from the console, under "My catalog."

POST/v1/events

Conversion & ROI

Reports clicks and purchases from your site back to Heurix, to measure the click-through rate on your searches and the revenue — even the margin — that results from them. Two event types: search_click (a click on a search result) and purchase (one or more products purchased, with amount and optional margin per product).

curl -X POST https://api.heurix.fr/v1/events \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"event_type": "search_click", "catalog": "mycatalog",
       "query": "drywall anchor", "product_id": "sku-123"}'

curl -X POST https://api.heurix.fr/v1/events \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"event_type": "purchase", "catalog": "mycatalog",
       "products": [{"id": "sku-123", "amount": 29.90, "margin": 8.50}]}'

This endpoint is designed to be called from your end visitor's browser, on your own site — not from heurix.fr — hence CORS open to every origin. Three ways to set it up, from most complete to simplest:

  • Heurix Tracker (recommended) — a persistent visitor identifier, to link a click to a later purchase from the same visitor rather than a simple aggregate correlation. One single script to drop site-wide, exposes the same functions as the snippet below.
  • Google Tag Manager tag template (.tpl file, import under Templates → Tag Templates → Import) — compatible with the tracker via an optional "Visitor ID" field
  • Simple JavaScript snippet, no persistent identifier — for a minimal integration with no GTM or tracker

Doesn't consume your request quota — it's not an engine call, it's a data report.

Worth knowing before reading your numbers: even with the tracker, search → purchase attribution is not a 100% guarantee. Without a visitor identifier, Heurix aggregates clicks and purchases received over the same period and account (total_revenue) — a correlation, not proof. With the tracker installed, an additional field (attributed_revenue) only counts purchases where the same visitor clicked from a search within the previous 24 hours — a real link, notably more reliable, but still dependent on the quality of your implementation.

GET/v1/analytics/conversion-summary

Click-through rate, attributed revenue, and margin over the period (?days=30 by default). Also returns attributed_revenue and attributed_productsnull if no event in the period carries a visitor identifier (the tracker isn't installed), a real figure otherwise.

GET/v1/analytics/top-products

Most-purchased products, sorted by volume or by margin (?sort_by=volume or margin).

Category page views

The tracker can report which products were shown on a category page, which makes it possible to spot ones that are shown a lot but rarely clicked — exactly the population that pinning and burying exist to correct.

Heurix.trackCategoryView("fasteners", ["sku-1", "sku-2", "sku-3"]);

One call per page view, carrying the list of displayed products — not one call per product. The engine then records one impression per product, which makes aggregation possible in a single request. Worth knowing on volume: a page showing 24 products generates 24 rows, automatically purged past the retention period. The list is capped at 50 identifiers.

GET/v1/analytics/category-views/{catalog}

Returns categories sorted by number of impressions, and within each, the most-viewed products with their click count from search. Doesn't consume your quota.

{"catalog": "mycatalog", "categories": [
  {"category": "fasteners", "total_views": 512, "products": [
     {"product_id": "sku-1", "views": 180, "search_clicks": 12},
     {"product_id": "sku-2", "views": 175, "search_clicks": 0}
  ]}
]}

Returns an empty list until the tracker reports this event — the case for any integration that predates this feature. Nothing breaks, the screen just stays empty.

Products frequently purchased in the same cart as {product_id} — a counted co-occurrence, not machine learning. Useful for an "often bought with" section on a product page.

{"product_id": "sku-123", "target_purchases": 47, "related": [
  {"product_id": "sku-456", "co_purchases": 12, "name": "M8 flat washer, stainless", "price": 0.12},
  {"product_id": "sku-789", "co_purchases": 4, "name": "13mm open-end wrench", "price": 4.90}
]}

target_purchases gives the total purchases of the requested product over the period — enough to compute a percentage instead of showing an isolated count ("12 out of 47 purchases", clearer than "12" alone). name and price are resolved from your current catalog; null if the related product has since been removed.

Empty list if fewer than 5 purchases are recorded for that specific product — not enough signal to be reliable, never an error. Requires the Heurix Tracker for a meaningful result on a high-traffic site: without a visitor identifier, two different customers' purchases occurring almost simultaneously can, rarely, be mistaken for a single cart.

GET/v1/analytics/intent-score/{catalog}/{visitor_id}

An intent score per visitor — distinct from popularity-boosted search, which is per product. Explainable by design: every component is returned separately, never collapsed into a single number you couldn't justify.

{"score": 0.72, "nb_recherches": 12, "nb_achats": 2,
 "composantes": {
   "precision_recherche": 0.83,
   "taux_clic": 0.58,
   "panier_moyen": 45.20
 }}

precision_recherche: share of this visitor's searches carrying a numeric reference ("M8x20") rather than a generic word ("bolt"). taux_clic: share of those same searches that led to a click on a result. score is their average — the only two signals it's made of, on the same 0-to-1 scale. panier_moyen (average basket, in euros, null with no purchase) is returned separately, for context: no obvious common scale with the other two signals, folding it in arbitrarily would have been an unjustifiable call.

Visitor never seen, or no search over the period: score at 0.0, never an error. Doesn't consume your quota.

GET/v1/analytics/segmentation/{catalog}?days=30

Splits every active visitor of a catalog into three intent tiers (same formula as the intent score above), compared against the previous period of equal length.

{"period_days": 30,
 "courant": {"total_visiteurs": 142, "repartition": {"fort": 38, "moyen": 71, "faible": 33}},
 "precedent": {"total_visiteurs": 98, "repartition": {"fort": 19, "moyen": 58, "faible": 21}},
 "variations": {"total_visiteurs": 44.9, "fort": 100.0, "moyen": 22.4, "faible": 57.1}}

variations is null rather than a percentage when the previous period was empty — going from 0 to 5 isn't a "500% increase", it's a start; better to show nothing than a misleading figure. Counts are aggregated, never a nominal list: Heurix only knows a visitor by a pseudonymous identifier, never a name or an email. Recalculated at most once an hour — an aggregated behavior signal that doesn't need to be any fresher than that.

GET/health

Service health

Liveness, no authentication — handy for your monitoring probes.

{"status": "ok", "catalogs": 3}
JSheurix-search.js

Ready-to-use search bar

Don't want to code the fetch call, result rendering, and facet handling yourself? A standalone JavaScript library (zero dependencies) renders a complete search bar — input, live results, clickable facets, keyboard navigation — connected to your catalog in a few lines.

Download heurix-search.js — a single file, drop it wherever you want on your site.

Use a public key (hxp_) — this script runs in the visitor's browser, where the key is readable by anyone. A server key would grant access to your billing.

Minimal integration

<div id="my-search"></div>
<script src="heurix-search.js"></script>
<script>
  Heurix.searchBox({
    apiKey: "YOUR_PUBLIC_KEY",
    catalog: "mycatalog",
    containerId: "my-search"
  });
</script>

That's enough for a functional search bar — input with built-in debounce, results shown from 2 characters, typo tolerance (handled engine-side, nothing to do here), automatic fallback on zero results if your catalog has featured products.

Configuration options

OptionTypeDescription
apiKey, catalog, containerIdstringThe only required options.
facetsstring[]Fields to offer as clickable filters (e.g. ["brand", "color"]). Absent by default — no facet shown until you list them explicitly.
accentColorstringAn accent color (e.g. "#2952E3") for focus and active filters — minimal personalization by design, not a full CSS overhaul.
placeholderstringSearch field text. Default: "Search…".
minCharsnumberNumber of characters before triggering a search. Default: 2.
debounceMsnumberDebounce delay in milliseconds. Default: 200.
limitnumberMaximum number of results shown. Default: 8.
renderItemfunction(hit)Customizes the HTML of each result. Receives a full hit (see the search reference); shows name, reference, price, and out-of-stock status by default.
resultHreffunction(hit) → stringIf provided, each result becomes an <a> link to the returned URL (e.g. the product page), rather than a plain clickable element.
onSelectfunction(hit)Called on click or keyboard confirmation (Enter) on a result — useful if you handle navigation yourself rather than via resultHref.
timeoutMsnumberHow long to wait before giving up on a call, in milliseconds. Default: 3000. A shopper is waiting in front of the page: better to hand control back than to keep them hanging. 0 disables the timeout.
fallbackHreffunction(query) → stringIf Heurix does not answer, the panel offers the shopper to carry on at the URL you return — typically your own results page. Without this option the panel still shows a "Try again" button: it never stays a dead end.
baseUrlstringDefault: https://api.heurix.fr. Only change this for a test environment.

Full example, with facets and product link

Heurix.searchBox({
  apiKey: "hxp_your_public_key",
  catalog: "mycatalog",
  containerId: "my-search",
  facets: ["brand"],
  accentColor: "#C0392B",
  resultHref: function (hit) {
    return "/products/" + hit.product.id;
  }
});

Every search through this widget consumes the normal quota of the key used, exactly like a direct fetch call to the search endpoint — it's just a facade, not a new billing mechanism.

The visual style provided stays deliberately minimal — functional and readable, designed to be easily customized (every element has a CSS class prefixed hx-) rather than to match a specific brand identity out of the box.

npm@heurix-site/client

Official TypeScript/JavaScript client

For a Node.js project, a TypeScript build, or simply preferring a module over a hand-written fetch: the official client covers search, Browse & Discovery, indexing, and synonyms. Zero runtime dependency (uses native fetch), full types.

npm install @heurix-site/client
import { HeurixClient } from "@heurix-site/client";

const client = new HeurixClient({
  apiKey: "hxp_your_public_key",
  catalog: "mysite",
});

const results = await client.search("m8 stainless screw");
console.log(results.hits);

With no build step, importing directly from a CDN works just as well:

<script type="module">
  import { HeurixClient } from "https://cdn.jsdelivr.net/npm/@heurix-site/client@latest/dist/index.js";
</script>

Same rule as everywhere else on the browser side: a public key (hxp_), never your server key, in code that runs on a visitor's machine.

One name to remember: @heurix-site/client. The package heurix-client (no organization) existed briefly during early development and is now deprecated — if it turns up in an older npm search result, ignore it.

MCPHeurix server for AI agents

Querying Heurix from Claude Desktop, Cursor, or an internal agent

An MCP (Model Context Protocol) server that exposes search and Browse as tools an AI agent can call natively — a team member can ask "do I have M8 screws in stock?" in natural language, without ever touching the API. Three tools: heurix_search, heurix_browse, heurix_catalog_stats.

Requirements: Python 3.10 or later, installed on the machine running the agent (your computer, not a server) — the MCP server launches locally, on demand, by Claude Desktop or Cursor themselves.

Download the MCP server — detailed installation and configuration (Claude Desktop and Cursor) on the dedicated guide.

pip install -r requirements.txt
HEURIX_API_KEY=hx_your_key python3 server.py

The API key lives only in the MCP client's local configuration (a JSON file on the user's machine), never sent in the clear in a tool call. Every call consumes the normal quota of the key used — heurix_search and heurix_browse count like any search or Browse call, heurix_catalog_stats does not.

Glossary

Heurix's own vocabulary, in the order a query actually travels through it: first indexing a catalog, then understanding what a visitor types, then ranking results, finally tracking and adjusting. For broader industry terms (index, facet, ranking...), see also the e-commerce search glossary on the blog.

Indexing a catalog

Catalog. A set of products indexed under one name, isolated from every other catalog. An account can hold several — one per language, per brand, or a test environment.

Rule. A recognition mechanism: a regular expression, a keyword list, or a prefix + number pattern that spots a pattern in text. A rule doesn't change anything — it produces an annotation when it recognizes something.

Annotation. The label attached to a product once a rule has recognized something in its text (reference, name, description). DIAM_M8, FAM_VIS are annotations. A single product accumulates several, one per rule that fired.

Rule pack. A ready-made set of rules for an industry (hardware, fashion, electronics...). Chosen at indexing time, it determines which annotations a catalog produces automatically.

Custom Rule. A rule you add yourself, specific to one catalog, for vocabulary no pack could have guessed. Two formats: keyword → label, or prefix + number → label.

Understanding a query

Typo tolerance. The ability to find a product despite a one- or two-letter mistake. How many mistakes are tolerated depends on the length of the typed word: the shorter it is, the smaller the accepted gap, to avoid confusing two unrelated short words.

Edit distance. The measurement behind typo tolerance: the minimum number of insertions, deletions, or substitutions needed to turn a typed word into a known one. "tshi" → "tshirt": distance 2.

Fuzzy matching. The process that accepts a typed word as a match for a catalog word as long as their edit distance stays within the allowed tolerance.

Prefix. A query still being typed. Unlike typo tolerance, which assumes a complete, possibly misspelled word, prefix search recognizes that an incomplete word unambiguously leads to a longer catalog word.

Synonym. A declared equivalence between several free-text words ("screw" = "bolt" = "vis"). Unlike an annotation, a synonym labels nothing on the product: it widens what a query can reach, at search time.

Facet. A filter built automatically from a catalog field (brand, color, material), letting a results list be narrowed without typing a new query.

Ranking results

Relevance (score). The value assigned to each result to determine its rank. It combines the nature of the match — exact, fuzzy, prefix, synonym — with its weight: a word found exactly always outweighs one found through typo tolerance.

Ranking. The final order results are presented in, once relevance is computed and priority rules are applied.

Search Override. A manual rule that pins or buries a specific product for a specific query ("on 'sale', always show X first"). Independent from the natural ranking.

Browse & Discovery. Ranking products on a category page, with no search query involved. Runs on its own priorities and boosts, independently from search.

Attribute boost / bury. A rule that features — or demotes — every product sharing a common attribute (a brand, a color), without targeting them one by one. A per-product demotion always keeps priority over an attribute boost.

Family grouping. Collapsing near-identical results (the same screw in twelve lengths) into one representative entry, so a broad query stays readable instead of drowning in variants.

Tracking and adjusting

Zero-result query. A search that found nothing. Every occurrence is a direct signal: either a synonym is missing, or the product being searched for doesn't exist in the catalog.

The matched field. The list, included with every result, of the terms and annotations that made it match. It's the diagnostic tool: if a result seems surprising, this field shows why.

API key (server / public). The server key authorizes everything, including indexing — it must never appear in a web page. The public key can only search and browse the catalog: it's the only one meant to sit in a site's code.

Sandbox. A catalog flagged as a test: it isn't billed and its searches don't appear in your statistics. Useful for trying a configuration without touching real data.

The annotation cascade

This is the engine's core. A rule pack is organized in levels: level 1 applies regular expressions to normalized text and produces annotations; the next levels apply to the annotation stream and compose them. Documents and queries go through the same cascade, so matching happens in a shared space:

"m8x20 stainless"                       "M8 x 20 — A2"   (product record)
      │                                        │
      ▼  level 1                               ▼  level 1
DIAM_M8, LONG_20, MAT_INOX             DIAM_M8, LONG_20, MAT_INOX
      │                                        │
      ▼  level 2                               ▼  level 2
VIS_M8X20  ──────────── match ─────────────  VIS_M8X20

Every shared annotation weighs heavily in the score — that's what puts the right reference first even when the written form differs. The matched field of each result makes those annotations visible.

Good practices

Fill ref, name, and description separately. Field weighting is what makes ranking good — concatenating everything into name flattens relevance.

Keep references as they are. Don't "clean" product references before indexing (stripping spaces, dashes...): normalization and the cascade handle it, and the original form stays available for display.

Index in batches of 1,000 to 5,000. One batch = one network call; too small multiplies calls, the cap is 5,000 per call.

Send stock with every update. Tie-break sorting relies on it; up-to-date stock avoids promoting unavailable products.

One catalog per language. If your store exists in French and English, create store-fr and store-en — synonyms and rules stay consistent per language.

Use matched to tune relevance. Every result explains why it ranks: if a product surfaces wrongly, this field shows which term or annotation made it match — it's your diagnostic tool.

Error codes

CodeMeaningWhat to do
401Authorization header missing or malformedSend Authorization: Bearer <key>
403Invalid API keyCheck the key; contact us if it was revoked
404Catalog or product not foundCheck the catalog name and id — the catalog is created on first indexing call, not before
422Invalid request bodyThe error detail names the field at fault (e.g. product missing an id)
5xxServer-side errorRetry; if it persists, write to contact@heurix.fr

Error messages are in French. The status code and the response shape are the stable, documented contract — branch on those, not on the text of detail, which is a sentence written for a human reading the raw response and may be reworded. On a 422, detail is the list of validation errors: loc names the field at fault and msg carries the validator's message, in English for everything the schema itself rejects.

Limits

Search query500 characters maximum
Results per page100 maximum (limit)
Indexing5,000 products per call — send multiple batches for a larger catalog
Synonym groups2,000 per catalog
CORSBrowser calls are not open: call the API from your server, not from public JavaScript (your key would be exposed)

Need more than these limits? Let's talk — that's what the Scale plan and dedicated setups are for.

14-day free trial

Try Heurix on your catalog, no credit card.

See pricing