Indexa API
API reference
Indexa is a managed search & discovery engine for Shopify storefronts. This reference covers the two surfaces your integration talks to: the search engine (typo-tolerant keyword + semantic search) and the indexer service (recommendations, events, reindex and webhooks). Hosts and keys are provisioned for you at onboarding — this document is how you query them.
Concepts
How it fits together
Your Shopify catalog is synced into a dedicated search index, one collection per language (products_it, products_en). Three components are involved:
| Field | Type | Description |
|---|---|---|
Search engine | read | Answers queries. You call POST /multi_search directly from your storefront with a scoped, read-only key. |
Indexer service | mixed | Recommendations (/reco/*), the interaction beacon, admin reindex, and Shopify product webhooks. |
Control plane | internal | Analytics, synonyms, relevance weights, and merchandising rules — pinning, hiding and boosting products per search query or collection page (see Merchandising). Managed by us; surfaced in your console. |
Managed service
$TYPESENSE_URL, $INDEXER_URL) — the exact values are handed to you at onboarding and shown in your console.Auth
Authentication
Three credential types, each with a different scope. Never expose the admin key client-side.
Search key
A read-only key scoped to documents:search on your language collections only. Safe to ship in the storefront. Sent as a header on every search call:
X-TYPESENSE-API-KEY: {your_search_key}Scoping rule
products_it,products_en). Globs such as products_* are not accepted and a key scoped that way is rejected at query time.Admin key
A bearer token for the indexer's /admin/* routes (reindex, mint a search key). Server-side only.
Authorization: Bearer {your_admin_key}Webhook signature
Shopify signs product webhooks with your shared secret; the indexer verifies the X-Shopify-Hmac-Sha256 header and rejects anything unsigned in production.
Environments
Base URLs
Every tenant gets its own isolated hosts — there is no shared public gateway. Your exact URLs are delivered at onboarding and shown in your console under API keys. The examples below use the two environment variables you set in your storefront.
| Field | Type | Description |
|---|---|---|
$TYPESENSE_URL | search | Your search engine host, e.g. https://acme.search.indexa.now — POST /multi_search |
$INDEXER_URL | indexer | Your indexer host, e.g. https://acme.api.indexa.now — /reco/*, /admin/*, /webhooks/*, /health |
Languages default to it, en. Every collection name is products_{lang}.
Quickstart
Your first query
Search the Italian collection for a plain-language query:
curl -X POST "$TYPESENSE_URL/multi_search" \
-H "X-TYPESENSE-API-KEY: $SEARCH_KEY" \
-H "Content-Type: application/json" \
-d '{
"searches": [{
"collection": "products_it",
"q": "maglione verde",
"query_by": "title,vendor,model_code,sku,tags,primary_category",
"query_by_weights": "6,3,4,4,2,2",
"sort_by": "_text_match:desc,score:desc",
"per_page": 12
}]
}'Search
Search
All searches go through a single endpoint. The body is always { "searches": [ SearchParams ] }; you may batch several searches in one call.
$TYPESENSE_URL/multi_searchParameters
| Field | Type | Description |
|---|---|---|
collectionreq | string | Target collection, e.g. products_it or products_en. |
qreq | string | The query. Use * to match everything (browse mode). |
query_by | string | Comma-separated fields to search. Default: title,vendor,model_code,sku,tags,primary_category. |
query_by_weights | string | Per-field weights, aligned to query_by. Default: 6,3,4,4,2,2. |
filter_by | string | Boolean filter expression (see Filtering). |
sort_by | string | Sort clause. Default: _text_match:desc,score:desc. |
facet_by | string | Comma-separated facet fields to compute counts for. |
per_page | int | Page size. Up to 250 on the results page; 12 for autocomplete. |
page | int | 1-based page number. |
typo_tokens_threshold | int | Min results before typo tolerance kicks in. Indexa uses 5. |
drop_tokens_threshold | int | Set to 0 when a strong attribute is detected so key terms are never dropped. |
exclude_fields | string | Fields to omit from hits. Always exclude embedding on the results page. |
prefix | bool | Prefix matching. Must be false when embedding is in query_by (semantic). |
Query understanding
Before searching, Indexa parses attributes out of the raw term and turns them into filters — so "maglione verde taglia XL" filters by colour and size instead of matching those words as text. Sizes and colours are read from your live facet vocabulary; gender is resolved separately.
- • Sizes — matched to the
sizesfacet (e.g.xl → XL). - • Colours — matched to the
colorsfacet, including Italian gender/number inflections (nero → nera/nere/neri,verde → verdi). - • EN → IT synonyms —
green→Verde, black→Nero, blue→Blu, red→Rosso…added only when the Italian value exists in the facet. - • Stopwords dropped:
da, di, del, con, per, the, of, for, with, and…
Detected attributes become filter clauses, joined with the caller's gender filter:
gender:=Donna && colors:=["Verde"] && sizes:=["XL"]Filtering & facets
filter_by uses Typesense syntax: field:=value (exact), field:=[a,b] (any of), price:>=n / price:<=n (range), joined with &&. Storefront URL parameters map to filters like this:
| Field | Type | Description |
|---|---|---|
productVendor | → vendor:= | Brand / vendor exact match. |
available | → available:= | true / false in-stock filter. |
variantOption (size) | → sizes:=[…] | Names: size / taglia / talla. |
variantOption (colour) | → colors:=[…] | Names: color / colour / colore. |
…Metafield gender | → gender:= | From product/variant gender metafield. |
minPrice / maxPrice | → price:>= / <= | Numeric price range. |
productType | ignored | No matching indexed field. |
Sorting & pagination
The default sort is relevance then score: _text_match:desc,score:desc. score is a blended rank (popularity, discount, freshness, availability) computed at index time. Sortable fields: price, discount_pct, popularity, published_at, score. Page size is up to 250.
Semantic (hybrid) search
When embeddings are enabled, append the embedding field to query_by with a trailing weight and set prefix: false. Keyword relevance and vector similarity are fused in one call — no separate vector query needed.
{
"searches": [{
"collection": "products_it",
"q": "qualcosa di caldo per l'inverno",
"query_by": "title,vendor,model_code,sku,tags,primary_category,embedding",
"query_by_weights": "6,3,4,4,2,2,1",
"prefix": false,
"exclude_fields": "embedding",
"sort_by": "_text_match:desc,score:desc",
"per_page": 24
}]
}Graceful fallback
The response
Results come back under results[0].hits; each hit's document is a product (see Product schema). facet_counts is present when you pass facet_by.
{
"results": [{
"found": 128,
"hits": [{
"document": {
"id": "7523891",
"title": "Maglione girocollo in lana",
"vendor": "Atelier Nord",
"price": 39.90,
"compare_at_price": 59.90,
"discount_pct": 33,
"colors": ["Verde"],
"sizes": ["S","M","L","XL"],
"image_url": "https://cdn.shopify.com/.../maglione.jpg",
"url": "/it/products/maglione-girocollo-lana"
},
"text_match": 1157451471441100900
}],
"facet_counts": []
}]
}Search
Merchandising
Merchandising rules pin, hide and boost products in both search results and category pages. Rules are authored and managed in your console — there is no public write endpoint for them — and are read at query time by the same shared query builder that assembles every other Indexa search.
The rule model
Rules live in a merch_rules table on the control plane. Each row has:
| Field | Type | Description |
|---|---|---|
lang | string | The language the rule applies to. |
scope | query | collection | Whether target is a search query or a Shopify collection handle. |
target | string | The search query (scope: query) or the Shopify collection handle (scope: collection). |
match_type | exact | contains | Applies only when scope is query. Collection rules always match the handle exactly. |
pinned_ids | string[] | Ordered product ids. A product's position in the results is its index in the array plus one. |
hidden_ids | string[] | Product ids removed from the results. |
strategy | object | null | Reserved for automatic rules. Currently only strategy.boost_collections (an array of collection handles) is read. |
Automatic rules are not shipped yet
Rule selection
At most one rule applies to a given search. Selection is deterministic — it never depends on the order rules happen to be stored or fetched in:
- • Exact beats contains — a scope: query rule with match_type: exact matching the whole normalized query wins over any contains rule.
- • Longest target wins among contains rules — if the query contains more than one rule's target as a substring (e.g. "scarpe" and "scarpe donna"), the longer, more specific target is selected.
- • Ties broken by the lowest rule id — when two competing contains targets have equal length, the rule with the lower id wins.
- • Collection rules match the handle exactly — match_type is not considered for scope: collection.
Matching is case-insensitive and ignores leading/trailing whitespace on both the rule target and the incoming query or handle.
From rules to request parameters
The selected rule is not pushed into Typesense as a stored override object. It is translated, at query time, into two parameters on the same /multi_search call used for every other search:
| Field | Type | Description |
|---|---|---|
pinned_hits | string | id:position pairs, comma-separated. Positions are 1-based, taken from the product's index in pinned_ids. |
hidden_hits | string | Comma-separated product ids removed from the result set. |
// merch_rules row
"pinned_ids": ["4102938", "4109221"]
// Typesense request parameter
"pinned_hits": "4102938:1,4109221:2"Cache freshness
filter_curated_hits is always set
filter_curated_hits: true. Without it, a pinned product is returned even when filter_by excludes it: a product pinned to a category page would keep appearing there after leaving the collection, and would survive a shopper's facet filters. With it, pinning decides ORDER while filter_by still decides MEMBERSHIP — a pinned product that no longer matches the filter is simply not returned.Category pages
A category listing is not a separate endpoint — it is the same /multi_search request, assembled by the same shared query builder, with:
- •
qset to*(match everything). - • A collection filter added to filter_by, with the handle backtick-quoted:
collections:=[`handle`]. - • The merchandising rule lookup run with
scope: 'collection'instead ofscope: 'query'.
collections:=[`giacche-uomo`]For a wildcard query, _text_match is dropped from sort_by — it is meaningless when there is no query text to match against:
score:desc_eval(collections:=[`giacche-uomo`]):desc,score:descSort field limit
Pinned-product edge cases
Verified against a live Typesense 27.1 instance — two behaviors worth knowing before you rely on pinning:
- • Pinning an id outside the result set leaves no gap. If a pinned product would not otherwise appear in the results, the remaining hits simply fill in — there is no empty slot left where it would have been.
- • A pinned product that fails filter_by is excluded, not injected. filter_curated_hits (above) means pinning never overrides an active filter — a pinned product a shopper's facets rule out just does not show up.
Storefront
Autocomplete
The storefront proxy exposes an instant-search endpoint that runs a keyword search plus popular-query suggestions and returns render-ready product cards.
https://{store}/{lang}/api/search-suggestions?q=magli{
"products": [ { "id": "gid://shopify/Product/7523891", "title": "Maglione…", "handle": "…", "price": {…} } ],
"queries": [ { "text": "maglione donna", "styledText": "maglione <b>donna</b>" } ]
}Discovery
Recommendations
Six recommendation types on the indexer service. Shopify-backed types return full products[]; analytics-backed types return ordered product_ids[] you resolve against your catalog.
| Field | Type | Description |
|---|---|---|
best-sellers | products[] | GET /reco/best-sellers?lang&limit&collection — Shopify best-selling, optionally within a collection handle. |
related | products[] | GET /reco/related/{id}?lang&intent=RELATED — Shopify product recommendations. |
complementary | products[] | GET /reco/related/{id}?lang&intent=COMPLEMENTARY — needs Shopify Search & Discovery. |
popular | product_ids[] | GET /reco/popular?lang&days&limit — most-viewed from your analytics. |
also-viewed | product_ids[] | GET /reco/also-viewed/{id}?limit — co-view neighbours (people also viewed). |
custom | product_ids[] | GET /reco/custom/{slug} — a curated pick list managed in your console. |
curl "$INDEXER_URL/reco/best-sellers?lang=it&limit=8&collection=donna"{
"products": [
{ "id": "7523891", "handle": "felpa-cappuccio-donna", "title": "Felpa con cappuccio",
"price": 29.99, "image_url": "https://cdn.shopify.com/.../felpa.jpg",
"url": "/it/products/felpa-cappuccio-donna" }
]
}Storefront proxy
GET /{lang}/api/ts-reco?type=best-sellers&limit=8 — which resolves ids to product cards for you.Analytics
Events
Send interaction beacons so Indexa can power popularity, "also viewed" and analytics. No auth — it's a public, fire-and-forget beacon.
https://{store}/{lang}/api/ts-event| Field | Type | Description |
|---|---|---|
product_idreq | string | The product id the event is about. |
event_typereq | enum | One of view, click, add_to_cart, purchase. |
sid | string | Optional session id for co-view grouping. |
curl -X POST "https://store.example/it/api/ts-event" \
-H "Content-Type: application/json" \
-d '{ "product_id": "7523891", "event_type": "click", "sid": "a1b2c3" }'Returns { "ok": true }. An unknown event_type returns 400.
Schema
Product document
Every indexed product — and every search hit — has this shape.
| Field | Type | Description |
|---|---|---|
id | string | Numeric product id. |
handle | string | Shopify handle (not searchable). |
title | string | Product title (primary search field). |
description | string? | Plain-text description. |
sku | string[] | Variant SKUs. |
model_code | string? | Model code metafield. |
vendor | string? | Brand / vendor (facet). |
gender | string? | Gender metafield (facet). |
primary_category | string? | Primary category (facet). |
category_2 / category_3 | string? | Sub-categories (facets). |
season / year | string? | Merch attributes (facets). |
collections | string[] | Collection handles (facet). |
tags | string[] | Product tags (facet). |
sizes | string[] | Size option values (facet). |
colors | string[] | Colour option values (facet). |
main_color | string? | Primary colour. |
price | float | Active sale price if any, else full price (facet, sortable). |
compare_at_price | float? | Struck-through full price when on sale. |
discount_pct | float | Discount percent (facet, sortable). |
on_sale | bool | discount_pct > 0 (facet). |
available | bool | In stock (facet). |
score | float | Blended rank; default sort field. |
popularity | int | Analytics-driven view count. |
published_at | int64? | Publish time (epoch seconds, sortable). |
image_url | string? | Featured image URL. |
url | string | /{lang}/products/{handle}. |
embedding | float[]? | Semantic vector — present only when embeddings are enabled. |
Pricing
custom.discounts metafield (per-market, date-bounded), not Shopify's native compare-at price — the lowest currently-active discount wins.Sync
Webhooks
Shopify product changes are pushed to the indexer, which upserts or deletes the document in every language collection in real time. You point Shopify at one of three routes.
$INDEXER_URL/webhooks/products/{create|update|delete}- • Signature header
X-Shopify-Hmac-Sha256, verified against your webhook secret. - • Responds
200 {"received":true}immediately, then processes asynchronously (no Shopify timeout). - • Missing / invalid signature →
401 {"error":"invalid hmac"}. Unsigned requests are rejected in production (fail-closed).
Admin
Admin
Server-side only. Both require the admin bearer key.
$INDEXER_URL/admin/reindexTriggers a full async reindex of all languages. Returns 202 { "started": true }.
$INDEXER_URL/admin/search-keyMints a fresh scoped search-only key. Returns 200 { "search_key": "…" }.
curl -X POST "$INDEXER_URL/admin/reindex" \
-H "Authorization: Bearer $ADMIN_KEY"Reference
Errors & status codes
| Field | Type | Description |
|---|---|---|
200 | OK | Search, reco, events, webhook received, search-key. |
202 | Accepted | Reindex started. |
400 | Bad request | Invalid event body or unknown recommendation type. |
401 | Unauthorized | Bad admin bearer key, or invalid/missing webhook HMAC. |
404 | Not found | Unknown route. |
500 | Internal error | Unhandled exception. |
Error bodies are JSON, e.g. { "error": "unauthorized" }.
Good to know
Limits & notes
- • Search keys are read-only and collection-scoped — safe to ship in the browser.
- • Merchandising (synonyms, pins, boosts, redirects) is managed in your console and applied to the engine for you — there is no public write endpoint for it.
- • CORS on the search engine is enabled so the storefront can query it directly.
- • Relevance weights per field and language are tuned from your real search data — the defaults above are a starting point.
- • Need higher throughput, more languages, or a dedicated region? Talk to us.