Skip to content

Documentation for the REST API v1

Hosts, keys, routes, errors and limits of the HorusFinder API, with fetch and curl examples.

Updated · The keys in the examples end with “…”: you create yours in the console.

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:

JavaScript
// 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:

curl
# 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

EnvironmentHostKeys
Productionhttps://api.horusfinder.comhrf_pk_live_…, hrf_sk_live_…
Testhttps://api-test.horusfinder.comhrf_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 use http://.
  • The {tenant} in the path is your store's slug and must match the key, otherwise 401.
  • A request whose Origin isn't among the key's origins, null included, gets 403. A request without Origin goes through.
  • The publishable key reads only the schema's public fields, 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-Key header 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-Key with the secret key; Content-Type: application/json on server bodies; Accept-Encoding: gzip if you want a compressed response. Requests aren't compressed.
  • Response: Cache-Control: no-store always; Retry-After, in seconds, on 429 and 503; Content-Encoding: gzip if you asked for it; apigw-requestid, the request id to quote when you write to us.
  • CORS: Access-Control-Allow-Origin: *, without credentials; Retry-After and apigw-requestid are readable by the page.

Common rules

  • Versions. /v1 only changes by addition: new optional request fields, new response fields and values. A breaking change opens /v2, and /v1 stays available for at least 6 months, with a notice on this page and by email.
  • Unknown response fields: ignore them. An item status you don't know counts as failed; a code you don't know counts as its status.
  • 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-public field. 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; version is in milliseconds since 1970. page starts 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. highlight is the only HTML field, with <mark> and escaping already done. Everything else, didYouMean and trending searches included, is shown as text.
  • Retrying is safe. Upserting the whole document with version, deleting by id, pruning and PUT on a collection give the same state when repeated; an event counts once thanks to its eventId. No idempotency header is needed.
  • Initial values. Defaults and some limits marked as "initial" may be tuned: every change is noted here.

Routes

RouteKeyResult
GET /v1/public/{tenant}/collections/{collection}/searchpk200
GET /v1/public/{tenant}/collections/{collection}/suggestpk200
GET /v1/public/{tenant}/collections/{collection}/trendingpk200
POST /v1/public/{tenant}/eventspk204
POST /v1/collections/{collection}/searchsk, search scope200
PUT /v1/collections/{collection}sk, ingest scope200
GET /v1/collections/{collection}sk, ingest scope200
GET /v1/tenantsk, ingest scope200
POST /v1/collections/{collection}/documents/batchsk, ingest scope200
POST /v1/collections/{collection}/documents/prunesk, ingest scope202
POST /v1/eventssk, events scope200

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
HTTP
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
JSON
{"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 q must match at least one searchable field, with typo tolerance: search is AND. In product@1 collections with "in stock first" on (the default), in-stock products come before sold-out ones even with an explicit sort.
  • queryId, a random, time-sortable identifier (ULID), is always there: it goes into events. total is exact.
  • score is null with an explicit sort and on results pinned to the top by a rule, which carry pinned: true. highlight has at most 3 fields. doc has the returnable fields, and with the publishable key only the public ones.
  • Facets are disjunctive: a facet's counts ignore the filter on its own field. values is sorted by count; min and max are present on numeric fields only.
  • didYouMean (text) only appears on page 0, with fewer than 3 results, if the collection has it on. highlight follows the collection's setting.

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.

HTTP
GET /v1/public/esempio/collections/products_en/suggest?q=trai&f.availability=in_stock&key=hrf_pk_live_…
JSON
{"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.

JSON
{"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; updatedAt is null until 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 updatedAt is 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.

HTTP
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}]}
typeOwn fieldsWhen
search_viewqueryIdthe search settles for 1 s, on enter or on a click; page 0 only, never on every keystroke
clickqueryId, objectId, position (from 1)on a suggestion too, with the suggest's queryId
viewqueryId, objectIdthe visitor opens the product
conversionqueryId, 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 the queryId), sessionId (random, valid for the session only) and clientId, only if the visitor gave the store consent. sessionId and clientId are 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.
  • position goes up to 10,000 and quantity up to 1,000,000.
  • An event is accepted only if its queryId belongs to the same store and collection, from the last 24 hours. Invalid events are dropped silently (the response stays 204), and an eventId already received doesn't count twice.
  • Errors: 400 for a body that isn't JSON or has no events; 401; 403 for the Origin; 413 above 50 events or 16 KB.

Search from the server

HTTP
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 public ones.
  • size goes up to 500 (default 24, initial value) and facets up to 20 of 100 values (default 10).
  • filters takes a value or a list per field: OR within a field, AND across fields. ranges uses gte, gt, lte, lt. sort defaults to desc. Highlighting is there only with highlight: true.
  • tags are labels for analytics: at most 5 (initial value), shaped ^[a-z0-9][a-z0-9._-]{0,31}$. The reserved tag as-you-type marks 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.

JSON
{"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, locale or 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. PUT errors: 409 collection_not_ready for a different schema during a reindex, 409 capacity_exceeded for an eleventh collection.

GET /v1/collections/{collection} reads a collection:

JSON
{"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:

JSON
{"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.

JSON
{"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"}
JSON
{"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.
  • version is 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 gives stale, which isn't an error: a retried batch may say stale where the first attempt said ok.
  • failed carries a code: schema_violation (undeclared field or wrong type), reserved_field (field starting with _), document_too_large (above 64 KB), invalid_version (out of range). Without a code it's an unexpected error: retry that item in a later batch.
  • A reindex in progress, or overload during the batch, gives 503 with Retry-After for the whole request. In the test environment a collection holds at most 100,000 documents: above that, 409 capacity_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.

JSON
{"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. A stale or failed result for a document that already exists counts as a write, so prune doesn't delete it.
  • Use as seenBefore the smallest receivedAt of the full upload's batches, and skip the prune if a batch was rejected as a whole.
  • If orphans exceed maxDeletePercent of the total (default 10): 409 with code: "delete_guard", total and orphans. Repeat with "force": true, but only after checking the numbers. During a reindex: 409 collection_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.

JSON
{"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.
StatusWhenWhat to do
400invalid 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 MBfix the request: don't retry and don't fall back
401key missing, wrong, revoked, expired, of another store or environment; store not active. With a secret key, also a temporary failure while checking the keydon't retry, fall back; never delete the key because of a 401
403missing scope; Origin not among the key's originsdon't retry, fall back
404unknown collection or routedon't retry, fall back
409 collection_not_readyreindex in progress: prune, or PUT of a different schematry again later; skip the prune
409 capacity_exceededeleventh collection; in the test environment, more than 100,000 documents in a collectionstop: some cleanup is needed, or write to us
409 delete_guardthe prune would delete more than maxDeletePercent; the body has total and orphanscheck the numbers; force only by hand
413body above the limitssplit the batch
429too many requestswait for Retry-After, if present; in the browser, fall back
500unexpected errordon't retry, fall back
502, 503, 504search 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 errorone 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

WhatLimitBeyond
Document batch; document500 items and 4 MB; 64 KB413; document_too_large item
Event batch50 events and 16 KB413
qthe first 16 words and 256 characters (initial values)truncated without an error
size; pagespublishable 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,000400
Facets; sort; rangespublishable key 5 of 50 values, secret key 20 of 100; 5 keys; 10 ranges400
Responseabout 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 only10; 100,000409 capacity_exceeded
Schema fields100400

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.

Stop losing sales on your search bar.

Request access

Access is on request: tell us about your store (platform, number of products, languages) and we'll tell you honestly whether HorusFinder is a fit and how to get started.

Email us