Search and relevance

Search API

The search request and response, every filter form, sorting, paging, highlighting and the inline profile for previews.

Every search runs through a search profile. The profile decides which fields a query looks in, the facets, the allowed sorts and what a hit returns. The request only says what the shopper did: the query, the filters, the sort and the page.

There are two routes. They take the same request fields and return the same response.

Public route

POST/v1/search/:profileKeyPublic key from an allowed origin, or a secret key

This is the route for storefronts. The profile key in the path picks the index and the profile, so the URL holds no project or index id. The route counts against the search rate limit: by default 600 requests a minute per key and 1200 a minute per client IP, then 429 RATE_LIMITED.

curl -X POST https://api.zaproo-search.com/v1/search/$PROFILE_KEY \
  -H "Authorization: Bearer $ZS_KEY" -H "Content-Type: application/json" \
  -d '{"q": "oled tv", "filters": {"brand": ["Samsung", "LG"]}, "sort": "price:asc", "page": 1}'

A public key (zs_pk_…) answers only requests whose Origin header is one of the key's allowed origins, and 403 otherwise. A key limited to some indices answers 404 PROFILE_NOT_FOUND for a profile of any other index.

Browsers on any domain may call this route: the API answers the CORS preflight for every origin, without cookies, and the key's allowed origins decide on the real request.

Project route

POST/v1/projects/:projectId/indices/:indexId/searchProject member or any secret key

This route is for your back office and for tools. It takes either a stored profile by profileKey in the body, or a whole profile inline (see Inline profile). Send exactly one of the two.

curl -X POST https://api.zaproo-search.com/v1/projects/$PROJECT_ID/indices/$INDEX_ID/search \
  -H "Authorization: Bearer $ZS_KEY" -H "Content-Type: application/json" \
  -d '{"profileKey": "'$PROFILE_KEY'", "q": "oled tv"}'

From an AI agent, the MCP tool search takes the same body plus the indexId.

Request

q
string
The search text, up to 500 characters. Empty (the default) returns every product the filters allow, which is what a category page needs.
filters
object
Field name to filter, plus optional $or, $and and $not groups, see Filters. Default {}.
fields
string[]
1 to 100 field names. Each hit returns only these fields and id. See Returned fields.
excludeIds
string[]
1 to 1000 document ids to leave out of the results. See Excluding products.
sort
string
One of the profile's sort options, written exactly as in the profile. Leave it out for the profile's default sort. See Sorting.
page
integer
The page to return, from 1. Default 1.
perPage
integer
Hits per page. Default: the profile's perPage.default. See Paging.
highlight
boolean
Adds _highlight to every hit. Default false. See Highlighting.
sessionId
string
An id for the visitor's session, so their pages come from the same copies of the index. See Session id.

The project route also takes profileKey, profile, rulesFor and explain. The public route takes profile from a secret key only.

Filters

filters maps a field name to one filter. A product must match the filters of every field (AND). Inside one field, a list matches any of its values (OR).

A request may filter only on the profile's facet fields and its extra filterable fields. Any other field answers 400 FILTER_NOT_ALLOWED. A filter that does not fit the field answers 400 INVALID_FILTER, for example a range on a text field.

One value

A string, a number or a boolean. Strings are matched exactly, capital letters included.

{ "filters": { "brand": "Samsung", "inStock": true } }

Any of several values

A list of 1 to 1000 strings or numbers.

{ "filters": { "brand": ["Samsung", "LG"], "size": [55, 65] } }

Range

gte (at least), gt (more than), lte (at most) and lt (less than), at least one of them. Ranges work on number and date fields. Dates are ISO strings.

{ "filters": { "price": { "gte": 300, "lt": 1000 }, "releasedAt": { "gte": "2026-01-01" } } }

Field is set or empty

{"exists": true} keeps products that have a value in the field. {"exists": false} keeps those that have none.

{ "filters": { "salePrice": { "exists": true } } }

Category nodes

On a hierarchy field, a node is written as its path with > between the levels and no spaces: TV>OLED. Spaces around > are allowed and dropped. terms takes 1 to 100 nodes, and a product in any of them, or below them, matches. The optional level (the top level is 0) makes the API check that every node sits at that depth.

{ "filters": { "category": { "terms": ["TV>OLED", "TV>QLED"], "level": 1 } } }

A hierarchy field also takes the plain value and list forms: {"category": "TV>OLED"}. The terms form works on hierarchy fields only.

Filter groups

