# Search

URL: /docs/concepts/search

Results pages, instant search suggestions, the merchant's search settings, the anonymous search events beacon, synonyms and search rate limits.

Every store has a search index that ranks results, tolerates typos, and stays in step with the catalogue. Four endpoints cover it: a results page, suggestions as the shopper types, the merchant's search settings, and a beacon that records what shoppers click.

| Endpoint | Use |
|---|---|
| `GET /v1/search` | The results page: products, facets and filters |
| `GET /v1/search/suggest` | Instant suggestions as the shopper types |
| `GET /v1/search/config` | The merchant's search settings for your search box |
| `POST /v1/search/events` | Anonymous click and add-to-basket events from search |

Search answers are never personalised. Call these endpoints from your storefront server without a bearer token or cart token.

## The results page [#the-results-page]

`GET /v1/search` takes a query `q` and the same `perPage`, `page` and `include` parameters as the [product list](/docs/concepts/catalogue#list-products). An empty `q` lists every published product, which is useful for brand and tag pages.

| Parameter | Matches |
|---|---|
| `q` | The search text. Whitespace is trimmed and collapsed. |
| `locale` | A store language code from `GET /v1/search/config`. Unknown or missing means the store's default. Always send it; the API does not read `Accept-Language`. |
| `filter[collection]` | A collection slug |
| `filter[brand]` | A brand slug. An unknown slug returns no products. |
| `filter[tag]` | A tag value, exactly, ignoring case |
| `filter[in_stock]` | `1` for only products in stock |
| `filter[on_sale]` | `1` for only products on sale |
| `filter[price_min]`, `filter[price_max]` | Integer minor units of the store's currency, never decimals |
| `filter[attr][handle]` | A filterable attribute's value |
| `sort` | `name`, `-name`, `price`, `-price` or `-created_at`. Leave it out to sort by relevance. |

Malformed parameters, such as a decimal price or `filter[in_stock]=maybe`, return `422 VALIDATION_FAILED`.

```bash tab="cURL"
curl -G https://api.elaraone.app/api/v1/search \
  -H "X-Tenant-Key: $ELARAONE_TENANT_KEY" \
  -H "X-Tenant-Secret: $ELARAONE_TENANT_SECRET" \
  -H "X-Client-IP: 203.0.113.7" \
  --data-urlencode "q=oak lamp" \
  --data-urlencode "locale=en" \
  --data-urlencode "filter[in_stock]=1" \
  --data-urlencode "include=variants.prices,images"
```

```ts tab="TypeScript"
const params = new URLSearchParams({ q: 'oak lamp', locale: 'en', 'filter[in_stock]': '1', include: 'variants.prices,images' })

const res = await fetch(`https://api.elaraone.app/api/v1/search?${params}`, {
  headers: {
    'X-Tenant-Key': process.env.ELARAONE_TENANT_KEY!,
    'X-Tenant-Secret': process.env.ELARAONE_TENANT_SECRET!,
    'X-Client-IP': shopperIp, // the shopper's IP, read from the incoming request
  },
})
const searchId = res.headers.get('X-Search-Id') // keep it for click events
const { data, meta } = await res.json()
```

Besides the usual pagination fields, `meta` carries:

- `total`: the number of matching products;
- `locale` and `currency`: what the answer was searched and priced in;
- `facets`: counts per value for each facet, such as `collection_slugs`, `brand_slug`, `tags`, `in_stock`, `on_sale` and `attr_{handle}` for each filterable attribute;
- `facet_labels`: the heading to show for each facet, in display order;
- `facet_value_labels`: display names for values that are identifiers, such as brand slugs;
- `search_engine`: `meilisearch` normally, or `database` when the index is briefly unavailable. The answer is still a `200` with products, just without facets: render what you get.

Each successful answer also carries an `X-Search-Id` response header. Keep it to report clicks (below).

## Instant suggestions [#instant-suggestions]

`GET /v1/search/suggest` returns grouped suggestions for a partly typed query.

```
GET /api/v1/search/suggest?q=oak%20de&locale=en&types=products,collections,pages
```

| Parameter | Rules |
|---|---|
| `q` | Required, 1 to 200 characters after trimming. Shorter than the store's `min_chars` returns empty groups without searching. |
| `locale` | As for `GET /v1/search` |
| `types` | A comma-separated subset of `products`, `collections`, `brands`, `tags`, `pages` and `posts`. Missing means every type the merchant has switched on. |

```json title="Response (trimmed)"
{
  "data": {
    "query": "oak de",
    "products": {
      "total": 3,
      "hits": [
        {
          "id": 101,
          "slug": "oak-desk-lamp",
          "name": "Oak desk lamp",
          "name_html": "<mark>Oak</mark> <mark>de</mark>sk lamp",
          "image": { "url": "https://…/lamp-small.webp", "url_medium": "https://…/lamp-medium.webp", "alt": "Oak desk lamp" },
          "price": { "amount": 8900, "currency": "GBP", "formatted": "£89.00" },
          "price_max": null,
          "sale_price": null,
          "on_sale": false,
          "in_stock": true,
          "default_variant_id": 1543
        }
      ]
    },
    "collections": { "total": 0, "hits": [] }
  },
  "meta": { "search_engine": "meilisearch", "locale": "en", "currency": "GBP" }
}
```

- A group is present only for types you asked for and the merchant has switched on. Treat a missing group as empty, and render groups in the order of `autocomplete.types` from the config.
- `name_html` and `title_html` contain escaped text plus `<mark>` tags around the matched parts. Render them by splitting on `<mark>` and `</mark>` into text, never by injecting HTML.
- `default_variant_id` is set when the product has exactly one variant you can buy, so an "Add to basket" button can add it directly.
- Cancel the previous request when a new keystroke arrives, and keep the current suggestions on screen while the next ones load.

## Search settings [#search-settings]

`GET /v1/search/config?locale=en` returns the merchant's choices from **Search settings** in the admin: whether search and suggestions are on, `min_chars`, `debounce_ms`, which suggestion types to show and how many of each, popular searches, layout, colours, the placeholder and the no-results text. Use it instead of hard-coding these values.

The response carries an `ETag` and `Cache-Control: max-age=60, private`. Cache it for 60 seconds on your server and revalidate with `If-None-Match`; a `304` means nothing changed. `meta.version` changes whenever the merchant saves, so clear your suggestion cache when it does.

## The search events beacon [#the-search-events-beacon]

`POST /v1/search/events` records what shoppers do with search results, so the merchant can see which searches lead to clicks and sales. Send events in batches of 1 to 20:

```json title="POST /v1/search/events"
{
  "events": [
    { "type": "settled", "search_id": "01JB8Z6Q4M9W2C7XK3T5VYHNPR", "trigger": "idle" },
    { "type": "click", "search_id": "01JB8Z6Q4M9W2C7XK3T5VYHNPR", "item_type": "product", "item_id": "101", "position": 1 },
    { "type": "add_to_cart", "search_id": "01JB8Z6Q4M9W2C7XK3T5VYHNPR", "product_id": 101 }
  ]
}
```

| Event | Send when | Fields |
|---|---|---|
| `settled` | The shopper stops typing in the suggestions box (`idle`), leaves it (`blur`) or presses Enter (`enter`) | `trigger` |
| `click` | The shopper chooses a result | `item_type` (`product`, `collection`, `brand`, `tag`, `page` or `post`), `item_id` as a string, `position` from 1 |
| `add_to_cart` | The shopper adds a product straight from search | `product_id` |

The answer is always `202 Accepted` with `{ "data": { "accepted": n } }`. Events for an unknown or expired search id (ids last 15 minutes) are dropped quietly. Do not send `settled` for the results page: the engine records results-page searches itself.

**No shopper identifiers.** The beacon carries only the search id and what was chosen. Never send the query, customer ids or anything personal; the engine looks the query up from the search id and keeps only daily totals per query and per item. A `422` means a client bug: fix it rather than retrying. A `429` drops the batch; do not retry it.

## Synonyms [#synonyms]

Merchants teach search their own words under **Search settings → Synonyms** in the admin, with no code needed:

- **Multi-way** synonyms make words equal: searching any of "sofa", "settee" and "couch" finds the others.
- **One-way** synonyms widen one word only: "tableware" can also find "mug" and "tray" without "mug" finding everything that is tableware.

A store can have up to 1,000 rules, each with up to 20 terms, and can import and export them as CSV. Changes apply to suggestions and results alike.

## Rate limits [#rate-limits]

| Endpoint | Budget |
|---|---|
| `GET /v1/search/suggest` | 1,200 requests a minute per store and shopper IP |
| `POST /v1/search/events` | 300 batches a minute per store and shopper IP |
| `GET /v1/search`, `GET /v1/search/config` | The store's general API budget |

Suggestions and events have their own budgets, so typing never uses up the general one. The budgets are per shopper only when your server sends `X-Client-IP` with the server secret; otherwise every shopper shares your server's IP. On a `429`, keep the current results and wait for `Retry-After`. See [rate limits](/docs/concepts/rate-limits).

<Cards>
  <Card title="Catalogue" href="/docs/concepts/catalogue" description="The product shape, includes and price filters." />
  <Card title="Rate limits" href="/docs/concepts/rate-limits" description="Per-shopper budgets with X-Client-IP." />
</Cards>
