> ## Documentation Index
> Fetch the complete documentation index at: https://docs.trygravity.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Product search

> Find consumer products a user can buy (shoes, cookware, electronics), not developer tools

The Index has two verticals behind one publisher key:

| The user wants…                                                           | Call                                    | You get back                                                                     |
| ------------------------------------------------------------------------- | --------------------------------------- | -------------------------------------------------------------------------------- |
| a developer service ("a Postgres with a free tier", "email API for Node") | [`POST /search`](/gravity-index/search) | **one** recommendation with reasoning and a tracked link                         |
| a physical product ("running shoes for wide feet under \$150")            | `POST /shop/search` (this page)         | a **ranked list** of in-stock products with price, image, and a tracked buy link |

Your agent decides which one to call from the user's intent. A simple rule that works: if the answer
is software or a service the user signs up for, use `/search`; if it would be shipped to a doorstep, use
`/shop/search`. Both accept the same identity/attribution fields, so the plumbing is shared.

## Authentication

Same key as every other Index endpoint: your publisher API key as the `X-API-Key` header or as
`platform_api_key` in the body. Any production-approved publisher key works; there is no separate
Index or shopping entitlement. Requests without a key get `401`; blocked publishers get `403`.

## Request

<ParamField body="query" type="string">
  Natural-language description of the product (up to 1000 chars). Matched with full-text search
  across name, brand, category and description, so concrete nouns beat vibes: "wide toe box running
  shoes" outperforms "something comfy for my feet". Required unless `context.messages` is sent.
</ParamField>

<ParamField body="context" type="object">
  Optional hints from the conversation. `context.style` (alias `context.vibe`) is a free-text
  string that adds a soft ranking boost, e.g. `"neutral cushioned daily trainer"` or `"minimalist
      Scandinavian"`. `context.messages` (`[{role, content}]`) lets the Index work out the product need
  from the conversation itself when there is no explicit `query`; the response's `query` field
  shows what it inferred. Other keys are stored with the search and are ignored today.
</ParamField>

<ParamField body="max_results" type="integer" default="5">
  1–25. Ask for what you will actually show; each result mints a tracked click link.
</ParamField>

<ParamField body="rerank" type="boolean" default="true">
  Run the retrieved set through the LLM reranker, which keeps only products that fit the ask and
  may return nothing. `false` returns raw keyword ranking with no LLM on the hot path.
</ParamField>

### Structured filters

Filters are applied **before** ranking, so they are hard constraints, not preferences. Only pass
them when the user stated them.

<ParamField body="price_min" type="number">
  Inclusive lower bound on the current (sale) price, in `currency`.
</ParamField>

<ParamField body="price_max" type="number">
  Inclusive upper bound on the current (sale) price, in `currency`.
</ParamField>

<ParamField body="currency" type="string" default="USD">
  ISO 4217 code. Price filters and returned prices are in this currency. The catalog is
  predominantly USD today.
</ParamField>

<ParamField body="brands" type="string[]">
  Up to 20 brand names, matched case-insensitively (`"Nike"` and `"nike"` are the same). Use it
  when the user names a brand; leave it out for "any brand".
</ParamField>

<ParamField body="merchants" type="string[]">
  Up to 20 merchant names (`"Nike"`, `"Walmart"`, `"Lululemon"`), matched case-insensitively.
  Use it when the user wants to buy from a specific store.
</ParamField>

<ParamField body="on_sale_only" type="boolean" default="false">
  Only return products currently discounted below their regular price.
</ParamField>

<ParamField body="colors" type="string[]">
  Up to 10 colour words (`"black"`, `"navy"`). Listings are product-level, so this matches the
  colour named in the product title.
</ParamField>

<ParamField body="sizes" type="string[]">
  Up to 10 sizes as the merchant lists them (`"10"`, `"M"`, `"32x34"`). Listings that name a
  different size are dropped; listings that name no size are kept, since the size may be picked at
  checkout.
</ParamField>

<ParamField body="gender" type="string">
  `men`, `women` or `kids`.
</ParamField>

