With the HorusFinder REST API you load your store's catalog and search your products, from the browser or from your server. It speaks JSON over HTTPS and works from any language. You create your keys in the HorusFinder console, which we open on request.
At a glance
From the browser, search with the publishable key (pk), which sits in plain sight in your store's page:
// From the browser, with the publishable key (pk)
const url = new URL("https://api.horusfinder.com/v1/public/esempio/collections/products_en/search");
url.search = new URLSearchParams({ q: "trail running shoes", "f.availability": "in_stock", size: "24", key: "hrf_pk_live_…" });
const res = await fetch(url);
if (!res.ok) throw new Error(`HorusFinder ${res.status}`); // your fallback goes here
const { queryId, total, hits } = await res.json();From your server, load the catalog and search with a secret key (sk), which stays on the server:
# From the server, with the secret key (sk, ingest scope): load or update your catalog
curl https://api.horusfinder.com/v1/collections/products_en/documents/batch \
-H "X-Api-Key: hrf_sk_live_…" -H "Content-Type: application/json" \
-d '{"upsert":[{"id":"2746","doc":{"name":"Trail shoe X","price":89.9,"currency":"EUR","availability":"in_stock","url":"https://shop.example/2746"}}]}'
# Search from the server (sk, search scope)
curl https://api.horusfinder.com/v1/collections/products_en/search \
-H "X-Api-Key: hrf_sk_live_…" -H "Content-Type: application/json" \
-d '{"q":"trail running shoes","filters":{"availability":"in_stock"},"size":24}'Hosts and keys
| Environment | Host | Keys |
|---|---|---|
| Production | https://api.horusfinder.com | hrf_pk_live_…, hrf_sk_live_… |
| Test | https://api-test.horusfinder.com | hrf_pk_test_…, hrf_sk_test_… |
- HTTPS only, with TLS 1.2 or higher. Requests go through Cloudflare's network, with no caching.
- A key only works on its own environment's host: anywhere else it gets 401.
- The store (the tenant) always comes from the key, never from the request body.
Publishable key (pk)
- For the browser. It goes in the
key=parameter and is accepted only on the/v1/public/{tenant}/…routes. - It isn't a secret: it sits in your store's HTML. That's why it's tied to your store's origins, which you set in the console:
https://addresses; local hosts (localhost,127.0.0.1,[::1]) work only with test keys, and only they may also usehttp://. - The
{tenant}in the path is your store's slug and must match the key, otherwise 401. - A request whose
Originisn't among the key's origins,nullincluded, gets 403. A request withoutOrigingoes through. - The publishable key reads only the schema's
publicfields, and the key's mandatory filters, if you set any, always apply, ANDed with yours.
Secret key (sk)
- For the server. It goes in the
X-Api-Keyheader and is accepted on every other route. - It has one or more scopes:
search,ingest,events. A missing scope gets 403. - Never put it in a browser or an app: whoever reads it can change your catalog.
Headers
- Request:
X-Api-Keywith the secret key;Content-Type: application/jsonon server bodies;Accept-Encoding: gzipif you want a compressed response. Requests aren't compressed. - Response:
Cache-Control: no-storealways;Retry-After, in seconds, on 429 and 503;Content-Encoding: gzipif you asked for it;apigw-requestid, the request id to quote when you write to us. - CORS:
Access-Control-Allow-Origin: *, without credentials;Retry-Afterandapigw-requestidare readable by the page.
Common rules
- Versions.
/v1only changes by addition: new optional request fields, new response fields and values. A breaking change opens/v2, and/v1stays available for at least 6 months, with a notice on this page and by email. - Unknown response fields: ignore them. An item
statusyou don't know counts asfailed; acodeyou don't know counts as itsstatus. - Unknown request fields are dropped without an error. A filter on a field the schema doesn't have is ignored, and with the publishable key so is a filter on a non-
publicfield. Sorting or a facet on a field that doesn't allow it gives 400. A name starting with_always gives 400. - The document you upload, on the other hand, is strict: a field not declared in the schema fails that item (see Document batches).
- Formats. JSON in UTF-8. Timestamps are ISO 8601 UTC with milliseconds;
versionis in milliseconds since 1970.pagestarts at 0. - Identifiers. Document:
^[A-Za-z0-9._:-]{1,128}$. Collection:^[a-z][a-z0-9_]{0,30}$. Tenant:^[a-z][a-z0-9]{2,30}$. - Text and HTML.
highlightis the only HTML field, with<mark>and escaping already done. Everything else,didYouMeanand trending searches included, is shown as text. - Retrying is safe. Upserting the whole document with
version, deleting by id, pruning andPUTon a collection give the same state when repeated; an event counts once thanks to itseventId. No idempotency header is needed. - Initial values. Defaults and some limits marked as "initial" may be tuned: every change is noted here.
Routes
| Route | Key | Result |
|---|---|---|
GET /v1/public/{tenant}/collections/{collection}/search | pk | 200 |
GET /v1/public/{tenant}/collections/{collection}/suggest | pk | 200 |
GET /v1/public/{tenant}/collections/{collection}/trending | pk | 200 |
POST /v1/public/{tenant}/events | pk | 204 |
POST /v1/collections/{collection}/search | sk, search scope | 200 |
PUT /v1/collections/{collection} | sk, ingest scope | 200 |
GET /v1/collections/{collection} | sk, ingest scope | 200 |
GET /v1/tenant | sk, ingest scope | 200 |
POST /v1/collections/{collection}/documents/batch | sk, ingest scope | 200 |
POST /v1/collections/{collection}/documents/prune | sk, ingest scope | 202 |
POST /v1/events | sk, events scope | 200 |
Search from the browser
Parameters arrive already decoded. Lists split on commas, so a value containing a comma can't be filtered with a GET.
q=<text> under 2 characters: the whole catalog, filters included
f.<field>=<v>[,<v>…] OR within a field, AND across fields; true/false, numbers with a dot
r.<field>=<min>..<max> inclusive bounds, also <min>.. or ..<max>; numbers or ISO dates
sort=<field>[:asc|:desc][,…] at most 5 keys, default desc; without sort, relevance
facets=<field>[:<n>][,…] at most 5 facets of 50 values; n defaults to 10 (initial value)
page=<n> size=<n> size at most 48, default 24 (initial value); only the first 480 results
retrieve=<field>[,…] fields to return, public ones only; without it, every returnable public field
key=hrf_pk_… required
GET /v1/public/esempio/collections/products_en/search?q=trail%20shoes&f.availability=in_stock&r.price=40..120&facets=manufacturer,price&key=hrf_pk_live_…
Origin: https://shop.example
{"queryId": "01K79Z4T8Q6M3VXR2C5N7PBJ0D", "total": 37, "page": 0, "size": 24, "tookMs": 48,
"hits": [{"id": "2746", "score": 12.4,
"doc": {"name": "Trail shoe X", "price": 89.9, "currency": "EUR", "availability": "in_stock", "url": "https://shop.example/2746"},
"highlight": {"name": ["<mark>Trail</mark> <mark>shoe</mark> X"]}}],
"facets": {"manufacturer": {"values": [{"value": "Brand One", "count": 21}, {"value": "Brand Two", "count": 9}]},
"price": {"values": [{"value": 89.9, "count": 5}], "min": 42.5, "max": 119.9}}}
- Every word of
qmust match at least one searchable field, with typo tolerance: search is AND. Inproduct@1collections with "in stock first" on (the default), in-stock products come before sold-out ones even with an explicitsort. queryId, a random, time-sortable identifier (ULID), is always there: it goes into events.totalis exact.scoreisnullwith an explicit sort and on results pinned to the top by a rule, which carrypinned: true.highlighthas at most 3 fields.dochas the returnable fields, and with the publishable key only thepublicones.- Facets are disjunctive: a facet's counts ignore the filter on its own field.
valuesis sorted by count;minandmaxare present on numeric fields only. didYouMean(text) only appears on page 0, with fewer than 3 results, if the collection has it on.highlightfollows the collection's setting.
Suggestions and trending searches
Suggestions (…/suggest): q, f.*, retrieve, size (at most 10, default 8) and key; no facets or didYouMean. The collection's rules apply, as in search.
GET /v1/public/esempio/collections/products_en/suggest?q=trai&f.availability=in_stock&key=hrf_pk_live_…
{"queryId": "01K79Z4W2H8F6S3D1A9G7K5M4N", "tookMs": 22,
"hits": [{"id": "2746", "doc": {"name": "Trail shoe X"}, "highlight": {"name": ["<mark>Trail</mark> shoe X"]}}]}
Highlighting covers the whole word, not just the letters typed.
Trending searches (…/trending): key only.
{"queries": [{"q": "trail shoes"}, {"q": "30 litre backpack"}], "updatedAt": "2026-10-09T08:05:00.000Z"}
- At most 10 terms, without counts, recomputed every hour at minute 5 (UTC). The list is empty while the collection has too few searches;
updatedAtisnulluntil it has been computed once. - In the console you can block a term, or pick the list yourself (curated mode): then the response is your list, and
updatedAtis when you last changed it.
Events from the browser
Events tell what happens after a search: they feed the console's analytics. The body is JSON but is sent as text/plain with navigator.sendBeacon, so the browser doesn't make a CORS preflight request. The response is 204, with no body.
POST /v1/public/esempio/events?key=hrf_pk_live_…
Content-Type: text/plain;charset=UTF-8
Origin: https://shop.example
{"events": [{"eventId": "5d2e7f90-1c3b-4a8e-b6d4-2f9a0c1e7b35", "type": "click", "collection": "products_en",
"sessionId": "8KfQ2xR7mN4pW1sL", "queryId": "01K79Z4T8Q6M3VXR2C5N7PBJ0D", "objectId": "2746", "position": 1}]}
type | Own fields | When |
|---|---|---|
search_view | queryId | the search settles for 1 s, on enter or on a click; page 0 only, never on every keystroke |
click | queryId, objectId, position (from 1) | on a suggestion too, with the suggest's queryId |
view | queryId, objectId | the visitor opens the product |
conversion | queryId, objectId, value, currency, quantity? | value as an integer in minor units (cents), currency ISO 4217 |
- Common fields:
eventId(UUID),type,collection(the one of the response that gave thequeryId),sessionId(random, valid for the session only) andclientId, only if the visitor gave the store consent.sessionIdandclientIdare base64url strings of 8–64 characters (^[A-Za-z0-9_-]{8,64}$): any other format makes the event invalid. - Never IP, User-Agent, URL, referrer, user id or email in events. From the console the store deletes a
clientId's events, at the visitor's request. positiongoes up to 10,000 andquantityup to 1,000,000.- An event is accepted only if its
queryIdbelongs to the same store and collection, from the last 24 hours. Invalid events are dropped silently (the response stays 204), and aneventIdalready received doesn't count twice. - Errors: 400 for a body that isn't JSON or has no
events; 401; 403 for theOrigin; 413 above 50 events or 16 KB.
Search from the server
POST /v1/collections/products_en/search
X-Api-Key: hrf_sk_live_…
Content-Type: application/json
{"q": "trail shoes", "filters": {"availability": "in_stock", "category_ids": [12, 15]},
"ranges": [{"field": "price", "gte": 40, "lte": 120}], "sort": [{"field": "price", "dir": "asc"}], "page": 0, "size": 50,
"facets": [{"field": "manufacturer", "size": 100}], "retrieve": ["name", "price"], "highlight": false, "tags": ["website"]}
- The response is 200, shaped like search from the browser. Every schema field is available, not just the
publicones. sizegoes up to 500 (default 24, initial value) and facets up to 20 of 100 values (default 10).filterstakes a value or a list per field: OR within a field, AND across fields.rangesusesgte,gt,lte,lt.sortdefaults todesc. Highlighting is there only withhighlight: true.tagsare labels for analytics: at most 5 (initial value), shaped^[a-z0-9][a-z0-9._-]{0,31}$. The reserved tagas-you-typemarks a search fired while the visitor is typing: it counts towards usage but stays out of analytics and trending searches.
Collections and tenant
PUT /v1/collections/{collection} creates the collection or replaces its schema. The body has kind (the preset: product@1 or custom), locale (it, en or neutral) and schema. With kind: "product@1" the schema is omitted and the preset's applies, as in {"kind": "product@1", "locale": "en"}.
Each schema field has name, type (text, keyword, integer, long, float, double, boolean, date, object), array and the flags searchable (true or {"prefix": true}), filterable, facetable, sortable, retrievable and public.
{"kind": "custom", "locale": "en", "schema": {"fields": [
{"name": "name", "type": "text", "searchable": {"prefix": true}, "sortable": true, "retrievable": true, "public": true},
{"name": "visible", "type": "boolean", "filterable": true}]}}
- The response is 200:
{"schemaVersion": 4, "state": "active"}, or{"schemaVersion": 5, "state": "reindexing", "reindex": true}when the change needs a reindex (type,localeor prefix of an existing field). A schema equal to the active one, or to the incoming one, answers 200 with no work. - Weights, synonyms, rules and deleting a collection are managed from the console only.
- A collection the store doesn't have gives 404, except on
PUT, which creates it.PUTerrors: 409collection_not_readyfor a different schema during a reindex, 409capacity_exceededfor an eleventh collection.
GET /v1/collections/{collection} reads a collection:
{"id": "products_en", "kind": "custom", "locale": "en", "state": "active", "schemaVersion": 4, "docCount": 7159,
"mappedFields": [{"name": "name", "type": "text", "prefix": true}, …]}
During a reindex schemaVersion is the active version and pendingSchemaVersion the incoming one: to wait for the end, read again until state is back to active. docCount is null if the search engine doesn't answer.
GET /v1/tenant tells you how the store stands:
{"tenant": {"slug": "esempio", "env": "live", "status": "active"},
"collections": [{"id": "products_en", "kind": "product@1", "locale": "en", "ready": true, "docCount": 5120}]}
env is live or test, as in the keys; ready is true when the collection is active.
Document batches
POST /v1/collections/{collection}/documents/batch, at most 500 items and 4 MB per batch, 64 KB per document.
{"upsert": [{"id": "2746", "version": 1791533520000, "doc": {"name": "Trail shoe X", "price": 89.9, "currency": "EUR", "availability": "in_stock"}}],
"delete": [{"id": "1259", "version": 1791533520000}], "refresh": "wait_for"}
{"receivedAt": "2026-10-09T08:12:03.123Z",
"items": [{"id": "2746", "status": "ok"}, {"id": "1259", "status": "not_found"}, {"id": "77", "status": "failed", "code": "schema_violation"}]}
- The response is 200 even with failed items: check each item's
status. - Upsert replaces the whole document; delete is by id, and a missing id gives
not_found, which counts as a success. An id repeated in the same batch: the last one wins. - Without
refresh, documents usually become searchable within a second or two; with"refresh": "wait_for"the response arrives once they are. versionis when you built the document, in milliseconds, between 10^12 and now plus 24 hours; without it, the time of receipt applies. A version older than the stored one givesstale, which isn't an error: a retried batch may saystalewhere the first attempt saidok.failedcarries acode:schema_violation(undeclared field or wrong type),reserved_field(field starting with_),document_too_large(above 64 KB),invalid_version(out of range). Without acodeit's an unexpected error: retry that item in a later batch.- A reindex in progress, or overload during the batch, gives 503 with
Retry-Afterfor the whole request. In the test environment a collection holds at most 100,000 documents: above that, 409capacity_exceeded. A request rejected as a whole counts as "every item failed".
Prune
POST /v1/collections/{collection}/documents/prune deletes what a full upload didn't write again.
{"seenBefore": "2026-10-09T02:30:04.120Z", "maxDeletePercent": 10}
The response is 202: {"total": 7159, "orphans": 12}.
- It deletes, in the background, the documents last written before
seenBefore; a later prune collects what's left. Astaleorfailedresult for a document that already exists counts as a write, so prune doesn't delete it. - Use as
seenBeforethe smallestreceivedAtof the full upload's batches, and skip the prune if a batch was rejected as a whole. - If orphans exceed
maxDeletePercentof the total (default 10): 409 withcode: "delete_guard",totalandorphans. Repeat with"force": true, but only after checking the numbers. During a reindex: 409collection_not_ready.
Events from the server
The same types, fields and limits as events from the browser, with Content-Type: application/json and a secret key with the events scope. From the server sessionId is optional, the queryId is valid for 30 days and is required for conversion too: a purchase is tied to the search through queryId and objectId, with no user id.
{"events": [{"eventId": "8f0a2c4e-3b1d-4e6f-9a7c-5d2b8e1f0a63", "type": "conversion", "collection": "products_en",
"queryId": "01K79Z4T8Q6M3VXR2C5N7PBJ0D", "objectId": "2746", "value": 8990, "currency": "EUR", "quantity": 1}]}
The response is 200: {"accepted": 1, "rejected": []}. rejected lists the events rejected by validation, such as [{"index": 0, "error": "value: Invalid input: expected number, received undefined"}]: don't retry them. accepted counts the events valid in shape, including duplicates and those with an expired queryId, which aren't stored. A body that isn't JSON, or has no events, gives 400.
Errors
The status decides. An error body has one of these three shapes:
{"error": "<text>"}. In validation 400s the text names the field, as in{"error": "size: Too big: expected number to be <=48"}; several problems are separated by;. The text is for humans: don't parse it, it may change.{"error": "<text>", "code": "<code>"}, only where you have to choose between cases: the 409s in the table and batch items.{"message": "<text>"}when the gateway answers: a secret key's 401, the 429 of server-route rate limits, its 5xx.
| Status | When | What to do |
|---|---|---|
| 400 | invalid request; sort or facet on a field that doesn't allow it; results past the 480th with the publishable key, past the 10,000th for everyone; response above about 5.5 MB | fix the request: don't retry and don't fall back |
| 401 | key missing, wrong, revoked, expired, of another store or environment; store not active. With a secret key, also a temporary failure while checking the key | don't retry, fall back; never delete the key because of a 401 |
| 403 | missing scope; Origin not among the key's origins | don't retry, fall back |
| 404 | unknown collection or route | don't retry, fall back |
409 collection_not_ready | reindex in progress: prune, or PUT of a different schema | try again later; skip the prune |
409 capacity_exceeded | eleventh collection; in the test environment, more than 100,000 documents in a collection | stop: some cleanup is needed, or write to us |
409 delete_guard | the prune would delete more than maxDeletePercent; the body has total and orphans | check the numbers; force only by hand |
| 413 | body above the limits | split the batch |
| 429 | too many requests | wait for Retry-After, if present; in the browser, fall back |
| 500 | unexpected error | don't retry, fall back |
| 502, 503, 504 | search engine slow or failing; index briefly paused while synonyms are saved; overload during a batch; write during a reindex; service not answering. Never partial results: a search is complete or it's an error | one retry after Retry-After; in the browser, fall back |
Falling back. On a 400 the request is wrong and needs fixing. On every other outcome the store must stay usable: give the call a time limit (from the browser a few seconds are enough) and, if it runs out or an error comes back, show the search the store had before. The PrestaShop module does exactly this: if HorusFinder doesn't answer, it switches back to PrestaShop's own search.
Limits
| What | Limit | Beyond |
|---|---|---|
| Document batch; document | 500 items and 4 MB; 64 KB | 413; document_too_large item |
| Event batch | 50 events and 16 KB | 413 |
q | the first 16 words and 256 characters (initial values) | truncated without an error |
size; pages | publishable key 48, secret key 500, suggestions 10; with the publishable key only the first 480 results, that is (page + 1) × size at most 480; for everyone the first 10,000 | 400 |
Facets; sort; ranges | publishable key 5 of 50 values, secret key 20 of 100; 5 keys; 10 ranges | 400 |
| Response | about 5.5 MB, counted on the response as sent (compressed, if you asked for gzip) | 400: reduce size or retrieve |
| Collections per store; documents per collection, test environment only | 10; 100,000 | 409 capacity_exceeded |
| Schema fields | 100 | 400 |
Rate limits. On the /v1/public/ routes there's a per-IP limit at Cloudflare and per-IP and per-store limits in the service, set above the traffic of a visitor who types, filters and scrolls through results. The service's 429 has a readable Retry-After; Cloudflare's arrives without CORS headers, and the browser sees it as a network error: treat it as an error and fall back. Secret-key routes have a per-route cap, whose 429 has {message} and no Retry-After.
Plan limits. Monthly requests count successful searches, suggestions and trending searches; events, catalog uploads and the console don't count. Plan limits never give a 429: if you go over, we let you know, and search keeps running.