検索API v1
- 利用条件: Pro プラン以上
- 想定読者: ストアフロントを実装されるエンドエンジニアの方
まるっと検索は通常、検索結果ページの HTML をアプリ側で描画します。検索APIでは アプリは一切描画せず、検索結果の JSON だけをテーマに渡します。
- 既存テーマのデザイン・レイアウトには一切触れません
- 日本語の形態素解析・表記ゆれ吸収・同義語・絞り込みのロジックだけを利用できます
- 描画は御社のコードで行っていただきます
1. 検索の実行
まるっと検索は JSON を返すだけです。呼び出し・状態管理・描画はすべて御社の実装になります。 こちらから JavaScript を読み込ませることはしません。
GET /apps/marutto-search/search?type=search&contract=v1&q=ワンピース&limit=24&offset=0- ストア自身のドメイン上の URL です(Shopify App Proxy)。API キーも CORS 設定も不要で、 ショップの特定は Shopify の署名により自動で行われます
- ブラウザからは、そのストアのページからのみ読み取れます。CORS ヘッダを付けていないため、 他ドメインのページからは応答を読めません
- ただしサーバ側からの呼び出し(curl 等)は、どこからでも到達します。URL を知っていれば 誰でも叩けるため、秘密を返すエンドポイントとしては設計されていません(返るのは公開カタログの 情報で、Shopify 自身も同じストアで
/products.jsonとして公開しています) contract=v1が本仕様を要求する印です。これを付けたリクエストにだけ、本ドキュメントの 形状が保証されます。付けない場合はアプリ内部のレスポンスが返り、そちらは予告なく変わります (まるっと検索のウィジェットが読むための形で、仕様ではありません)contract=v1は Pro プラン以上で利用できます。プランが足りない場合は403と{"code": "plan_required"}を返します。検索が壊れたのか契約が切れたのかを 御社のコードで区別できるようにするためです- v1 が定めるのは確定検索(
type=search)のレスポンスのみです。suggest(入力補完)にcontract=v1を付けた場合は400と{"code": "unsupported_contract_type"}を返します。 仕様として書いていない形を、契約として返すことはしません
クエリパラメータ
| パラメータ | 既定 | 内容 |
|---|---|---|
contract | — | v1。必須(本仕様の形状を要求する印) |
type | — | search(確定検索)。必須。contract=v1 は search のみ対応 |
q | — | 検索語。必須(全件ブラウズは *) |
session_id | — | セッション識別子。必須(7章) |
limit | 8 | 取得件数。上限 50 |
offset | 0 | 開始位置。上限 2,000(超えた値は 2,000 に丸められます) |
sort | relevance | relevance / price_asc / price_desc / newest |
locale | ストア既定 | ja / en など。多言語ストアで解析言語を切り替える |
filter_vendor | — | ベンダー名 |
filter_type | — | 商品タイプ |
filter_tags | — | タグ(複数はカンマ区切り。AND 条件) |
filter_price_min / filter_price_max | — | 価格帯 |
filter_available | — | true / false(在庫あり) |
filter_on_sale | — | true / false(セール中) |
filter_content_type | — | product / article / page |
filter_collection | — | コレクションのハンドル |
filter_option_name / filter_option_value | — | バリエーション(例: カラー / ベージュ) |
filter_metafield | — | メタフィールド(namespace.key:値)。繰り返し指定可 |
メタフィールドによる絞り込みは
filter_metafieldを繰り返し指定します。 値の形式はnamespace.key:値で、最初の:で分割されます(キーに:は含まれないため、 値に:があってもそのまま扱えます)。項目をまたいだ指定は AND になります。&filter_metafield=custom.material:リネン&filter_metafield=custom.origin:日本対象になるのは、管理画面で「絞り込みに使う」を有効にしたメタフィールドのみです (テキスト型・リスト型、最大10個)。
絞り込みが効かない指定について。 管理画面で「絞り込みに使う」を有効にしていない メタフィールドを
filter_metafieldに渡した場合、その指定はエラーにならず無視されます (全件が返ります)。設定を切り替えたあとも古い URL が共有され続けるため、残ったまま解除できない 状態を避ける仕様です。開発中にキー名を打ち間違えると全件が返るので、まずファセットに出ているnameと一致しているかご確認ください。
全件ブラウズ(
q=*)でできないこと。 ブラウズは商品テーブルだけを読む別経路です。 通常の検索語では動く次の3点が、q=*のときだけ変わります。
指定 q=*のときの挙動filter_content_type=article/page常に0件(記事・ページはブラウズに含まれません) facets.tags/content_types/variant_options常に [](キーは存在します)上記以外のフィルタ・ filter_metafield検索語のときと同じく効きます 記事・ページを一覧したい場合は検索語を伴う
type=searchをご利用ください。
エラーの返り方
| 状況 | ステータス | code |
|---|---|---|
q が無い | 400 | query_required |
q が200文字を超える | 400 | query_too_long |
type が search / suggest / preview 以外 | 400 | invalid_type |
contract が v1 以外 | 400 | unsupported_contract |
contract=v1 を type=search 以外に付けた | 400 | unsupported_contract_type |
| プランが Pro 未満 | 403 | plan_required |
| 検索基盤の障害 | 503 | 本文は通常の形(degraded: true) |
リクエストの誤り(4xx)は { error, code } だけを返します。 空の検索結果として返すと、 total: 0 に見えて原因が分からなくなるためです。code で分岐してください。
検索基盤側の問題(503)は通常のレスポンス形で返します。障害の上に data.results で例外まで起こさないためです。
参照実装
そのまま動く最小構成を search-api-sample.js に 置いています。描画は御社のデザインに書き換えていただく前提ですが、書き換えないでいただきたい 箇所が3つあります。
contract=v1を必ず付ける(これが本仕様の形を要求する印)degradedを見る(新規ストアは導入初日に必ずこの経路を通ります)Shopify.analytics.publishの2つ(実装しないと検索経由売上が計上されません)
呼び出し側でお願いしたいこと
まるっと検索側の JS が無いため、以下は御社の実装に委ねられます。
- 入力補完のデバウンス(推奨 250〜300ms)。1打鍵ごとに
type=suggestを送ると レート制限に達します - 応答の順序保証。高速に入力すると応答が前後します。レスポンスの
query/offset/limit/sortはリクエスト値のエコーバックなので、古い応答を破棄する 判定に使えます - 同一条件の連続リクエストの抑制
2. ページング
レスポンスは自己完結しており、次に何を要求すればよいかがレスポンス自身から分かります。
| フィールド | 用途 |
|---|---|
total | 全ヒット件数 |
offset | このレスポンスが何件目から始まっているか |
limit | このレスポンスの1ページあたり件数 |
// 「もっと読む」/無限スクロール
const MAX_OFFSET = 2000; // 1章のとおり、これを超える指定は 2000 に丸められます
const next = data.offset + data.results.length;
if (next < data.total && next < MAX_OFFSET) {
// offset=next で再取得し、既存リストに追加する
}
next < data.totalだけで判定しないでください。offsetの上限に達したあとも サーバは同じページを返すため、totalがそれより大きいと「もっと読む」が消えず、 同じ商品が何度も追加され続けます。上限との比較を必ず入れてください。
ページ番号ナビ・「もっと読む」・無限スクロールのいずれも、offset と limit の指定だけで 実現できます。
注意:
offset方式のため、ページを送っている最中に商品の追加・削除・在庫変動で インデックスが更新されると、同じ商品が2回出る/1件飛ばされることがあります。 無限スクロールで顕在化しやすい挙動です。厳密な一貫性が必要な場合はご相談ください。
3. レート制限
不正利用対策のため、10秒あたりのリクエスト数に上限があります。超過時は HTTP 429 を返します。
| 種別 | ショップ単位 | 訪問者単位 | 全ストア合計 |
|---|---|---|---|
type=search | 50 / 10秒 | 20 / 10秒 | 125 / 10秒 |
type=suggest | 150 / 10秒 | 40 / 10秒 | 150 / 10秒 |
通常の買い物客の操作でこの上限に当たることはありませんが、デバウンスなしで入力補完を 呼ぶと訪問者単位の上限に容易に達します。429 を受けた場合は、時間をおいて再試行するか、 前回の結果を表示したままにする実装を推奨します。
「全ストア合計」について。 まるっと検索は単一のインスタンスで全ストアを処理して いるため、ショップ単位の上限内であっても、同時刻の他ストアの負荷と合わさって合計上限に 達すると 429 になることがあります。自社のリクエスト数だけからは予測できない 429 が あり得るということです(実運用でこの合計に達したことはありませんが、仕様として明記して おきます)。負荷試験など短時間に大量のリクエストを送る予定がある場合は、事前にご連絡ください。
4. レスポンスの保証
トップレベルのキーは常に存在します
結果が0件でも、内部でエラーが起きていても、results / facets / total などのキーが 省略されることはありません(それぞれ [] / {} / 0 になります)。 data.facets.tags.forEach(...) のような記述が、状況によって例外になることはありません。
一時的に検索が縮退することがあります
インストール直後の初回同期中、多言語ストアの翻訳同期中など、まるっと検索の索引が まだ使えない期間があります。この間は degraded が立ち、結果が空または Shopify 標準検索の 結果になります。
{
"schemaVersion": "v1",
"degraded": true,
"degraded_reason": "sync_pending",
"total": 0,
"results": [],
"facets": {}
}if (data.degraded) {
// 例: 「検索を準備しています」を出す、Shopify標準の検索にフォールバックする 等
}導入初日に必ず通る経路です。 新規ストアはインストール後しばらく
sync_pendingを 返すため、degradedを見ていない実装は「入れた直後は動かない」ように見えます。 必ずご対応ください。
degraded_reason は原因の識別用で、値の集合は将来増えます。分岐条件には degraded (真偽値)をお使いいただき、degraded_reason は表示やログの用途に留めてください。
5. レスポンス例
検索語「ワンピース」でアパレルストアを検索した場合の例です。値はすべてサンプルです。
{
"schemaVersion": "v1",
"degraded": false,
"degraded_reason": null,
"query": "ワンピース",
"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": "リネンブレンド ロングワンピース",
"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": ["春夏", "洗える", "新作"],
"variants": [
{
"id": "44001",
"title": "ベージュ / 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": "カラー", "value": "ベージュ" },
{ "name": "サイズ", "value": "M" }
]
},
{
"id": "44002",
"title": "ベージュ / L",
"price": 12800,
"available": false,
"options": [
{ "name": "カラー", "value": "ベージュ" },
{ "name": "サイズ", "value": "L" }
]
}
]
},
{
"content_type": "article",
"product_id": "gid://shopify/Article/5500112233",
"title": "ワンピースの着回しコーデ 12選",
"handle": "one-piece-coordinate",
"url": "/blogs/journal/one-piece-coordinate",
"image_url": "https://cdn.shopify.com/s/files/1/0001/0002/articles/coordinate.jpg",
"tags": ["コーディネート"],
"excerpt": "一枚で決まるワンピースを、季節ごとに着回すコツをご紹介します。",
"published_at": "2026-04-18T09:00:00Z",
"author": "スタッフ 田中",
"blog_handle": "journal"
}
],
"facets": {
"vendors": [
{ "value": "ATELIER NOA", "count": 42 },
{ "value": "LUMIÈRE", "count": 31 }
],
"product_types": [{ "value": "ワンピース", "count": 118 }],
"tags": [
{ "value": "春夏", "count": 64 },
{ "value": "洗える", "count": 38 }
],
"content_types": [
{ "value": "product", "count": 131 },
{ "value": "article", "count": 5 }
],
"variant_options": [
{
"name": "カラー",
"values": [
{ "value": "ベージュ", "count": 57 },
{ "value": "ブラック", "count": 49 }
]
}
],
"metafields": [
{
"name": "custom.material",
"label": "素材",
"values": [{ "value": "リネン", "count": 42 }]
}
],
"price_range": [3900, 42000],
"on_sale_count": 18
},
"suggestions": [],
"related_keywords": ["ワンピース 春", "ロングワンピース", "シャツワンピース"]
}6. フィールド定義
トップレベル
| フィールド | 型 | 内容 |
|---|---|---|
schemaVersion | string | データ構造のバージョン。初版は "v1" |
degraded | boolean | 索引が一時的に使えない状態か(→ 4章)。常に存在します |
degraded_reason | string | null | 縮退の原因。degraded が false のときは null(キーは常に存在) |
query | string | 検索語(入力そのまま) |
query_id | string | この検索の識別子。クリックをこれで紐付けます(→ 7章) |
measurement_proof | string | 売上紐付け用の署名。検索経由売上の計測に必須(→ 7章) |
total | number | ヒット総件数(ページングされる前の全件) |
limit | number | このレスポンスの1ページあたり件数 |
offset | number | このレスポンスが何件目から始まっているか |
sort | string | relevance / price_asc / price_desc / newest |
redirect_url | string | null | 管理画面で設定したキーワードリダイレクトの遷移先。値がある場合は結果を描画せず遷移する想定 |
results | array | 検索結果(商品・記事・ページが混在。content_type で判別) |
facets | object | 絞り込み用の集計値(下記) |
suggestions | string[] | 0件時の候補語。該当なしは空配列(キーは常に存在) |
related_keywords | string[] | 他の購入者が続けて検索した語。該当なしは空配列(キーは常に存在) |
ここに載っていないフィールドは返りません。 内部のスコアやレビュー照合の詳細は、 改善のたびに変わるため公開対象から外しています。
resultsは並び替え済みで届きます。
facets
| フィールド | 型 | 内容 |
|---|---|---|
vendors | array | ベンダー別の件数。常に存在(該当なしは []) |
product_types | array | 商品タイプ別。常に存在 |
tags | array | タグ別。常に存在 |
content_types | array | 種別(product / article / page)別。常に存在 |
variant_options | array | バリエーション軸別(カラー・サイズ等)。常に存在 |
metafields | array | メタフィールド別。常に存在 |
price_range | number[] | null | 価格の下限・上限。該当なしは null([0, 0] は返しません) |
on_sale_count | number | セール中の件数。該当なしは 0 |
facets.metafields[] は { name, label?, values[] } です。name は namespace.key、 label は管理画面で設定した見出しで、未設定なら省略されます。表示には label ?? name をお使いください。
並び順は件数の降順、同数のときは値の昇順です。同じ検索・同じカタログなら常に同じ順で 返ります。各グループは上位20件までです。
results[](共通)
| フィールド | 型 | 内容 |
|---|---|---|
content_type | string | product / article / page |
product_id | string | Shopify の GID。種別を問わずこのキー名です(歴史的経緯) |
title | string | タイトル |
handle | string | ハンドル |
url | string | ストア内の相対 URL |
image_url | string? | 画像 URL(無い場合あり) |
tags | string[] | Shopify のタグ。付いていない場合は空配列(キーは常に存在) |
results[](content_type: "product" のみ)
| フィールド | 型 | 内容 |
|---|---|---|
price_min / price_max | number | 価格帯(下記) |
compare_at_price | number | null | 参考価格(セール表示用) |
available | boolean | 購入可能な在庫があるか |
variants[] | array? | バリエーション。id は数値の ID(下記) |
価格は「ショップの基準通貨」です。 税抜/税込は Shopify の設定に準拠します。
Shopify Markets で多通貨展開されている場合、買い物客が実際に見ている通貨とは 一致しません。表示通貨に合わせる必要がある場合は、御社側で換算していただくか、 価格の描画のみ Shopify の Storefront API から取得してください。 多通貨対応のご要望があればお知らせください。
variants[].idは数値の ID です。 商品のproduct_idがgid://shopify/Product/…形式なのに対し、バリエーションの ID は"44001"のような数値文字列を返します。これは Shopify 自身がこの形を要求するためです。
- 商品ページでバリエーションを選ばせる:
/products/xxx?variant=44001- カートに入れる(Cart AJAX API):
{ "id": 44001, "quantity": 1 }どちらも GID を渡すとエラーにならずに無視されます(選択されないまま商品ページが 開きます)。GID が必要な場合は
"gid://shopify/ProductVariant/" + variant.idで 組み立ててください。
results[](content_type: "article" / "page" のみ)
| フィールド | 型 | 内容 |
|---|---|---|
excerpt | string? | 本文の抜粋 |
published_at | string? | 公開日時(ISO 8601) |
author | string? | 著者(記事のみ) |
blog_handle | string? | 所属ブログのハンドル(記事のみ) |
7. 計測
まるっと検索が商品カードを描画しないため、何も実装しないと管理画面の検索分析が機能しません。 影響するのはクリック率・クリックされた商品ランキング・検索経由売上です。
計測は Shopify の Web Pixel 経由で行うため、御社の実装ではイベントを publish していただく だけです(HTTP リクエストはまるっと検索の Pixel が行います)。
検索リクエストに session_id を付けてください(必須)
このエンドポイントは URL を知っていれば誰でも到達できるため、session_id の無い リクエストは買い物客の検索ではないと判断し、検索分析に計上しません(検索結果は通常どおり 返ります)。付け忘れると、検索は動くのに管理画面の分析が空のままになります。
session_id は御社で発番していただく任意の文字列です。同一セッション内で同じ値であれば 形式は問いません。
1) 検索したとき
Shopify.analytics.publish("marutto_search", {
query: data.query,
query_id: data.query_id,
session_id: sessionId, // 検索リクエストに付けたものと同じ値
measurement_proof: data.measurement_proof,
result_count: data.total,
});2) 検索結果をクリックしたとき
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, // 0 始まり
});measurement_proof について
検索経由売上の紐付けはこの値で行われます。 まるっと検索の Web Pixel が marutto_search 受信時にこれを保存し、その後の「カート追加」「購入完了」に自動で付け直す ことで、どの検索が売上につながったかを追跡します。
この値を publish していただかないと、そのストアの検索経由売上は計上されません。 検索は正常に動くため気づきにくい箇所なので、実装時にご確認ください。
8. バージョン運用
v1として公開したフィールド名・型・意味は変更しません。イベント名も同様です- フィールドの追加は
v1のまま行います(既存実装は影響を受けません) - 変更が必要な場合は
v2として新規に提供し、v1は維持します - 廃止する場合は事前に告知期間を設けます
9. 利用できる範囲
検索API は検索結果ページ専用ではありません。App Proxy 経由の同一オリジン通信のため、 ストア内のどのページからでも利用できます。
- 検索結果ページ
- コレクションページの絞り込み
- ヘッダーのサジェスト(入力補完)
- 商品ページの関連商品
- 特集ページ・LP への「このキーワードの商品一覧」の埋め込み
一方、ストア外(独自ドメインの Hydrogen、ネイティブアプリ等)からの利用は App Proxy の 対象外です。現在は提供していません。ご要望があればお知らせください。