Next to the field names, filters takes three group keys. Field names cannot contain $, so a group key never clashes with a field.

  • $or: a list of 2 to 20 filters. A product must match at least one of them.
  • $and: a list of 1 to 20 filters. A product must match all of them.
  • $not: one filter. A product that matches it is left out.

Each filter in a group is written like filters itself: field names ANDed, and groups of its own. This request finds posters that are at least 200 by 200, or at most 400 wide and at least 400 high, and leaves out the Acme posters that are on discount:

{
  "filters": {
    "category": "Posters",
    "$or": [
      { "width": { "gte": 200 }, "height": { "gte": 200 } },
      { "width": { "lte": 400 }, "height": { "gte": 400 } }
    ],
    "$not": { "brand": ["Acme"], "discount": { "exists": true } }
  }
}

A field inside a group follows the same rules as a top-level one: the profile must allow it (400 FILTER_NOT_ALLOWED) and the filter must fit its type (400 INVALID_FILTER). The error's path names the place, for example $or[1].width.

Groups nest up to 4 levels deep, and a request holds at most 100 field filters in all, groups included. A group member cannot be empty. Past these limits, or with an unknown key such as $any, the API answers 400 VALIDATION_FAILED.

Groups and facets. Top-level field filters work as facet selections: a facet's counts leave out the filter on its own field, so the shopper still sees the other values. A group narrows the whole result instead, and every facet counts only the products the group lets through. A facet value shows selected: true only for a top-level filter on its field, never for a field inside a group.

Returned fields

fields trims every hit to the fields listed, plus id. Each field must be one the profile returns, or the API answers 400 FIELD_NOT_RETURNED. Use it when a page or an agent needs a few fields of each hit, such as a quick list of titles and prices.

{ "q": "oled tv", "fields": ["title", "price", "imageUrl"] }

Excluding products

excludeIds leaves the listed documents out of the results, for example the products already in the cart or the one on the current product page. An id that a ranking rule pins is left out too. Unknown ids are ignored.

{ "q": "oled tv", "excludeIds": ["tv-1", "tv-7"] }

Sorting

sort must be one of the profile's sort options, character for character, or the API answers 400 SORT_NOT_ALLOWED. An option is up to 10 clauses joined by commas:

  • _relevance: best match first.
  • field:asc or field:desc on a sortable field, for example price:asc.

inStock:desc,price:asc sorts in-stock products first and then by price. Products that tie on every field clause come out best match first. The profile's always-show-first clauses lead every sort.

Paging

page starts at 1. perPage can be at most the profile's perPage.max, else 400 PER_PAGE_TOO_LARGE. page × perPage can be at most 10,000, else 400 PAGE_OUT_OF_RANGE. The API never pages past the first 10,000 results.

total counts matching products up to 10,000. A search with more matches answers 10000. Show "10,000+" in the storefront rather than a page count past that.

Highlighting

With "highlight": true and a non-empty q, every hit gets _highlight. It holds, for each query field where the query matched, a list of text fragments with the matching words wrapped in <em> tags.

"_highlight": { "title": ["55 inch <em>OLED</em> <em>TV</em>"] }

The key is the field the query ran on. For a text field that is its name. For a keyword field it is the field's text subfield, such as brand.text.

The fragments come from your own product data. Escape everything in them except the <em> tags before you insert them into a page as HTML.

Session id

sessionId is 1 to 100 letters, digits, - or _, and cannot start with _. Searches with the same session id are served by the same copies of the index, so scores, and with them the order of the results, stay the same from one page to the next. Use your storefront's session or visitor id. The search log stores it with each search.

Inline profile

The cabinet's live preview runs a profile that is not saved yet. You can do the same by sending the whole profile as profile in the body. It must be a complete profile, as POST .../profiles takes it, and it is checked against the mapping first (400 PROFILE_INVALID).

  • On the public route, only a secret key may send profile. A public key gets 403 INLINE_PROFILE_FORBIDDEN. The inline profile replaces the stored one for that request.
  • On the project route, send profile instead of profileKey. Add rulesFor with a stored profile's id to apply the ranking rules of that profile. Without it, only rules for every profile apply.
curl -X POST https://api.zaproo-search.com/v1/projects/$PROJECT_ID/indices/$INDEX_ID/search \
  -H "Authorization: Bearer $ZS_KEY" -H "Content-Type: application/json" \
  -d '{
    "profile": {"name": "Draft", "queryFields": {"title": 3, "brand": 2}, "match": "any"},
    "rulesFor": "'$PROFILE_ID'",
    "q": "samsung oled"
  }'

