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.
| Type | Prefix | Scope | Where 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:
| Search | 1 search call = 1 request |
| Indexing | never counted against your request quota — update your catalog as often as needed, only the total number of indexed products is capped by plan |
| Federated search | 1 request per catalog queried |
| Management | Most management calls are not counted — keys, accounts, subscription, merchandising priorities, analytics. Nine endpoints are the exception, listed just below |
| Ranking | 1 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}/stats | Catalog statistics |
GET /v1/index/{catalog}/synonyms | Read synonyms |
PUT /v1/index/{catalog}/synonyms | Replace synonyms |
GET /v1/index/{catalog}/synonym-suggestions | Synonym suggestions |
GET /v1/rulepacks | List of rule packs |
PUT /v1/index/{catalog}/config | Change the active pack |
POST /v1/index/{catalog}/custom-rules | Create 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:
| Plan | Requests / month | Max products | Catalogs |
|---|---|---|---|
| Trial (14 days) | 2,000 | 2,000 | 2 |
| Starter — €19/month | 15,000 | 8,000 | 1 |
| Growth — €49/month | 30,000 | 25,000 | 3 |
| Scale — €139/month | 150,000 then billed | 50,000 | unlimited |
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 does — plan 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:
| Field | Type | Role |
|---|---|---|
id | string required | Unique identifier within the catalog. Re-indexing the same id replaces the product (upsert). |
ref | string | Product reference (SKU, manufacturer code). Indexed, highest weight. |
name | string | Product name. Indexed, high weight. |
description | string | Description. Indexed, standard weight. |
stock | number | bool | string | Availability — used for sorting at equal relevance. 0, false, "out" = unavailable. |
lat / lon | number | string | Position, 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.
/v1/index/{catalog}/searchSearch
Runs a search on a catalog. Results sorted by relevance, then availability at equal score. An empty query (q: "") is accepted: that's browse mode, for navigating a catalog by facets alone, before any text input.
Request body
| Parameter | Type | Description |
|---|---|---|
q | string | The search query (0 to 500 characters — empty allowed, see browse mode above). |
limit | integer | Results per page (1–100, default 10). |
offset | integer | Pagination offset (default 0). |
facets | array of string | Annotation groups to return counts for (e.g. ["FORMAT", "LANG"]). See Facets and filters. |
filters | array of string | Required filters, combined with AND. Two forms, told apart by the ":":["FORMAT_POCHE", "LANG_FR"] — exact annotations.["brand:Makita", "color:blue"] — product fields, the same ones Browse uses: any non-reserved text field becomes filterable at indexing time, no configuration. The pipe means OR within one field (brand:Makita|Bosch).Both forms mix freely in one list. No annotation shipped in the rule packs contains a " :", and a custom rule cannot produce one, so the distinction can never turn ambiguous. |
latlonradius_km | number | Restricts results to a radius around a point. All three together, or none: a partial radius is an integration mistake rather than a request to interpret, and the API answers 422 naming what is missing.lat from −90 to 90, lon from −180 to 180, radius_km above 0 and up to 200. Past 200 km, "near me" stops describing a radius and starts describing a region — and a region is filtered by field (filters: ["region:Brittany"]), which costs less and reads better.A product with no position is excluded from a radius search, deliberately: a branch whose coordinates you have not entered stays invisible to every radius search, even at 200 km. Keeping it instead would return out-of-area results that nothing in the response tells apart from the good ones — and would be most wrong when your data is newest. |
in_stock_only | bool | Excludes out-of-stock products (default false — some catalogs deliberately show out-of-stock items to signal a product exists). |
lang | string | Filters by language if your products carry a lang field (e.g. "fr", "en"). A product without this field stays visible regardless of the requested language — useful for a mostly single-language catalog with a few bilingual entries. Omitted, no filtering is applied. |
exclude_description | bool | Omits the description field from each product in the results (default false, nothing changes). Useful if your descriptions are long: on a real catalog, this field alone accounts for 80%+ of each product's weight in the response — relevant for a mobile widget where every kilobyte counts. |
visitor_id | string | Optional (64 chars max). Sent automatically by heurix-search.js when the Tracker is loaded on the same page — no glue code to write. Feeds the intent score, segmentation, and slightly re-ranks results based on this visitor's purchase and click history — never strong enough to overturn a real relevance gap, only to break a near-tie, same logic as popularity. Without it, all three stay empty for that visitor, nothing else changes. |
include_highlights | bool | Adds, for every relevant field (ref, name, description), the exact positions of matches found by this specific query — never every characteristic the product happens to have. Default false: zero cost when not requested, computed once per query, never per candidate product. See Highlighting search results below for the exact format. |
Response
total (match count), hits — each hit contains the full product as indexed, its score, in_stock, and matched: a readable list of why it matched (terms found, typos corrected, shared annotations) — useful for understanding and tuning relevance. If facets was requested, an additional facets field lists the counts per group. If include_highlights was requested, an additional highlights field appears on every relevant hit.
With a radius, every hit also carries distance_km — the distance to the requested point, rounded to two decimals. It sits on the hit, not inside product: it is a property of your query rather than of the product, and putting it in product would make it look like a field of your catalog.
Two diagnostic keys, absent whenever they have nothing to report. Do not treat them as always present — absence is the normal case.
filters_unknown— the filters that designate nothing in this catalog: an unknown field name and a misspelled annotation alike, one key for both, because you send strings infiltersand want to know which ones caught nothing. The filter still applies — an absent field excludes everything andtotalstays 0. Only the field name is reported, never the value:county:Cantalis a perfectly valid filter whose answer happens to be "no results".radius_no_positions—truewhen a radius was requested and not one candidate carries a position. Without it, a radius on a catalog that has no coordinates returns zero in silence, which reads exactly like a radius that is simply too small.
Highlighting search results
With include_highlights: true, each hit may carry a highlights field: one set of spans per relevant field, each span a [start, end] pair to use for wrapping the matching fragment in your own rendering (a <mark> tag, for instance).
{
"product": {
"ref": "VIS-M8X20-INOX-A2",
"name": "Vis à métaux M8x20 inox A2, lot de 50"
},
"highlights": {
"ref": [[0, 3], [4, 9], [10, 14]],
"name": [[0, 3], [13, 18], [19, 23]]
}
}
Positions are in Unicode code points (codepoints), not bytes or UTF-16 units — Python's native indexing. For the vast majority of real product titles this matches your own language's native indexing; an emoji or a character outside the Unicode basic plane would introduce a mismatch with a language that indexes differently (JavaScript, for instance, natively iterates in UTF-16 — use Array.from(text) rather than text[i] if your titles might contain one).
A field with no match for this query simply doesn't appear in highlights — never an empty array. A product that only matches through its reference won't have a name key in its highlights object, even though its title is still present in the response.
Highlighting reflects what the query actually triggered, never everything the product happens to have. A product characteristic unrelated to the current search (a material, a standard, a screw head type mentioned deep in a long description) is never highlighted, even though it's indexed and searchable on its own.
Two spans can partially overlap within the same field (e.g. [4, 7] and [4, 9] on the same reference, if several rules match nested fragments of the same term) — merge them before rendering rather than treating them as independent spans, or you'll end up with malformed <mark> tags.
Rule simulation on Browse
/v1/browse/{catalog}/{category}/simulateCounterpart of the search's simulate_overrides field, for category-page merchandising. Previews a ranking with unsaved rules:
{
"overrides": [{"product_id": "PER-BOSCH-18V", "action": "pin", "position": 1}],
"sort": "stock",
"limit": 20
}
A dedicated POST endpoint rather than a parameter on the existing GET: a list of draft rules quickly exceeds a reasonable URL length, and this leaves the normal read path entirely untouched.
| Guarantee | Behavior |
|---|---|
| Database write | None. Your saved rules are unchanged. |
| Browse quota | Not consumed. These are your tests, not customer traffic. |
| Key required | Server key. A public key gets a 403. |
| Attribute rules | Saved ones keep applying — the simulation covers per-product merchandising, not catalog configuration. |
overrides replaces the saved set for this call. An empty list therefore previews "with no rules at all," which lets you see the effect of a deletion before making it.
Rule simulation
Testing a query priority used to require saving it — and therefore applying it to your visitors immediately. The simulate_overrides field lets you see the effect without writing anything:
{
"q": "vis",
"simulate_overrides": [
{"query": "vis", "product_id": "PER-BOSCH-18V", "action": "pin", "position": 1}
]
}
The response carries "simulated": true, and results are ranked as if these rules were active.
The list replaces your entire saved set of priorities for this call — it doesn't add to it. That's what lets you preview an addition, a change, or a deletion the same way: just send the complete state you want. An empty list therefore previews "with no rules at all."
| Guarantee | Behavior |
|---|---|
| Database write | None. Your saved priorities are unchanged. |
| Quota | Not consumed. These are your own tests on your catalog, not your customers' searches. |
| Search statistics | Not logged. Your trials don't pollute your own analytics. |
| Key required | Server key only. A public key gets a 403 — this is an admin tool, not visitor traffic. |
Per-key pricing
A B2B distributor has list prices and net prices per customer population, and many show no price at all without a trade account. A public key therefore carries which product field stands as the price, and whether it is served at all.
You push your prices as ordinary product fields — price, price_pro, whatever you name them — then create one public key per population and serve the right one from your own back office, the only place where the customer's identity exists.
curl -X PUT https://api.heurix.fr/v1/keys/public/hxp_xxx/pricing \
-H "Authorization: Bearer YOUR_SERVER_KEY" \
-H "Content-Type: application/json" \
-d '{"catalog": "mycatalog", "price_field": "price_pro"}'
| Field | Type | Description |
|---|---|---|
catalog | string | Required. The catalog to validate the requested field against — without it a typo would go through and every product would look priceless. |
price_field | string | The product field served as the price. Defaults to price. |
price_visible | bool | At false, no price is served. Defaults to true. |
Server keys only. A public key lives in a browser: if it could declare its own price, any visitor would read trade prices by changing one parameter. For the same reason there is no price parameter in a search request — the configuration comes from the key, never from the call.
A field no product carries is refused, and the message lists the ones that exist (in French, like every error message):
{"detail": "Aucun des 5000 produits de « mycatalog » ne porte le champ
« price_prro ». Champs de prix présents : price, price_pro."}
Partial coverage is accepted and counted. Products without the field have no price at all — never a silent fallback to price, which would serve the list price to a trade buyer without saying so.
Hiding removes every price field, not just price: "no price without an account" would mean nothing if price_pro kept coming out because it isn't called price. Fields whose name contains price, prix or tarif are removed — and the response lists them rather than letting you find out later:
{"price_visible": false,
"removed_fields": ["price", "price_pro", "tarif_livraison"]}
What search returns. The product carries a single price, under the price key whatever its source: your front end doesn't change. The response adds "custom_price": true when it isn't the list price, or "price_visible": false when no price is served. The field name never reaches the browser — it says something about your pricing structure; you read it back on GET /v1/keys/public.
An unconfigured key behaves exactly as before: the price field, served, and neither of those two keys in the response.
The limit, and it is a clear one: one set of prices per public key, not one price per customer. A distributor creates one key per population — anonymous visitors, trade accounts, key accounts — and serves the right one from their back office, the only place where the customer's identity exists. Heurix does not carry individual pricing: a price negotiated per customer calls for a pricing-rules engine, which is not what Heurix does.
Natural-language price constraints
A price constraint phrased in plain language is recognized in the query and converted into a filter — "stainless screws under €2" filters on price instead of searching for the words "under" and "euros" in your catalog. English, French, Italian, Spanish, German and Portuguese phrasings are recognized.
{"query": "stainless screws", "total": 3, "hits": [...],
"price_filter": {"min": null, "max": 2.0}}
The returned query is stripped of the consumed fragment ("stainless screws", not "stainless screws under €2"): the constraint's words don't weigh on the relevance score. The price_filter field only appears if a constraint was detected — useful for displaying it to the buyer and letting them remove it.
Recognized phrasings — a deliberately short, documented list rather than an exhaustive one:
| Effect | Phrasings |
|---|---|
| Maximum price | under X, less than X, below X, up to X |
| Minimum price | over X, more than X, above X, from X |
| Range | between X and Y |
The currency marker is optional on every phrasing above, and it may sit on either side of the number: under €2, under 2 euros and under 2 are read the same way, and $ and £ work like €. Only max X and min X require a currency, because "max 20" could just as easily mean 20 mm. The decimal comma is accepted (3,50 just like 3.50).
The engine reads a number, never a currency: it does not convert. under $2 on a catalog priced in euros filters on 2, not on a converted amount.
The constraint applies to the price the calling key serves, not necessarily to price — "under €5" filters on the trade price for a key configured that way (see Per-key pricing).
A product with no price field is excluded as soon as a constraint is active: there's no way to confirm it satisfies it, and surfacing it anyway would be misleading. It stays findable through a search with no price constraint.
Query still being typed
A word of 3 characters or more that matches the start of a vocabulary word also surfaces results — not just an exact match or a full typo correction. Typing "per" already finds "perceuse" (drill), without waiting for the whole word. A result found this way always counts for less than an exact match or an ordinary typo correction: as soon as the query is complete, ranking shifts back toward the most relevant results. Below 3 characters, no prefix matching is attempted at all — too many vocabulary words would share a 1- or 2-letter start by pure coincidence.
Zero-result fallback
A search that finds nothing (total: 0) on a catalog containing at least one product marked featured: true at indexing time returns up to 6 of these products in hits, with fallback: true — to offer a way forward instead of a blank page. total stays 0: these aren't real search results, so present them distinctly on your end (e.g. "No results for '…' — our picks" rather than blending them with real results). Without any featured product in the catalog, hits stays empty and fallback is false, as before.
Suggested category
If a word in the query (4 characters or more) overlaps with a Browse category known to this catalog, the response includes a suggested_category field — a cue for offering "did you mean to search in this category?" in your interface, never an automatic filter: the sort order and content of hits stay unchanged.
{"query": "perceuse", "total": 4, "hits": [...],
"suggested_category": {"category": "perceuses-visseuses", "products": 12}}
Matching is done by substring, not just strict equality — "perceuse" (singular) does find the "perceuses-visseuses" category (plural, with a hyphen). If several categories match at once, the most populated one wins. Absent from the response if there's no match, or in browse mode (empty query).
Highlighting a pack or bundle
A technical catalog rarely sells only standalone products: a 4-tool pack, a cable-and-connector kit, a lot of 100 screws — these bundles often have a much higher average order value than a single product, but describing them in one long text naturally puts them at a disadvantage in pure ranking (a relevant word repeated several times in a long description accumulates more weight than a short, precise listing — the right behavior elsewhere, not here).
Rather than artificially deciding "pack or standalone product first" inside the score itself, the response includes a separate field, highlighted_bundle: the best-matching pack for the query, distinct from hits.
{"query": "perceuse 18V", "total": 47, "hits": [...],
"highlighted_bundle": {
"product": {"id": "pack-42", "name": "Pack 4 outils 18V Bosch", "category": "Pack 4 outils", "price": 1200},
"score": 35.66,
"in_stock": true
}}
hits doesn't change: it's still the natural ranking, standalone products included — it's up to your interface to decide how to present the two zones (for example, a visually distinct highlight above the regular results list).
Detection via the category field: the response treats a product as a bundle if its category contains "pack," "kit," or "bundle" (case-insensitive) — no extra data to supply, it works with a catalog you've already indexed. The trade-off, knowingly accepted: it depends on the vocabulary already present in your categories, not an explicit declaration. Respects stock: an out-of-stock bundle is never chosen, even if it's the best match among matching products — selection moves on to the next one. If no matching, in-stock product contains one of these words, the field is simply absent from the response.
Popularity on search
When several products answer a query equally well, the one that actually sells moves up — not a plain text-relevance sort. The principle: never strong enough to overturn a real relevance gap, only to break a near-tie. A product with an exact match on the name always stays ahead, whatever the other product's popularity.
final_score = text_score × (1 + boost_max × normalized_popularity)
normalized_popularity compares each product against the most popular one in the entire catalog (not just this query's results) — a stable scale that doesn't shift from one search to the next depending on what matches. boost_max defaults to 10%: even the catalog's single most popular product can never lift its score by more than a tenth.
Popularity itself combines search clicks and purchases, weighting a purchase (5) more heavily than a click (1) — a signal aggregated over 90 days, recalculated hourly, never on every search. Requires the Heurix Tracker: without it, this behavior stays off and ranking is identical to before this feature existed.
/v1/index/{catalog}/search-overridesQuery 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.
| Field | Type | Description |
|---|---|---|
query | string | The trigger, normalized like the rest of the engine (accents, case) before comparison. |
product_id | string | The affected product. |
action | string | pin (exact position, requires position) or bury (end of list, only applies to a product already present naturally). |
position | integer | The 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_POCHEdoesn't makeFORMAT_BROCHEdisappear 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
LANGshows 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 too — filters: ["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.
/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"
| Parameter | Type | Description |
|---|---|---|
sort | string | stock (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) |
filters | string | "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. |
facets | string | "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 / offset | int | Pagination, same as search. |
in_stock_only | bool | Excludes out-of-stock products (default false), before facet counts are computed — a hidden product doesn't weigh into the counts. |
lang | string | Same 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.
/v1/federated-searchFederated 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
| Parameter | Type | Description |
|---|---|---|
catalogs | array of string required | 1 to 10 catalog names to query. |
q | string | The search query. |
limit / offset | integer | Pagination over the merged set. |
filters / facets | array of string | Same 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_km | number | Same 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.
/v1/index/{catalog}/itemsIndexing
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
| Parameter | Type | Description |
|---|---|---|
items | array required | 1 to 5,000 products per call. Every product must have an id. |
rulepack | string | Rule 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"]
}
]
}
| Parameter | Effect |
|---|---|
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.
| Level | What you replace | Order 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.
| Platform | Hook point | Nature | Order 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.
| Compatibility | PrestaShop 8.x and 9.x. The 1.7 branch is not supported: the module refuses to install below 8.0.0 |
|---|---|
| Dependencies | None — native cURL |
| What it replaces | Search results only. Theme, templates, and filters stay yours |
| Fallback | PrestaShop's native search if Heurix doesn't respond |
Installation
- Copy the
heurixsearchfolder into your store'smodules/ - Back office → Modules → install "Heurix Search"
- Enter your server key (
hx_) and your catalog's name - 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 formats | CSV and XML, detected automatically — a single drop button |
|---|---|
| Detection — CSV | Separator (;, ,, tab) and encoding, including Latin-1, common on French exports |
| Detection — XML | The 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 |
| Matching | Suggested from your column headers (CSV) or elements and attributes (XML), editable, with a live value preview |
| Numbers | 1,24 and 1 234,56 are understood; in XML, in stock / out of stock are recognized for stock |
| Batching | Automatic, in batches of 5,000 |
| Rule pack | Recommended 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_idyour 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_fieldparameter 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 ... ]
}'
/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"
/v1/index/{catalog}/synonymsSynonyms
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"]]}'
/v1/index/{catalog}/synonym-suggestionsSynonym 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}
]}
]}
/v1/index/{catalog}/statsStatistics
Catalog state: number of products, indexed terms, annotations produced by the cascade, active rule pack, synonym groups.
/v1/usageUsage
Request counter for the current month, for the calling key.
{"month": "2026-07", "requests": 1284}
/v1/rulepacksRule 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.
/v1/index/{catalog}/rulepack-suggestionAutomatic 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
/v1/rulepacks/suggestThe 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"}}
/v1/index/{catalog}/custom-rulesCustom 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."
/v1/eventsConversion & 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 (
.tplfile, 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.
/v1/analytics/conversion-summaryClick-through rate, attributed revenue, and margin over the period (?days=30 by default). Also returns attributed_revenue and attributed_products — null if no event in the period carries a visitor identifier (the tracker isn't installed), a real figure otherwise.
/v1/analytics/top-productsMost-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.
/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.
/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.
/v1/analytics/segmentation/{catalog}?days=30Splits 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.
/healthService health
Liveness, no authentication — handy for your monitoring probes.
{"status": "ok", "catalogs": 3}
heurix-search.jsReady-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
| Option | Type | Description |
|---|---|---|
apiKey, catalog, containerId | string | The only required options. |
facets | string[] | Fields to offer as clickable filters (e.g. ["brand", "color"]). Absent by default — no facet shown until you list them explicitly. |
accentColor | string | An accent color (e.g. "#2952E3") for focus and active filters — minimal personalization by design, not a full CSS overhaul. |
placeholder | string | Search field text. Default: "Search…". |
minChars | number | Number of characters before triggering a search. Default: 2. |
debounceMs | number | Debounce delay in milliseconds. Default: 200. |
limit | number | Maximum number of results shown. Default: 8. |
renderItem | function(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. |
resultHref | function(hit) → string | If provided, each result becomes an <a> link to the returned URL (e.g. the product page), rather than a plain clickable element. |
onSelect | function(hit) | Called on click or keyboard confirmation (Enter) on a result — useful if you handle navigation yourself rather than via resultHref. |
timeoutMs | number | How 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. |
fallbackHref | function(query) → string | If 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. |
baseUrl | string | Default: 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.
@heurix-site/clientOfficial 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.
Heurix server for AI agentsQuerying 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
| Code | Meaning | What to do |
|---|---|---|
401 | Authorization header missing or malformed | Send Authorization: Bearer <key> |
403 | Invalid API key | Check the key; contact us if it was revoked |
404 | Catalog or product not found | Check the catalog name and id — the catalog is created on first indexing call, not before |
422 | Invalid request body | The error detail names the field at fault (e.g. product missing an id) |
5xx | Server-side error | Retry; 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 query | 500 characters maximum |
| Results per page | 100 maximum (limit) |
| Indexing | 5,000 products per call — send multiple batches for a larger catalog |
| Synonym groups | 2,000 per catalog |
| CORS | Browser 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.