Skip to content

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=v1 is 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 contract
  • contract=v1 requires the Pro plan or above. A shop below it gets 403 with {"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. Adding contract=v1 to suggest returns 400 with {"code": "unsupported_contract_type"}. We do not return a shape as a contract when we have not written that shape down

Query parameters

ParameterDefaultMeaning
contractv1. Required — this is what asks for the documented shape
typesearch. Required. contract=v1 supports search only
qSearch term. Required (* browses everything)
session_idSession identifier. Required (see §7)
limit8Results per page. Maximum 50
offset0Starting position. Maximum 2,000 (larger values are clamped)
sortrelevancerelevance / price_asc / price_desc / newest
localestore'sja, en, … — picks the analyzer language on a multilingual store
filter_vendorVendor name
filter_typeProduct type
filter_tagsTags (comma-separated; combined with AND)
filter_price_min / filter_price_maxPrice range
filter_availabletrue / false (in stock)
filter_on_saletrue / false (on sale)
filter_content_typeproduct / article / page
filter_collectionCollection handle
filter_option_name / filter_option_valueVariant option (e.g. Color / Beige)
filter_metafieldMetafield (namespace.key:value). Repeatable

Metafield filtering repeats filter_metafield. The value is namespace.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:japan

Only 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_metafield whose 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 a name in the facets first.

What browsing (q=*) cannot do. Browse is a separate path that reads only the products table. Three things behave differently there:

RequestBehaviour 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=search with an actual search term.

Errors

SituationStatuscode
q missing400query_required
q longer than 200 characters400query_too_long
type other than search / suggest / preview400invalid_type
contract other than v1400unsupported_contract
contract=v1 on something other than type=search400unsupported_contract_type
Plan below Pro403plan_required
Search infrastructure failure503Normal 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:

  1. Always send contract=v1 — this is what asks for the documented shape
  2. Check degraded — every new store goes through that path on day one
  3. Both Shopify.analytics.publish calls — 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=suggest per keystroke reaches the rate limit quickly
  • Ordering of responses. Fast typing makes responses arrive out of order. The response echoes back query / offset / limit / sort from 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.

FieldPurpose
totalTotal number of hits
offsetWhere this response starts
limitHow many results this response holds
js
// "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.total alone. Past the offset ceiling the server keeps returning the same page, so if total is 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.

TypePer shopPer visitorAll stores combined
type=search50 / 10s20 / 10s125 / 10s
type=suggest150 / 10s40 / 10s150 / 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.

json
{
  "schemaVersion": "v1",
  "degraded": true,
  "degraded_reason": "sync_pending",
  "total": 0,
  "results": [],
  "facets": {}
}
js
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_pending for a while after installation, so an integration that ignores degraded looks 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.

json
{
  "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

FieldTypeMeaning
schemaVersionstringVersion of the data structure. "v1" for this one
degradedbooleanWhether the index is temporarily unusable (see §4). Always present
degraded_reasonstring | nullWhy. null when degraded is false — the key is always present
querystringThe search term, exactly as sent
query_idstringIdentifier for this search; clicks are tied to it (see §7)
measurement_proofstringSignature for revenue attribution. Required for search-attributed revenue (§7)
totalnumberTotal hits before paging
limitnumberResults per page in this response
offsetnumberWhere this response starts
sortstringrelevance / price_asc / price_desc / newest
redirect_urlstring | nullDestination of a keyword redirect configured in the admin. When set, navigate instead of rendering results
resultsarrayProducts, articles and pages mixed; tell them apart with content_type
facetsobjectAggregates for filtering (below)
suggestionsstring[]Candidate terms for a zero-result page. Empty array rather than absent
related_keywordsstring[]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. results arrives sorted.

facets

FieldTypeMeaning
vendorsarrayCounts by vendor. Always present ([] when nothing matched)
product_typesarrayCounts by product type. Always present
tagsarrayCounts by tag. Always present
content_typesarrayCounts by product / article / page. Always present
variant_optionsarrayCounts by variant axis (colour, size…). Always present
metafieldsarrayCounts by metafield. Always present
price_rangenumber[] | nullLowest and highest price. null when nothing matched — never [0, 0]
on_sale_countnumberHow 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)

FieldTypeMeaning
content_typestringproduct / article / page
product_idstringShopify GID. The key is named this way for every type (historical)
titlestringTitle
handlestringHandle
urlstringRelative URL within the store
image_urlstring?Image URL, when there is one
tagsstring[]Shopify tags. Empty array when there are none — the key is always present

results[] (content_type: "product" only)

FieldTypeMeaning
price_min / price_maxnumberPrice range (see below)
compare_at_pricenumber | nullCompare-at price, for sale badges
availablebooleanWhether 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[].id is the numeric id. While a product's product_id is a gid://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.id when you need one.

results[] (content_type: "article" / "page" only)

FieldTypeMeaning
excerptstring?Excerpt of the body
published_atstring?Publication time (ISO 8601)
authorstring?Author (articles only)
blog_handlestring?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.

js
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

js
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 v1 do 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 v2 and v1 is 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.

Marutto Search