<ParamField body="categories" type="string[]">
  Up to 10 of: `apparel`, `footwear`, `furniture_decor`, `home_kitchen`, `beauty_personal_care`,
  `electronics_computing`, `jewelry_watches`, `sports_outdoors`, `tools_hardware`, `toys_kids`,
  `baby`, `pets`, `auto`, `books_media`, `other`. Anything else is a `422`.
</ParamField>

<Note>
  Region is implied by the merchant catalog (US retailers today); there is no region filter.
</Note>

### Identity and attribution (optional)

Identical to `/search`. Send them if you want per-user attribution and conversion reporting.

<ParamField body="external_session_id" type="string">
  Your identifier for this conversation, up to 200 characters on this endpoint.
</ParamField>

<ParamField body="external_user_id" type="string">
  Your stable identifier for this user.
</ParamField>

<ParamField body="hashed_email" type="string">
  SHA-256 of the user's lowercased, trimmed email.
</ParamField>

<ParamField body="metadata" type="object">
  Free-form key/values stored with the search (surface, locale, A/B arm…). Never put secrets or
  raw PII here.
</ParamField>

## Response

<ResponseField name="vertical" type="string">
  Always `"consumer"` on this endpoint. Use it to branch your rendering if you funnel both
  endpoints through one handler.
</ResponseField>

<ResponseField name="api_version" type="string">
  Response contract version, currently `"2026-09-09"`.
</ResponseField>

<ResponseField name="search_id" type="string">
  Unique ID for this search. Log it; it is the join key for click and conversion reporting.
</ResponseField>

<ResponseField name="query" type="string">
  The product ask that was searched: your `query`, or what the Index inferred from
  `context.messages`.
</ResponseField>

<ResponseField name="recommendation" type="object | null">
  `products[0]`, or `null` when nothing fits.
</ResponseField>

<ResponseField name="reasoning" type="string">
  One sentence on why the top product was picked, or why nothing was.
</ResponseField>