Searches with an inline profile are not counted in Analytics. The MCP tool preview_profile runs a draft profile the same way.

Explaining scores

On the project route and in POST .../rules/test, send "explain": true to see why each hit ranks where it does. Every hit then gets _explain, and the response gets sortedBy. The public route never explains. An explained search costs a little more, so leave it off in production.

"_explain": {
  "score": 43.2,
  "matches": [
    { "kind": "query", "text": "oled tv", "boost": 1, "fields": [
      { "field": "title", "weight": 5, "score": 9.6 },
      { "field": "shortDescription", "weight": 2, "score": 3.1 }
    ] },
    { "kind": "phrase", "text": "oled tv", "boost": 2, "fields": [{ "field": "title", "weight": 5, "score": 4.8 }] }
  ],
  "relevance": 14.4,
  "boosts": [{ "rule": { "id": "…", "name": "Sony week" }, "factor": 3 }],
  "multiplier": 3,
  "pin": null
}
  • matches lists each part of the query that matched. The kind is query, synonym (a synonym of the query), code (the query against normalized product codes), phrase or wordPairs. A part searches every query field, but only its best field counts. That field comes first.
  • relevance is the sum of each match's best field score.
  • boosts are the rule boosts whose filter the hit matches. multiplier is their factors multiplied, at most 100.
  • pin is set when a rule pinned the hit. It holds the rule, the position (1 is the top) and the lift added to the score.

The score is relevance × multiplier + lift. With an empty q, every hit has a relevance of 1.

sortedBy lists the sort keys in the order they apply, such as ["inStock:desc", "_relevance"]. The score decides only where the keys before _relevance tie.

Response

{
  "items": [
    { "id": "tv-1", "title": "55 inch OLED TV", "brand": "Samsung", "price": 899, "_pinned": true },
    { "id": "tv-7", "title": "65 inch OLED TV", "brand": "LG", "price": 1299 }
  ],
  "total": 2,
  "page": 1,
  "perPage": 24,
  "facets": [
    { "type": "terms", "field": "brand", "label": "Brand", "items": [
      { "key": "Samsung", "count": 1, "selected": true },
      { "key": "LG", "count": 1, "selected": false }
    ] }
  ],
  "correctedQuery": null,
  "tookMs": 12
}
items
array
The hits of this page. A hit holds the fields the profile returns, plus _highlight when asked for and _pinned: true when a ranking rule put it there.
total
integer
Matching products, counted up to 10,000.
page
integer
The page returned.
perPage
integer
The page size used: the request's or the profile's default.
facets
array
The profile's facets in order, each with its type, field, label (null when none is set) and values. See Facets.
correctedQuery
string | null
Set when the query found nothing and did you mean found a better spelling. When the profile searches the suggestion, items are the results for correctedQuery; otherwise items is empty.
tookMs
integer
How long the API took, in milliseconds.

A hit returns only the fields in the profile's fields list, or the request's fields and id when sent. When you set the profile's list, keep id in it. Without it the hits carry no id, and the storefront cannot link or track the product.

Errors

Errors come as {"error": {"code", "message", "details"}}. The codes a search can answer:

StatusCodeWhy
400FILTER_NOT_ALLOWEDThe profile does not allow filtering on that field.
400INVALID_FILTERThe field is not mapped, or the filter does not fit its type.
400FIELD_NOT_RETURNEDA name in fields is not one the profile returns.
400SORT_NOT_ALLOWEDsort is not one of the profile's sort options.
400PER_PAGE_TOO_LARGEperPage is above the profile's maximum.
400PAGE_OUT_OF_RANGEpage × perPage is above 10,000.
400PROFILE_INVALIDThe inline profile does not fit the mapping.
401UNAUTHENTICATEDNo key, or an unknown, revoked or expired one.
403INLINE_PROFILE_FORBIDDENA public key sent an inline profile.
404PROFILE_NOT_FOUNDNo such profile for this key's project and indices.
429RATE_LIMITEDToo many searches in the last minute.

Try it in the playground

In the cabinet, open an index and then Playground. Pick a profile, type a search, choose a sort, and click facet values to filter. Advanced filters (JSON) takes any filters object, groups included, and shows the first problem under the field while the JSON is not valid. The results show as product cards; Card fields picks which field fills the title, the image and the price. The playground calls the project route with the profile's key, so its searches count in Analytics like storefront traffic.

The Playground with the Storefront profile, the query tape, category facets with counts and result cards, the first two marked PinnedThe playground searches with a profile, as the storefront does