Con l'API REST di HorusFinder carichi il catalogo del negozio e cerchi i prodotti, dal browser o dal tuo server. Parla JSON su HTTPS e si usa da qualsiasi linguaggio. Le chiavi le crei nella console di HorusFinder, che apriamo su richiesta.
In breve
Dal browser cerchi con la chiave pubblica (pk), che va in chiaro nella pagina del negozio:
// Dal browser, con la chiave pubblica (pk)
const url = new URL("https://api.horusfinder.com/v1/public/esempio/collections/products_it/search");
url.search = new URLSearchParams({ q: "scarpe da trail", "f.availability": "in_stock", size: "24", key: "hrf_pk_live_…" });
const res = await fetch(url);
if (!res.ok) throw new Error(`HorusFinder ${res.status}`); // qui il tuo ripiego
const { queryId, total, hits } = await res.json();Dal server carichi il catalogo e cerchi con una chiave segreta (sk), che resta sul server:
# Dal server, con la chiave segreta (sk, scope ingest): carichi o aggiorni il catalogo
curl https://api.horusfinder.com/v1/collections/products_it/documents/batch \
-H "X-Api-Key: hrf_sk_live_…" -H "Content-Type: application/json" \
-d '{"upsert":[{"id":"2746","doc":{"name":"Scarpa da trail X","price":89.9,"currency":"EUR","availability":"in_stock","url":"https://shop.example/2746"}}]}'
# Ricerca dal server (sk, scope search)
curl https://api.horusfinder.com/v1/collections/products_it/search \
-H "X-Api-Key: hrf_sk_live_…" -H "Content-Type: application/json" \
-d '{"q":"scarpe da trail","filters":{"availability":"in_stock"},"size":24}'Host e chiavi
| Ambiente | Host | Chiavi |
|---|---|---|
| Produzione | https://api.horusfinder.com | hrf_pk_live_…, hrf_sk_live_… |
| Test | https://api-test.horusfinder.com | hrf_pk_test_…, hrf_sk_test_… |
- Solo HTTPS, con TLS 1.2 o superiore. Le richieste passano dalla rete di Cloudflare, senza cache.
- Una chiave vale solo sull'host del suo ambiente: altrove riceve 401.
- Il negozio (il tenant) lo decide sempre la chiave, mai il corpo della richiesta.
Chiave pubblica (pk)
- Per il browser. Va nel parametro
key=ed è ammessa solo sulle rotte/v1/public/{tenant}/…. - Non è un segreto: sta nell'HTML del negozio. Per questo è legata alle origini del negozio, che imposti nella console: indirizzi
https://; gli host locali (localhost,127.0.0.1,[::1]) valgono solo con le chiavi di test, e solo loro anche inhttp://. - Il
{tenant}del percorso è lo slug del tuo negozio e deve essere quello della chiave, altrimenti 401. - Una richiesta con un
Originche non è fra le origini della chiave, anchenull, riceve 403. Una richiesta senzaOriginpassa. - Con la chiave pubblica si leggono solo i campi
publicdello schema, e i filtri obbligatori della chiave, se ne hai impostati, valgono sempre, in AND con i tuoi.
Chiave segreta (sk)
- Per il server. Va nell'header
X-Api-Keyed è ammessa su tutte le altre rotte. - Ha uno o più scope:
search,ingest,events. Uno scope mancante dà 403. - Non va mai in un browser né in un'app: chi la legge può cambiare il tuo catalogo.
Header
- In richiesta:
X-Api-Keycon la chiave segreta;Content-Type: application/jsonsui corpi dal server;Accept-Encoding: gzip, se vuoi la risposta compressa. Le richieste non si comprimono. - In risposta:
Cache-Control: no-storesempre;Retry-After, in secondi, su 429 e 503;Content-Encoding: gzipse l'hai chiesto;apigw-requestid, l'identificativo della richiesta da citare quando ci scrivi. - CORS:
Access-Control-Allow-Origin: *, senza credenziali;Retry-Aftereapigw-requestidsono leggibili dalla pagina.
Regole comuni
- Versioni.
/v1cambia solo per aggiunta: campi facoltativi nuovi in richiesta, campi e valori nuovi in risposta. Una modifica che rompe la compatibilità apre/v2, e/v1resta servita almeno 6 mesi, con un avviso su questa pagina e per email. - Campi sconosciuti in risposta: ignorali. Uno
statusdi un elemento che non conosci valefailed, uncodeche non conosci vale come il suostatus. - Campi sconosciuti in richiesta: si scartano senza errore. Un filtro su un campo che lo schema non ha si ignora, e con la chiave pubblica anche un filtro su un campo non
public. Un ordinamento o una facet su un campo non ammesso danno 400. Un nome che inizia con_dà sempre 400. - Il documento che carichi, invece, è stretto: un campo non dichiarato nello schema fa fallire quell'elemento (vedi Lotti di documenti).
- Formati. JSON in UTF-8. Gli istanti sono in ISO 8601 UTC con i millisecondi;
versionè in millisecondi dal 1970.pageparte da 0. - Identificativi. Documento:
^[A-Za-z0-9._:-]{1,128}$. Collection:^[a-z][a-z0-9_]{0,30}$. Tenant:^[a-z][a-z0-9]{2,30}$. - Testo e HTML.
highlightè l'unico campo in HTML, con<mark>e l'escape già fatto. Tutto il resto,didYouMeane le ricerche del momento compresi, si mostra come testo. - Ripetere è sicuro. L'upsert del documento intero con
version, il delete per id, il prune e ilPUTdella collection danno lo stesso stato se ripetuti; un evento conta una volta sola grazie al suoeventId. Non serve un header d'idempotenza. - Valori iniziali. I valori di default e alcuni limiti indicati come «iniziali» possono essere ritoccati: ogni modifica si annota qui.
Rotte
| Rotta | Chiave | Esito |
|---|---|---|
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, scope search | 200 |
PUT /v1/collections/{collection} | sk, scope ingest | 200 |
GET /v1/collections/{collection} | sk, scope ingest | 200 |
GET /v1/tenant | sk, scope ingest | 200 |
POST /v1/collections/{collection}/documents/batch | sk, scope ingest | 200 |
POST /v1/collections/{collection}/documents/prune | sk, scope ingest | 202 |
POST /v1/events | sk, scope events | 200 |
Ricerca dal browser
I parametri arrivano già decodificati. Le liste si dividono sulla virgola, quindi un valore che contiene una virgola non si filtra con una GET.
q=<testo> sotto i 2 caratteri: tutto il catalogo, filtri compresi
f.<campo>=<v>[,<v>…] OR nel campo, AND fra campi; true/false, numeri col punto
r.<campo>=<min>..<max> estremi inclusi, anche <min>.. o ..<max>; numeri o date ISO
sort=<campo>[:asc|:desc][,…] al più 5 chiavi, default desc; senza sort vale la rilevanza
facets=<campo>[:<n>][,…] al più 5 facet da 50 valori; n di default 10 (valore iniziale)
page=<n> size=<n> size al più 48, default 24 (valore iniziale); solo i primi 480 risultati
retrieve=<campo>[,…] campi da restituire, solo fra i public; senza, tutti i public restituibili
key=hrf_pk_… obbligatoria
GET /v1/public/esempio/collections/products_it/search?q=scarpe%20trail&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": "Scarpa da trail X", "price": 89.9, "currency": "EUR", "availability": "in_stock", "url": "https://shop.example/2746"},
"highlight": {"name": ["<mark>Scarpa</mark> da <mark>trail</mark> X"]}}],
"facets": {"manufacturer": {"values": [{"value": "Marca Uno", "count": 21}, {"value": "Marca Due", "count": 9}]},
"price": {"values": [{"value": 89.9, "count": 5}], "min": 42.5, "max": 119.9}}}
- Ogni parola di
qdeve trovarsi in almeno un campo cercabile, con la tolleranza ai refusi: la ricerca è in AND. Nelle collectionproduct@1con «disponibili prima» acceso (il default) i prodotti disponibili precedono gli esauriti anche con unsortesplicito. queryId, un identificativo casuale ordinabile nel tempo (ULID), c'è sempre: va negli eventi.totalè esatto.scoreènullcon un ordinamento esplicito e sui risultati fissati in cima da una regola, che portanopinned: true.highlightha al più 3 campi.docha i campi restituibili, e con la chiave pubblica solo quellipublic.- Le facet sono disgiuntive: i conteggi di una facet ignorano il filtro sul suo stesso campo.
valuesè ordinato per conteggio;minemaxci sono solo sui campi numerici. didYouMean(testo) compare solo a pagina 0, con meno di 3 risultati, se la collection lo ha acceso.highlightsegue l'impostazione della collection.
Suggerimenti e ricerche del momento
Suggerimenti (…/suggest): q, f.*, retrieve, size (al più 10, default 8) e key; niente facet né didYouMean. Valgono le regole della collection, come nella ricerca.
GET /v1/public/esempio/collections/products_it/suggest?q=scar&f.availability=in_stock&key=hrf_pk_live_…
{"queryId": "01K79Z4W2H8F6S3D1A9G7K5M4N", "tookMs": 22,
"hits": [{"id": "2746", "doc": {"name": "Scarpa da trail X"}, "highlight": {"name": ["<mark>Scarpa</mark> da trail X"]}}]}
L'evidenziazione copre la parola intera, non solo le lettere digitate.
Ricerche del momento (…/trending): solo key.
{"queries": [{"q": "scarpe trail"}, {"q": "zaino 30 litri"}], "updatedAt": "2026-10-09T08:05:00.000Z"}
- Al più 10 termini, senza conteggi, ricalcolati ogni ora al minuto 5 (UTC). La lista è vuota se la collection ha ancora poche ricerche;
updatedAtènullfinché non è mai stata calcolata. - Dalla console puoi bloccare un termine, oppure scegliere tu la lista (modalità curata): in quel caso la risposta è la tua lista e
updatedAtè l'ora dell'ultima modifica.
Eventi dal browser
Gli eventi dicono cosa succede dopo una ricerca: servono alle statistiche della console. Il corpo è JSON ma parte come text/plain con navigator.sendBeacon, così il browser non fa la richiesta preliminare CORS. La risposta è 204, senza corpo.
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_it",
"sessionId": "8KfQ2xR7mN4pW1sL", "queryId": "01K79Z4T8Q6M3VXR2C5N7PBJ0D", "objectId": "2746", "position": 1}]}
type | Campi propri | Quando |
|---|---|---|
search_view | queryId | la ricerca si ferma per 1 s, con invio o con un clic; solo per la pagina 0, mai a ogni tasto |
click | queryId, objectId, position (da 1) | anche su un suggerimento, con il queryId del suggest |
view | queryId, objectId | il visitatore apre il prodotto |
conversion | queryId, objectId, value, currency, quantity? | value intero in unità minori (centesimi), currency ISO 4217 |
- Campi comuni:
eventId(UUID),type,collection(quella della risposta che ha dato ilqueryId),sessionId(casuale, vale per la sola sessione) eclientId, solo se il visitatore ha dato il consenso al negozio.sessionIdeclientIdsono stringhe base64url di 8–64 caratteri (^[A-Za-z0-9_-]{8,64}$): con un altro formato l'evento non è valido. - Mai IP, User-Agent, URL, referrer, id dell'utente o email negli eventi. Dalla console il negozio cancella gli eventi di un
clientId, su richiesta del visitatore. positionarriva al più a 10.000 equantityal più a 1.000.000.- Un evento entra solo se il suo
queryIdè dello stesso negozio e della stessa collection, da al più 24 ore. Gli eventi non validi si scartano in silenzio (la risposta resta 204) e uneventIdgià ricevuto non conta due volte. - Errori: 400 per un corpo che non è JSON o senza
events; 401; 403 per l'Origin; 413 oltre 50 eventi o 16 KB.
Ricerca dal server
POST /v1/collections/products_it/search
X-Api-Key: hrf_sk_live_…
Content-Type: application/json
{"q": "scarpe trail", "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": ["sito-web"]}
- La risposta è 200, con la forma della ricerca dal browser. Valgono tutti i campi dello schema, non solo i
public. sizearriva a 500 (default 24, valore iniziale) e le facet a 20 da 100 valori (default 10).filtersvuole un valore o un elenco per campo: OR nel campo, AND fra campi.rangesusagte,gt,lte,lt.sortha direzione di defaultdesc. L'evidenziazione c'è solo conhighlight: true.tagssono etichette per le statistiche: al più 5 (valore iniziale), nella forma^[a-z0-9][a-z0-9._-]{0,31}$. Il tag riservatoas-you-typesegna una ricerca lanciata mentre il visitatore scrive: conta nei consumi ma resta fuori da statistiche e ricerche del momento.
Collection e tenant
PUT /v1/collections/{collection} crea la collection o ne sostituisce lo schema. Il corpo ha kind (il preset: product@1 o custom), locale (it, en o neutral) e schema. Con kind: "product@1" lo schema si omette e vale quello del preset, come in {"kind": "product@1", "locale": "en"}.
Ogni campo dello schema ha name, type (text, keyword, integer, long, float, double, boolean, date, object), array e i flag searchable (true o {"prefix": true}), filterable, facetable, sortable, retrievable e public.
{"kind": "custom", "locale": "it", "schema": {"fields": [
{"name": "name", "type": "text", "searchable": {"prefix": true}, "sortable": true, "retrievable": true, "public": true},
{"name": "visible", "type": "boolean", "filterable": true}]}}
- La risposta è 200:
{"schemaVersion": 4, "state": "active"}, oppure{"schemaVersion": 5, "state": "reindexing", "reindex": true}quando il cambio richiede una reindicizzazione (tipo,localeo prefisso di un campo esistente). Uno schema uguale a quello attivo, o a quello in arrivo, risponde 200 senza lavoro. - Pesi, sinonimi, regole e cancellazione di una collection si gestiscono solo dalla console.
- Una collection che il negozio non ha dà 404, tranne nel
PUT, che la crea. Errori delPUT: 409collection_not_readyper uno schema diverso durante una reindicizzazione, 409capacity_exceededper l'undicesima collection.
GET /v1/collections/{collection} legge una collection:
{"id": "products_it", "kind": "custom", "locale": "it", "state": "active", "schemaVersion": 4, "docCount": 7159,
"mappedFields": [{"name": "name", "type": "text", "prefix": true}, …]}
Durante una reindicizzazione schemaVersion è la versione attiva e pendingSchemaVersion quella in arrivo: chi aspetta la fine rilegge finché state torna active. docCount vale null se il motore di ricerca non risponde.
GET /v1/tenant dice com'è messo il negozio:
{"tenant": {"slug": "esempio", "env": "live", "status": "active"},
"collections": [{"id": "products_it", "kind": "product@1", "locale": "it", "ready": true, "docCount": 5120}]}
env vale live o test, come nelle chiavi; ready è true quando la collection è active.
Lotti di documenti
POST /v1/collections/{collection}/documents/batch, al più 500 elementi e 4 MB per lotto, 64 KB per documento.
{"upsert": [{"id": "2746", "version": 1791533520000, "doc": {"name": "Scarpa da trail 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"}]}
- La risposta è 200 anche con elementi falliti: guarda lo
statusdi ognuno. - L'upsert sostituisce il documento intero; il delete è per id, e un id assente dà
not_found, che conta come riuscito. Un id ripetuto nello stesso lotto: vale l'ultimo. - Senza
refreshi documenti diventano cercabili in genere entro uno o due secondi; con"refresh": "wait_for"la risposta arriva quando lo sono. versionè il momento in cui hai costruito il documento, in millisecondi, fra 10^12 e adesso più 24 ore; senza, vale l'istante di ricezione. Una versione più vecchia di quella salvata dàstale, che non è un errore: un lotto ripetuto può direstaledove il primo tentativo dicevaok.failedporta uncode:schema_violation(campo non dichiarato o di tipo sbagliato),reserved_field(campo che inizia con_),document_too_large(oltre 64 KB),invalid_version(fuori intervallo). Senzacodeè un errore imprevisto: riprova quell'elemento in un lotto successivo.- Una reindicizzazione in corso, o un sovraccarico durante il lotto, dà 503 con
Retry-Afterall'intera richiesta. Nell'ambiente di test una collection tiene al più 100.000 documenti: oltre, 409capacity_exceeded. Una richiesta respinta per intero vale «tutti gli elementi falliti».
Prune
POST /v1/collections/{collection}/documents/prune cancella ciò che un caricamento completo non ha più scritto.
{"seenBefore": "2026-10-09T02:30:04.120Z", "maxDeletePercent": 10}
La risposta è 202: {"total": 7159, "orphans": 12}.
- Cancella in background i documenti scritti l'ultima volta prima di
seenBefore; un prune successivo raccoglie i resti. Unostaleo unfaileddi un documento già presente conta come scrittura, quindi il prune non lo cancella. - Come
seenBeforeusa il più piccolo deireceivedAtdei lotti del caricamento completo, e salta il prune se un lotto è stato respinto per intero. - Se gli orfani superano
maxDeletePercentdel totale (default 10): 409 concode: "delete_guard",totaleorphans. Si ripete con"force": true, ma solo dopo aver controllato i numeri. Durante una reindicizzazione: 409collection_not_ready.
Eventi dal server
Gli stessi tipi, campi e limiti degli eventi dal browser, con Content-Type: application/json e la chiave segreta con scope events. Dal server sessionId è facoltativo, il queryId vale 30 giorni ed è obbligatorio anche per le conversion: un acquisto si lega alla ricerca con queryId e objectId, senza id dell'utente.
{"events": [{"eventId": "8f0a2c4e-3b1d-4e6f-9a7c-5d2b8e1f0a63", "type": "conversion", "collection": "products_it",
"queryId": "01K79Z4T8Q6M3VXR2C5N7PBJ0D", "objectId": "2746", "value": 8990, "currency": "EUR", "quantity": 1}]}
La risposta è 200: {"accepted": 1, "rejected": []}. rejected elenca gli eventi respinti dalla validazione, come [{"index": 0, "error": "value: Invalid input: expected number, received undefined"}]: non vanno ripetuti. accepted conta gli eventi validi nella forma, compresi i doppioni e quelli con un queryId scaduto, che non si salvano. Un corpo che non è JSON, o senza events, dà 400.
Errori
Decide lo status. Il corpo di un errore ha una di queste tre forme:
{"error": "<testo>"}. Nei 400 di validazione il testo nomina il campo, come{"error": "size: Too big: expected number to be <=48"}; più problemi si separano con;. Il testo è in inglese e serve a chi legge: non interpretarlo, perché può cambiare.{"error": "<testo>", "code": "<codice>"}, solo dove devi scegliere fra più casi: i 409 della tabella e gli elementi dei lotti.{"message": "<testo>"}quando risponde il gateway: il 401 di una chiave segreta, il 429 dei limiti di velocità sulle rotte del server, i suoi 5xx.
| Status | Quando | Cosa fare |
|---|---|---|
| 400 | richiesta non valida; ordinamento o facet su un campo non ammesso; risultati oltre il 480° con la chiave pubblica, oltre il 10.000° per tutti; risposta oltre circa 5,5 MB | correggi la richiesta: non ripeterla e non ripiegare |
| 401 | chiave assente, sbagliata, revocata, scaduta, di un altro negozio o ambiente; negozio non attivo. Con una chiave segreta anche un guasto temporaneo nella verifica della chiave | non ripetere e ripiega; non cancellare mai la chiave per un 401 |
| 403 | scope mancante; Origin fuori dalle origini della chiave | non ripetere e ripiega |
| 404 | collection o rotta sconosciuta | non ripetere e ripiega |
409 collection_not_ready | reindicizzazione in corso: prune, oppure PUT di uno schema diverso | riprova più tardi; il prune si salta |
409 capacity_exceeded | undicesima collection; nell'ambiente di test, oltre 100.000 documenti in una collection | fermati: serve una pulizia, o scrivici |
409 delete_guard | il prune cancellerebbe più di maxDeletePercent; il corpo ha total e orphans | controlla i numeri; forza solo a mano |
| 413 | corpo oltre i limiti | dividi il lotto |
| 429 | troppe richieste | aspetta Retry-After, se c'è; dal browser ripiega |
| 500 | errore imprevisto | non ripetere e ripiega |
| 502, 503, 504 | motore di ricerca lento o in errore; indice fermo per un attimo mentre si salvano i sinonimi; sovraccarico durante un lotto; scrittura durante una reindicizzazione; servizio che non risponde. Mai risultati parziali: una ricerca è completa o è un errore | un solo tentativo dopo Retry-After; dal browser ripiega |
Il ripiego. Su un 400 la richiesta è sbagliata e va corretta. Su ogni altro esito il negozio deve restare usabile: dai un tempo massimo alla chiamata (dal browser bastano pochi secondi) e, se scade o se arriva un errore, mostra la ricerca che il negozio aveva prima. Il modulo PrestaShop fa così: se HorusFinder non risponde, torna da solo alla ricerca di PrestaShop.
Limiti
| Cosa | Limite | Oltre |
|---|---|---|
| Lotto di documenti; documento | 500 elementi e 4 MB; 64 KB | 413; elemento document_too_large |
| Lotto di eventi | 50 eventi e 16 KB | 413 |
q | le prime 16 parole e 256 caratteri (valori iniziali) | si tronca senza errore |
size; pagine | chiave pubblica 48, chiave segreta 500, suggerimenti 10; con la chiave pubblica solo i primi 480 risultati, cioè (page + 1) × size al più 480; per tutti i primi 10.000 | 400 |
Facet; sort; ranges | chiave pubblica 5 da 50 valori, chiave segreta 20 da 100; 5 chiavi; 10 intervalli | 400 |
| Risposta | circa 5,5 MB, contati sulla risposta come parte (compressa, se hai chiesto gzip) | 400: riduci size o retrieve |
| Collection per negozio; documenti per collection, solo nell'ambiente di test | 10; 100.000 | 409 capacity_exceeded |
| Campi dello schema | 100 | 400 |
Limiti di velocità. Sulle rotte /v1/public/ ci sono un limite per indirizzo IP di Cloudflare e limiti per IP e per negozio nel servizio, tarati sopra il traffico di un visitatore che scrive, filtra e scorre i risultati. Il 429 del servizio ha Retry-After leggibile; quello di Cloudflare arriva senza gli header CORS, e il browser lo vede come un errore di rete: trattalo come un errore e ripiega. Le rotte con la chiave segreta hanno un tetto per rotta, il cui 429 ha {message} e nessun Retry-After.
Limiti del piano. Nelle richieste del mese contano le ricerche, i suggerimenti e le ricerche del momento andati a buon fine; eventi, caricamento del catalogo e console non contano. I limiti del piano non danno mai un 429: se li superi te lo diciamo, e la ricerca continua.