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:

FieldTypeDescription
Search enginereadAnswers queries. You call POST /multi_search directly from your storefront with a scoped, read-only key.
Indexer servicemixedRecommendations (/reco/*), the interaction beacon, admin reindex, and Shopify product webhooks.
Control planeinternalAnalytics, 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

You don't stand up any infrastructure. We provision your engine, keys and sync, and keep tuning relevance. Endpoints below use your per-client host variables ($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.

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:

header
X-TYPESENSE-API-KEY: {your_search_key}

Scoping rule

A search key is scoped to explicit collections (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.

header
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.

FieldTypeDescription
$TYPESENSE_URLsearchYour search engine host, e.g. https://acme.search.indexa.now — POST /multi_search
$INDEXER_URLindexerYour 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
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

All searches go through a single endpoint. The body is always { "searches": [ SearchParams ] }; you may batch several searches in one call.

POST$TYPESENSE_URL/multi_search

Parameters

FieldTypeDescription
collectionreqstringTarget collection, e.g. products_it or products_en.
qreqstringThe query. Use * to match everything (browse mode).
query_bystringComma-separated fields to search. Default: title,vendor,model_code,sku,tags,primary_category.
query_by_weightsstringPer-field weights, aligned to query_by. Default: 6,3,4,4,2,2.
filter_bystringBoolean filter expression (see Filtering).
sort_bystringSort clause. Default: _text_match:desc,score:desc.
facet_bystringComma-separated facet fields to compute counts for.
per_pageintPage size. Up to 250 on the results page; 12 for autocomplete.
pageint1-based page number.
typo_tokens_thresholdintMin results before typo tolerance kicks in. Indexa uses 5.
drop_tokens_thresholdintSet to 0 when a strong attribute is detected so key terms are never dropped.
exclude_fieldsstringFields to omit from hits. Always exclude embedding on the results page.
prefixboolPrefix 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 sizes facet (e.g. xl → XL).
  • Colours — matched to the colors facet, including Italian gender/number inflections (nero → nera/nere/neri, verde → verdi).
  • EN → IT synonymsgreen→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:

filter_by
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:

FieldTypeDescription
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.
productTypeignoredNo 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.

hybrid search body
{
  "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

Integrations try hybrid first, fall back to keyword-only on error, and finally to native Shopify search — so a query always returns something.

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.

200 OK
{
  "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:

FieldTypeDescription
langstringThe language the rule applies to.
scopequery | collectionWhether target is a search query or a Shopify collection handle.
targetstringThe search query (scope: query) or the Shopify collection handle (scope: collection).
match_typeexact | containsApplies only when scope is query. Collection rules always match the handle exactly.
pinned_idsstring[]Ordered product ids. A product's position in the results is its index in the array plus one.
hidden_idsstring[]Product ids removed from the results.
strategyobject | nullReserved for automatic rules. Currently only strategy.boost_collections (an array of collection handles) is read.

Automatic rules are not shipped yet

strategy exists so a future rule can boost collections without being pinned to a specific query or handle. boost_collections is the only key currently read — a full rule builder on top of it is planned, not available today.

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:

FieldTypeDescription
pinned_hitsstringid:position pairs, comma-separated. Positions are 1-based, taken from the product's index in pinned_ids.
hidden_hitsstringComma-separated product ids removed from the result set.
pinned_ids -> pinned_hits
// merch_rules row
"pinned_ids": ["4102938", "4109221"]

// Typesense request parameter
"pinned_hits": "4102938:1,4109221:2"

Cache freshness

Rule configuration is cached briefly at query time, so a change made in your console is live within about a minute.

filter_curated_hits is always set

Every request — search or category — sets 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:

  • q set 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 of scope: 'query'.
filter_by
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:

sort_by — category page, no boost rule
score:desc
sort_by — category page, with a boost_collections rule
_eval(collections:=[`giacche-uomo`]):desc,score:desc

Sort field limit

Typesense accepts at most three sort_by fields. The query builder enforces this itself and throws before a request would ever be sent with more.

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.

GEThttps://{store}/{lang}/api/search-suggestions?q=magli
200 OK
{
  "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.

FieldTypeDescription
best-sellersproducts[]GET /reco/best-sellers?lang&limit&collection — Shopify best-selling, optionally within a collection handle.
relatedproducts[]GET /reco/related/{id}?lang&intent=RELATED — Shopify product recommendations.
complementaryproducts[]GET /reco/related/{id}?lang&intent=COMPLEMENTARY — needs Shopify Search & Discovery.
popularproduct_ids[]GET /reco/popular?lang&days&limit — most-viewed from your analytics.
also-viewedproduct_ids[]GET /reco/also-viewed/{id}?limit — co-view neighbours (people also viewed).
customproduct_ids[]GET /reco/custom/{slug} — a curated pick list managed in your console.
curl
curl "$INDEXER_URL/reco/best-sellers?lang=it&limit=8&collection=donna"
200 OK
{
  "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

In a Hydrogen storefront you can also call one wrapper — 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.

POSThttps://{store}/{lang}/api/ts-event
FieldTypeDescription
product_idreqstringThe product id the event is about.
event_typereqenumOne of view, click, add_to_cart, purchase.
sidstringOptional session id for co-view grouping.
curl
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.

FieldTypeDescription
idstringNumeric product id.
handlestringShopify handle (not searchable).
titlestringProduct title (primary search field).
descriptionstring?Plain-text description.
skustring[]Variant SKUs.
model_codestring?Model code metafield.
vendorstring?Brand / vendor (facet).
genderstring?Gender metafield (facet).
primary_categorystring?Primary category (facet).
category_2 / category_3string?Sub-categories (facets).
season / yearstring?Merch attributes (facets).
collectionsstring[]Collection handles (facet).
tagsstring[]Product tags (facet).
sizesstring[]Size option values (facet).
colorsstring[]Colour option values (facet).
main_colorstring?Primary colour.
pricefloatActive sale price if any, else full price (facet, sortable).
compare_at_pricefloat?Struck-through full price when on sale.
discount_pctfloatDiscount percent (facet, sortable).
on_salebooldiscount_pct > 0 (facet).
availableboolIn stock (facet).
scorefloatBlended rank; default sort field.
popularityintAnalytics-driven view count.
published_atint64?Publish time (epoch seconds, sortable).
image_urlstring?Featured image URL.
urlstring/{lang}/products/{handle}.
embeddingfloat[]?Semantic vector — present only when embeddings are enabled.

Pricing

Sale prices come from the variant 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.

POST$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.

POST$INDEXER_URL/admin/reindex

Triggers a full async reindex of all languages. Returns 202 { "started": true }.

POST$INDEXER_URL/admin/search-key

Mints a fresh scoped search-only key. Returns 200 { "search_key": "…" }.

curl
curl -X POST "$INDEXER_URL/admin/reindex" \
  -H "Authorization: Bearer $ADMIN_KEY"

Reference

Errors & status codes

FieldTypeDescription
200OKSearch, reco, events, webhook received, search-key.
202AcceptedReindex started.
400Bad requestInvalid event body or unknown recommendation type.
401UnauthorizedBad admin bearer key, or invalid/missing webhook HMAC.
404Not foundUnknown route.
500Internal errorUnhandled 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.
Indexa — a Barikreativa product. Managed AI search & discovery for Shopify.