Search API v1
- Requires: Pro plan or above
- Written for: front-end engineers building the storefront
Marutto Search normally renders the search results page itself. With the Search API it renders nothing at all and hands your theme the result JSON instead.
- Your theme's design and layout are left untouched
- You get the Japanese morphological analysis, spelling-variant handling, synonyms and faceting, and nothing else
- The rendering is your code
1. Making a search request
Marutto Search returns JSON and nothing else. Calling, state, and rendering are all yours. We never load JavaScript into your storefront.
GET /apps/marutto-search/search?type=search&contract=v1&q=dress&limit=24&offset=0- The URL is on the store's own domain (a Shopify App Proxy). No API key and no CORS configuration — Shopify's signature identifies the shop for us
- From a browser it is readable only from that store's own pages. We send no CORS headers, so a page on another domain cannot read the response
- A server-side call (curl and friends) reaches it from anywhere, though. Anyone who knows the URL can call it, so this is not an endpoint designed to return secrets — what it returns is public catalogue data, which Shopify itself publishes on the same store as
/products.json contract=v1is what asks for this specification. Only requests carrying it are guaranteed the shape documented here. Without it you get the app's internal response, which changes without notice — it is the shape our own widget reads, not a contractcontract=v1requires the Pro plan or above. A shop below it gets403with{"code": "plan_required"}, so your code can tell a lapsed subscription apart from a broken search- v1 covers the confirmed-search response (
type=search) only. Addingcontract=v1tosuggestreturns400with{"code": "unsupported_contract_type"}. We do not return a shape as a contract when we have not written that shape down
Query parameters
| Parameter | Default | Meaning |
|---|---|---|
contract | — | v1. Required — this is what asks for the documented shape |
type | — | search. Required. contract=v1 supports search only |
q | — | Search term. Required (* browses everything) |
session_id | — | Session identifier. Required (see §7) |
limit | 8 | Results per page. Maximum 50 |
offset | 0 | Starting position. Maximum 2,000 (larger values are clamped) |
sort | relevance | relevance / price_asc / price_desc / newest |
locale | store's | ja, en, … — picks the analyzer language on a multilingual store |
filter_vendor | — | Vendor name |
filter_type | — | Product type |
filter_tags | — | Tags (comma-separated; combined with AND) |
filter_price_min / filter_price_max | — | Price range |
filter_available | — | true / false (in stock) |
filter_on_sale | — | true / false (on sale) |
filter_content_type | — | product / article / page |
filter_collection | — | Collection handle |
filter_option_name / filter_option_value | — | Variant option (e.g. Color / Beige) |
filter_metafield | — | Metafield (namespace.key:value). Repeatable |
Metafield filtering repeats
filter_metafield. The value isnamespace.key:value, split on the first colon only — a key never contains one, so a value that does survives intact. Separate keys combine with AND.&filter_metafield=custom.material:linen&filter_metafield=custom.origin:japanOnly metafields the merchant has enabled for filtering in the admin are honoured (text and list types, up to ten).
A filter that does nothing. If you pass a
filter_metafieldwhose key the merchant has not enabled for filtering, the pair is ignored rather than rejected — you get the unfiltered result set. This is deliberate: a shared or bookmarked URL keeps naming a filter the merchant has since switched off, and refusing it would leave shoppers stuck with a filter they cannot clear. While developing, a typo in a key name therefore looks like "everything matched" — check it against anamein the facets first.
What browsing (
q=*) cannot do. Browse is a separate path that reads only the products table. Three things behave differently there:
Request Behaviour when q=*filter_content_type=article/pageAlways zero results — browse holds products only facets.tags/content_types/variant_optionsAlways [](the keys are still present)Every other filter, and filter_metafieldWork exactly as they do with a search term To list articles or pages, use
type=searchwith an actual search term.
Errors
| Situation | Status | code |
|---|---|---|
q missing | 400 | query_required |
q longer than 200 characters | 400 | query_too_long |
type other than search / suggest / preview | 400 | invalid_type |
contract other than v1 | 400 | unsupported_contract |
contract=v1 on something other than type=search | 400 | unsupported_contract_type |
| Plan below Pro | 403 | plan_required |
| Search infrastructure failure | 503 | Normal response body (degraded: true) |
A bad request (4xx) returns { error, code } and nothing else. Returning it as an empty result set would look like total: 0 and hide the cause. Branch on code.
A problem on our side (503) returns the normal response shape, so an outage does not also throw an exception inside your data.results loop.
Reference implementation
A working minimum lives at search-api-sample.js. The rendering is meant to be replaced with your own, but three things should stay:
- Always send
contract=v1— this is what asks for the documented shape - Check
degraded— every new store goes through that path on day one - Both
Shopify.analytics.publishcalls — without them, search-attributed revenue is never recorded
What your code is responsible for
Because we ship no JavaScript, these are yours:
- Debouncing autocomplete (250–300ms recommended). One
type=suggestper keystroke reaches the rate limit quickly - Ordering of responses. Fast typing makes responses arrive out of order. The response echoes back
query/offset/limit/sortfrom the request, so you can use them to discard a stale response - Suppressing repeat requests for identical parameters
2. Paging
The response is self-contained: it tells you what to ask for next.
| Field | Purpose |
|---|---|
total | Total number of hits |
offset | Where this response starts |
limit | How many results this response holds |
// "Load more" / infinite scroll
const MAX_OFFSET = 2000; // as in §1, larger values are clamped to 2000
const next = data.offset + data.results.length;
if (next < data.total && next < MAX_OFFSET) {
// fetch with offset=next and append
}Do not test
next < data.totalalone. Past the offset ceiling the server keeps returning the same page, so iftotalis larger your "load more" button never disappears and the same products are appended over and over. Always compare against the ceiling too.
Numbered pages, "load more" and infinite scroll are all expressible with offset and limit alone.
Note: because paging is offset-based, products added, removed or restocked while a shopper is paging can make a product appear twice or be skipped. Infinite scroll shows this most. Talk to us if you need stricter consistency.
3. Rate limits
Requests are capped per 10-second window. Exceeding a cap returns HTTP 429.
| Type | Per shop | Per visitor | All stores combined |
|---|---|---|---|
type=search | 50 / 10s | 20 / 10s | 125 / 10s |
type=suggest | 150 / 10s | 40 / 10s | 150 / 10s |
Ordinary shopper behaviour does not reach these, but autocomplete without debouncing reaches the per-visitor cap easily. On a 429, retry after a pause or leave the previous results on screen.
About "all stores combined". Marutto Search runs as a single instance serving every store, so a request inside your shop's own cap can still be refused when concurrent load from other stores pushes the combined counter over. In other words, a 429 you cannot predict from your own request rate is possible. We have never reached this ceiling in production, but it is part of the specification. Tell us in advance if you plan a load test or any short burst of heavy traffic.
4. What the response guarantees
Top-level keys are always present
Even with zero results, and even when something failed internally, results, facets, total and the rest are never omitted — they become [], {} and 0. An expression like data.facets.tags.forEach(...) will not throw under some conditions and not others.
Search degrades temporarily
While a store's first sync is running, or while translations are syncing on a multilingual store, the index is not usable yet. During that window degraded is set and the results are either empty or Shopify's own search results.
{
"schemaVersion": "v1",
"degraded": true,
"degraded_reason": "sync_pending",
"total": 0,
"results": [],
"facets": {}
}if (data.degraded) {
// e.g. show "search is being prepared", or fall back to Shopify's own search
}Every store goes through this on day one. A new install returns
sync_pendingfor a while after installation, so an integration that ignoresdegradedlooks like "it doesn't work right after installing". Please handle it.
degraded_reason identifies the cause, and the set of values will grow. Branch on degraded (the boolean); keep degraded_reason for display and logs.
5. Example response
A search for "dress" on an apparel store. All values are illustrative.
{
"schemaVersion": "v1",
"degraded": false,
"degraded_reason": null,
"query": "dress",
"query_id": "q_8f3c1ad24b7e",
"measurement_proof": "mp_5c19e0b7a3d84f26",
"total": 137,
"limit": 24,
"offset": 0,
"sort": "relevance",
"redirect_url": null,
"results": [
{
"content_type": "product",
"product_id": "gid://shopify/Product/8123456789",
"title": "Linen Blend Long Dress",
"handle": "linen-blend-long-dress",
"url": "/products/linen-blend-long-dress",
"image_url": "https://cdn.shopify.com/s/files/1/0001/0002/products/linen-dress.jpg",
"price_min": 12800,
"price_max": 12800,
"compare_at_price": 16000,
"available": true,
"tags": ["spring", "machine-washable", "new"],
"variants": [
{
"id": "44001",
"title": "Beige / M",
"image_url": "https://cdn.shopify.com/s/files/1/0001/0002/products/linen-dress-beige.jpg",
"price": 12800,
"compare_at_price": 16000,
"available": true,
"options": [
{ "name": "Color", "value": "Beige" },
{ "name": "Size", "value": "M" }
]
},
{
"id": "44002",
"title": "Beige / L",
"price": 12800,
"available": false,
"options": [
{ "name": "Color", "value": "Beige" },
{ "name": "Size", "value": "L" }
]
}
]
},
{
"content_type": "article",
"product_id": "gid://shopify/Article/5500112233",
"title": "12 Ways to Style a Dress",
"handle": "dress-styling",
"url": "/blogs/journal/dress-styling",
"image_url": "https://cdn.shopify.com/s/files/1/0001/0002/articles/styling.jpg",
"tags": ["styling"],
"excerpt": "How to wear one dress through every season.",
"published_at": "2026-04-18T09:00:00Z",
"author": "Tanaka",
"blog_handle": "journal"
}
],
"facets": {
"vendors": [
{ "value": "ATELIER NOA", "count": 42 },
{ "value": "LUMIÈRE", "count": 31 }
],
"product_types": [{ "value": "Dresses", "count": 118 }],
"tags": [
{ "value": "spring", "count": 64 },
{ "value": "machine-washable", "count": 38 }
],
"content_types": [
{ "value": "product", "count": 131 },
{ "value": "article", "count": 5 }
],
"variant_options": [
{
"name": "Color",
"values": [
{ "value": "Beige", "count": 57 },
{ "value": "Black", "count": 49 }
]
}
],
"metafields": [
{
"name": "custom.material",
"label": "Material",
"values": [{ "value": "Linen", "count": 42 }]
}
],
"price_range": [3900, 42000],
"on_sale_count": 18
},
"suggestions": [],
"related_keywords": ["spring dress", "long dress", "shirt dress"]
}6. Field reference
Top level
| Field | Type | Meaning |
|---|---|---|
schemaVersion | string | Version of the data structure. "v1" for this one |
degraded | boolean | Whether the index is temporarily unusable (see §4). Always present |
degraded_reason | string | null | Why. null when degraded is false — the key is always present |
query | string | The search term, exactly as sent |
query_id | string | Identifier for this search; clicks are tied to it (see §7) |
measurement_proof | string | Signature for revenue attribution. Required for search-attributed revenue (§7) |
total | number | Total hits before paging |
limit | number | Results per page in this response |
offset | number | Where this response starts |
sort | string | relevance / price_asc / price_desc / newest |
redirect_url | string | null | Destination of a keyword redirect configured in the admin. When set, navigate instead of rendering results |
results | array | Products, articles and pages mixed; tell them apart with content_type |
facets | object | Aggregates for filtering (below) |
suggestions | string[] | Candidate terms for a zero-result page. Empty array rather than absent |
related_keywords | string[] | What other shoppers searched next. Empty array rather than absent |
Nothing outside this table is returned. Internal scores and review-matching details change with every improvement, so they are not published.
resultsarrives sorted.
facets
| Field | Type | Meaning |
|---|---|---|
vendors | array | Counts by vendor. Always present ([] when nothing matched) |
product_types | array | Counts by product type. Always present |
tags | array | Counts by tag. Always present |
content_types | array | Counts by product / article / page. Always present |
variant_options | array | Counts by variant axis (colour, size…). Always present |
metafields | array | Counts by metafield. Always present |
price_range | number[] | null | Lowest and highest price. null when nothing matched — never [0, 0] |
on_sale_count | number | How many are on sale. 0 when none |
Each entry in facets.metafields[] is { name, label?, values[] }. name is namespace.key; label is the heading the merchant set in the admin and is omitted when unset. Render label ?? name.
Ordering is by count descending, then by value ascending. The same search over the same catalogue always comes back in the same order. Each group holds at most 20 entries.
results[] (all content types)
| Field | Type | Meaning |
|---|---|---|
content_type | string | product / article / page |
product_id | string | Shopify GID. The key is named this way for every type (historical) |
title | string | Title |
handle | string | Handle |
url | string | Relative URL within the store |
image_url | string? | Image URL, when there is one |
tags | string[] | Shopify tags. Empty array when there are none — the key is always present |
results[] (content_type: "product" only)
| Field | Type | Meaning |
|---|---|---|
price_min / price_max | number | Price range (see below) |
compare_at_price | number | null | Compare-at price, for sale badges |
available | boolean | Whether purchasable stock exists |
variants[] | array? | Variants. id is the numeric id (see below) |
Prices are in the shop's base currency. Whether they include tax follows the store's Shopify settings.
On a store using Shopify Markets for multiple currencies, this will not match the currency the shopper is actually seeing. If you need the presentment currency, convert on your side, or fetch prices for rendering from Shopify's Storefront API. Let us know if multi-currency support matters to you.
variants[].idis the numeric id. While a product'sproduct_idis agid://shopify/Product/…, a variant id comes back as a numeric string such as"44001", because that is the form Shopify itself requires.
- Selecting a variant on the product page:
/products/xxx?variant=44001- Adding to cart (Cart AJAX API):
{ "id": 44001, "quantity": 1 }A GID in either place is ignored rather than rejected — the product page simply opens with nothing selected. Build a GID with
"gid://shopify/ProductVariant/" + variant.idwhen you need one.
results[] (content_type: "article" / "page" only)
| Field | Type | Meaning |
|---|---|---|
excerpt | string? | Excerpt of the body |
published_at | string? | Publication time (ISO 8601) |
author | string? | Author (articles only) |
blog_handle | string? | Handle of the parent blog (articles only) |
7. Analytics
Because Marutto Search does not render the product cards, without this the merchant's search analytics stop working. Click-through rate, the ranking of clicked products, and search-attributed revenue all depend on it.
Measurement travels through Shopify's Web Pixel, so all you do is publish two events. The HTTP requests are made by our own pixel.
Send session_id on the search request (required)
Anyone who knows the URL can reach this endpoint, so a request without session_id is treated as not-a-shopper and excluded from search analytics (the results come back normally). Forget it and search works while the merchant's analytics stay empty.
session_id is any string you generate. The format does not matter as long as it is stable for the duration of a session.
1) On search
Shopify.analytics.publish("marutto_search", {
query: data.query,
query_id: data.query_id,
session_id: sessionId, // the same value you sent on the request
measurement_proof: data.measurement_proof,
result_count: data.total,
});2) On a result click
Shopify.analytics.publish("marutto_result_clicked", {
query_id: data.query_id,
session_id: sessionId,
measurement_proof: data.measurement_proof,
product_id: item.product_id,
position: index, // zero-based
});About measurement_proof
Revenue attribution runs on this value. Our Web Pixel stores it when it receives marutto_search and re-attaches it to the subsequent add-to-cart and checkout events, which is how a purchase is traced back to the search that led to it.
Without publishing it, that store records no search-attributed revenue. Search keeps working, so this is easy to miss — please verify it during integration.
8. Versioning
- Field names, types and meanings published as
v1do not change. The same holds for event names - Fields may be added within
v1— existing integrations are unaffected - If something must change, it ships as
v2andv1is kept - Removal is announced in advance
9. Where you can use it
The Search API is not limited to the search results page. It is same-origin traffic through the App Proxy, so it works from any page in the store.
- Search results page
- Filtering on collection pages
- Header autocomplete
- Related products on a product page
- "Products matching this keyword" embedded in a campaign or landing page
Use from outside the store — Hydrogen on your own domain, a native app — is not covered by the App Proxy and is not offered today. Tell us if you need it.