<ResponseField name="products" type="object[]">
  Ranked best-first. Empty when nothing in stock matches the query **and** every filter; loosen a
  filter or rephrase before telling the user there is nothing.

  <Expandable title="product">
    <ResponseField name="product_id" type="string">Stable catalog ID.</ResponseField>
    <ResponseField name="name" type="string">Merchant's product title, verbatim.</ResponseField>

    <ResponseField name="brand" type="string | null" />

    <ResponseField name="merchant" type="string">Who sells and ships it.</ResponseField>
    <ResponseField name="category" type="string | null">Merchant taxonomy path, e.g. `Clothing > Shoes > Mens Shoes > Mens Running Shoes`. `null` for live Shopify listings.</ResponseField>
    <ResponseField name="description" type="string">Truncated to \~400 words.</ResponseField>

    <ResponseField name="currency" type="string" />

    <ResponseField name="price" type="number">Current price the user will pay.</ResponseField>
    <ResponseField name="regular_price" type="number | null">List price. Equals `price` when not on sale; `null` when the merchant does not publish one.</ResponseField>

    <ResponseField name="on_sale" type="boolean" />

    <ResponseField name="image_url" type="string">Product image. Always present; products without one are excluded.</ResponseField>

    <ResponseField name="click_url" type="string">
      Tracked link to the merchant's product page. **This is the only URL you should show the
      user.** It redirects to the merchant, records the click against `search_id`, and unfurls
      as a product card (image, price, merchant) in iMessage, Slack and WhatsApp.
    </ResponseField>

    <ResponseField name="grclid" type="string">Click ID embedded in `click_url`; useful if you report conversions yourself.</ResponseField>

    <ResponseField name="checkout" type="object | null">
      Machine-payable destination when the merchant accepts agent checkout (today: every Shopify
      storefront, via [UCP](https://ucp.dev)). `null` for listings that only convert through
      `click_url`. See [Buying on the user's behalf](#buying-on-the-user’s-behalf).

      <Expandable title="checkout">
        <ResponseField name="protocol" type="string">`"ucp"`.</ResponseField>
        <ResponseField name="endpoint" type="string">The merchant's own UCP MCP endpoint (`https://{store}/api/ucp/mcp`). Your agent calls it directly; Gravity is not in the payment path.</ResponseField>
        <ResponseField name="continue_url" type="string">The merchant's browser checkout for this variant, for handing the user off when the agent will not complete the purchase itself.</ResponseField>
        <ResponseField name="item_id" type="string">The ID to put in `line_items[].item.id`, e.g. `gid://shopify/ProductVariant/32262292013136`.</ResponseField>
        <ResponseField name="agent_profile" type="string">A hosted UCP agent profile you can pass as `meta.ucp-agent.profile` if you do not host your own.</ResponseField>
        <ResponseField name="attribution" type="object">Pass through verbatim as the `attribution` argument of `create_checkout`. It carries the `grclid` onto the merchant's order, the same slot Google uses for `gclid`.</ResponseField>
      </Expandable>
    </ResponseField>
  </Expandable>
</ResponseField>

<ResponseField name="count" type="integer">
  `products.length`.
</ResponseField>

<ResponseField name="web_results" type="object[]">
  Only non-empty when `products` is empty: up to 5 plain web listings for the same ask so the
  agent still has something to show. Each has `name`, `brand`, `price`, `currency`, `description`,
  `image_url`, `url` and `site`. `price` and `currency` are `null` unless the page states both;
  never invent one. These carry **no** tracked link and **no** `checkout`, so link `url` directly,
  say they are from the web rather than the catalog, and do not offer to buy them.

  <Expandable title="web result">
    <ResponseField name="name" type="string">Product or page title.</ResponseField>

    <ResponseField name="brand" type="string | null" />

    <ResponseField name="price" type="number | null">Listed price in `currency`, or `null`.</ResponseField>
    <ResponseField name="currency" type="string | null">ISO 4217, only when the page states it.</ResponseField>
    <ResponseField name="description" type="string | null">One sentence.</ResponseField>
    <ResponseField name="image_url" type="string | null">Verified raster image, or `null`.</ResponseField>
    <ResponseField name="url" type="string">The page itself. Not tracked.</ResponseField>
    <ResponseField name="site" type="string">Hostname without `www.`, e.g. `chewy.com`.</ResponseField>
  </Expandable>
</ResponseField>

<ResponseField name="ranking" type="string">
  How the list was ordered. `"llm"`: the reranker kept only genuine fits, best relevance tier
  first, and inside a tier the listing that pays your platform more. `"retrieval"`: raw keyword
  order (`rerank: false`, or the reranker was unavailable). Informational.
</ResponseField>

<ResponseField name="facets" type="object">
  What the agent could filter on next, observed across the retrieved set: `brands`, `merchants`,
  `categories` (top 10 each) and `price_range` (`{min, max}` or `null`).
</ResponseField>

There is no per-result `reasoning` here: products are ranked, not reasoned about one at a time.

<RequestExample>
  ```json Query + price cap theme={null}
  {
    "query": "running shoes for wide feet",
    "price_max": 150,
    "max_results": 2,
    "external_user_id": "user_8f3a"
  }
  ```

  ```json Brand + size + style hint theme={null}
  {
    "query": "running shoes",
    "brands": ["Nike", "Brooks", "Hoka"],
    "sizes": ["10"],
    "gender": "men",
    "price_min": 80,
    "price_max": 180,
    "context": { "style": "neutral cushioned daily trainer" },
    "max_results": 3
  }
  ```

  ```json From conversation only theme={null}
  {
    "context": {
      "messages": [
        { "role": "user", "content": "my espresso machine died, I want something under $500 that does milk too" }
      ]
    },
    "max_results": 3
  }
  ```
</RequestExample>

<ResponseExample>
  ```json 200 with products theme={null}
  {
    "api_version": "2026-09-09",
    "vertical": "consumer",
    "search_id": "885eb1e4-ad46-417a-ae3f-7259291132e6",
    "query": "running shoes for wide feet",
    "recommendation": {
      "product_id": "1024849053674592846",
      "name": "Fdrone Women s Wide Toe Box Mesh Shoes Road Running Shoes",
      "brand": "Fdrone",
      "merchant": "Walmart",
      "category": "Clothing > Shoes > Womens Shoes > Womens Shoes",
      "description": "Elevate your active lifestyle with our premium women s sports shoes ...",
      "currency": "USD",
      "price": 17.0,
      "regular_price": 18.89,
      "on_sale": true,
      "image_url": "https://i5.walmartimages.com/asr/7593a41e-....jpeg",
      "click_url": "https://index.trygravity.ai/go/e92f37c0-e2a3-4ba0-8526-b93e783becd1",
      "grclid": "e92f37c0-e2a3-4ba0-8526-b93e783becd1",
      "checkout": null
    },
    "reasoning": "Fdrone Women s Wide Toe Box Mesh Shoes from Walmart is the closest match; the Allbirds pair also has a wide fit and is under the cap.",
    "products": [
      {
        "product_id": "1024849053674592846",
        "name": "Fdrone Women s Wide Toe Box Mesh Shoes Road Running Shoes",
        "brand": "Fdrone",
        "merchant": "Walmart",
        "category": "Clothing > Shoes > Womens Shoes > Womens Shoes",
        "description": "Elevate your active lifestyle with our premium women s sports shoes ...",
        "currency": "USD",
        "price": 17.0,
        "regular_price": 18.89,
        "on_sale": true,
        "image_url": "https://i5.walmartimages.com/asr/7593a41e-....jpeg",
        "click_url": "https://index.trygravity.ai/go/e92f37c0-e2a3-4ba0-8526-b93e783becd1",
        "grclid": "e92f37c0-e2a3-4ba0-8526-b93e783becd1",
        "checkout": null
      },
      {
        "product_id": "shopify:32262292013136",
        "name": "Women's Tree Dasher 2 - Natural White",
        "brand": "Allbirds",
        "merchant": "Allbirds",
        "category": null,
        "description": "A lightweight everyday running shoe with a roomy toe box ...",
        "currency": "USD",
        "price": 135.0,
        "regular_price": null,
        "on_sale": false,
        "image_url": "https://cdn.shopify.com/s/files/1/1104/4168/files/TD2W-NW-1.png",
        "click_url": "https://index.trygravity.ai/go/2a7f0c9e-51d3-4c0a-9b8e-6f2d1e4c8a11",
        "grclid": "2a7f0c9e-51d3-4c0a-9b8e-6f2d1e4c8a11",
        "checkout": {
          "protocol": "ucp",
          "endpoint": "https://www.allbirds.com/api/ucp/mcp",
          "continue_url": "https://www.allbirds.com/cart/32262292013136:1",
          "item_id": "gid://shopify/ProductVariant/32262292013136",
          "agent_profile": "https://index.trygravity.ai/ucp/agent-profile.json",
          "attribution": {
            "referring_domain": "index.trygravity.ai",
            "click_id_tag": "grclid",
            "click_id_value": "2a7f0c9e-51d3-4c0a-9b8e-6f2d1e4c8a11"
          }
        }
      }
    ],
    "count": 2,
    "web_results": [],
    "ranking": "llm",
    "facets": {
      "brands": ["Fdrone", "Allbirds", "Brooks", "New Balance"],
      "merchants": ["Walmart", "Allbirds", "Zappos"],
      "categories": ["Clothing > Shoes > Womens Shoes"],
      "price_range": { "min": 17.0, "max": 139.95 }
    }
  }
  ```

  ```json 200 with no catalog match (web fallback) theme={null}
  {
    "api_version": "2026-09-09",
    "vertical": "consumer",
    "search_id": "bc9fe59c-0b1e-4d3a-9c7f-2e8a4d6b1f05",
    "query": "Zorblax titanium dog leash",
    "recommendation": null,
    "reasoning": "No in-stock product in the catalog matches this request and its filters; web_results are plain web listings with no tracked link or checkout.",
    "products": [],
    "count": 0,
    "web_results": [
      {
        "name": "Titanium Dog Leash - Ultralight",
        "brand": "Zorblax",
        "price": 18.5,
        "currency": "USD",
        "description": "A 5 ft titanium-clip leash for dogs up to 60 lb.",
        "image_url": "https://zorblax.example/cdn/leash-ti.jpg",
        "url": "https://zorblax.example/products/titanium-dog-leash",
        "site": "zorblax.example"
      },
      {
        "name": "Zorblax leash review",
        "brand": null,
        "price": null,
        "currency": null,
        "description": "Hands-on review of the Zorblax titanium leash.",
        "image_url": null,
        "url": "https://dogblog.example/zorblax-leash-review",
        "site": "dogblog.example"
      }
    ],
    "ranking": "retrieval",
    "facets": {
      "brands": [],
      "merchants": [],
      "categories": [],
      "price_range": null
    }
  }
  ```

  ```json 422 validation theme={null}
  {
    "detail": [
      {
        "type": "value_error",
        "loc": ["body"],
        "msg": "Value error, query is required unless context.messages carries the conversation"
      }
    ]
  }
  ```
</ResponseExample>

## Examples

### Natural-language query with a price cap

<CodeGroup>
  ```bash cURL theme={null}
  curl -X POST https://index.trygravity.ai/shop/search \
    -H "X-API-Key: $GRAVITY_PUBLISHER_KEY" \
    -H "Content-Type: application/json" \
    -d '{
      "query": "running shoes for wide feet",
      "price_max": 150,
      "max_results": 3,
      "external_user_id": "user_8f3a"
    }'
  ```

  ```typescript TypeScript theme={null}
  const res = await fetch("https://index.trygravity.ai/shop/search", {
    method: "POST",
    headers: {
      "X-API-Key": process.env.GRAVITY_PUBLISHER_KEY!,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      query: "running shoes for wide feet",
      price_max: 150,
      max_results: 3,
      external_user_id: "user_8f3a",
    }),
  });
  const { products, search_id } = await res.json();
  ```

  ```python Python theme={null}
  import httpx, os

  r = httpx.post(
      "https://index.trygravity.ai/shop/search",
      headers={"X-API-Key": os.environ["GRAVITY_PUBLISHER_KEY"]},
      json={
          "query": "running shoes for wide feet",
          "price_max": 150,
          "max_results": 3,
          "external_user_id": "user_8f3a",
      },
  )
  products = r.json()["products"]
  ```
</CodeGroup>

Example response, trimmed to one product:

```json theme={null}
{
  "api_version": "2026-09-09",
  "vertical": "consumer",
  "search_id": "885eb1e4-ad46-417a-ae3f-7259291132e6",
  "query": "running shoes for wide feet",
  "reasoning": "Fdrone Women s Wide Toe Box Mesh Shoes from Walmart is the closest match.",
  "products": [
    {
      "product_id": "1024849053674592846",
      "name": "Fdrone Women s Wide Toe Box Mesh Shoes Road Running Shoes ...",
      "brand": "Fdrone",
      "merchant": "Walmart",
      "category": "Clothing > Shoes > Womens Shoes > Womens Shoes",
      "description": "Elevate your active lifestyle with our premium women s sports shoes ...",
      "currency": "USD",
      "price": 17.0,
      "regular_price": 18.89,
      "on_sale": true,
      "image_url": "https://i5.walmartimages.com/asr/7593a41e-...jpeg",
      "click_url": "https://index.trygravity.ai/go/e92f37c0-e2a3-4ba0-8526-b93e783becd1",
      "grclid": "e92f37c0-e2a3-4ba0-8526-b93e783becd1",
      "checkout": null
    }
  ],
  "count": 3,
  "facets": {
    "brands": ["Fdrone", "Brooks", "New Balance"],
    "merchants": ["Walmart", "Zappos"],
    "categories": ["Clothing > Shoes > Womens Shoes"],
    "price_range": { "min": 17.0, "max": 139.95 }
  }
}
```

`recommendation` (omitted above) repeats `products[0]`; `checkout` is `null` for catalog listings
and populated for Shopify storefronts. The full two-product response, the zero-match response
with `web_results`, and a `422` are in the response panel at the top of this page.

### Brand, price band and a style hint

The user said "I want Nikes or Brooks, something cushioned for daily miles, under \$180."

```bash theme={null}
curl -X POST https://index.trygravity.ai/shop/search \
  -H "X-API-Key: $GRAVITY_PUBLISHER_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "query": "running shoes",
    "brands": ["Nike", "Brooks", "Hoka"],
    "price_min": 80,
    "price_max": 180,
    "context": { "style": "neutral cushioned daily trainer" },
    "max_results": 3
  }'
```

In testing this returned three Nike Journey Run road-running variants at $95–$100 sold by Nike;
without `brands` the same query returns marketplace listings first.

### Only deals

```json theme={null}
{ "query": "cast iron dutch oven", "on_sale_only": true, "merchants": ["Walmart"], "max_results": 5 }
```

## Rendering results

Show `name`, `brand`, `merchant`, `price` (with `regular_price` struck through when `on_sale`),
`image_url`, and link the whole card to `click_url`. In chat surfaces the `click_url` alone is
enough; it unfurls into the card.

Do not rewrite or shorten `click_url`, and do not link to the merchant page directly; the
redirect is what attributes the sale to your platform.

## Buying on the user's behalf

When a product carries a non-null `checkout`, your agent can complete the purchase itself instead
of handing the user a link. Call the merchant's `endpoint` (an MCP server) directly and pass the
`attribution` object through unchanged; that is what attributes the order to your platform when
no click happens. Gravity never sees the payment.

```json theme={null}
"checkout": {
  "protocol": "ucp",
  "endpoint": "https://www.allbirds.com/api/ucp/mcp",
  "continue_url": "https://www.allbirds.com/cart/32262292013136:1",
  "item_id": "gid://shopify/ProductVariant/32262292013136",
  "agent_profile": "https://index.trygravity.ai/ucp/agent-profile.json",
  "attribution": {
    "referring_domain": "index.trygravity.ai",
    "click_id_tag": "grclid",
    "click_id_value": "e92f37c0-e2a3-4ba0-8526-b93e783becd1"
  }
}
```

```bash theme={null}
curl -X POST https://www.allbirds.com/api/ucp/mcp \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -d '{
    "jsonrpc": "2.0", "id": 1, "method": "tools/call",
    "params": {
      "name": "create_checkout",
      "arguments": {
        "meta": { "ucp-agent": { "profile": "https://index.trygravity.ai/ucp/agent-profile.json" } },
        "checkout": {
          "currency": "USD",
          "line_items": [{ "quantity": 1, "item": { "id": "gid://shopify/ProductVariant/32262292013136" } }],
          "attribution": {
            "referring_domain": "index.trygravity.ai",
            "click_id_tag": "grclid",
            "click_id_value": "e92f37c0-e2a3-4ba0-8526-b93e783becd1"
          }
        }
      }
    }
  }'
```

The merchant returns a checkout (`status: "requires_escalation"` until buyer, delivery and
payment are supplied) and echoes `attribution` back. Continue with `update_checkout` and
`complete_checkout` per the merchant's UCP profile at `https://{store}/.well-known/ucp`, or hand
the user `checkout.continue_url` to finish in a browser. Attribution rules:

* Always send `attribution` exactly as returned; do not mint your own click IDs.
* Get the user's explicit consent before `complete_checkout`; it charges the payment method.
* If the merchant rejects the call or `checkout` is `null`, use `click_url` as usual.

## Deciding between `/search` and `/shop/search`

An agent-side router in three lines of prompt usually suffices. Over REST the two verticals are
separate endpoints; over MCP there is one tool, `gravity_index_search`, and the consumer vertical
is selected with `vertical: "consumer"`.

```text theme={null}
If the user is asking for software, an API, hosting, a database, or anything
they would sign up for → call POST /search (MCP: gravity_index_search).
If they are asking for a physical product or something delivered to them
→ call POST /shop/search (MCP: gravity_index_search with vertical "consumer").
Otherwise, answer normally.
```

If your platform only serves one audience, wire only that endpoint; there is no penalty for
ignoring the other vertical.

## Errors

| Status                                  | Meaning                                                                                                                                                                                                            |
| --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `401` `platform_api_key required`       | No key in header or body.                                                                                                                                                                                          |
| `403` `valid platform_api_key required` | Key not recognised.                                                                                                                                                                                                |
| `403` `publisher_not_approved`          | Publisher is blocked. Same gate as production ad serving.                                                                                                                                                          |
| `422`                                   | Validation: neither `query` nor `context.messages` given, `query` over 1000 chars, `max_results` outside 1–25, more than 20 brands/merchants or 10 colours/sizes/categories, `external_session_id` over 200 chars. |
