visualAIAPI Docs

discoverGPT API Docs

Developer documentation for discoverGPT — the unified REST API + MCP gateway exposing search, data cleaning, AI-discovery feeds & GEO, and virtual try-on to agency partners and developers. One set of credentials, one canonical catalog, four AI capabilities.

Welcome to the developer documentation for discoverGPT — visualAI's unified REST API + MCP gateway for agency partners and developers. One platform, one set of credentials, four AI capabilities: search, data cleaning, feeds & GEO, and virtual try-on. This entire site is a single scrollable document — use the left sidebar to jump to any section.

Introduction

discoverGPT is a unified REST API and MCP gateway that exposes visualAI's four AI capabilities to agency partners and developers through one set of credentials and one canonical catalog.

What's included at every tier

Onboard once: shared ingest and enrichment mean every product you load flows into all four capabilities without per-capability setup. Each capability becomes available once its pipeline finishes — see How ingestion works.

What you can build

Search:

  • Natural-language product search. Convert free-text queries like "something comfortable for a beach wedding under $200" into ranked product results.
  • Precise-color search. Mix a text query with an RGB or named color and rank products by both semantic and precise-color similarity.
  • Image-similarity search. Upload a reference image and return the closest matching products from your catalog.
  • Personalized results. Layer optional shopper preferences — style, colors, size, budget — over any of the above.

Data cleaning:

  • Batch enrichment. Submit raw products and get back cleaned titles, rewritten descriptions, and normalized attributes, ready to publish.
  • Quality scoring. Surface thin, duplicated, or low-quality records before they reach your storefront or your feeds.

Feeds & GEO:

  • AI-readable feeds. Generate Schema.org JSON-LD, an llms.txt summary, plus ACP and Perplexity Shopping feeds from one enriched catalog. The same catalog also backs a UCP (Universal Commerce Protocol) query API for agents that discover and query rather than download.
  • Multi-protocol syndication. Distribute to ChatGPT, Gemini, Perplexity, and MCP-aware agents without betting on a single protocol.
  • GEO dashboards. Track which products surface inside AI assistants and get recommendations to improve visibility.

Virtual try-on:

  • AI image composition. Generate on-model and lifestyle images from product photos — no photoshoot required. Each compose call costs ~$0.18; credits are prepaid or metered per tier.

Agent-native: built on MCP

Beyond REST, every capability above is also exposed through the Model Context Protocol (MCP) — the open standard that lets AI agents discover and call tools in a uniform way. Point an MCP-aware host (Claude, ChatGPT, Cursor, or your own agent) at discoverGPT's /mcp/sse endpoint and your catalog becomes a set of ready-to-use agent tools, with no glue code. See Model Context Protocol (MCP) for how it works and why it matters.

Platform-neutral by design

discoverGPT does not require Shopify. Every capability operates on a single canonical products table, and Shopify is one of several supported ways to populate it.

If your data lives in BigCommerce, WooCommerce, Magento, a custom PIM, a flat CSV export, or an S3 data lake, you can still use the API — see the Data Model and Ingesting Your Data sections for the canonical schema and available ingest paths.

Shopify is an example integration

The most common way to get started today is via the Shopify app, which handles ingest automatically. The underlying API is not Shopify-specific — every endpoint on this site works against any merchant whose catalog matches the canonical data model.

Plans & Pricing

discoverGPT is gated by aggregate SKUs at the billing-account (agency) level — not per store. Stores, seats, and all four capabilities are unlimited at every tier.

TierSKU limit (account total)MonthlyAnnual
Starter≤ 500Free for life for the first 500 orgs · then $20/mo
Growth≤ 5,000$99$999 (~2 months free)
Pro≤ 25,000$399$3,999 (~2 months free)
Platinum25,000+Custom (sales-led)Custom

Founding offer — Starter free for life

The first 500 organizations to sign up keep Starter free for life — no card required. After the first 500, Starter is $20/mo with a 14-day free trial. Growth, Pro, and Platinum are unaffected.

Rules:

  • The full platform is included at every tier, including Starter — every API and MCP tool: trimodal search, data enrichment, universal data feeds for agentic discovery, and virtual try-on.
  • Every variant counts as a SKU. A product with 20 size/color variations is 20 SKUs — each variant is individually enriched, embedded, and searchable. Your SKU count is your total variant count across all connected stores.
  • Unlimited stores on paid tiers (Growth and up); Starter includes 1 store. The SKU gate is always aggregate across all your stores, not per store.
  • Unlimited seats.
  • Virtual try-on (VTO) compose is the one usage meter: ~$0.18/compose, sold as prepaid credit packs or metered overage. Each paid tier includes a monthly allotment.
  • Search, cleaning, feeds, and GEO run flat under a generous fair-use cap — exceeding it triggers an upgrade prompt, never a surprise invoice.

Fair use vs. reselling the engine

Every tier is licensed for you to power the storefronts and applications of merchants you serve. Repackaging discoverGPT's search, discovery, enrichment, feeds, or try-on as your own standalone or white-label service for third parties is not included in self-serve tiers — that requires the sales-led Platinum tier under a separate written agreement. See Acceptable Use & Restrictions.

No self-serve key UI yet

To get provisioned as an agency partner, email enterprise@vairetail.com. Credentials (client_id / client_secret) are issued per agency during onboarding.

Where to start

Base URL

All API requests go to:

https://api.vairetail.com

This host is a placeholder — the final production endpoint is TBD and will be published here before GA.

Support

Questions about integration, enterprise access, or data migration? Email enterprise@vairetail.com or visit vairetail.com.

Platform overview

discoverGPT is one 34-claim patent-pending engine with four capability APIs in front of it. All four run on the same canonical products data model and the same ingest + enrichment pipeline — so a catalog you onboard once is usable by every capability.

The unified gateway

CapabilityWhat it doesGateway pathAuth
SearchNatural-language, precise-color, and image-similarity searchPOST /v1/searchBearer JWT
Data cleaningAI cleaning & enrichment, quality scoringPOST /v1/enrich · GET /v1/quality/*Bearer JWT
Feeds & GEOAI-discovery feeds + GEO visibilityGET /v1/feed/* · GET /v1/visibility/*Bearer JWT
Virtual try-onVirtual try-on image compositionPOST /v1/vto/compose · GET /v1/vto/metricsBearer JWT

Base URLs are placeholders

The product-specific hostnames elsewhere on this page — developer-search-devID.region.run.app, developer-clean-devID.region.run.app, and developer-catalog-devID.region.run.app — are placeholders for the individual product services, retained for reference. The primary external surface is the unified gateway at https://api.vairetail.com.

Each capability also maintains a direct service endpoint with its own base URL, sharing the same JSON conventions, error shapes, and — for direct endpoints — the X-Merchant-ID header. See Authentication for both auth modes.

How they fit together

Used as a pipeline:

  1. Ingest a catalog — Shopify pull, push ingest, CSV/JSON, or a custom source — into the canonical products model. See Ingesting Your Data.
  2. Clean & enrich it: review AI-rewritten titles, descriptions, and attributes, then push approved changes back to the store.
  3. Search it, syndicate it with feeds so AI agents can discover and quote your products, and compose hero imagery.

Used independently, each capability is a standalone HTTP API — adopt only what you need.

Direct service APIs vs. the unified gateway

Most sections below document each capability's direct service APIsearch, data cleaning, and feeds & GEO each have their own — which authenticate with the X-Merchant-ID header. The Gateway API (/v1) section documents the unified, OAuth-authenticated surface: billing, feed, quality, checkout preview, and MCP transport.

Jump to a capability

Model Context Protocol (MCP)

discoverGPT speaks two protocols over the same engine: a REST API for your own code, and MCP for AI agents. You don't have to choose — both run on the same account and canonical catalog (REST authenticates with client-credentials; MCP authorizes once via browser consent).

What MCP is

MCP (Model Context Protocol) is an open standard for connecting AI agents to tools and data — a universal adapter for AI. Instead of every agent author writing custom integration code against every API, an MCP-aware host — Claude, ChatGPT, Cursor, or a custom agent — connects to an MCP server, discovers its tools automatically, and calls them as the conversation requires.

Why we offer it

A growing share of shopping and merchandising will happen through AI agents. Without MCP, every agency building such an agent would hand-write REST calls, paginate results, reshape responses into something a model can reason over, and repeat that work for every AI host. With MCP, none of that is necessary — discoverGPT hands the agent ready-to-use tools backed by the same canonical catalog as the REST API.

How it works

  ┌───────────────────────────┐         ┌──────────────────────────────┐
  │  MCP-aware host            │   MCP   │  discoverGPT · /mcp (SSE)   │
  │  Claude · ChatGPT ·        │ ◄─────► │  one OAuth credential        │
  │  Cursor · your own agent   │  tools  │                              │
  └───────────────────────────┘         └───────────────┬──────────────┘
        discover + call tools                            │ tools run on the
                                                          ▼ same engine as REST
              ┌──────────────────────────────────────────────────────┐
              │  one canonical catalog →                              │
              │  Search · Cleaning · Feeds · Try-on                   │
              └──────────────────────────────────────────────────────┘
  1. Your agent's host connects to https://api.vairetail.com/mcp/sse (SSE transport) and authorizes once through a browser consent screen — no API keys to copy or rotate (see MCP transport).
  2. The host discovers discoverGPT's tools and exposes them to the model.
  3. As the user converses, the model calls the tools — search, product detail, recommendations, brand context, feeds, quality — and discoverGPT runs them against the merchant's canonical catalog, returning agent-ready structured data (the same discovery + transaction shapes documented under Search API).

What it makes easier

For developers

  • Zero glue code. The agent discovers and calls tools; you don't hand-write requests, pagination, or response reshaping.
  • Agent-ready data. Tools return structured results designed for model reasoning and agentic checkout (the ACP transaction shape) — not raw rows.
  • One integration, every host. The same MCP server works across Claude, ChatGPT, Cursor, and custom agents — no per-host rewrites.

For agencies

  • Ship AI commerce agents fast. Stand up a shopping or merchandising agent for a merchant in hours, not weeks — one endpoint, one credential, all four capabilities as tools.
  • Future-proof. As new MCP-aware hosts appear, your agent works on them with no changes.
  • Bill once. MCP usage flows through the same account, tiers, and quotas as REST.

Tools exposed over MCP

ToolCapabilityWhat it does
search_products_trimodalSearchNatural-language + precise-color + image-similarity search
browse_catalogSearchFaceted catalog browse
get_product_detailsSearch · Feeds & GEOFull product detail (discovery + transaction shapes)
recommend_complementary_productsSearch"Goes well with" recommendations
get_merchant_brand_contextFeeds & GEOBrand summary / llms.txt grounding for the agent
…and 19 moreFeeds & GEO · Data cleaning · Virtual try-on · UCPAI-visibility (score / products / trends / geo), feed status + ACP + full feed export, catalog + product quality, checkout (preview), virtual try-on (+ credits / metrics), and UCP (Universal Commerce Protocol) catalog search / lookup / product — 24 tools total; full list under MCP transport

Connection details — endpoint, transport, and auth — are in MCP transport under the Gateway API.

Quickstart

Go from zero to a live text + color search in under five minutes. Every new account starts on the free Starter tier with a read-only demo merchant already authorized for your keys — so the calls below return real results on your very first request, no catalog upload required.

Free Starter tier + demo merchant

Signing up provisions your organization on the free Starter tier and auto-authorizes a shared demo catalog (merchant 98906636598). Scope your token to it and search immediately. For your own merchants or production access, email enterprise@vairetail.com — the interim onboarding path while self-serve merchant provisioning rolls out.

1. Sign up

Create your account at developer.vairetail.com — the free Starter tier, no card. Self-serve signup provisions your org and the demo merchant automatically.

2. Create an API key

In the dashboard, open API Keys → Create key. You'll get a client_id and a client_secret.

Copy your secret now

The client_secret is shown once. Store it in a secrets manager or .env. If you lose it, revoke the key and create a new one.

export CLIENT_ID="YOUR_CLIENT_ID"
export CLIENT_SECRET="YOUR_CLIENT_SECRET"
export MERCHANT_ID="98906636598"   # the shared demo catalog

3. Exchange credentials for a token

Mint a short-lived Bearer token with the OAuth 2.0 client_credentials grant. The request is form-encoded; scope names the merchant you act on as merchant:{id}.

curl -s -X POST https://api.vairetail.com/oauth/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d grant_type=client_credentials \
  -d client_id="$CLIENT_ID" \
  -d client_secret="$CLIENT_SECRET" \
  -d scope="merchant:$MERCHANT_ID"

Response:

{
  "access_token": "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9...",
  "token_type": "Bearer",
  "expires_in": 900,
  "scope": "merchant:98906636598"
}

Tokens expire after 15 minutes (re-mint on 401). Grab the token:

export TOKEN="eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9..."

Call POST /v1/search. This searches for a midi dress in a deep red.

Color is structured JSON, never a name

query_color must be an HSV or RGB object — the gateway forwards it verbatim and does not interpret color words ("red" will not filter by color).

  • HSV — {"space": "hsv", "values": [h, s, v]} · h 0–360, s/v 0–100
  • RGB — {"rgb": [r, g, b]} · each channel 0–255

Both describe the same red: {"space": "hsv", "values": [0, 90, 85]}{"rgb": [217, 22, 22]}.

curl -s -X POST https://api.vairetail.com/v1/search \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "query_text": "midi dress",
    "query_color": { "space": "hsv", "values": [0, 90, 85] },
    "limit": 5
  }'

Python

import requests

BASE = "https://api.vairetail.com"
tok = requests.post(f"{BASE}/oauth/token", data={
    "grant_type": "client_credentials",
    "client_id": "YOUR_CLIENT_ID",
    "client_secret": "YOUR_CLIENT_SECRET",
    "scope": "merchant:98906636598",
}).json()

res = requests.post(f"{BASE}/v1/search",
    headers={"Authorization": f"Bearer {tok['access_token']}"},
    json={"query_text": "midi dress",
          "query_color": {"space": "hsv", "values": [0, 90, 85]},
          "limit": 5}).json()

for p in res["discovery"]:
    price = p["offers"][0]["price_cents"] / 100 if p["offers"] else None
    print(p["product_id"], p["name"], f"${price:.2f}" if price else "—")

JavaScript

const BASE = "https://api.vairetail.com";
const tokenRes = await fetch(`${BASE}/oauth/token`, {
  method: "POST",
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
  body: new URLSearchParams({
    grant_type: "client_credentials",
    client_id: "YOUR_CLIENT_ID",
    client_secret: "YOUR_CLIENT_SECRET",
    scope: "merchant:98906636598",
  }),
});
const { access_token } = await tokenRes.json();

const searchRes = await fetch(`${BASE}/v1/search`, {
  method: "POST",
  headers: { Authorization: `Bearer ${access_token}`, "Content-Type": "application/json" },
  body: JSON.stringify({
    query_text: "midi dress",
    query_color: { space: "hsv", values: [0, 90, 85] },
    limit: 5,
  }),
});
const { discovery } = await searchRes.json();
for (const p of discovery) {
  const price = p.offers[0] ? (p.offers[0].price_cents / 100).toFixed(2) : "—";
  console.log(p.product_id, p.name, `$${price}`);
}

A successful call returns a SearchResponse with discovery and transaction arrays:

{
  "discovery": [
    {
      "product_id": "8471290374",
      "name": "Satin Wrap Midi Dress",
      "brand": "Trendsi",
      "category": "Dresses",
      "images": [{ "url": "https://cdn.shopify.com/s/files/ruby-wrap-midi.jpg" }],
      "offers": [{ "price_cents": 6699, "currency": "USD", "availability": { "in_stock": true } }]
    }
  ],
  "transaction": [],
  "total": 5
}

That's it — you minted a token and ran a live text + color search against the demo catalog. Each discovery hit is an agent-ready product shape (product_id, name, images, offers).

5. Next steps

401 on every request?

Your token has a 15-minute TTL. Fetch a new one via POST /oauth/token and retry. If you keep getting 401s, confirm your client_id / client_secret and that your token's scope names a merchant you're authorized for (the demo 98906636598, or one of yours).

Legacy quickstart (direct product API)

If you integrated before the unified gateway launched, the direct search service is still active. Replace steps 1–3 above with:

curl -X POST \
  https://developer-search-devID.region.run.app/api/v0/product/search/text \
  -H "X-Merchant-ID: 12345" \
  -H "Content-Type: application/json" \
  -d '{
    "query_str": "comfortable linen shirt under $80",
    "limit": 5
  }'

See Authentication → Direct product API (legacy) for full details.

Wiring search into your storefront

vairetail gives you a search API, not a drop-in widget — you own the UX. A minimal search bar calls POST /v1/search from your backend (never expose your token to the browser) and renders the discovery array it returns.

// server route: POST /api/search  { q: string }
export async function search(req, res) {
  const r = await fetch("https://api.vairetail.com/v1/search", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.VAIRETAIL_TOKEN}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ query_text: req.body.q, limit: 20 }),
  });
  const data = await r.json();
  res.json(data.discovery ?? []);
}
// client: a search bar that renders results
function SearchBar() {
  const [results, setResults] = useState([]);
  async function onSubmit(q) {
    const r = await fetch("/api/search", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ q }),
    });
    setResults(await r.json());
  }
  return (
    <>
      <input onKeyDown={(e) => e.key === "Enter" && onSubmit(e.target.value)} />
      <ul>
        {results.map((p) => (
          <li key={p.product_id}>{p.name} — {p.brand}</li>
        ))}
      </ul>
    </>
  );
}

Want to try queries before writing code? Use the live search demo (no token needed), or the playground to hit /v1/search against the demo merchant with your own token.

Authentication

discoverGPT supports two authentication modes: OAuth 2.0 client-credentials (the primary mode for all gateway /v1/* endpoints) and the legacy X-Merchant-ID header (direct product-service endpoints, retained for backward compatibility).

OAuth 2.0 client-credentials (primary)

All gateway endpoints under /v1/* require a Bearer JWT obtained via the OAuth token endpoint.

Step 1 — request a token

POST /oauth/token HTTP/1.1
Host: api.vairetail.com
Content-Type: application/x-www-form-urlencoded

grant_type=client_credentials&client_id=YOUR_CLIENT_ID&client_secret=YOUR_CLIENT_SECRET&scope=merchant:YOUR_MERCHANT_ID

Fields:

FieldRequiredValue
grant_typeyesclient_credentials
client_idyesIssued during onboarding
client_secretyesIssued during onboarding
scopeyesmerchant:{merchant_id} — the merchant you act for (one token = one merchant; your agency must be authorized for it)

Response — 200 OK:

{
  "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
  "token_type": "bearer",
  "expires_in": 900
}

The access_token is a JWT with a 15-minute TTL. Re-request a new token before it expires; do not cache it beyond expires_in seconds.

Step 2 — send Bearer on every request

POST /v1/search HTTP/1.1
Host: api.vairetail.com
Content-Type: application/json
Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...

Every /v1/* endpoint requires this header. A missing or expired token returns 401 Unauthorized.

How to get credentials

Credentials (client_id / client_secret) are issued per agency partner during onboarding. There is no self-serve key UI yet — email enterprise@vairetail.com to get provisioned.

Credentials are per-agency, not per-store

One set of credentials covers all stores and all seats in your agency account. You do not need separate credentials per merchant or per developer on your team.

Direct product API (legacy)

The individual backend services accept an integer X-Merchant-ID header identifying your catalog. This mode is retained for backward compatibility and is documented here so integrations written against it remain valid.

POST /api/v0/product/search/text HTTP/1.1
Host: developer-search-devID.region.run.app
Content-Type: application/json
X-Merchant-ID: 12345

If the header is missing or non-integer, the server returns:

{ "detail": "Missing or invalid X-Merchant-ID header" }

with HTTP status 400.

Endpoints that do NOT require X-Merchant-ID

  • GET /health — platform health check
  • GET /api/v1/proxy-image — public image proxy

Every other direct-service endpoint requires the header.

How merchant IDs are issued

  • Shopify merchants: your merchant ID is provisioned automatically during the Shopify app install flow and stored in the theme extension's local storage as merchant_id.
  • Non-Shopify merchants: merchant IDs are issued by the visualAI team during onboarding — contact enterprise@vairetail.com.

Trust model

The direct-service production API accepts requests only from approved origins via a CORS allow-list. The X-Merchant-ID header identifies which catalog to operate on; it does not itself authenticate the caller. This model is safe for the current browser-only deployment surface but is not suitable for server-to-server or cross-organization use. For server-to-server integrations, use the OAuth gateway.

Decoupling from Shopify

Today, a merchant's ID happens to equal their Shopify Shop ID for Shopify installs. This is an implementation detail and will change — new non-Shopify merchants already receive IDs from a separate range. Always treat the merchant ID as an opaque integer.

Errors

discoverGPT uses conventional HTTP status codes and returns all error details in a JSON body with a detail field.

Error shape

{
  "detail": "Missing or invalid X-Merchant-ID header"
}

detail is a human-readable string. In some validation errors it is a list of FastAPI validation objects — see Validation errors below.

Status codes

StatusMeaning
200Success.
400Bad request. Missing or malformed X-Merchant-ID, missing required body fields, invalid color input, or a search body with neither text nor color.
403Forbidden. The authenticated merchant does not have access to the target resource (e.g. calling another merchant's pipeline status).
404Not found. A referenced user profile, pipeline status, or resource does not exist for this merchant.
422Unprocessable entity. FastAPI schema validation failed (wrong field types, missing required fields).
500Internal server error. A bug or downstream failure. Retry with backoff and contact support if it persists.
502Bad gateway. The upstream service a utility endpoint depends on (e.g. proxy-image) was unreachable.

Common error examples

400 — missing merchant ID

HTTP/1.1 400 Bad Request
Content-Type: application/json

{"detail": "Missing or invalid X-Merchant-ID header"}

400 — empty search body

Sent to POST /api/v0/product/search/textandcolor with neither a query_str nor a color:

{ "detail": "At least one of 'text' or 'color' must be provided." }

403 — cross-merchant access

Attempting to read another merchant's pipeline status:

{ "detail": "Access denied to this merchant's pipeline status" }

404 — resource not found

{ "detail": "User preferences not found" }

422 — validation error

{
  "detail": [
    {
      "loc": ["body", "query_str"],
      "msg": "field required",
      "type": "value_error.missing"
    }
  ]
}

Retries

The search endpoints are idempotent — identical POSTs return identical results (and often a cached payload). Safe to retry on 5xx with exponential backoff.

Mutation endpoints (POST /api/v1/user/profile, POST /api/v1/user/preferences, PUT /api/v1/pipeline/status/{merchant_id}) are also safe to retry with the same body — they upsert based on their natural keys.

The DELETE /api/v1/merchant/{merchant_id}/data endpoint is not automatically retried and requires an explicit environment flag on the server; do not retry on a 403 for this endpoint.

Request ID

Every response includes an X-Request-ID header containing a UUID that uniquely identifies the request on the server side. Include this when reporting issues to enterprise@vairetail.com.

HTTP/1.1 200 OK
X-Request-ID: f2c8e9d1-0b3a-4a74-9cc6-b1d3a2a0f0ab

Data Model

discoverGPT operates on a small, fixed set of canonical tables. Every search, every recommendation, every preference query runs against these tables regardless of where your data originated.

Products

products is the single table every search endpoint reads. The model is variant-centric: it holds one row per variant (identified by variant_id), so a product with three sizes is three rows sharing a product_id. A product with no variants is a single row whose variant_id defaults to its product_id. Regardless of whether your data came from Shopify, a CSV export, or a custom PIM, discoverGPT reads it here.

Rows are scoped by merchant_id — multi-tenant isolation is enforced at the database level via row-level security.

Fields you provide

FieldTypeRequiredConstraintsNotes
product_idstringyesUnique per merchant, ≤ 2048 charsOpaque stable ID from your source system — SKU, UUID, slug, or numeric ID, your choice. Shared across a product's variants.
variant_idstringoptionalUnique within a product_idPer-variant discriminator. Omit for a product with no variants — it defaults to product_id. Two rows with the same product_id but different variant_id are two variants of one product.
product_namestringyesPlaintextShopify title, Woo name, custom productName, etc.
handlestringrecommendedURL-safe slug, lowercase, hyphenatedStorefront URL slug.
product_detailsstringrecommendedPlaintext or HTML (HTML is stripped during enrich)Long description. Drives semantic text search.
brandstringoptionalPlaintextShopify vendor.
categorystringrecommendedFree text (tops, dresses, accessories)Enrichment fills this in if missing.
sub_categorystringoptionalFree text
colorstringoptionalFree text (navy blue) or hex (#0a1f44)The color pipeline will derive color from image1 if this is missing.
tagsstringoptionalComma-separated
pricestringyesCurrency-formatted string, e.g. "29.99"Display field. Must agree with price_numeric.
price_numericfloatyesNon-negative decimalUsed for range filters, min/max price queries, and price-aware ranking.
variant_compare_at_pricestringoptionalCurrency stringOriginal / MSRP price for sale display.
option1_namestringoptionale.g. "Size", "Color"First variant option label (same across a product's variants).
option1_valuestringoptionale.g. "M", "Navy"This variant's value for option1_name.
option2_namestringoptionale.g. "Color"Second variant option label.
option2_valuestringoptionalPlaintextThis variant's value for option2_name.
option3_namestringoptionalPlaintextThird variant option label.
option3_valuestringoptionalPlaintextThis variant's value for option3_name.
variant_skustringoptionalPlaintextVariant-specific SKU/stock code.
variant_imagestringoptionalPublicly reachable HTTPS URLVariant-specific image, if it differs from image1.
inventory_quantityintegeroptionalWhole number ≥ 0Per-variant stock on hand. Persisted verbatim; omit or leave blank for untracked inventory.
genderenumoptionalmens | womens | unisex | kidsEnrichment will fill this from product_name / product_details if missing.
image1stringyesPublicly reachable HTTPS URL, ≤ 10 MB, CORS-readableThe primary product image. All image embeddings and color extraction are computed from this URL.
image1_descriptionstringoptionalPlaintextIf absent, enrichment generates a description via a vision model.
statusenumoptionalactive | draft | archived (default active)Only active rows are searchable.
display_flagbooleanoptionalDefault trueSoft-hide: set to false to exclude from search without deleting.
metadata_jsonobjectoptionalArbitrary JSONFree-form field for source-specific fields you want to preserve round-trip.

Required minimum

At an absolute minimum, a product row must have product_id, product_name, price, price_numeric, and image1. Everything else is optional but strongly recommended — enrichment can fill gaps but the best results come from the richest input.

Fields we compute

These columns are populated by the discoverGPT ETL pipeline during ingest and enrichment. Do not provide them — sending them on ingest is an error.

FieldPopulated by
tags1Enrichment (Gemini) — 5–15 generated keyword tags. Distinct from the tags you provide.
text_embeddingText embedding pipeline (create_text_embeddings)
text_embedding_1Text embedding pipeline
img_embeddingImage embedding pipeline (create_img_embeddings)
image_desc_embedVision description pipeline
color_embeddingColor pipeline (create_color_embeddings)
color_embedding_1..3Color pipeline
color_hsv_embedding_1..3Color pipeline
percent_1..3Color pipeline (dominant-color coverage percentages)
merchant_idSet server-side from the authenticated identity.
last_updatedDatabase-managed timestamp.
idDatabase-managed primary key.

Uniqueness & upserts

Rows are unique on (merchant_id, product_id, variant_id) — one row per variant. Ingesting a row whose (product_id, variant_id) already exists for the same merchant is treated as an upsert: non-null fields from the new payload overwrite the existing row, and the computed columns are recomputed in the next enrichment cycle. (For a product with no variants, variant_id defaults to product_id, so the key collapses to one row per product.)

User profiles

user_profile holds optional per-shopper identity data. Every profile is scoped to a single merchant (merchant_id) and keyed by email within that merchant.

User profiles are optional. Anonymous search works perfectly well — you only need profiles when you want to attach preferences or personalize results over time.

Fields

FieldTypeRequiredNotes
idintegerserverAuto-generated primary key.
user_namestringyesDisplay name (first name is acceptable).
first_namestringyesFirst name.
last_namestringyesLast name.
email_idstringyesEmail address. Unique within a merchant.
profile_namestringoptionalA label for the profile (e.g. "Work", "Home").

Uniqueness

Rows are unique on (merchant_id, email_id). Creating a profile with an existing email returns the existing row rather than a duplicate — see POST /api/v1/user/profile.

User preferences

user_preferences holds optional per-shopper preferences that search and recommendation endpoints use to personalize results. Each row is attached to a user_profile by user_profile_id, and has a one-to-one relationship with it.

Preferences are optional and independent of the rest of the catalog — you can ship discoverGPT with no preferences at all and still get a great baseline search experience.

Fields

FieldTypeRequiredNotes
idintegerserverAuto-generated primary key.
user_profile_idintegeryesForeign key to user_profile.id. Unique — one preferences row per user.
conversational_stylestringoptionalFree text (concise, friendly, technical, …). Biases recommendation tone.
personal_fashion_stylestringoptionalFree text (minimalist, streetwear, classic, …).
favorite_colorsarray of stringsoptionalE.g. ["navy", "olive"]. Influences color-aware ranking.
fashion_material_preferarray of stringsoptionalE.g. ["linen", "cotton"].
body_typestringoptionalFree text.
fashion_goalsstringoptionalFree text ("dress for job interviews", "beach vacation outfits").
age_groupstringoptionalFree text.
is_modifiedbooleanservertrue after a user has explicitly edited their preferences.
product_idsarray of integersserverInternal — used by the randomdata endpoint to seed cold-start results.

How preferences are used

When a search request is made with preferences: true and a user_profile_id, the API loads that profile's preferences and:

  1. Feeds conversational_style into the recommendation LLM.
  2. Adds favorite_colors and fashion_material_prefer as soft ranking signals — products matching preferred attributes are nudged up.
  3. Uses body_type, age_group, and personal_fashion_style as context in the recommendation text.

Preferences are never used as a hard filter — they influence ranking, they don't exclude products.

Pipeline status

pipeline_status tracks the progress of a merchant's ingest and enrichment pipeline — from initial catalog import all the way through embedding generation and extension deployment. It is populated by discoverGPT's internal ETL workers and read by admin UIs to display onboarding progress.

You don't usually write to this table directly — the POST /api/v1/pipeline/status endpoint initializes a row, and the internal pipeline workers update it as each step completes. Your admin UI reads it via GET /api/v1/pipeline/status/{merchant_id}.

Fields

FieldTypeNotes
merchant_idintegerThe merchant this pipeline run belongs to.
shop_domainstringOptional source-platform domain (e.g. store.myshopify.com). Informational.
current_stepstringThe step currently running. See Pipeline steps below.
completed_stepsarray of stringSteps that have completed successfully, in order.
failed_stepsarray of stringSteps that failed. Parallel to completed_steps.
statusstringrunning | completed | failed.
started_atdatetime (UTC)When the run started.
completed_atdatetime (UTC)When the run finished. null while running.
last_updateddatetime (UTC)When the row was most recently touched.
error_messagestringPopulated when status = failed.
configobjectSource-specific configuration carried through the pipeline.

Response-only fields

The PipelineStatusResponse returned by the API endpoints adds two computed fields that are not stored in the database:

FieldTypeNotes
progress_percentagefloat (0–100)completed_steps.length / total_steps * 100.
estimated_completiondatetime (UTC)Heuristic estimate based on average step duration.

Pipeline steps

The default dev pipeline has these steps, in order:

  1. shopify_etl_dev — initial catalog import (Shopify merchants only today)
  2. enrichment_dev — description enrichment, category classification, gender inference
  3. create_color_embeddings — extract and embed dominant colors
  4. add_color_names — map color embeddings to human-readable color names
  5. create_text_embeddings — generate semantic text embeddings
  6. create_img_embeddings — generate image similarity embeddings
  7. deploy_shoppergpt_extension — deploy the storefront extension (Shopify only)

Non-Shopify merchants use a modified pipeline that skips shopify_etl_dev and deploy_shoppergpt_extension.

CSV format

discoverGPT accepts UTF-8 CSV files whose columns map to the canonical products fields.

Requirements

  • Encoding: UTF-8 (no BOM required, but accepted).
  • Delimiter: comma (,).
  • Header row: required. Column names must match the canonical field names exactly, or a mapping must be supplied.
  • Quoting: RFC 4180 — wrap any field containing commas, quotes, or newlines in double quotes, with interior quotes doubled ("").
  • Row order: unimportant. Rows will be imported in the order given.
  • Max size: 100 MB per file for direct upload. For larger catalogs, split into multiple files or use NDJSON streaming.

Column mapping

If your source system uses different column names — and it almost certainly does — use the Column Mapper to paste your headers and produce a mapping.json file. You can then either:

  1. Run the mapping yourself (Node or Python snippets) to produce a canonical CSV, or
  2. Ship mapping.json alongside the raw CSV (once the push-ingest endpoint is GA).

Sample file

The template is a UTF-8 CSV with a header row; one row per product variant. Each column maps to a canonical products field — for the complete column-by-column reference (type, required/optional, allowed values, and data formats for every field) see Products → Fields you provide. Column order doesn't matter; header names do (case-insensitive, matched to canonical names or mapped). Data-format essentials:

  • Required in every row: product_id, product_name, price, price_numeric, image1.
  • Prices: price is a currency-formatted string ("69.00"); price_numeric is a bare non-negative decimal (69.00) used for filtering and ranking. There are no date columns — timestamps are database-managed.
  • Variants: a product with variants is multiple rows sharing one product_id, each with its own variant_id and option*_value. Omit variant_id for a no-variant product and it defaults to product_id.
  • Lists: tags is comma-separated inside one quoted cell.

A minimal canonical CSV — note SKU-1001 appears twice (two size variants), and SKU-1002 omits variant_id (single-variant product):

product_id,variant_id,product_name,handle,product_details,brand,category,color,tags,price,price_numeric,option1_name,option1_value,gender,image1,status
SKU-1001,SKU-1001-M,Relaxed Linen Button-Up,relaxed-linen-button-up,"Breezy 100% linen with a relaxed cut.",Cove & Coast,tops,white,"linen,summer",69.00,69.00,Size,M,mens,https://cdn.example.com/images/relaxed-linen.jpg,active
SKU-1001,SKU-1001-L,Relaxed Linen Button-Up,relaxed-linen-button-up,"Breezy 100% linen with a relaxed cut.",Cove & Coast,tops,white,"linen,summer",69.00,69.00,Size,L,mens,https://cdn.example.com/images/relaxed-linen.jpg,active
SKU-1002,,Classic Chinos,classic-chinos,"Midweight cotton chinos with a tailored leg.",Cove & Coast,bottoms,khaki,"cotton,classic",89.00,89.00,Size,32,mens,https://cdn.example.com/images/classic-chinos.jpg,active

Download the full sample: products.csv

Validation rules

During ingest discoverGPT applies the following rules to every row:

  • product_id, product_name, price, price_numeric, and image1 must be present and non-empty.
  • price_numeric must parse as a non-negative float.
  • image1 must be a valid HTTPS URL.
  • status, if provided, must be one of active, draft, archived.
  • display_flag, if provided, must be true or false (case-insensitive).
  • gender, if provided, must be one of mens, womens, unisex, kids.
  • Any additional columns not in the canonical schema are placed into metadata_json as string values.

Rows that fail validation are skipped with a line number and a reason in the ingest log; valid rows in the same file still import.

JSON format

This section covers file ingest / bulk upload shapes only — not the POST /v1/ingest push body. For bulk file uploads, discoverGPT accepts two JSON shapes: newline-delimited JSON (NDJSON) and an array of objects. For any catalog over a few thousand rows, prefer NDJSON — it streams and doesn't require the whole catalog to live in memory on either side.

The push endpoint (POST /v1/ingest) does not accept NDJSON or a bare array; its body is a single {"products": [...]} JSON object. See Push ingest → Contract for the exact accepted fields.

NDJSON (preferred for large catalogs)

One JSON object per line. No array brackets, no trailing commas. UTF-8. Content-type application/x-ndjson.

{"product_id":"SKU-1001","variant_id":"SKU-1001-M","product_name":"Relaxed Linen Button-Up","handle":"relaxed-linen-button-up","product_details":"Breezy 100% linen with a relaxed cut.","brand":"Cove & Coast","category":"tops","color":"white","tags":"linen,summer","price":"69.00","price_numeric":69.0,"option1_name":"Size","option1_value":"M","gender":"mens","image1":"https://cdn.example.com/images/relaxed-linen.jpg","status":"active"}
{"product_id":"SKU-1001","variant_id":"SKU-1001-L","product_name":"Relaxed Linen Button-Up","handle":"relaxed-linen-button-up","product_details":"Breezy 100% linen with a relaxed cut.","brand":"Cove & Coast","category":"tops","color":"white","tags":"linen,summer","price":"69.00","price_numeric":69.0,"option1_name":"Size","option1_value":"L","gender":"mens","image1":"https://cdn.example.com/images/relaxed-linen.jpg","status":"active"}

Download the full sample: products.ndjson

Array-of-objects (small batches)

For small imports (under a few thousand products), a standard JSON array works fine. Content-type application/json.

[
  {
    "product_id": "SKU-1001",
    "product_name": "Relaxed Linen Button-Up",
    "handle": "relaxed-linen-button-up",
    "product_details": "Breezy 100% linen with a relaxed cut.",
    "brand": "Cove & Coast",
    "category": "tops",
    "color": "white",
    "tags": "linen,summer",
    "price": "69.00",
    "price_numeric": 69.0,
    "gender": "mens",
    "image1": "https://cdn.example.com/images/relaxed-linen.jpg",
    "status": "active"
  },
  {
    "product_id": "SKU-1002",
    "product_name": "Classic Chinos",
    "handle": "classic-chinos",
    "product_details": "Midweight cotton chinos with a tailored leg.",
    "brand": "Cove & Coast",
    "category": "bottoms",
    "color": "khaki",
    "tags": "cotton,classic",
    "price": "89.00",
    "price_numeric": 89.0,
    "gender": "mens",
    "image1": "https://cdn.example.com/images/classic-chinos.jpg",
    "status": "active"
  }
]

Type coercion

  • Strings vs numbers: price must be a JSON string; price_numeric must be a JSON number. These are separate fields for a reason — the numeric version is what search uses, the string version is what the UI renders.
  • Booleans: display_flag must be a JSON boolean, not "true" as a string.
  • Arrays: tags must be a string (comma-separated). It is stored that way in the database and split at query time only when needed.
  • Unknown fields: any field not in the canonical schema is written to metadata_json.

Fields you must not include

Never include these computed fields on ingest — the server will reject them:

  • merchant_id (set server-side from auth)
  • id, last_updated (database-managed)
  • text_embedding, text_embedding_1, img_embedding, image_desc_embed
  • color_embedding, color_embedding_1, color_embedding_2, color_embedding_3
  • color_hsv_embedding_1, color_hsv_embedding_2, color_hsv_embedding_3
  • percent_1, percent_2, percent_3

See products → Fields we compute.

How ingestion and orchestration works

Your catalog moves through a two-tier pipeline. You only ever choose outcomes — the platform runs the right jobs, in the right order, and skips anything already done.

The two tiers

Foundation (runs once per store, then reused). When a catalog first arrives, two foundation steps run: import (your products land in the catalog) and enrichment (category, gender, sub-category, enriched tags, and an AI vision description of each product image). Foundation runs a single time per store — if you use more than one vairetail product, the second and third reuse the first's foundation instead of re-importing or re-enriching.

Outcomes (you choose these). On top of the foundation, you turn on the capabilities you want. Each maps to a set of jobs:

OutcomeWhat it enablesJobs it runs (after foundation)Selectable per push request?
Natural-language searchSemantic text searchtext embeddingsYes
Precise-color searchColor-aware filtering & searchcolor embeddings → color namesYes
Image-similarity search"More like this" by imageimage embeddingsYes
Clean product dataNormalized titles/descriptions & sellabilitydata cleaningYes
Generate narrativesAI-written product narratives, for agentic/LLM shopping surfacesnarrative generationYes
Generate Q&AAI-generated product Q&A pairsnarrative generation (prerequisite) → Q&A generationYes
Catalog feedACP / Schema.org JSON-LD / UCP feedsfeed compileOnboarding flow only — not yet selectable per push request
AI visibility (GEO)Presence across AI answer enginesvisibility check*Onboarding flow only — not yet selectable per push request

* Runs automatically once the catalog feed compiles; it comes with the Catalog feed outcome and isn't scheduled on its own.

Requesting Generate Q&A runs narrative generation first automatically if you haven't also requested Generate narratives — you don't need to request both.

One ordering rule worth knowing

If you enable natural-language search together with precise-color search, color runs first and NL runs last — on purpose. The natural-language index embeds each product's color name (produced by the color step) directly into the searchable text, so a query like "navy blazer" matches on the color word. Enable color before (or with) NL and the platform orders it for you; enable NL alone and it still works, just without color words baked into the text index. Image search is independent — it lives in its own vector space and does not change NL quality. Enrichment (including the vision description that also strengthens NL) always runs first, underneath everything.

You won't step on your own data

Requesting an outcome runs only the work that's missing. Foundation and any enrichment another vairetail product already produced are reused, never re-run, and never overwritten. Re-requesting an outcome later safely backfills only what isn't done yet. You never have to sequence jobs yourself or worry about clobbering existing results.

Dos and don'ts

  • Do ask for outcomes, not jobs. Don't try to trigger individual pipeline steps.
  • Do expect a short pending window — an outcome is ready once its jobs finish, not the instant ingest returns. Poll pipeline status.
  • Do turn on color search before or with natural-language search if you want color-aware text results.
  • Don't assume a product is color- or image-searchable just because text search works — each outcome is independent (the one link is color → richer NL).
  • Re-requesting is safe — it fills only the gaps.

What runs today via push

POST /v1/ingest and POST /v1/ingest/file are live and support natural-language search, precise-color search, image-similarity search, clean product data, generate narratives, and generate Q&A as per-request outcomes — request any combination on a single push. Catalog feed and AI visibility (GEO) are still produced only through the product onboarding flow, not yet selectable per push request.

Ingesting Your Data

discoverGPT supports two ingest models: pull (discoverGPT reaches into your commerce platform on a schedule) and push (you send us your catalog). Pull is available today for Shopify; push is live today with six selectable outcomes — see How ingestion works.

Shopify (Pull)

The Shopify integration is the most common ingest path and the one available today. It is also the only ingest path that requires zero custom code on your side.

What it does

  1. You install the discoverGPT app from the Shopify App Store.
  2. You grant the app read access to your products and storefront.
  3. discoverGPT pulls your product catalog via the Shopify Admin API and populates the canonical products table.
  4. The enrichment pipeline runs: categories, color extraction, embeddings.
  5. discoverGPT installs a theme extension that wires the search UI into your storefront. Semantic search goes live for shoppers once the search pipeline finishes (typically minutes after import); track it via pipeline status.
  6. Ongoing updates stream in via Shopify webhooks — product creates, updates, and deletes are reflected in the canonical table within seconds.

Field mapping (Shopify → canonical)

Shopify field (Admin API)Canonical products field
idproduct_id
titleproduct_name
handlehandle
body_htmlproduct_details (HTML stripped on enrich)
vendorbrand
product_typecategory
tagstags
variants[0].priceprice / price_numeric
variants[0].compare_at_pricevariant_compare_at_price
options[0].nameoption1_name
images[0].srcimage1
statusstatus

Gender is inferred during enrichment from product_name, product_type, and body_html. Color is extracted from image1 if not present in tags.

Onboarding status

Pipeline progress (shopify_etl_dev → enrichment_dev → ... → deploy_shoppergpt_extension) is tracked in pipeline_status and can be read via GET /api/v1/pipeline/status/{merchant_id}.

Merchant ID

Your discoverGPT merchant_id is assigned during install and happens to equal your Shopify Shop ID for today's installs. Always treat it as an opaque integer — future non-Shopify merchant IDs will come from a separate range.

Uninstall

Uninstalling the Shopify app removes the theme extension and stops the webhook stream. Product rows remain in the canonical table by default (so reinstalling a week later doesn't require a full re-import).

For full data deletion on uninstall, see DELETE /api/v1/merchant/{merchant_id}/data.

Push ingest

Status: Live

POST /v1/ingest is live on https://api.vairetail.com. Every push request selects one or more outcomes — see How ingestion works for the full outcome model and which ones are selectable today.

Push ingest lets you send catalog rows to discoverGPT from any source — BigCommerce, WooCommerce, Magento, a custom PIM, a Python script reading from Snowflake, whatever.

Contract

POST /v1/ingest HTTP/1.1
Host: api.vairetail.com
Content-Type: application/json
Authorization: Bearer <access_token>

Body: a single JSON object with products (array, 1–1000 items) and outcomes (array of strings, at least one — see Outcomes for the valid values). The top-level object rejects unknown keys — there are no query parameters or form fields, only this JSON body.

Each product requires product_id (string). Recognized fields: variant_id (distinguishes variants of the same product; defaults to product_id if omitted — one row per product), handle, product_name, description, image_url, category, sub_category, brand, tags. Note the names — it's description, not product_details; image_url, not image1. Extra fields beyond these are accepted and passed through, but only the fields above are guaranteed to map to the canonical catalog.

Response
{
  "job_id": "sgpt-upsert-12345-4810",
  "accepted_product_count": 4810,
  "run_id": "push-8f3d2e1a-....",
  "outcome_errors": {}
}

Ingest returns once your rows are accepted (written synchronously); the outcomes you requested then run asynchronously. Track progress via GET /v1/ingest/runs/{run_id} (below) or pipeline status. See How ingestion works for what each outcome runs.

New products aren't searchable the instant ingest returns

A 200 from POST /v1/ingest means your rows were written, not yet indexed. Products become searchable only once the nl_search outcome finishes embedding — typically a minute or two after a successful push. A search run immediately after ingest can return zero results; that's expected, not an error. Wait until GET /v1/ingest/runs/{run_id} reports nl_search: "ready" (or poll pipeline status) before treating an empty result set as a problem.

outcome_errors maps an outcome to a failure reason if the gateway couldn't forward your products to that outcome's backend — an outcome listed here was never dispatched, but every other requested outcome still processes normally.

Rows that fail validation are rejected and do not count toward accepted_product_count. dry_run (validate without writing) is not yet supported — planned, but not accepted today.

Check push-run status
GET /v1/ingest/runs/{run_id}
{
  "run_id": "push-8f3d2e1a-....",
  "status": "running",
  "outcomes": {
    "nl_search": "ready",
    "clean_data": "running"
  }
}

Each outcome you requested reports its own status: queued, running, ready, or failed. status at the top level reflects the run as a whole.

Running a mapping client-side

If you'd rather produce the canonical {"products":[...]} JSON body yourself before calling the ingest endpoint, see:

Example

curl -X POST https://api.vairetail.com/v1/ingest \
  -H "Authorization: Bearer <access_token>" \
  -H "Content-Type: application/json" \
  -d '{"outcomes":["nl_search"],"products":[{"product_id":"SKU-1","variant_id":"SKU-1-blue","product_name":"Aurora Linen Tee","description":"Lightweight linen tee","image_url":"https://example.com/tee.jpg","category":"Apparel","brand":"Aurora"}]}'

Other platforms

If your catalog lives somewhere other than Shopify, the path today is:

  1. Export your catalog to CSV or NDJSON.
  2. Use the Column Mapper to map your source columns to the canonical products schema.
  3. Apply the mapping to produce a canonical file (Node or Python snippets).
  4. Send it to us via push ingest or share it with the visualAI team during onboarding.

Below are the typical field mappings for the common platforms. These are seeded into the Column Mapper's auto-suggest.

BigCommerce

BigCommerce fieldCanonical products field
id or skuproduct_id
nameproduct_name
custom_url.urlhandle
descriptionproduct_details
brand_namebrand
categoriescategory
priceprice_numeric
retail_pricevariant_compare_at_price
primary_image.url_standardimage1
is_visibledisplay_flag

WooCommerce

WooCommerce fieldCanonical products field
id or skuproduct_id
nameproduct_name
slughandle
descriptionproduct_details
(attribute) brandbrand
categories[0].namecategory
priceprice_numeric
regular_pricevariant_compare_at_price
tags[].nametags (comma-joined)
images[0].srcimage1
statusstatus

Magento

Magento fieldCanonical products field
skuproduct_id
nameproduct_name
url_keyhandle
descriptionproduct_details
brand (custom attr)brand
category_namescategory
priceprice_numeric
base_imageimage1
statusstatus

Custom PIM / flat export

If you own the source format, the fastest path is to export a CSV whose headers already match the canonical schema. See CSV format for the required columns.

If you can't rename columns at the source, use the Column Mapper — paste your headers, accept the auto-suggestions, download mapping.json, and run it through the Node or Python transformer.

File ingest API (CSV and XLSX)

Unlike Push ingest above, which sends canonical JSON, file ingest lets you upload a .csv or .xlsx file directly and the gateway auto-detects your column mapping, runs the import as an async job, and reports per-row results — no NDJSON transform required. All endpoints below require Authorization: Bearer <jwt> — see Authentication for how to mint one.

Limits

  • Max upload size: 20 MiB (413 above that).
  • Rows are ingested in batches of 1,000.
  • Up to 100 row errors are returned inline on the status response; once the job finishes, the full list is written to error_report_uri.

Upload a file

POST /v1/ingest/file

Starts an async ingest job from an uploaded file. The gateway auto-detects a column mapping from header synonyms (e.g. handleproduct_id, skuvariant_id, titleproduct_name, msrpprice); pass an explicit mapping to override individual fields, or a saved template_id to reuse an agency-wide mapping. Precedence is explicit mapping > template_id > auto-detected, merged field-by-field.

SKU alone no longer maps to product_id

sku/id-style headers map to variant_id, not product_id — a catalog with a Handle (or similar product-grouping) column resolves both correctly; a catalog with only a SKU-style column and nothing else that looks like a product id now gets a 400 (product_id unmapped) instead of silently treating SKU as the product id. Map product_id explicitly (e.g. onto the same SKU column) if your catalog genuinely has no separate product-grouping field.

Headers

HeaderRequiredValue
AuthorizationyesBearer <jwt> — see Authentication
Content-Typeyesmultipart/form-data (set automatically when you attach form fields)

Request body (multipart form fields)

FieldTypeRequiredDescription
filefileyesThe .csv or .xlsx file to ingest.
outcomesstring (JSON array)yesJSON-array-as-string of one or more outcome values — see Outcomes for the full list and what each enables. E.g. '["nl_search","clean_data"]'.
mappingstring (JSON)noExplicit column mapping: a flat JSON object of { "target_field": "source_column" }. Wins over both template_id and auto-detection for the fields it sets.
template_idstring (UUID)noID of a saved mapping template (see Save a mapping template) to apply instead of auto-detection.

Target fields: product_id (required — the upload is rejected with 400 if it can't be resolved by explicit mapping, template, or auto-detection), variant_id (distinguishes variants of the same product; defaults to product_id if omitted), product_name, description, price, currency, image_url, additional_image_urls (|-delimited in the source column), product_url, category, sub_category, brand, tags. Any source column not consumed by the mapping is passed through under its normalized header name.

Response — 202 Accepted

{
  "job_id": "6f1b1a2e-8c3d-4e9a-9d1a-2b7c9e6f1234",
  "detected_mapping": {
    "product_id": "SKU",
    "product_name": "Title",
    "price": "Price (USD)",
    "image_url": "Image URL"
  },
  "estimated_rows": 4821,
  "sample_rows": [
    {
      "product_id": "SKU-1001",
      "product_name": "Relaxed Linen Button-Up",
      "price": 69.0,
      "image_url": "https://cdn.example.com/images/relaxed-linen.jpg"
    }
  ],
  "headers": ["SKU", "Title", "Price (USD)", "Image URL"],
  "outcome_errors": {}
}
  • detected_mapping — the mapping that will actually be used, keyed by canonical target field.
  • sample_rows — up to 20 transformed preview rows. A row that would be skipped by the real import (e.g. an empty product_id) is simply omitted from the preview.
  • headers — the raw column headers read from the file — handy for building your own mapping UI on top of this response.
  • estimated_rows — total data rows parsed from the file.

Example

curl -X POST https://api.vairetail.com/v1/ingest/file \
  -H "Authorization: Bearer $TOKEN" \
  -F "file=@catalog.csv" \
  -F 'outcomes=["nl_search"]' \
  -F 'mapping={"product_id":"SKU","product_name":"Title"}'
import httpx

with open("catalog.csv", "rb") as f:
    r = httpx.post(
        "https://api.vairetail.com/v1/ingest/file",
        headers={"Authorization": f"Bearer {token}"},
        files={"file": ("catalog.csv", f, "text/csv")},
        data={
            "outcomes": '["nl_search"]',
            "mapping": '{"product_id":"SKU","product_name":"Title"}',
        },
    )
job_id = r.json()["job_id"]
const form = new FormData();
form.append('file', fileInput.files[0]);
form.append('outcomes', JSON.stringify(['nl_search']));
form.append('mapping', JSON.stringify({ product_id: 'SKU', product_name: 'Title' }));

const res = await fetch('https://api.vairetail.com/v1/ingest/file', {
  method: 'POST',
  headers: { Authorization: `Bearer ${token}` },
  body: form,
});
const { job_id } = await res.json();

Errors

StatusResponse
400Unsupported file type (not .csv/.xlsx), unreadable/empty file (detail: file could not be parsed), outcomes missing/empty/not valid JSON/contains an unrecognized value, mapping isn't valid JSON (or isn't flat string-to-string), product_id couldn't be mapped, template_id isn't a valid UUID, or mapped column(s) not found in headers.
404template_id doesn't exist, or belongs to a different agency — both return the same detail so a foreign template can't be probed.
413File exceeds the 20 MiB cap.
429{ "error": "quota_exceeded", "bucket": "shared", "limit": ..., "counted_units": ... } — plan quota exhausted.

Check ingest job status

GET /v1/ingest/file/{job_id}

Durable, poll-to-terminal processing

Ingest jobs run out-of-band and survive a gateway restart — poll this endpoint until state reaches a terminal value. A bad row never aborts the whole file: it's skipped, recorded in errors, and processing continues, so a file with a few invalid rows finishes partial rather than failed. Rows are upserted by product_id, so re-running the same file is safe. Deletion-sync (removing products absent from a later file) is not performed — a documented gap, not a bug.

Response — 200 OK

{
  "job_id": "6f1b1a2e-8c3d-4e9a-9d1a-2b7c9e6f1234",
  "state": "partial",
  "processed": 4821,
  "total": 4821,
  "skipped": 3,
  "accepted_product_count": 4818,
  "errors": [
    { "row": 142, "reason": "empty product_id" }
  ],
  "error_report_uri": "https://storage.googleapis.com/.../ingest/6f1b1a2e.../errors.json",
  "run_id": "push-8f3d2e1a-....",
  "outcome_errors": {}
}
  • statequeuedrunning → terminal: succeeded (no skipped rows), partial (some rows skipped), or failed (the job itself errored).
  • errors — up to 100 entries inline as { row, reason }. error_report_uri holds the complete list once the job finishes.
  • run_id — identifies the push-run for your requested outcomes; poll it via GET /v1/ingest/runs/{run_id}. null until at least one product has been accepted, and permanently null for a failed job or one where every row was skipped.
  • outcome_errors — maps an outcome to a failure reason if the gateway couldn't forward your accepted products to that outcome's backend; every other requested outcome still processes normally.

Example

curl https://api.vairetail.com/v1/ingest/file/$JOB_ID \
  -H "Authorization: Bearer $TOKEN"

Errors

StatusResponse
404Unknown job, or the job belongs to a different agency/merchant than your token — both return { "detail": "unknown job" } so a foreign job's existence can't be probed.

Save a mapping template

POST /v1/ingest/mappings

Persists a reusable, agency-scoped column mapping so you don't have to resend an explicit mapping JSON blob on every upload — pass its id as template_id on Upload a file. Templates are agency-wide (not merchant-specific).

Request body

FieldTypeRequiredDescription
namestringyes1–120 chars. Must be unique within your agency.
mappingobjectyes{ "target_field": "source_column" }, at least one entry.

Response — 201 Created

{ "id": "9a4e2f10-6b3c-4a1d-8e7f-1234567890ab" }

Example

curl -X POST https://api.vairetail.com/v1/ingest/mappings \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Acme PIM export",
    "mapping": { "product_id": "SKU", "product_name": "Title", "price": "Price (USD)" }
  }'

Errors

StatusResponse
409A template named name already exists for your agency.

List mapping templates

GET /v1/ingest/mappings

Response — 200 OK

{
  "templates": [
    {
      "id": "9a4e2f10-6b3c-4a1d-8e7f-1234567890ab",
      "name": "Acme PIM export",
      "mapping": { "product_id": "SKU", "product_name": "Title", "price": "Price (USD)" },
      "created_at": "2026-07-01T12:00:00Z"
    }
  ]
}

Example

curl https://api.vairetail.com/v1/ingest/mappings \
  -H "Authorization: Bearer $TOKEN"

Ingest quickstart

Zero to ingested: mint a token, upload a CSV, poll the job, and confirm the result. If you haven't created an API key yet, do Quickstart steps 1–2 first.

1. Mint a token scoped to your merchant

export CLIENT_ID="YOUR_CLIENT_ID"
export CLIENT_SECRET="YOUR_CLIENT_SECRET"
export MERCHANT_ID="98906636598"   # or your own merchant

curl -s -X POST https://api.vairetail.com/oauth/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d grant_type=client_credentials \
  -d client_id="$CLIENT_ID" \
  -d client_secret="$CLIENT_SECRET" \
  -d scope="merchant:$MERCHANT_ID"
export TOKEN="eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9..."   # access_token from the response

2. Upload your CSV

curl -s -X POST https://api.vairetail.com/v1/ingest/file \
  -H "Authorization: Bearer $TOKEN" \
  -F "file=@catalog.csv"
{
  "job_id": "6f1b1a2e-8c3d-4e9a-9d1a-2b7c9e6f1234",
  "detected_mapping": { "product_id": "SKU", "product_name": "Title", "price": "Price (USD)" },
  "estimated_rows": 4821,
  "sample_rows": [{ "product_id": "SKU-1001", "product_name": "Relaxed Linen Button-Up" }],
  "headers": ["SKU", "Title", "Price (USD)"]
}

Check detected_mapping before moving on. If it's missing a field you care about or mapped one wrong, re-upload with an explicit mapping JSON blob (or save a template and pass template_id) rather than proceeding with a bad mapping.

export JOB_ID="6f1b1a2e-8c3d-4e9a-9d1a-2b7c9e6f1234"   # job_id from the response

3. Poll until the job is terminal

import time
import httpx

BASE = "https://api.vairetail.com"
headers = {"Authorization": f"Bearer {token}"}

while True:
    status = httpx.get(f"{BASE}/v1/ingest/file/{job_id}", headers=headers).json()
    print("state:", status["state"], "processed:", status["processed"], "/", status["total"])
    if status["state"] in ("succeeded", "partial", "failed"):
        break
    time.sleep(2)

Or poll by hand with curl every couple of seconds:

curl -s https://api.vairetail.com/v1/ingest/file/$JOB_ID \
  -H "Authorization: Bearer $TOKEN"

4. Confirm the result

{
  "job_id": "6f1b1a2e-8c3d-4e9a-9d1a-2b7c9e6f1234",
  "state": "succeeded",
  "processed": 4821,
  "total": 4821,
  "skipped": 0,
  "accepted_product_count": 4821,
  "errors": [],
  "error_report_uri": null
}

state: "succeeded" with skipped: 0 means every row was accepted. A partial state with skipped > 0 isn't a failure — inspect errors (or error_report_uri for the full list), fix the source rows, and re-upload; the upsert-by-product_id semantics make re-running safe.

Column mapping & templates

This section covers the mapping engine behind Upload a file — the auto-detect logic, override precedence, and per-field coercion rules that decide what detected_mapping looks like on every upload. It's a different (smaller) schema than the full products table used by Push ingest and CSV format: file ingest doesn't require your columns to match canonical names at all — anything it doesn't recognize is passed through unchanged rather than rejected.

Canonical target schema

Target fieldRequiredNotes
product_idyesThe only required field. Upload is rejected with 400 if it can't be resolved by explicit mapping, template, or auto-detection. An empty product_id value on a given row causes that row to be skipped and recorded in the job's errors (see Check ingest job status).
product_nameno
descriptionno
pricenoCoerced to a number where possible — see Price coercion.
currencyno
image_urlno
additional_image_urlsno`
product_urlno
categoryno
brandno

Any source column not consumed by the resolved mapping still makes it into the transformed row — it's passed through under its own normalized header (lowercased, punctuation stripped, internal whitespace collapsed to single spaces), so "Fabric %" becomes the key fabric. Nothing in your file is silently dropped.

Auto-detect synonyms

When you don't pass an explicit mapping or template_id, the gateway matches your file's headers against a synonym table in two passes: an exact pass (normalized header text equals a synonym) runs across every target field first, then a prefix pass (normalized header starts with a synonym plus at most one trailing word — "Price (USD)" normalizes to "price usd" and matches price) picks up whatever's left. Running exact matching to completion before any prefix matching starts means a field's exact synonym is never stolen by a looser prefix match on another field — e.g. "Product Type" is an exact synonym of category, and although it would also prefix-match product_name's "product" synonym, category claims it in the exact pass before that prefix match ever gets a chance.

Target fieldSynonyms (normalized)
product_idsku, id, handle, product id, item number, style number, upc, gtin
product_nametitle, name, product, product name, item name
descriptiondescription, desc, details, body, body html
priceprice, cost, msrp, retail price, list price, amount
currencycurrency, currency code
image_urlimage, image url, photo, img, main image, image src, featured image
additional_image_urlsimages, additional images, gallery
product_urlurl, product url, link, permalink
categorycategory, type, product type, department, collection
brandbrand, vendor, manufacturer, make

Mapping precedence

Override precedence is explicit mapping > template_id > auto-detect, merged field-by-field — not a whole-mapping replacement. For example, given headers ["SKU", "Title"], an explicit mapping: {"product_name": "SKU"}, and a template mapping {"product_name": "Title"}:

  • product_id comes from auto-detect ("SKU" — nothing else set it).
  • product_name comes from the explicit mapping ("SKU"), even though the template also set it — explicit always wins per field.

Ghost columns are a 400, not a silent no-op

If an explicit mapping or a saved template names a source column that isn't in the file's actual headers, the whole upload is rejected with 400 and the response names every missing column, e.g. mapped column(s) not found in headers: ghost_col. Re-check header spelling (including case and punctuation) if you hit this after editing a template.

Price coercion

price values are coerced to a number: a leading $ and thousands-separator commas are stripped, then the remainder is parsed as a float ("$1,299.00"1299.0). If the cleaned value doesn't parse as a number, the original string is kept as-is rather than the row being dropped or flagged as an error — a bad price never causes a row to be skipped. Only an empty product_id does that (recorded in the job's errors, see Check ingest job status).

Additional image URLs

additional_image_urls maps from a single source column whose value is |-delimited — "a.jpg|b.jpg|c.jpg" becomes ["a.jpg", "b.jpg", "c.jpg"]. The delimiter is fixed at | and isn't configurable via the API today.

If your source spreads gallery images across separate columns instead (image2, image3, … imageN) rather than one delimited column, those aren't auto-merged — none of them match the additional_image_urls synonyms above, so each passes through individually under its own normalized header rather than collapsing into additional_image_urls. Concatenate them into one |-delimited column before uploading (or map one of them explicitly) if you want a single additional_image_urls array.

Mapping templates

Save a mapping you like as a reusable, agency-scoped template with POST /v1/ingest/mappings, then apply it on any later upload with template_id instead of resending the JSON blob — see Save a mapping template and List mapping templates for full request/response shapes. Template names must be unique within your agency (409 on a duplicate); templates are agency-scoped, not merchant-scoped, so the same template applies across every merchant your agency ingests for. The dashboard wizard below is the easiest way to build and save one interactively.

Ingest from the dashboard

The dashboard wraps File ingest API in a three-step wizard — upload → review mapping → confirm & track — for teams who'd rather not script it. It's not a separate ingest path: uploads go through token-gated /internal/ingest/* routes that call the exact same mapping/job machinery as POST /v1/ingest/file, so they're metered identically to the API — there's no quota advantage to using the UI.

1. Upload

Open Ingest in the dashboard sidebar (/dashboard/ingest), pick one of your organization's authorized merchants from the dropdown (authorize one on the Merchants page first if the list is empty), choose a .csv or .xlsx file, and check at least one of Natural-language search, Color search, or Image search — these map directly to the API's outcomes field (see Upload a file) and control which search capabilities your catalog gets indexed for once the job completes. Upload & preview stays disabled until a merchant, a file, and at least one outcome are all set.

4 MB dashboard cap

The dashboard enforces a client-side 4 MB limit and rejects larger files before they're sent — the underlying gateway endpoint's cap is 20 MiB. For larger catalogs, call POST /v1/ingest/file directly.

Clicking Upload & preview creates the ingest job immediately, using whatever mapping the gateway auto-detects from your file's headers, and publishes a push-run for your chosen outcomes once the job finishes accepting at least one product (see Check ingest job status's run_id).

2. Review the detected mapping

The map step shows every canonical field next to a <select> pre-filled with the auto-detected source column, a preview of the first 20 transformed rows in canonical shape, and quick stats (e.g. "92% of sampled rows have a price"). You can:

  • Change any field's source column from the dropdown of your file's raw headers.
  • Load a saved template from the template selector — it merges the template's mapping into your current selections.
  • Save the current selections as a new template by naming it and clicking "Save as template" (agency-scoped; a duplicate name returns a 409, surfaced inline).

Phase 3 limitation: editing here doesn't retarget the existing job

The job was already created — with the auto-detected mapping — the moment you clicked Upload & preview in step 1. Editing the selects on this screen only changes what gets saved as a template; it does not retroactively change how that job's rows get transformed. To actually apply an edited mapping to the ingest itself, click "Re-upload with this mapping" (re-sends the same file with your edits as an explicit mapping override, creating a new job), or save it as a template and select it before uploading next time.

3. Confirm & track progress

Confirm & track progress is disabled until product_id is mapped. Once confirmed, the wizard polls the job's status every 3 seconds until it reaches a terminal state, showing:

  • A progress bar (processed / total rows).
  • A state pill (queued, running, succeeded, partial, failed).
  • On completion: an accepted/skipped/total summary and, if any rows were skipped, the error_report_uri and a table of the first 100 row errors ({ row, reason }) — same data as Check ingest job status.

If status checks fail repeatedly (five in a row) — or the job disappears entirely (404) — polling stops and shows a Retry button. That only pauses the live view; the ingest job itself keeps running server-side regardless. Once the job reaches a terminal state, "Start a new upload" resets the wizard to step 1.

Column Mapper

The Column Mapper is an interactive tool that maps your source catalog columns to the canonical products schema. It runs entirely in your browser — nothing is uploaded. Once you've exported a mapping.json, use one of the recipes below to transform your raw CSV into a canonical {"products": [...]} payload.

Apply a mapping (Node.js)

This snippet takes a mapping.json file (downloaded from the Column Mapper) and uses it to convert a raw source CSV into a canonical {"products": [...]} payload ready for push ingest.

Install dependencies

npm install csv-parse

Script

import fs from 'node:fs';
import { parse } from 'csv-parse/sync';

// 1. Load the mapping file produced by the Column Mapper.
const mappingConfig = JSON.parse(fs.readFileSync('mapping.json', 'utf8'));
const { mapping } = mappingConfig;

// 2. Read the raw CSV.
const rawCsv = fs.readFileSync('raw-products.csv', 'utf8');
const rows = parse(rawCsv, { columns: true, skip_empty_lines: true });

// 3. Transform each row into the canonical shape.
const canonical = rows.map((row) => {
  const out = {};
  for (const [canonicalField, spec] of Object.entries(mapping)) {
    if (typeof spec === 'string') {
      out[canonicalField] = row[spec];
    } else {
      const value = row[spec.source];
      out[canonicalField] = applyTransform(value, spec.transform);
    }
  }
  return out;
});

// 4. Wrap in the push-ingest request body and write as JSON.
const payload = { products: canonical };
fs.writeFileSync('products.json', JSON.stringify(payload));

console.log(`Wrote ${canonical.length} canonical products`);

function applyTransform(value, transform) {
  if (value == null || value === '') return null;
  switch (transform) {
    case 'parse_float': {
      const n = parseFloat(String(value).replace(/[^0-9.-]/g, ''));
      return Number.isFinite(n) ? n : null;
    }
    case 'trim':
      return String(value).trim();
    case 'uppercase':
      return String(value).toUpperCase();
    case 'lowercase':
      return String(value).toLowerCase();
    case 'strip_html':
      return String(value).replace(/<[^>]*>/g, '');
    default:
      return value;
  }
}

Output

products.json will contain a single JSON object, {"products": [...]}, ready to be POSTed to the push-ingest endpoint.

Rows that fail validation are rejected and do not count toward accepted_product_countdry_run is not yet supported (planned).

Apply a mapping (Python)

This snippet takes a mapping.json file (downloaded from the Column Mapper) and uses it to convert a raw source CSV into a canonical {"products": [...]} payload, ready for push ingest.

Standard library only — no dependencies.

Script

import csv
import json
import re
from pathlib import Path


def apply_transform(value, transform):
    if value is None or value == "":
        return None
    if transform == "parse_float":
        cleaned = re.sub(r"[^0-9.\-]", "", str(value))
        try:
            return float(cleaned)
        except ValueError:
            return None
    if transform == "trim":
        return str(value).strip()
    if transform == "uppercase":
        return str(value).upper()
    if transform == "lowercase":
        return str(value).lower()
    if transform == "strip_html":
        return re.sub(r"<[^>]*>", "", str(value))
    return value


def transform_row(row, mapping):
    out = {}
    for canonical_field, spec in mapping.items():
        if isinstance(spec, str):
            out[canonical_field] = row.get(spec)
        else:
            value = row.get(spec["source"])
            out[canonical_field] = apply_transform(value, spec.get("transform"))
    return out


def main():
    mapping_config = json.loads(Path("mapping.json").read_text())
    mapping = mapping_config["mapping"]

    with open("raw-products.csv", newline="", encoding="utf-8") as src:
        reader = csv.DictReader(src)
        canonical = [transform_row(row, mapping) for row in reader]

    payload = {"products": canonical}
    Path("products.json").write_text(json.dumps(payload))

    print(f"Wrote {len(canonical)} canonical products")


if __name__ == "__main__":
    main()

Output

products.json will contain a single JSON object, {"products": [...]}, ready to be POSTed to the push-ingest endpoint.

Rows that fail validation are rejected and do not count toward accepted_product_countdry_run is not yet supported (planned).

Search API

Query response

POST /api/v0/product/search/queryresponse

Generates an LLM-driven natural-language recommendation for a shopper's query — the conversational text shown above product results. This endpoint does not return products; it returns a single recommendation string.

The theme extension calls this endpoint first, then calls /api/v0/product/search/text or /api/v0/product/search/textandcolor to fetch the actual matching products.

Results are cached per (user_profile_id, normalized_query[, color]) so repeat queries return the same recommendation instantly.

Auth modes supported

Today: Shopify-embedded only (CORS-enforced, X-Merchant-ID header). API key support coming soon.

Headers

HeaderRequiredValue
X-Merchant-IDyesYour merchant ID (integer)
Content-Typeyesapplication/json

Request body

FieldTypeRequiredDescription
query_strstringyesThe shopper's search query in plain language.
preferencesbooleannoIf true, merge the user's stored preferences into the prompt.
userPrefsNotPresentbooleannoHint that the user profile has no preferences yet.
current_hourintegernoLocal hour (0–23) — used to pick a time-of-day greeting.
user_namestringnoShopper first name.
color_inputstring or objectnoOptional color (named or hex). When set, the recommendation is color-aware.
user_profile_idintegernoAttach this query to a known user profile.
conversational_stylestringnoStyle hint for the LLM (friendly, concise, technical, …).
personal_fashion_stylestringnoFashion style hint.
favorite_colorsstring[]noSoft color preferences.
fashion_material_preferstring[]noPreferred materials.
body_typestringno
fashion_goalsstringno
age_groupstringno
session_idstringnoOpaque session identifier for analytics.
client_ipstringnoClient IP (otherwise inferred server-side).
locationstringnoCity/region string for locale hints.

Response — 200 OK

{
  "results": {
    "recommendation": "Here are a few breezy linen shirts under $80 that should feel great all day."
  }
}

Example

curl -X POST \
  https://developer-search-devID.region.run.app/api/v0/product/search/queryresponse \
  -H "X-Merchant-ID: 12345" \
  -H "Content-Type: application/json" \
  -d '{
    "query_str": "comfortable linen shirt under $80",
    "user_name": "Alex",
    "current_hour": 14
  }'
const res = await fetch(
  'https://developer-search-devID.region.run.app/api/v0/product/search/queryresponse',
  {
    method: 'POST',
    headers: {
      'X-Merchant-ID': '12345',
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      query_str: 'comfortable linen shirt under $80',
      user_name: 'Alex',
    }),
  },
);
const data = await res.json();
console.log(data.results.recommendation);
import httpx

r = httpx.post(
    "https://developer-search-devID.region.run.app/api/v0/product/search/queryresponse",
    headers={"X-Merchant-ID": "12345", "Content-Type": "application/json"},
    json={"query_str": "comfortable linen shirt under $80", "user_name": "Alex"},
)
print(r.json()["results"]["recommendation"])

Errors

StatusResponse
400{ "detail": "Missing or invalid X-Merchant-ID header" }
422Validation error — usually a missing query_str.
500Internal error reaching the LLM. Safe to retry with backoff.
POST /api/v0/product/search/text

The workhorse search endpoint. Takes a natural-language query, runs a semantic-similarity search over the merchant's catalog, filters by price, and returns ranked products plus a conversational recommendation.

Supports natural-language price constraints in the query string — "linen shirt under $80", "dress between $50 and $120", "sneakers around $200" — which are parsed out and applied as numeric filters.

Results are cached for 7 days, keyed on the normalized query plus any relevant preference fields.

Auth modes supported

Today: Shopify-embedded only (CORS-enforced, X-Merchant-ID header). API key support coming soon.

Headers

HeaderRequiredValue
X-Merchant-IDyesYour merchant ID (integer)
Content-Typeyesapplication/json

Request body

FieldTypeRequiredDefaultDescription
query_strstringyesThe shopper's search query. May contain price phrases like "under $80".
limitintegerno10Maximum number of results.
preferencesbooleannofalseIf true, blend the user's stored preferences into ranking.
user_profile_idintegernoAttach results to a user profile for analytics.
user_namestringnoShopper first name (used in the recommendation).
conversational_stylestringnoStyle hint for the recommendation LLM.
personal_fashion_stylestringnoFashion style hint.
favorite_colorsstring[]noSoft color preferences — nudge ranking toward matching colors.
fashion_material_preferstring[]noPreferred materials.
body_typestringno
fashion_goalsstringno
age_groupstringno
genderstring[]noFilter: any of mens, womens, unisex, kids.
min_pricenumbernoExplicit minimum price filter (takes precedence over in-query price phrases).
max_pricenumbernoExplicit maximum price filter.
session_id, client_ip, locationstringnoAnalytics fields.

Response — 200 OK

{
  "results": {
    "data": [
      {
        "product_id": "SKU-1001",
        "product_name": "Relaxed-Fit Linen Button-Up",
        "handle": "relaxed-fit-linen-button-up",
        "brand": "Cove & Coast",
        "category": "tops",
        "price": "69.00",
        "price_numeric": 69.0,
        "image1": "https://cdn.example.com/images/relaxed-linen.jpg",
        "distance": 0.213
      }
    ]
  },
  "recommendation": "Here are a few breezy linen shirts under $80 that should feel great all day.",
  "success": true
}

distance is the similarity score between the query and the product's embedding — lower is better. Each product also carries every canonical products field that was populated for the row.

Example

curl -X POST \
  https://developer-search-devID.region.run.app/api/v0/product/search/text \
  -H "X-Merchant-ID: 12345" \
  -H "Content-Type: application/json" \
  -d '{
    "query_str": "comfortable linen shirt under $80",
    "limit": 10,
    "gender": ["mens", "unisex"]
  }'
const res = await fetch(
  'https://developer-search-devID.region.run.app/api/v0/product/search/text',
  {
    method: 'POST',
    headers: {
      'X-Merchant-ID': '12345',
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      query_str: 'comfortable linen shirt under $80',
      limit: 10,
    }),
  },
);
const data = await res.json();
console.log(`${data.results.data.length} products`);
import httpx

r = httpx.post(
    "https://developer-search-devID.region.run.app/api/v0/product/search/text",
    headers={"X-Merchant-ID": "12345", "Content-Type": "application/json"},
    json={"query_str": "comfortable linen shirt under $80", "limit": 10},
)
print(len(r.json()["results"]["data"]))

Errors

StatusResponse
400{ "detail": "Missing or invalid X-Merchant-ID header" }
422Validation error — usually a missing query_str.
500{ "detail": "An error occurred during the search." }
POST /api/v0/product/search/textandcolor

Runs a combined text and color search. Either query_str or color can be omitted individually, but at least one must be provided. The endpoint determines its operation_type automatically:

  • Both text and color → textandcolor
  • Color only → color
  • Text only → text

Natural-language price constraints in query_str are parsed and applied, just like text search. Results are cached for 7 days on a normalized query|color cache key.

Auth modes supported

Today: Shopify-embedded only (CORS-enforced, X-Merchant-ID header). API key support coming soon.

Headers

HeaderRequiredValue
X-Merchant-IDyesYour merchant ID (integer)
Content-Typeyesapplication/json

Request body

FieldTypeRequiredDefaultDescription
query_strstringone of query_str / colorThe text query.
colorstring or objectone of query_str / colorNamed color ("navy"), hex ("#0a1f44"), or { "rgb": [r, g, b] }.
limitintegerno10Maximum results.
min_pricenumbernoExplicit minimum price.
max_pricenumbernoExplicit maximum price.
user_profile_idintegernoAttach for analytics and personalization.
user_namestringnoUsed in the recommendation text.
conversational_stylestringnoStyle hint for the recommendation LLM.
session_id, client_ip, locationstringnoAnalytics fields.

400 if both are empty

If both query_str and color are missing or empty, the endpoint returns 400: At least one of 'text' or 'color' must be provided.

Response — 200 OK

{
  "results": {
    "data": [
      {
        "product_id": "SKU-2003",
        "product_name": "Navy Linen Blazer",
        "price": "189.00",
        "price_numeric": 189.0,
        "image1": "https://cdn.example.com/images/navy-blazer.jpg",
        "color": "navy blue",
        "distance": 0.187
      }
    ]
  },
  "recommendation": "These navy pieces should layer beautifully for a smart-casual look.",
  "success": true
}

Example

curl -X POST \
  https://developer-search-devID.region.run.app/api/v0/product/search/textandcolor \
  -H "X-Merchant-ID: 12345" \
  -H "Content-Type: application/json" \
  -d '{
    "query_str": "blazer for a wedding",
    "color": "navy",
    "limit": 8
  }'
const res = await fetch(
  'https://developer-search-devID.region.run.app/api/v0/product/search/textandcolor',
  {
    method: 'POST',
    headers: {
      'X-Merchant-ID': '12345',
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      query_str: 'blazer for a wedding',
      color: 'navy',
      limit: 8,
    }),
  },
);
import httpx

r = httpx.post(
    "https://developer-search-devID.region.run.app/api/v0/product/search/textandcolor",
    headers={"X-Merchant-ID": "12345", "Content-Type": "application/json"},
    json={"query_str": "blazer for a wedding", "color": "navy", "limit": 8},
)

Errors

StatusResponse
400{ "detail": "Missing or invalid X-Merchant-ID header" }
400{ "detail": "At least one of 'text' or 'color' must be provided." }
422Validation error.
500Internal search error. Safe to retry with backoff.
POST /api/v1/product/search/image

Upload an image and get the closest visually matching products from the catalog. Internally, the image is embedded, its dominant colors extracted, and a hybrid vector + color similarity search is run.

Unlike the text endpoints, this is a multipart/form-data upload.

Auth modes supported

Today: Shopify-embedded only (CORS-enforced, X-Merchant-ID header). API key support coming soon.

No text + image fusion

Image search runs on the image alone. There is no single-call mode that blends a reference image with a text query or color — combine them with separate requests. See Demo 2.

Headers

HeaderRequiredValue
X-Merchant-IDyesYour merchant ID (integer)
Content-Typeyesmultipart/form-data (set by your client)

Form fields

FieldTypeRequiredDefaultDescription
filefileyesThe reference image (JPEG/PNG/WebP, up to 10 MB).
limitintegerno10Maximum number of results.
crop_valuesstringnoJSON string of {x, y, width, height} pixels to crop before embedding.
user_profile_idintegernoAttach results to a user profile.
user_namestringnoShopper first name, used in recommendation text.
conversational_stylestringnoStyle hint for the recommendation LLM.

Response — 200 OK

{
  "results": [
    {
      "product_id": "SKU-4471",
      "product_name": "Olive Utility Jacket",
      "price": "149.00",
      "price_numeric": 149.0,
      "image1": "https://cdn.example.com/images/olive-utility.jpg",
      "distance": 0.142
    }
  ],
  "recommendation": "Here are some olive-toned outerwear options with a similar silhouette.",
  "success": true
}

Note the shape difference: image search returns results as a flat array, not wrapped in results.data like the text endpoints. This is a legacy difference that will be reconciled in a future /api/v2/ namespace.

Example

curl -X POST \
  https://developer-search-devID.region.run.app/api/v1/product/search/image \
  -H "X-Merchant-ID: 12345" \
  -F "file=@reference.jpg" \
  -F "limit=8"
const form = new FormData();
form.append('file', fileInput.files[0]);
form.append('limit', '8');

const res = await fetch(
  'https://developer-search-devID.region.run.app/api/v1/product/search/image',
  {
    method: 'POST',
    headers: { 'X-Merchant-ID': '12345' },
    body: form,
  },
);
const data = await res.json();
console.log(`${data.results.length} visually similar products`);
import httpx

with open("reference.jpg", "rb") as f:
    r = httpx.post(
        "https://developer-search-devID.region.run.app/api/v1/product/search/image",
        headers={"X-Merchant-ID": "12345"},
        files={"file": ("reference.jpg", f, "image/jpeg")},
        data={"limit": 8},
    )
print(len(r.json()["results"]))

Errors

StatusResponse
400{ "detail": "Missing or invalid X-Merchant-ID header" }
422Validation error — usually a missing file.
500Internal error during image embedding. Safe to retry.

Text search cache check

POST /api/v0/product/search/text/cache-check

A lightweight companion to /api/v0/product/search/text that only checks whether a cached result exists for the given query — it does not run the search. Use this when you want to show a "loading…" state only if a real search is about to run.

Takes the same body as the text search endpoint so the cache key matches exactly.

Auth modes supported

Today: Shopify-embedded only. API key support coming soon.

Headers

HeaderRequiredValue
X-Merchant-IDyesYour merchant ID (integer)
Content-Typeyesapplication/json

Request body

Identical to /api/v0/product/search/text. The body is used only to compute the cache key — preferences, favorite_colors, price filters, and the query itself all contribute.

Response — 200 OK

Cache hit

{
  "cached": true,
  "result_count": 10,
  "cache_key": "comfortable linen shirt",
  "hits": 47
}

Cache miss

{
  "cached": false,
  "result_count": 0,
  "cache_key": "comfortable linen shirt"
}
FieldTypeDescription
cachedbooleanWhether a cached result exists.
result_countintegerNumber of products in the cached result set (0 on a miss).
cache_keystringThe cache key the server computed from your request body.
hitsintegerNumber of times the cached entry has been served (omitted on a miss).

Example

curl -X POST \
  https://developer-search-devID.region.run.app/api/v0/product/search/text/cache-check \
  -H "X-Merchant-ID: 12345" \
  -H "Content-Type: application/json" \
  -d '{"query_str": "comfortable linen shirt"}'
const res = await fetch(
  'https://developer-search-devID.region.run.app/api/v0/product/search/text/cache-check',
  {
    method: 'POST',
    headers: {
      'X-Merchant-ID': '12345',
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ query_str: 'comfortable linen shirt' }),
  },
);
const { cached, result_count } = await res.json();
if (!cached) showLoadingState();
import httpx

r = httpx.post(
    "https://developer-search-devID.region.run.app/api/v0/product/search/text/cache-check",
    headers={"X-Merchant-ID": "12345", "Content-Type": "application/json"},
    json={"query_str": "comfortable linen shirt"},
)
print(r.json())

Errors

StatusResponse
400{ "detail": "Missing or invalid X-Merchant-ID header" }
422Validation error — usually a missing query_str.

Text and color search cache check

POST /api/v0/product/search/textandcolor/cache-check

The cache-check companion to /api/v0/product/search/textandcolor. Returns whether a cached result exists for a given (query_str, color) combination without running the search.

Auth modes supported

Today: Shopify-embedded only. API key support coming soon.

Headers

HeaderRequiredValue
X-Merchant-IDyesYour merchant ID (integer)
Content-Typeyesapplication/json

Request body

Identical to /api/v0/product/search/textandcolor. At least one of query_str and color must be provided.

Response — 200 OK

Cache hit

{
  "cached": true,
  "result_count": 8,
  "cache_key": "blazer for a wedding|[0,0,128]",
  "operation_type": "textandcolor",
  "hits": 12
}

Cache miss

{
  "cached": false,
  "result_count": 0,
  "cache_key": "blazer for a wedding|[0,0,128]",
  "operation_type": "textandcolor"
}
FieldTypeDescription
cachedbooleanWhether a cached result exists.
result_countintegerNumber of products in the cached result set.
cache_keystringThe cache key the server computed (`normalized_query
operation_typestringtext, color, or textandcolor.
hitsintegerCached-entry hit count (omitted on a miss).

Example

curl -X POST \
  https://developer-search-devID.region.run.app/api/v0/product/search/textandcolor/cache-check \
  -H "X-Merchant-ID: 12345" \
  -H "Content-Type: application/json" \
  -d '{"query_str": "blazer for a wedding", "color": "navy"}'
const res = await fetch(
  'https://developer-search-devID.region.run.app/api/v0/product/search/textandcolor/cache-check',
  {
    method: 'POST',
    headers: {
      'X-Merchant-ID': '12345',
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ query_str: 'blazer for a wedding', color: 'navy' }),
  },
);
const data = await res.json();
import httpx

r = httpx.post(
    "https://developer-search-devID.region.run.app/api/v0/product/search/textandcolor/cache-check",
    headers={"X-Merchant-ID": "12345", "Content-Type": "application/json"},
    json={"query_str": "blazer for a wedding", "color": "navy"},
)
print(r.json())

Errors

StatusResponse
400{ "detail": "Missing or invalid X-Merchant-ID header" }
400{ "detail": "At least one of 'text' or 'color' must be provided." }
422Validation error.

Max product price

GET /api/v0/product/max-price

Returns the highest price_numeric value across the merchant's catalog. Used to size the upper bound of price range filters in the storefront UI.

Cached for 1 hour server-side.

Auth modes supported

Today: Shopify-embedded only. API key support coming soon.

Headers

HeaderRequiredValue
X-Merchant-IDyesYour merchant ID (integer)

Response — 200 OK

{
  "max_price": 799.0,
  "product_count": 4821
}
FieldTypeDescription
max_pricenumberHighest price_numeric across status = 'active' rows. 0 if empty.
product_countintegerTotal number of active products in the catalog.

Example

curl https://developer-search-devID.region.run.app/api/v0/product/max-price \
  -H "X-Merchant-ID: 12345"
const res = await fetch(
  'https://developer-search-devID.region.run.app/api/v0/product/max-price',
  { headers: { 'X-Merchant-ID': '12345' } },
);
const { max_price } = await res.json();
import httpx

r = httpx.get(
    "https://developer-search-devID.region.run.app/api/v0/product/max-price",
    headers={"X-Merchant-ID": "12345"},
)
print(r.json()["max_price"])

Errors

StatusResponse
400{ "detail": "Missing or invalid X-Merchant-ID header" }
500Internal database error. Safe to retry with backoff.

User API

Create user profile

POST /api/v1/user/profile

Creates a new user profile for this merchant, or returns the existing row if one already exists with the same email. Profiles are unique on (merchant_id, email_id).

Auth modes supported

Today: Shopify-embedded only. API key support coming soon.

Headers

HeaderRequiredValue
X-Merchant-IDyesYour merchant ID (integer)
Content-Typeyesapplication/json

Request body

FieldTypeRequiredDescription
user_namestringyesDisplay name.
first_namestringyesFirst name.
last_namestringyesLast name.
email_idstringyesEmail address (unique per merchant).
profile_namestringnoOptional profile label.

Response — 200 OK

{
  "id": 812,
  "user_name": "alex",
  "first_name": "Alex",
  "last_name": "Martinez",
  "email_id": "alex@example.com",
  "profile_name": null
}

Example

curl -X POST \
  https://developer-search-devID.region.run.app/api/v1/user/profile \
  -H "X-Merchant-ID: 12345" \
  -H "Content-Type: application/json" \
  -d '{
    "user_name": "alex",
    "first_name": "Alex",
    "last_name": "Martinez",
    "email_id": "alex@example.com"
  }'
const res = await fetch(
  'https://developer-search-devID.region.run.app/api/v1/user/profile',
  {
    method: 'POST',
    headers: {
      'X-Merchant-ID': '12345',
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      user_name: 'alex',
      first_name: 'Alex',
      last_name: 'Martinez',
      email_id: 'alex@example.com',
    }),
  },
);
const profile = await res.json();
import httpx

r = httpx.post(
    "https://developer-search-devID.region.run.app/api/v1/user/profile",
    headers={"X-Merchant-ID": "12345", "Content-Type": "application/json"},
    json={
        "user_name": "alex",
        "first_name": "Alex",
        "last_name": "Martinez",
        "email_id": "alex@example.com",
    },
)
print(r.json())

Errors

StatusResponse
400{ "detail": "Missing or invalid X-Merchant-ID header" }
422Validation error — missing required fields.
500Database error. Safe to retry.

Get profile by email

POST /api/v1/user/profile/email

Returns the user profile matching the supplied email within the authenticated merchant. This is a POST (not a GET) so the email can be passed in a JSON body rather than a query string — keeps shopper PII out of URLs and logs.

Returns 404 if no profile with that email exists.

Auth modes supported

Today: Shopify-embedded only. API key support coming soon.

Headers

HeaderRequiredValue
X-Merchant-IDyesYour merchant ID (integer)
Content-Typeyesapplication/json

Request body

FieldTypeRequiredDescription
email_idstringyesEmail address (valid format).

Response — 200 OK

{
  "id": 812,
  "user_name": "alex",
  "first_name": "Alex",
  "last_name": "Martinez",
  "email_id": "alex@example.com",
  "profile_name": null
}

Example

curl -X POST \
  https://developer-search-devID.region.run.app/api/v1/user/profile/email \
  -H "X-Merchant-ID: 12345" \
  -H "Content-Type: application/json" \
  -d '{"email_id": "alex@example.com"}'
const res = await fetch(
  'https://developer-search-devID.region.run.app/api/v1/user/profile/email',
  {
    method: 'POST',
    headers: {
      'X-Merchant-ID': '12345',
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ email_id: 'alex@example.com' }),
  },
);
if (res.status === 404) {
  // Profile doesn't exist yet — create one.
}
import httpx

r = httpx.post(
    "https://developer-search-devID.region.run.app/api/v1/user/profile/email",
    headers={"X-Merchant-ID": "12345", "Content-Type": "application/json"},
    json={"email_id": "alex@example.com"},
)
if r.status_code == 404:
    ...  # Profile not found

Errors

StatusResponse
400{ "detail": "Missing or invalid X-Merchant-ID header" }
404{ "detail": "User profile not found" }
422Validation error — invalid email format.

Create preferences

POST /api/v1/user/preferences

Creates a user_preferences row for a user profile. There can be only one preferences row per user_profile_id.

If a row already exists, use POST /api/v1/user/preferences/update instead.

Auth modes supported

Today: Shopify-embedded only. API key support coming soon.

Headers

HeaderRequiredValue
X-Merchant-IDyesYour merchant ID (integer)
Content-Typeyesapplication/json

Request body

FieldTypeRequiredDescription
user_profile_idintegeryesForeign key to user_profile.id.
conversational_stylestringnoFree text (friendly, concise, technical, …).
personal_fashion_stylestringnoFree text (minimalist, streetwear, classic, …).
favorite_colorsstring[]noPreferred colors.
fashion_material_preferstring[]noPreferred materials.
body_typestringno
fashion_goalsstringno
age_groupstringno

Response — 200 OK

Returns the stored preferences row including server-managed fields (id, is_modified, product_ids).

{
  "id": 43,
  "user_profile_id": 812,
  "conversational_style": "friendly",
  "personal_fashion_style": "classic",
  "favorite_colors": ["navy", "olive"],
  "fashion_material_prefer": ["linen", "cotton"],
  "body_type": null,
  "fashion_goals": "beach vacation outfits",
  "age_group": null,
  "is_modified": false,
  "product_ids": null
}

Example

curl -X POST \
  https://developer-search-devID.region.run.app/api/v1/user/preferences \
  -H "X-Merchant-ID: 12345" \
  -H "Content-Type: application/json" \
  -d '{
    "user_profile_id": 812,
    "conversational_style": "friendly",
    "personal_fashion_style": "classic",
    "favorite_colors": ["navy", "olive"],
    "fashion_material_prefer": ["linen", "cotton"],
    "fashion_goals": "beach vacation outfits"
  }'
await fetch(
  'https://developer-search-devID.region.run.app/api/v1/user/preferences',
  {
    method: 'POST',
    headers: {
      'X-Merchant-ID': '12345',
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      user_profile_id: 812,
      favorite_colors: ['navy', 'olive'],
      fashion_material_prefer: ['linen', 'cotton'],
    }),
  },
);
import httpx

httpx.post(
    "https://developer-search-devID.region.run.app/api/v1/user/preferences",
    headers={"X-Merchant-ID": "12345", "Content-Type": "application/json"},
    json={
        "user_profile_id": 812,
        "favorite_colors": ["navy", "olive"],
        "fashion_material_prefer": ["linen", "cotton"],
    },
)

Errors

StatusResponse
400{ "detail": "Missing or invalid X-Merchant-ID header" }
422Validation error — missing user_profile_id.
500Database error, likely a unique-constraint violation (preferences already exist for this profile — use update instead).

Update preferences

POST /api/v1/user/preferences/update

Updates an existing user_preferences row. Fields omitted from the request are left unchanged.

Sets is_modified = true to mark that the user has explicitly edited their preferences — useful for distinguishing user-set from defaulted values.

Auth modes supported

Today: Shopify-embedded only. API key support coming soon.

Headers

HeaderRequiredValue
X-Merchant-IDyesYour merchant ID (integer)
Content-Typeyesapplication/json

Request body

Same shape as POST /api/v1/user/preferences. user_profile_id is required — everything else is optional.

Response — 200 OK

Returns the updated row.

{
  "id": 43,
  "user_profile_id": 812,
  "conversational_style": "concise",
  "favorite_colors": ["forest green", "charcoal"],
  "is_modified": true,
  ...
}

Example

curl -X POST \
  https://developer-search-devID.region.run.app/api/v1/user/preferences/update \
  -H "X-Merchant-ID: 12345" \
  -H "Content-Type: application/json" \
  -d '{
    "user_profile_id": 812,
    "conversational_style": "concise",
    "favorite_colors": ["forest green", "charcoal"]
  }'
await fetch(
  'https://developer-search-devID.region.run.app/api/v1/user/preferences/update',
  {
    method: 'POST',
    headers: {
      'X-Merchant-ID': '12345',
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      user_profile_id: 812,
      conversational_style: 'concise',
    }),
  },
);
import httpx

httpx.post(
    "https://developer-search-devID.region.run.app/api/v1/user/preferences/update",
    headers={"X-Merchant-ID": "12345", "Content-Type": "application/json"},
    json={"user_profile_id": 812, "conversational_style": "concise"},
)

Errors

StatusResponse
400{ "detail": "Missing or invalid X-Merchant-ID header" }
404Preferences row not found for user_profile_id.
422Validation error.

Get preferences

GET /api/v1/user/preferences/{user_profile_id}

Returns the user_preferences row for the given user_profile_id. Returns 404 if the profile has no preferences row yet.

Cached for 5 minutes per user_profile_id.

Auth modes supported

Today: Shopify-embedded only. API key support coming soon.

Headers

HeaderRequiredValue
X-Merchant-IDyesYour merchant ID (integer)

Path parameters

ParameterTypeDescription
user_profile_idintegerThe user profile's primary key.

Response — 200 OK

{
  "id": 43,
  "user_profile_id": 812,
  "conversational_style": "friendly",
  "personal_fashion_style": "classic",
  "favorite_colors": ["navy", "olive"],
  "fashion_material_prefer": ["linen", "cotton"],
  "body_type": null,
  "fashion_goals": "beach vacation outfits",
  "age_group": null,
  "is_modified": true,
  "product_ids": null
}

Example

curl https://developer-search-devID.region.run.app/api/v1/user/preferences/812 \
  -H "X-Merchant-ID: 12345"
const res = await fetch(
  `https://developer-search-devID.region.run.app/api/v1/user/preferences/${userProfileId}`,
  { headers: { 'X-Merchant-ID': '12345' } },
);
if (res.status === 404) {
  // No preferences set yet — show the onboarding form.
}
import httpx

r = httpx.get(
    f"https://developer-search-devID.region.run.app/api/v1/user/preferences/{user_profile_id}",
    headers={"X-Merchant-ID": "12345"},
)
if r.status_code == 404:
    ...  # no preferences yet

Errors

StatusResponse
400{ "detail": "Missing or invalid X-Merchant-ID header" }
404{ "detail": "User preferences not found" }

Random data by preferences

POST /api/v1/user/preferences/randomdata

Returns a batch of products seeded from a user's stored preferences, plus a time-of-day appropriate greeting. Used to populate the cold-start state of the storefront chat widget (before the shopper has typed a query).

Auth modes supported

Today: Shopify-embedded only. API key support coming soon.

Headers

HeaderRequiredValue
X-Merchant-IDyesYour merchant ID (integer)
Content-Typeyesapplication/json

Request body

FieldTypeRequiredDescription
user_profile_idintegeryesThe user profile whose preferences should seed the results.
userPrefsNotPresentbooleanyesHint the server that the user has no stored preferences.
current_hourintegeryesLocal hour (0–23), used to pick a time-of-day greeting.
user_namestringyesShopper first name.
limitintegeryesMaximum number of products to return.
conversational_stylestringnoStyle hint.
personal_fashion_stylestringno
favorite_colorsstring[]no
fashion_material_preferstring[]no
body_typestringno
fashion_goalsstringno
age_groupstringno

Response — 200 OK

{
  "results": {
    "greeting": "Good afternoon, Alex",
    "data": [
      {
        "product_id": "SKU-2301",
        "product_name": "Oversized Boyfriend Tee",
        "price_numeric": 42.0,
        "image1": "https://cdn.example.com/images/boyfriend-tee.jpg"
      }
    ]
  }
}
FieldTypeDescription
results.greetingstringA time-of-day greeting. null if no greeting template matched.
results.dataobject[]Randomized preference-aware products.

Example

curl -X POST \
  https://developer-search-devID.region.run.app/api/v1/user/preferences/randomdata \
  -H "X-Merchant-ID: 12345" \
  -H "Content-Type: application/json" \
  -d '{
    "user_profile_id": 812,
    "userPrefsNotPresent": false,
    "current_hour": 14,
    "user_name": "Alex",
    "limit": 12
  }'
const res = await fetch(
  'https://developer-search-devID.region.run.app/api/v1/user/preferences/randomdata',
  {
    method: 'POST',
    headers: {
      'X-Merchant-ID': '12345',
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      user_profile_id: 812,
      userPrefsNotPresent: false,
      current_hour: new Date().getHours(),
      user_name: 'Alex',
      limit: 12,
    }),
  },
);
import httpx

httpx.post(
    "https://developer-search-devID.region.run.app/api/v1/user/preferences/randomdata",
    headers={"X-Merchant-ID": "12345", "Content-Type": "application/json"},
    json={
        "user_profile_id": 812,
        "userPrefsNotPresent": False,
        "current_hour": 14,
        "user_name": "Alex",
        "limit": 12,
    },
)

Errors

StatusResponse
400{ "detail": "Missing or invalid X-Merchant-ID header" }
422Validation error — missing required fields.

Pipeline API

Create pipeline status

POST /api/v1/pipeline/status

Creates a new pipeline_status row for this merchant. If a row already exists and current_step is the first step (shopify_etl_dev), the existing row is resetcompleted_steps and failed_steps are cleared and status returns to running. This is how reinstalls are handled.

The merchant_id in the request body must match the authenticated X-Merchant-ID header.

Auth modes supported

Today: Shopify-embedded only. API key support coming soon.

Headers

HeaderRequiredValue
X-Merchant-IDyesYour merchant ID (integer)
Content-Typeyesapplication/json

Request body

FieldTypeRequiredDescription
merchant_idintegeryesMust match X-Merchant-ID header.
current_stepstringnoInitial step name (default initialized). Use shopify_etl_dev for a fresh install.
shop_domainstringnoOptional source-platform domain (e.g. store.myshopify.com).
configobjectnoArbitrary configuration carried through the pipeline.

Response — 200 OK

Returns the full PipelineStatusResponse.

{
  "merchant_id": 12345,
  "shop_domain": "store.myshopify.com",
  "current_step": "shopify_etl_dev",
  "completed_steps": [],
  "failed_steps": [],
  "status": "running",
  "started_at": "2026-04-10T16:14:22.109Z",
  "completed_at": null,
  "last_updated": "2026-04-10T16:14:22.109Z",
  "error_message": null,
  "progress_percentage": 0.0,
  "estimated_completion": null
}

Example

curl -X POST \
  https://developer-search-devID.region.run.app/api/v1/pipeline/status \
  -H "X-Merchant-ID: 12345" \
  -H "Content-Type: application/json" \
  -d '{
    "merchant_id": 12345,
    "current_step": "shopify_etl_dev",
    "shop_domain": "store.myshopify.com"
  }'
await fetch(
  'https://developer-search-devID.region.run.app/api/v1/pipeline/status',
  {
    method: 'POST',
    headers: {
      'X-Merchant-ID': '12345',
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      merchant_id: 12345,
      current_step: 'shopify_etl_dev',
    }),
  },
);
import httpx

httpx.post(
    "https://developer-search-devID.region.run.app/api/v1/pipeline/status",
    headers={"X-Merchant-ID": "12345", "Content-Type": "application/json"},
    json={"merchant_id": 12345, "current_step": "shopify_etl_dev"},
)

Errors

StatusResponse
400{ "detail": "Missing or invalid X-Merchant-ID header" }
400{ "detail": "Merchant ID in header must match merchant_id in request body" }
500Database error. Safe to retry.

Get pipeline status

GET /api/v1/pipeline/status/{merchant_id}

Returns the current pipeline_status for this merchant — current step, completed steps, failure state, and a calculated progress_percentage.

The merchant_id in the path must match the authenticated X-Merchant-ID header. Cross-merchant access returns 403.

Auth modes supported

Today: Shopify-embedded only. API key support coming soon.

Headers

HeaderRequiredValue
X-Merchant-IDyesYour merchant ID (integer)

Path parameters

ParameterTypeDescription
merchant_idintegerThe merchant whose status to read. Must match header.

Response — 200 OK

{
  "merchant_id": 12345,
  "shop_domain": "store.myshopify.com",
  "current_step": "create_text_embeddings",
  "completed_steps": [
    "shopify_etl_dev",
    "enrichment_dev",
    "create_color_embeddings",
    "add_color_names"
  ],
  "failed_steps": [],
  "status": "running",
  "started_at": "2026-04-10T16:14:22.109Z",
  "completed_at": null,
  "last_updated": "2026-04-10T16:21:03.447Z",
  "error_message": null,
  "progress_percentage": 57.14,
  "estimated_completion": "2026-04-10T16:28:40.000Z"
}

Example

curl https://developer-search-devID.region.run.app/api/v1/pipeline/status/12345 \
  -H "X-Merchant-ID: 12345"
const res = await fetch(
  `https://developer-search-devID.region.run.app/api/v1/pipeline/status/${merchantId}`,
  { headers: { 'X-Merchant-ID': String(merchantId) } },
);
const status = await res.json();
console.log(`${status.progress_percentage.toFixed(0)}% complete`);
import httpx

r = httpx.get(
    f"https://developer-search-devID.region.run.app/api/v1/pipeline/status/{merchant_id}",
    headers={"X-Merchant-ID": str(merchant_id)},
)
status = r.json()

Errors

StatusResponse
400{ "detail": "Missing or invalid X-Merchant-ID header" }
403{ "detail": "Access denied to this merchant's pipeline status" }
404{ "detail": "Pipeline status not found for this merchant" }
500Database error. Safe to retry with backoff.

Update pipeline status

PUT /api/v1/pipeline/status/{merchant_id}

Updates a merchant's pipeline_status row. Any non-null field in the request body is applied; omitted fields are left unchanged.

The merchant_id in the path must match the authenticated X-Merchant-ID.

Auth modes supported

Today: Shopify-embedded only. API key support coming soon.

Typical caller

This endpoint is primarily called by internal pipeline workers as they complete steps. Admin UIs read status via GET /api/v1/pipeline/status/{merchant_id} but rarely write here directly.

Headers

HeaderRequiredValue
X-Merchant-IDyesYour merchant ID (integer)
Content-Typeyesapplication/json

Path parameters

ParameterTypeDescription
merchant_idintegerThe merchant whose status to update. Must match header.

Request body

FieldTypeRequiredDescription
current_stepstringnoNew current step. Also appended to completed_steps if not already there.
completed_stepstringnoExplicitly append a step to completed_steps (legacy path from theme extension).
statusstringnorunning, completed, or failed. Setting completed stamps completed_at.
error_messagestringnoError detail. Setting this automatically switches status to failed.
configobjectnoPartial config patch — merged onto existing config.

Response — 200 OK

Returns the updated PipelineStatusResponse.

Example

curl -X PUT \
  https://developer-search-devID.region.run.app/api/v1/pipeline/status/12345 \
  -H "X-Merchant-ID: 12345" \
  -H "Content-Type: application/json" \
  -d '{"current_step": "create_img_embeddings"}'
await fetch(
  `https://developer-search-devID.region.run.app/api/v1/pipeline/status/${merchantId}`,
  {
    method: 'PUT',
    headers: {
      'X-Merchant-ID': String(merchantId),
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ current_step: 'create_img_embeddings' }),
  },
);
import httpx

httpx.put(
    f"https://developer-search-devID.region.run.app/api/v1/pipeline/status/{merchant_id}",
    headers={"X-Merchant-ID": str(merchant_id), "Content-Type": "application/json"},
    json={"current_step": "create_img_embeddings"},
)

Errors

StatusResponse
400{ "detail": "Missing or invalid X-Merchant-ID header" }
403{ "detail": "Access denied to this merchant's pipeline status" }
404{ "detail": "Pipeline status not found for this merchant" }
500Database error. Safe to retry.

Delete pipeline status

DELETE /api/v1/pipeline/status/{merchant_id}

Deletes a merchant's pipeline_status row. Typically used during uninstall or when forcing a clean reset before reinstalling.

The merchant_id in the path must match the authenticated X-Merchant-ID.

Auth modes supported

Today: Shopify-embedded only. API key support coming soon.

Does not delete catalog data

This endpoint only removes the pipeline tracking row. It does not delete products, user profiles, or preferences. For that, see DELETE /api/v1/merchant/{merchant_id}/data.

Headers

HeaderRequiredValue
X-Merchant-IDyesYour merchant ID (integer)

Path parameters

ParameterTypeDescription
merchant_idintegerThe merchant whose status to delete. Must match header.

Response — 200 OK

When a row existed

{
  "status": "deleted",
  "message": "Pipeline status deleted for merchant 12345"
}

When no row existed

{
  "status": "not_found",
  "message": "No pipeline status found for merchant 12345"
}

Example

curl -X DELETE \
  https://developer-search-devID.region.run.app/api/v1/pipeline/status/12345 \
  -H "X-Merchant-ID: 12345"
await fetch(
  `https://developer-search-devID.region.run.app/api/v1/pipeline/status/${merchantId}`,
  {
    method: 'DELETE',
    headers: { 'X-Merchant-ID': String(merchantId) },
  },
);
import httpx

httpx.delete(
    f"https://developer-search-devID.region.run.app/api/v1/pipeline/status/{merchant_id}",
    headers={"X-Merchant-ID": str(merchant_id)},
)

Errors

StatusResponse
400{ "detail": "Missing or invalid X-Merchant-ID header" }
403{ "detail": "Access denied to this merchant's pipeline status" }
500Database error. Safe to retry.

Merchant API

Delete merchant data

DELETE /api/v1/merchant/{merchant_id}/data

Deletes all merchant-specific rows from the product database — products, user profiles, user preferences, user greetings, product synonyms, and pipeline status. This is used during an uninstall when the merchant has explicitly requested data deletion.

Auth modes supported

Today: Shopify-embedded only. API key support coming soon.

Disabled by default

This endpoint is gated by the server-side DELETE_MERCHANT_DATA_ON_UNINSTALL environment flag. If that flag is not true, every call returns 403 without deleting anything. Contact enterprise@vairetail.com to enable deletion for your merchant account.

What is NOT deleted

  • Merchant metadata (merchant_info, credentials) is preserved so reinstalling is zero-friction.
  • search_results rows are preserved for analytics and historical reporting. They are query logs, not personal data.

Headers

HeaderRequiredValue
X-Merchant-IDyesYour merchant ID (integer)

Path parameters

ParameterTypeDescription
merchant_idintegerThe merchant whose data to delete. Must match header.

Response — 200 OK

{
  "status": "deleted",
  "message": "All merchant data deleted for merchant 12345",
  "deleted_counts": {
    "user_preferences": 42,
    "user_greetings": 6,
    "user_profile": 41,
    "product_synonyms": 188,
    "products": 4821,
    "pipeline_status": 1
  }
}

Example

curl -X DELETE \
  https://developer-search-devID.region.run.app/api/v1/merchant/12345/data \
  -H "X-Merchant-ID: 12345"
const res = await fetch(
  `https://developer-search-devID.region.run.app/api/v1/merchant/${merchantId}/data`,
  {
    method: 'DELETE',
    headers: { 'X-Merchant-ID': String(merchantId) },
  },
);
import httpx

httpx.delete(
    f"https://developer-search-devID.region.run.app/api/v1/merchant/{merchant_id}/data",
    headers={"X-Merchant-ID": str(merchant_id)},
)

Errors

StatusResponse
400{ "detail": "Missing or invalid X-Merchant-ID header" }
400{ "detail": "Invalid merchant_id: <id>" } — non-positive ID
403{ "detail": "Merchant data deletion is disabled. Set DELETE_MERCHANT_DATA_ON_UNINSTALL=true…" }
403{ "detail": "Access denied to this merchant's data" }
500{ "detail": "RLS context setup failed: …" } — safety check tripped, nothing was deleted

Utilities

Image proxy

GET /api/v1/proxy-image

Fetches an image URL server-side and returns its bytes with the original Content-Type. Used by the storefront extension to load product images into canvas elements without running into cross-origin restrictions on the CDN.

Auth modes supported

No auth required. This endpoint is a stateless utility — no X-Merchant-ID header, no API key.

Query parameters

ParameterTypeRequiredDescription
urlstringyesThe image URL to fetch. Must be URL-encoded.

Response — 200 OK

The raw image bytes with the upstream Content-Type (e.g. image/jpeg, image/png, image/webp). No JSON wrapping.

Example

curl "https://developer-search-devID.region.run.app/api/v1/proxy-image?url=https%3A%2F%2Fcdn.example.com%2Fimages%2Flinen-shirt.jpg" \
  --output linen-shirt.jpg
const imageUrl = 'https://cdn.example.com/images/linen-shirt.jpg';
const proxied = `https://developer-search-devID.region.run.app/api/v1/proxy-image?url=${encodeURIComponent(imageUrl)}`;
const res = await fetch(proxied);
const blob = await res.blob();
const objectUrl = URL.createObjectURL(blob);
document.getElementById('preview').src = objectUrl;
import httpx

r = httpx.get(
    "https://developer-search-devID.region.run.app/api/v1/proxy-image",
    params={"url": "https://cdn.example.com/images/linen-shirt.jpg"},
)
with open("linen-shirt.jpg", "wb") as f:
    f.write(r.content)

Errors

StatusResponse
502{ "detail": "Failed to fetch image from source." }

Health check

GET /health

Returns { "status": "ok" } as soon as the API is ready to serve requests. Models are loaded at process startup (not lazily), so a successful /health response implies the text and image embedding services are warm.

Auth modes supported

No auth required. Intended for uptime monitors and load balancer health probes — no X-Merchant-ID header, no API key.

Response — 200 OK

{ "status": "ok" }

Example

curl https://developer-search-devID.region.run.app/health
const res = await fetch(
  'https://developer-search-devID.region.run.app/health',
);
if (!res.ok) alert('discoverGPT API is down');
import httpx

r = httpx.get("https://developer-search-devID.region.run.app/health")
assert r.status_code == 200

Errors

Any non-200 response (5xx, network error, timeout) means the API is not ready or is experiencing an outage. Exponentially back off retries.

Demo queries

GET /api/v1/demo-queries

Returns up to 12 active demo queries for the merchant, selected at random on each request. These are the curated query suggestions the frontend can display to demonstrate discoverGPT's capabilities (distinct from the user-facing example searches, which are shorter and appear in the search bar placeholder UI).

Response — 200 OK

{
  "demo_queries": [
    {
      "id": 7,
      "query_text": "floral midi dress for a summer wedding",
      "category": "dresses",
      "subcategory": "midi",
      "gender": "womens",
      "season": "summer",
      "style": "floral",
      "materials": null,
      "price_range": null,
      "use_case": "wedding",
      "created_at": "2026-04-10T09:00:00",
      "updated_at": "2026-04-10T09:00:00"
    }
  ],
  "count": 12,
  "merchant_id": 12345
}

An empty demo_queries array with count: 0 is returned when no active rows exist for the merchant (no error is raised).

Example

curl https://developer-search-devID.region.run.app/api/v1/demo-queries \
  -H "X-Merchant-ID: 12345"
const res = await fetch(
  'https://developer-search-devID.region.run.app/api/v1/demo-queries',
  { headers: { 'X-Merchant-ID': '12345' } },
);
const { demo_queries, count } = await res.json();
console.log(`${count} demo queries available`);
import httpx

r = httpx.get(
    "https://developer-search-devID.region.run.app/api/v1/demo-queries",
    headers={"X-Merchant-ID": "12345"},
)
print(r.json()["count"])
GET /api/v1/searchlookup/trending

Returns trending SearchLookup rows for a specific user profile. Used by the frontend to surface personalised trending searches for a logged-in shopper.

Query parameters

ParamTypeRequiredDescription
user_profile_idintegeryesThe shopper's user profile ID.

Response — 200 OK

A JSON array of SearchLookupRead objects (shape mirrors the search_lookup table). Each object includes at minimum the lookup query text, operation type, and timestamps; additional fields may be present.

[
  {
    "id": 412,
    "query_string": "olive linen trousers",
    "operation_type": "text",
    "user_profile_id": 99,
    "merchant_id": 12345,
    "created_at": "2026-06-20T11:42:00"
  }
]

Example

curl -G https://developer-search-devID.region.run.app/api/v1/searchlookup/trending \
  -H "X-Merchant-ID: 12345" \
  --data-urlencode "user_profile_id=99"
const res = await fetch(
  'https://developer-search-devID.region.run.app/api/v1/searchlookup/trending?user_profile_id=99',
  { headers: { 'X-Merchant-ID': '12345' } },
);
const trending = await res.json();
console.log(`${trending.length} trending searches`);
import httpx

r = httpx.get(
    "https://developer-search-devID.region.run.app/api/v1/searchlookup/trending",
    headers={"X-Merchant-ID": "12345"},
    params={"user_profile_id": 99},
)
print(r.json())

Synonyms API

Per-merchant synonym rules let the search engine treat terms as equivalent, boost exact-match phrases, or penalise antonym pairs. The rules are stored in the merchant's catalog database and applied automatically at query time.

Status: Private beta

The Synonyms API is in private beta and access-gated — authentication is being finalized and these endpoints are not yet generally available. Email enterprise@vairetail.com to request access. The discover, rules, stats, preview, and quality endpoints are read-only; generate and validate-a-pair write to your catalog, so try them with dry_run first.

Discover synonyms

GET /api/v1/synonyms/discover/{merchant_id}

Analyses the merchant's product catalog and returns a read-only preview of synonym, antonym, and exact-match candidates. No rules are written to the database — it surfaces candidate terms without changing your catalog. Results are filtered to high-confidence candidates before being returned (antonym confidence ≥ 0.5, synonym confidence ≥ 0.3 with ≥ 3 shared categories).

Query parameters

ParamTypeDefaultRangeDescription
min_frequencyinteger51–100Minimum term frequency to include in analysis.
max_antonym_termsinteger20050–1000Maximum number of terms to check for antonyms.
max_synonym_termsinteger30050–1000Maximum number of terms to check for synonyms.

Response — 200 OK

{
  "total_products": 4200,
  "frequent_terms_count": 312,
  "antonyms_count": 18,
  "exact_matches_count": 7,
  "synonyms_count": 42,
  "frequent_terms": {
    "dress": 980,
    "linen": 430,
    "midi": 220
  },
  "sample_antonyms": [
    { "term": "formal", "related_term": "casual" }
  ],
  "sample_exact_matches": [
    { "term": "button up", "related_term": "button up" }
  ],
  "sample_synonyms": [
    { "term": "trousers", "related_term": "pants" }
  ]
}

Example

curl -G https://developer-search-devID.region.run.app/api/v1/synonyms/discover/12345 \
  --data-urlencode "min_frequency=3" \
  --data-urlencode "max_synonym_terms=500"
const params = new URLSearchParams({ min_frequency: '3', max_synonym_terms: '500' });
const res = await fetch(
  `https://developer-search-devID.region.run.app/api/v1/synonyms/discover/12345?${params}`,
);
const preview = await res.json();
console.log(`${preview.synonyms_count} synonym candidates found`);
import httpx

r = httpx.get(
    "https://developer-search-devID.region.run.app/api/v1/synonyms/discover/12345",
    params={"min_frequency": 3, "max_synonym_terms": 500},
)
print(r.json()["synonyms_count"])

List synonym rules

GET /api/v1/synonyms/rules/{merchant_id}

Returns existing synonym rules for the merchant, optionally filtered by type.

Query parameters

ParamTypeDefaultDescription
synonym_typestringOptional filter: synonym, antonym, or exact_match.
limitinteger100Maximum rules to return (1–1000).

Response — 200 OK

[
  {
    "merchant_id": 12345,
    "term": "trousers",
    "synonym_type": "synonym",
    "related_term": "pants",
    "boost_factor": 1.0,
    "penalty_factor": 1.0
  },
  {
    "merchant_id": 12345,
    "term": "formal",
    "synonym_type": "antonym",
    "related_term": "casual",
    "boost_factor": 1.0,
    "penalty_factor": 0.5
  }
]

Example

curl -G https://developer-search-devID.region.run.app/api/v1/synonyms/rules/12345 \
  --data-urlencode "synonym_type=synonym" \
  --data-urlencode "limit=50"
const params = new URLSearchParams({ synonym_type: 'synonym', limit: '50' });
const res = await fetch(
  `https://developer-search-devID.region.run.app/api/v1/synonyms/rules/12345?${params}`,
);
const rules = await res.json();
console.log(`${rules.length} synonym rules`);
import httpx

r = httpx.get(
    "https://developer-search-devID.region.run.app/api/v1/synonyms/rules/12345",
    params={"synonym_type": "synonym", "limit": 50},
)
print(len(r.json()))

Synonym rule statistics

GET /api/v1/synonyms/stats/{merchant_id}

Returns a count summary of synonym rules for the merchant, broken down by type.

Response — 200 OK

{
  "merchant_id": 12345,
  "total_rules": 87,
  "by_type": {
    "synonym": 42,
    "antonym": 18,
    "exact_match": 27
  }
}

Example

curl https://developer-search-devID.region.run.app/api/v1/synonyms/stats/12345
const res = await fetch(
  'https://developer-search-devID.region.run.app/api/v1/synonyms/stats/12345',
);
const stats = await res.json();
console.log(`${stats.total_rules} total rules`);
import httpx

r = httpx.get(
    "https://developer-search-devID.region.run.app/api/v1/synonyms/stats/12345",
)
print(r.json()["by_type"])

Generate synonym rules

POST /api/v1/synonyms/generate/{merchant_id}

Analyses the catalog and writes the high-confidence synonym, antonym, and exact-match rules it finds into the merchant's rule set. Pass dry_run=true to get a preview of what would be inserted without changing anything — always do a dry run first.

Query parameters

ParamTypeDefaultRangeDescription
min_frequencyinteger51–100Minimum term frequency to include.
max_antonym_termsinteger20050–1000Maximum terms to check for antonyms.
max_synonym_termsinteger30050–1000Maximum terms to check for synonyms.
dry_runbooleanfalsePreview only; do not write rules.

Response — 200 OK

Dry run — rules_preview carries up to the first 20 rule objects that would be written:

{
  "status": "dry_run",
  "rules_generated": 12,
  "rules_preview": [
    { "term": "trousers", "synonym_type": "synonym", "related_term": "pants", "confidence": 0.62 }
  ],
  "message": "No changes were made. Remove dry_run=true to apply."
}

Applied (dry_run=false):

{
  "status": "success",
  "rules_generated": 12,
  "rules_inserted": 9,
  "rules_skipped": 3,
  "errors": []
}

Example

# Preview first — writes nothing
curl -X POST -G https://developer-search-devID.region.run.app/api/v1/synonyms/generate/12345 \
  --data-urlencode "dry_run=true"

# Apply
curl -X POST https://developer-search-devID.region.run.app/api/v1/synonyms/generate/12345

Preview generated rules

GET /api/v1/synonyms/validate/{merchant_id}

A read-only discovery preview that goes deeper than discover: it reports how many candidates were found at each confidence level, how many were filtered out, and estimated_rules_to_insert — i.e. exactly what generate would write. Writes nothing.

Query parameters

ParamTypeDefaultRangeDescription
min_frequencyinteger51–100Minimum term frequency to include.
max_antonym_termsinteger20050–1000Maximum terms to check for antonyms.
max_synonym_termsinteger30050–1000Maximum terms to check for synonyms.

Response — 200 OK

{
  "total_products": 4200,
  "frequent_terms_count": 312,
  "antonyms_discovered": 24,
  "antonyms_high_confidence": 18,
  "antonyms_filtered_out": 6,
  "exact_matches_count": 7,
  "synonyms_discovered": 61,
  "synonyms_high_confidence": 42,
  "synonyms_filtered_out": 19,
  "sample_high_confidence_antonyms": [
    { "term": "formal", "related_term": "casual", "confidence": 0.71 }
  ],
  "sample_high_confidence_synonyms": [
    { "term": "trousers", "related_term": "pants", "confidence": 0.62 }
  ],
  "estimated_rules_to_insert": 67
}

The sample_* arrays carry up to 20 pairs each.

Example

curl -G https://developer-search-devID.region.run.app/api/v1/synonyms/validate/12345 \
  --data-urlencode "min_frequency=3"

Validate a discovered pair

POST /api/v1/synonyms/validate/{merchant_id}

Record a human approve/reject decision for one discovered pair. This feedback trains the precision metric reported by the quality report.

Request body

FieldTypeRequiredDescription
term1stringyesFirst term of the pair.
term2stringyesSecond term of the pair.
synonym_typestringyessynonym, antonym, or exact_match.
validation_statusstringyesapproved or rejected.
notesstringnoOptional reviewer note.

Response — 200 OK

{ "status": "success", "message": "Pair validated: trousers ↔ pants = approved" }

404 { "detail": "Pair not found for validation" } if the pair was never discovered.

Example

curl -X POST https://developer-search-devID.region.run.app/api/v1/synonyms/validate/12345 \
  -H "Content-Type: application/json" \
  -d '{ "term1": "trousers", "term2": "pants", "synonym_type": "synonym", "validation_status": "approved" }'

Synonym quality report

GET /api/v1/synonyms/quality/{merchant_id}

Historical quality metrics for the merchant's synonym discovery runs: per-run counts and confidence, the distribution of validation decisions, and overall precision once enough pairs have been validated. Returns 404 { "detail": "No metrics found" } until at least one discovery run has been recorded.

Query parameters

ParamTypeDefaultRangeDescription
discovery_run_idstringScope to one discovery run.
daysinteger1–365Look-back window.

Response — 200 OK

{
  "discovery_runs": [
    {
      "discovery_run_id": "run_2026-06-20T14:00:00",
      "run_timestamp": "2026-06-20T14:00:00",
      "total_discovered": 85,
      "antonyms_discovered": 24,
      "synonyms_discovered": 61,
      "exact_matches_discovered": 7,
      "avg_confidence": 0.58,
      "discovery_method_distribution": { "embedding": 60, "cooccurrence": 25 },
      "category_distribution": { "tops": 30, "bottoms": 22 },
      "total_products_analyzed": 4200,
      "frequent_terms_count": 312
    }
  ],
  "validation_stats": {
    "approved": { "count": 40, "avg_confidence": 0.66 },
    "rejected": { "count": 8,  "avg_confidence": 0.41 }
  },
  "precision": 0.83,
  "total_validated": 48,
  "pending_count": 37
}

Example

curl -G https://developer-search-devID.region.run.app/api/v1/synonyms/quality/12345 \
  --data-urlencode "days=30"

Analytics API

All analytics endpoints require the X-Merchant-ID header unless otherwise noted.

HeaderRequiredValue
X-Merchant-IDyes (most endpoints)Your merchant ID (integer)
Content-Typeon POSTapplication/json

Analytics summary

GET /api/v1/analytics/summary

Returns a comprehensive analytics summary for the merchant over a configurable time window. The response is large — it covers search volume, engagement funnel, intent quality, image-similarity quality, color demand vs. catalog coverage, session depth, commerce metrics (orders, revenue, AOV, returning-customer rate), and a comparison against a pre-install baseline or prior period. Fields may be added in future releases without a version bump; treat unknown keys as informational.

Query parameters

ParamTypeDefaultDescription
rangestring30dDate window: 24h, 1d, mtd, prev_month, ytd, 7d, 14d, 30d, 90d.

Response — 200 OK (representative structure, trimmed)

{
  "date_range": { "start": "2026-05-23", "end": "2026-06-22" },

  "search_volume": {
    "totals": { "total": 8420, "nlq": 6100, "color": 1200, "image": 800, "combo": 310, "other": 10 },
    "timeseries": [
      { "date": "2026-06-21", "total": 312, "nlq": 210, "color": 60, "image": 32, "combo": 10, "other": 0 }
    ]
  },

  "engagement": {
    "ctr":             { "value": 0.42, "clicks": 3530, "impressions": 8420 },
    "zero_result_rate":{ "value": 0.03, "zero_results": 253, "total_queries": 8420 },
    "add_to_cart_rate":{ "value": 0.18, "sessions_with_add_to_cart": 720, "total_sessions": 4000 }
  },

  "intent_insights": {
    "intent_rich_rate": 0.34,
    "intent_rich_count": 2862,
    "keyword_only_count": 5558,
    "total_queries": 8420,
    "top_attributes": [
      { "category": "occasion", "attribute": "work", "count": 610 }
    ]
  },

  "image_similarity": {
    "total_queries": 800,
    "no_close_match_rate": 0.12,
    "no_close_match_count": 96,
    "avg_best_score": 0.31,
    "hero_products": [
      { "product_id": "SKU-1001", "product_name": "Relaxed Linen Shirt", "count": 42 }
    ]
  },

  "color_demand": {
    "total_color_searches": 1510,
    "total_products_with_color": 3800,
    "top_gaps": [
      { "color_family": "sage", "demand_count": 280, "product_count": 12, "demand_per_product": 23.3 }
    ]
  },

  "session_depth": {
    "views_per_query": 8.2,
    "views_per_session": 24.6,
    "sessions_with_search": 3800,
    "total_product_views": 93480
  },

  "messages": [],

  "visits": 4000,
  "time_on_site_avg": 182.4,
  "time_on_site": { "p50_seconds": 145.0, "p90_seconds": 420.0, "avg_seconds": 182.4, "sessions": 3800 },
  "searches_per_visit": 2.1,
  "product_page_visits": 8420,
  "added_to_cart": 1440,
  "checkouts": 310,
  "revenue": 18600.0,
  "revenue_per_checkout": 60.0,
  "churn_rate": 0.21,

  "popular_colors": [
    { "color": "Navy", "count": 620, "hex": "#000080" }
  ],
  "image_searches_by_photo": 520,
  "image_searches_by_file": 280,
  "user_preferences_set": 94,

  "top_product_matches": [
    { "product_id": "SKU-1001", "product_name": "Relaxed Linen Shirt", "search_count": 182 }
  ],
  "top_categories": [
    { "category": "tops", "search_count": 2400 }
  ],
  "top_subcategories": [
    { "subcategory": "blouses", "search_count": 910 }
  ],

  "bounce_rate": { "value": 0.18, "bounce_sessions": 720, "total_sessions": 4000 },
  "results_per_query": {
    "avg_results": 9.4, "total_queries": 8420,
    "zero_results_queries": 253, "low_results_queries": 510, "low_results_rate": 0.06
  },
  "time_to_first_click": { "p50_seconds": 4.2, "p90_seconds": 18.1, "avg_seconds": 6.8, "sessions": 3530 },
  "top_zero_result_queries": [
    { "query": "cashmere joggers", "count": 18 }
  ],

  "commerce": {
    "available": true,
    "compare_available": true,
    "compare_label": "Pre-install 30 days",
    "compare_range": { "start": "2026-03-01", "end": "2026-03-31" },
    "current": {
      "orders_count": 310, "revenue_gross": 18600.0, "revenue_net": 17900.0,
      "refunds_total": 700.0, "aov": 60.0, "items_per_order": 2.1,
      "returning_customer_rate": 0.34, "repeat_purchase_rate": 0.22
    },
    "compare": { "orders_count": 210, "revenue_gross": 11000.0, "..." : "..." },
    "delta": { "orders_count": 100, "revenue_gross": 7600.0, "..." : "..." },
    "delta_pct": { "orders_count": 0.476, "revenue_gross": 0.691, "..." : "..." }
  }
}

Example

curl -G https://developer-search-devID.region.run.app/api/v1/analytics/summary \
  -H "X-Merchant-ID: 12345" \
  --data-urlencode "range=7d"
const res = await fetch(
  'https://developer-search-devID.region.run.app/api/v1/analytics/summary?range=7d',
  { headers: { 'X-Merchant-ID': '12345' } },
);
const summary = await res.json();
console.log(`${summary.visits} visits, ${summary.revenue} revenue`);
import httpx

r = httpx.get(
    "https://developer-search-devID.region.run.app/api/v1/analytics/summary",
    headers={"X-Merchant-ID": "12345"},
    params={"range": "7d"},
)
data = r.json()
print(data["search_volume"]["totals"]["total"])

Record analytics event

POST /api/v1/analytics/event

Records a single analytics event from the storefront or Shopify Web Pixel. For calls from the Web Pixel sandbox (where navigator.sendBeacon cannot attach custom headers), the merchant can be identified via the ?merchant_id= query parameter instead of the X-Merchant-ID header.

Headers / query fallback

SourceValue
X-Merchant-ID headerPreferred. Integer merchant ID.
?merchant_id= queryFallback for navigator.sendBeacon / Pixel sandbox.

Request body

FieldTypeRequiredDescription
event_typestringyesSee valid types below.
event_dataobjectnoArbitrary payload for the event.
session_idstringnoBrowser session identifier.
user_idintegernoShopper profile ID.
timestampstringnoISO-8601 timestamp; defaults to server time.

Valid event_type values:

Event typeWhen to fire
page_viewedAny page visit.
product_viewedProduct detail page view.
collection_viewedCollection / category page view.
search_submittedShopper submitted a search query.
search_icon_clickedShopper opened the discoverGPT search panel (counts as a visit).
product_card_clickedShopper clicked a product card in results.
product_eye_icon_clickedShopper used visual search on a product.
eye_icon_clickedShopper clicked the eye icon in the search bar.
product_added_to_cartAdd-to-cart action.
checkout_startedCheckout flow initiated.
checkout_completedPurchase complete. Include revenue in event_data.
session_endSession ended. Include time_on_site_seconds in event_data.

Response — 200 OK

{ "success": true, "message": "Event recorded" }

Example

curl -X POST \
  https://developer-search-devID.region.run.app/api/v1/analytics/event \
  -H "X-Merchant-ID: 12345" \
  -H "Content-Type: application/json" \
  -d '{
    "event_type": "product_added_to_cart",
    "event_data": { "product_id": "SKU-1001", "price": 69.00 },
    "session_id": "sess_abc123"
  }'
await fetch(
  'https://developer-search-devID.region.run.app/api/v1/analytics/event',
  {
    method: 'POST',
    headers: {
      'X-Merchant-ID': '12345',
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      event_type: 'product_added_to_cart',
      event_data: { product_id: 'SKU-1001', price: 69.00 },
      session_id: 'sess_abc123',
    }),
  },
);
import httpx

httpx.post(
    "https://developer-search-devID.region.run.app/api/v1/analytics/event",
    headers={"X-Merchant-ID": "12345"},
    json={
        "event_type": "product_added_to_cart",
        "event_data": {"product_id": "SKU-1001", "price": 69.00},
        "session_id": "sess_abc123",
    },
)

Record analytics events (batch)

POST /api/v1/analytics/events

Records multiple analytics events in a single request. Use this instead of repeated single-event POSTs when flushing a buffered event queue. Unlike the single-event endpoint, the batch endpoint requires the X-Merchant-ID header — it does not accept the ?merchant_id= query fallback.

Request body

{
  "events": [
    {
      "event_type": "page_viewed",
      "session_id": "sess_abc123",
      "timestamp": "2026-06-22T14:00:00Z"
    },
    {
      "event_type": "product_added_to_cart",
      "event_data": { "product_id": "SKU-1001", "price": 69.00 },
      "session_id": "sess_abc123"
    }
  ]
}

Each object in events follows the same shape as the single-event body above.

Response — 200 OK

{ "success": true, "message": "Recorded 2 events" }

Example

curl -X POST \
  https://developer-search-devID.region.run.app/api/v1/analytics/events \
  -H "X-Merchant-ID: 12345" \
  -H "Content-Type: application/json" \
  -d '{"events":[{"event_type":"page_viewed","session_id":"sess_abc123"}]}'
await fetch(
  'https://developer-search-devID.region.run.app/api/v1/analytics/events',
  {
    method: 'POST',
    headers: {
      'X-Merchant-ID': '12345',
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      events: [
        { event_type: 'page_viewed', session_id: 'sess_abc123' },
        { event_type: 'product_added_to_cart', event_data: { product_id: 'SKU-1001' }, session_id: 'sess_abc123' },
      ],
    }),
  },
);
import httpx

httpx.post(
    "https://developer-search-devID.region.run.app/api/v1/analytics/events",
    headers={"X-Merchant-ID": "12345"},
    json={
        "events": [
            {"event_type": "page_viewed", "session_id": "sess_abc123"},
            {"event_type": "product_added_to_cart", "event_data": {"product_id": "SKU-1001"}, "session_id": "sess_abc123"},
        ]
    },
)

A/B comparison

GET /api/v1/analytics/ab-comparison

Returns a side-by-side funnel comparison between the control (native Shopify search) and spt (discoverGPT search) variants for sessions in the given date range. Metrics are computed from analytics_events using a "control wins" attribution rule: if any event in a session is tagged control, the whole session is attributed to the control cohort.

Query parameters

ParamTypeDefaultDescription
rangestring7dDate window: 24h, 7d, 14d, 30d, 90d.

Response — 200 OK

{
  "range": "7d",
  "start": "2026-06-15",
  "end": "2026-06-22",
  "control": {
    "sessions": 1800,
    "ctr": 12.5,
    "atc_rate": 8.2,
    "checkout_rate": 2.1,
    "rev_per_session": 1.24
  },
  "spt": {
    "sessions": 2200,
    "ctr": 41.8,
    "atc_rate": 18.6,
    "checkout_rate": 5.4,
    "rev_per_session": 3.87
  },
  "test_running": true,
  "ab_config": {
    "split_pct": 50,
    "seed": "a3f9c12b",
    "enabled": true
  }
}

ctr, atc_rate, and checkout_rate are percentages (0–100). ab_config is null if the merchant has no config row yet.

Example

curl -G https://developer-search-devID.region.run.app/api/v1/analytics/ab-comparison \
  -H "X-Merchant-ID: 12345" \
  --data-urlencode "range=30d"
const res = await fetch(
  'https://developer-search-devID.region.run.app/api/v1/analytics/ab-comparison?range=30d',
  { headers: { 'X-Merchant-ID': '12345' } },
);
const { control, spt } = await res.json();
console.log(`CTR lift: ${(spt.ctr - control.ctr).toFixed(1)}pp`);
import httpx

r = httpx.get(
    "https://developer-search-devID.region.run.app/api/v1/analytics/ab-comparison",
    headers={"X-Merchant-ID": "12345"},
    params={"range": "30d"},
)
data = r.json()
print(f"SPT checkout rate: {data['spt']['checkout_rate']}%")

Resolve merchant by shop domain

GET /api/v1/analytics/resolve-merchant

Resolves a Shopify myshopifyDomain to the platform's internal numeric merchant_id. This is the canonical "look up a merchant by shop domain" endpoint.

No authentication required

This endpoint is intentionally public. It is used by the Shopify Web Pixel sandbox — a strict iframe environment that cannot carry custom headers. The response only exposes the numeric ID for an already-installed store; unregistered domains receive a 404.

Query parameters

ParamTypeRequiredDescription
shopstringyesThe store's myshopifyDomain, e.g. acme.myshopify.com.

Response — 200 OK

{ "merchant_id": 12345, "shop": "acme.myshopify.com" }

Errors

StatusResponse
404Domain is not registered on the platform.

Example

curl "https://developer-search-devID.region.run.app/api/v1/analytics/resolve-merchant?shop=acme.myshopify.com"
const res = await fetch(
  'https://developer-search-devID.region.run.app/api/v1/analytics/resolve-merchant?shop=acme.myshopify.com',
);
const { merchant_id } = await res.json();
import httpx

r = httpx.get(
    "https://developer-search-devID.region.run.app/api/v1/analytics/resolve-merchant",
    params={"shop": "acme.myshopify.com"},
)
print(r.json()["merchant_id"])

A/B Testing

Controls what percentage of a merchant's storefront traffic sees discoverGPT versus the store's native search (the control variant). The split is deterministic per-visitor, keyed on the configured seed.

Get A/B config

GET /api/v1/ab/{merchant_id}/config

Returns the merchant's current A/B split configuration. Called by the storefront theme extension on every page load.

Origin / Referer authentication

This endpoint does not use X-Merchant-ID. The request's Origin (or Referer) header must match the merchant's registered Shopify domain or an allowed custom domain. Mismatched origins receive a 403. On first read for a new merchant, a default config of {split_pct: 0, seed: "<random hex>", enabled: true} is auto-created — 0% means all visitors see native search until you explicitly dial up split_pct.

Response — 200 OK

{ "split_pct": 50, "seed": "a3f9c12b", "enabled": true }

Responses are cached at the edge for 30 seconds (Cache-Control: private, max-age=30). Combined with a 60-second server-side cache, worst-case rollback propagation is ~90 seconds.

Errors

StatusResponse
403{ "detail": "Origin not allowed for this merchant" }
404{ "detail": "Merchant not found" }

Example

curl https://developer-search-devID.region.run.app/api/v1/ab/12345/config \
  -H "Origin: https://acme.myshopify.com"
// Called automatically by the theme extension; shown here for reference.
const res = await fetch(
  'https://developer-search-devID.region.run.app/api/v1/ab/12345/config',
  { headers: { Origin: 'https://acme.myshopify.com' } },
);
const { split_pct, seed, enabled } = await res.json();
import httpx

r = httpx.get(
    "https://developer-search-devID.region.run.app/api/v1/ab/12345/config",
    headers={"Origin": "https://acme.myshopify.com"},
)
print(r.json())

Update A/B config

POST /api/v1/ab/{merchant_id}/config

Updates the merchant's A/B split configuration from the merchant admin dashboard. All body fields are optional (PATCH semantics) — omitted fields retain their current values.

X-Merchant-ID must match path

The X-Merchant-ID header is required and must equal the merchant_id path parameter. Mismatches return 403.

Request body

FieldTypeRequiredConstraintsDescription
split_pctintegerno0–100Percentage of visitors who see discoverGPT.
seedstringno1–64 chars, alnum/-/_Deterministic bucketing seed. Changing it re-randomises cohorts.
enabledbooleannofalse immediately pauses the test (all → control).

Response — 200 OK

Returns the full updated config:

{ "split_pct": 75, "seed": "a3f9c12b", "enabled": true }

Errors

StatusResponse
401{ "detail": "X-Merchant-ID header required" }
403{ "detail": "X-Merchant-ID header does not match path merchant_id" }

Example

curl -X POST \
  https://developer-search-devID.region.run.app/api/v1/ab/12345/config \
  -H "X-Merchant-ID: 12345" \
  -H "Content-Type: application/json" \
  -d '{"split_pct": 75, "enabled": true}'
const res = await fetch(
  'https://developer-search-devID.region.run.app/api/v1/ab/12345/config',
  {
    method: 'POST',
    headers: {
      'X-Merchant-ID': '12345',
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ split_pct: 75, enabled: true }),
  },
);
const config = await res.json();
console.log(`Split: ${config.split_pct}%`);
import httpx

r = httpx.post(
    "https://developer-search-devID.region.run.app/api/v1/ab/12345/config",
    headers={"X-Merchant-ID": "12345"},
    json={"split_pct": 75, "enabled": True},
)
print(r.json())

Example Searches

Up to 10 merchant-specific suggested queries shown in the discoverGPT search UI when the panel opens. If a merchant has not configured custom examples, the platform falls back to a global default set.

All endpoints require X-Merchant-ID.

Get example searches

GET /api/v1/example-searches

Returns the merchant's active example searches. If the merchant has no custom examples, returns global defaults and sets is_default: true.

Response — 200 OK

{
  "merchant_id": 12345,
  "example_searches": [
    { "id": 1, "search_query": "linen dress for summer", "display_order": 1, "is_active": true },
    { "id": 2, "search_query": "navy blazer under $200", "display_order": 2, "is_active": true }
  ],
  "is_default": false
}

is_default: true means the merchant has not set custom examples and these are the platform's global defaults.

Example

curl https://developer-search-devID.region.run.app/api/v1/example-searches \
  -H "X-Merchant-ID: 12345"
const res = await fetch(
  'https://developer-search-devID.region.run.app/api/v1/example-searches',
  { headers: { 'X-Merchant-ID': '12345' } },
);
const { example_searches, is_default } = await res.json();
import httpx

r = httpx.get(
    "https://developer-search-devID.region.run.app/api/v1/example-searches",
    headers={"X-Merchant-ID": "12345"},
)
data = r.json()
print(data["is_default"], [e["search_query"] for e in data["example_searches"]])

Set example searches

PUT /api/v1/example-searches

Atomically replaces all of the merchant's custom example searches with the provided list (1–10 non-empty strings). Any previously stored examples for this merchant are deleted before the new ones are inserted.

Request body

FieldTypeRequiredConstraintsDescription
example_searchesstring[]yes1–10 itemsOrdered list of query strings.

Response — 200 OK

{ "status": "success", "message": "Updated 4 example searches", "merchant_id": 12345 }

Errors

StatusResponse
400{ "detail": "Maximum 10 example searches allowed" }
400{ "detail": "At least one example search is required" }

Example

curl -X PUT \
  https://developer-search-devID.region.run.app/api/v1/example-searches \
  -H "X-Merchant-ID: 12345" \
  -H "Content-Type: application/json" \
  -d '{
    "example_searches": [
      "linen dress for summer",
      "navy blazer under $200",
      "wide leg trousers",
      "casual white sneakers"
    ]
  }'
const res = await fetch(
  'https://developer-search-devID.region.run.app/api/v1/example-searches',
  {
    method: 'PUT',
    headers: {
      'X-Merchant-ID': '12345',
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      example_searches: [
        'linen dress for summer',
        'navy blazer under $200',
        'wide leg trousers',
        'casual white sneakers',
      ],
    }),
  },
);
const result = await res.json();
console.log(result.message);
import httpx

r = httpx.put(
    "https://developer-search-devID.region.run.app/api/v1/example-searches",
    headers={"X-Merchant-ID": "12345"},
    json={
        "example_searches": [
            "linen dress for summer",
            "navy blazer under $200",
            "wide leg trousers",
            "casual white sneakers",
        ]
    },
)
print(r.json()["message"])

Reset example searches

DELETE /api/v1/example-searches

Deletes all merchant-specific example searches, reverting the search UI to the global platform defaults. Subsequent GET calls will return is_default: true.

Response — 200 OK

{ "status": "success", "message": "Reset to default examples (deleted 4 custom examples)", "merchant_id": 12345 }

Example

curl -X DELETE \
  https://developer-search-devID.region.run.app/api/v1/example-searches \
  -H "X-Merchant-ID: 12345"
await fetch(
  'https://developer-search-devID.region.run.app/api/v1/example-searches',
  { method: 'DELETE', headers: { 'X-Merchant-ID': '12345' } },
);
import httpx

httpx.delete(
    "https://developer-search-devID.region.run.app/api/v1/example-searches",
    headers={"X-Merchant-ID": "12345"},
)

Quality API

discoverGPT cleans and enriches messy product data, then gives you a review workflow to approve those changes and publish them back to your store. The enrichment pipeline — the same one that runs during ingest — rewrites titles and descriptions, normalizes tags, and scores each change. The endpoints below are how you review those results: list them, approve or reject, edit, push approved changes to Shopify, and roll them back if needed.

Every cleaned record carries a status that moves through the workflow:

pending  →  approved  →  pushed
   ↑            │            │
   └─ undo ─────┘            └─ rollback ─→ pending

(or rejected from pending). Scores range 0.01.0; quality bands are excellent (≥ 0.8), good (≥ 0.6), fair (≥ 0.4), else low.

Quality API base URL

All Quality API requests go to:

https://developer-clean-devID.region.run.app

The Quality API uses the same X-Merchant-ID trust model as the Search API — see Authentication. Every endpoint requires it:

HeaderRequiredValue
X-Merchant-IDyesYour merchant ID (integer)
Content-Typeon POSTapplication/json

You don't call an endpoint to generate clean data

Enrichment runs automatically as part of the ingest/cleaning pipeline. These endpoints operate on the results — review, approve, publish, and roll back.

Overview

GET /api/v1/cleaner/overview

Dashboard stats for the merchant's catalog: product counts by status, average quality scores, and a quality-band distribution (counted per product).

Response — 200 OK

{
  "total": 1240,
  "pending": 312,
  "approved": 95,
  "pushed": 820,
  "rejected": 13,
  "avg_score": 0.642,
  "avg_title_score": 0.701,
  "avg_desc_score": 0.585,
  "quality_distribution": { "excellent": 410, "good": 520, "fair": 230, "low": 80 }
}

Example

curl https://developer-clean-devID.region.run.app/api/v1/cleaner/overview \
  -H "X-Merchant-ID: 12345"
const res = await fetch(
  'https://developer-clean-devID.region.run.app/api/v1/cleaner/overview',
  { headers: { 'X-Merchant-ID': '12345' } },
);
const stats = await res.json();
console.log(`${stats.pending} pending, ${stats.pushed} pushed`);
import httpx

r = httpx.get(
    "https://developer-clean-devID.region.run.app/api/v1/cleaner/overview",
    headers={"X-Merchant-ID": "12345"},
)
print(r.json()["quality_distribution"])

List products

GET /api/v1/cleaner/products

Paginated before/after list. By default, variants are grouped under their parent product and nested in a variants array; the parent's overall_improvement_score is the average across its variants and its status is the least-advanced status among them.

Query parameters

ParamTypeDefaultDescription
statusstringFilter by status: pending, approved, pushed, rejected.
searchstringCase-insensitive match on original or clean title.
pageinteger1Page number (≥ 1).
per_pageinteger20Results per page (1–100).
sort_bystringoverall_improvement_scoreOne of overall_improvement_score, title_quality_score, generated_at, product_id, status.
sort_orderstringdescasc or desc.
group_variantsbooleantrueGroup variants under their parent product.

Response — 200 OK

{
  "products": [
    {
      "product_id": "8472910564",
      "original_title": "BLUE DRESS WOMENS SUMMER NEW",
      "clean_title": "Women's Blue Summer Midi Dress",
      "original_description": "<p>nice dress</p>",
      "clean_description": "A breezy blue midi dress cut for warm-weather wear...",
      "title_quality_score": 0.82,
      "description_quality_score": 0.71,
      "overall_improvement_score": 0.77,
      "status": "pending",
      "generated_at": "2026-05-02 18:04:11+00:00",
      "reviewed_at": null,
      "image_url": "https://cdn.example.com/blue-dress.jpg",
      "price": "58.00",
      "clean_tags": "dress, summer, blue, midi",
      "original_tags": "dress",
      "variant_count": 3,
      "variants": [
        { "variant_id": "4471", "original_title": "BLUE DRESS WOMENS SUMMER NEW", "clean_title": "Women's Blue Summer Midi Dress", "price": "58.00", "status": "pending", "overall_improvement_score": 0.77 }
      ]
    }
  ],
  "total": 312,
  "page": 1,
  "per_page": 20,
  "total_pages": 16
}

Example

curl -G https://developer-clean-devID.region.run.app/api/v1/cleaner/products \
  -H "X-Merchant-ID: 12345" \
  --data-urlencode "status=pending" \
  --data-urlencode "per_page=50"
const params = new URLSearchParams({ status: 'pending', per_page: '50' });
const res = await fetch(
  `https://developer-clean-devID.region.run.app/api/v1/cleaner/products?${params}`,
  { headers: { 'X-Merchant-ID': '12345' } },
);
const { products, total } = await res.json();
import httpx

r = httpx.get(
    "https://developer-clean-devID.region.run.app/api/v1/cleaner/products",
    headers={"X-Merchant-ID": "12345"},
    params={"status": "pending", "per_page": 50},
)
print(r.json()["total"])

Get product detail

GET /api/v1/cleaner/product/{product_id}

Full before/after comparison for a single product, including the enrichment model used and any Shopify push error.

Response — 200 OK

{
  "product_id": "8472910564",
  "variant_id": null,
  "original": { "title": "BLUE DRESS WOMENS SUMMER NEW", "description": "<p>nice dress</p>" },
  "clean": {
    "title": "Women's Blue Summer Midi Dress",
    "description": "A breezy blue midi dress cut for warm-weather wear...",
    "tags": "dress, summer, blue, midi"
  },
  "scores": { "title": 0.82, "description": 0.71, "overall": 0.77 },
  "status": "pending",
  "model_used": "gemini-2.5-flash",
  "generated_at": "2026-05-02 18:04:11+00:00",
  "reviewed_at": null,
  "pushed_at": null,
  "push_error": null,
  "product": {
    "image_url": "https://cdn.example.com/blue-dress.jpg",
    "price": "58.00",
    "brand": "Cove & Coast",
    "category": "dresses",
    "tags": "dress",
    "image_description": "A blue sleeveless midi dress on a hanger."
  }
}

Example

curl https://developer-clean-devID.region.run.app/api/v1/cleaner/product/8472910564 \
  -H "X-Merchant-ID: 12345"
const res = await fetch(
  'https://developer-clean-devID.region.run.app/api/v1/cleaner/product/8472910564',
  { headers: { 'X-Merchant-ID': '12345' } },
);
const detail = await res.json();
import httpx

r = httpx.get(
    "https://developer-clean-devID.region.run.app/api/v1/cleaner/product/8472910564",
    headers={"X-Merchant-ID": "12345"},
)
print(r.json()["clean"]["title"])

Errors

StatusResponse
404{ "detail": "Product override not found" }

Approve products

POST /api/v1/cleaner/approve

Move products from pending to approved. Approve a specific set, or every pending product.

Request body

FieldTypeRequiredDefaultDescription
product_idsstring[]noProducts to approve. Omit (or send null) to approve all pending.
approve_allbooleannofalseIf true, approve every pending product (ignores product_ids).

Response — 200 OK

{ "approved": 24 }

Example

curl -X POST https://developer-clean-devID.region.run.app/api/v1/cleaner/approve \
  -H "X-Merchant-ID: 12345" \
  -H "Content-Type: application/json" \
  -d '{ "product_ids": ["8472910564", "8472910565"] }'
await fetch(
  'https://developer-clean-devID.region.run.app/api/v1/cleaner/approve',
  {
    method: 'POST',
    headers: { 'X-Merchant-ID': '12345', 'Content-Type': 'application/json' },
    body: JSON.stringify({ approve_all: true }),
  },
);
import httpx

httpx.post(
    "https://developer-clean-devID.region.run.app/api/v1/cleaner/approve",
    headers={"X-Merchant-ID": "12345"},
    json={"product_ids": ["8472910564"]},
)

Reject products

POST /api/v1/cleaner/reject

Mark products as rejected so they are excluded from pushes.

Request body

FieldTypeRequiredDescription
product_idsstring[]yesProducts to reject.

Response — 200 OK

{ "rejected": 2 }

Example

curl -X POST https://developer-clean-devID.region.run.app/api/v1/cleaner/reject \
  -H "X-Merchant-ID: 12345" -H "Content-Type: application/json" \
  -d '{ "product_ids": ["8472910599"] }'
await fetch('https://developer-clean-devID.region.run.app/api/v1/cleaner/reject', {
  method: 'POST',
  headers: { 'X-Merchant-ID': '12345', 'Content-Type': 'application/json' },
  body: JSON.stringify({ product_ids: ['8472910599'] }),
});
import httpx

httpx.post(
    "https://developer-clean-devID.region.run.app/api/v1/cleaner/reject",
    headers={"X-Merchant-ID": "12345"},
    json={"product_ids": ["8472910599"]},
)

Undo a review decision

POST /api/v1/cleaner/undo

Send approved or rejected products back to pending. Does not affect products that have already been pushed (use rollback for those).

Request body

FieldTypeRequiredDescription
product_idsstring[]yesProducts to reset.

Response — 200 OK

{ "undone": 3 }

Example

curl -X POST https://developer-clean-devID.region.run.app/api/v1/cleaner/undo \
  -H "X-Merchant-ID: 12345" -H "Content-Type: application/json" \
  -d '{ "product_ids": ["8472910564"] }'
await fetch('https://developer-clean-devID.region.run.app/api/v1/cleaner/undo', {
  method: 'POST',
  headers: { 'X-Merchant-ID': '12345', 'Content-Type': 'application/json' },
  body: JSON.stringify({ product_ids: ['8472910564'] }),
});
import httpx

httpx.post(
    "https://developer-clean-devID.region.run.app/api/v1/cleaner/undo",
    headers={"X-Merchant-ID": "12345"},
    json={"product_ids": ["8472910564"]},
)

Edit clean fields

POST /api/v1/cleaner/edit

Manually override the AI's clean_title and/or clean_description before approving. Only allowed while the product is pending or approved.

Request body

FieldTypeRequiredDescription
product_idstringyesProduct to edit.
clean_titlestringnoNew cleaned title.
clean_descriptionstringnoNew cleaned description.

At least one of clean_title / clean_description must be provided.

Response — 200 OK

{ "updated": "8472910564" }

Errors

StatusResponse
400{ "detail": "No fields to update" } or Cannot edit product with status '...'
404{ "detail": "Product not found" }

Example

curl -X POST https://developer-clean-devID.region.run.app/api/v1/cleaner/edit \
  -H "X-Merchant-ID: 12345" -H "Content-Type: application/json" \
  -d '{ "product_id": "8472910564", "clean_title": "Women'\''s Blue Linen Midi Dress" }'
await fetch('https://developer-clean-devID.region.run.app/api/v1/cleaner/edit', {
  method: 'POST',
  headers: { 'X-Merchant-ID': '12345', 'Content-Type': 'application/json' },
  body: JSON.stringify({ product_id: '8472910564', clean_title: "Women's Blue Linen Midi Dress" }),
});
import httpx

httpx.post(
    "https://developer-clean-devID.region.run.app/api/v1/cleaner/edit",
    headers={"X-Merchant-ID": "12345"},
    json={"product_id": "8472910564", "clean_title": "Women's Blue Linen Midi Dress"},
)

Push to Shopify

POST /api/v1/cleaner/push

Write approved clean data back to the merchant's Shopify store. Small batches (≤ 10 products) push synchronously and return per-product results; larger batches run in the background — poll push status for progress. Before overwriting, the original Shopify values are backed up so the change can be rolled back.

Request body

FieldTypeRequiredDefaultDescription
product_idsstring[]noApproved products to push. Omit to push all approved.

Response — 200 OK

Synchronous (≤ 10 products):

{
  "status": "complete",
  "pushed": 4,
  "failed": 0,
  "results": [
    { "product_id": "8472910564", "status": "pushed" }
  ]
}

Background (> 10 products):

{ "status": "queued", "products_to_push": 240, "message": "Push started in background" }

Nothing to push:

{ "status": "complete", "pushed": 0, "failed": 0, "message": "No approved products to push" }

Example

curl -X POST https://developer-clean-devID.region.run.app/api/v1/cleaner/push \
  -H "X-Merchant-ID: 12345" -H "Content-Type: application/json" \
  -d '{}'
const res = await fetch('https://developer-clean-devID.region.run.app/api/v1/cleaner/push', {
  method: 'POST',
  headers: { 'X-Merchant-ID': '12345', 'Content-Type': 'application/json' },
  body: JSON.stringify({}),
});
console.log((await res.json()).status); // "complete" or "queued"
import httpx

r = httpx.post(
    "https://developer-clean-devID.region.run.app/api/v1/cleaner/push",
    headers={"X-Merchant-ID": "12345"},
    json={},
)
print(r.json()["status"])

Push status

GET /api/v1/cleaner/push-status

Progress of the merchant's current (or most recent) push job. Returns { "status": "idle" } when no push has run.

Response — 200 OK

{
  "status": "running",
  "total": 240,
  "pushed": 96,
  "failed": 1,
  "started_at": "2026-05-02T18:20:04+00:00",
  "completed_at": null
}

status becomes complete and completed_at is set when the job finishes.

Example

curl https://developer-clean-devID.region.run.app/api/v1/cleaner/push-status \
  -H "X-Merchant-ID: 12345"
const res = await fetch(
  'https://developer-clean-devID.region.run.app/api/v1/cleaner/push-status',
  { headers: { 'X-Merchant-ID': '12345' } },
);
import httpx

r = httpx.get(
    "https://developer-clean-devID.region.run.app/api/v1/cleaner/push-status",
    headers={"X-Merchant-ID": "12345"},
)

Push history

GET /api/v1/cleaner/history

Paginated write-back log: every field pushed to Shopify (and every rollback), newest first.

Query parameters

ParamTypeDefaultDescription
pageinteger1Page number (≥ 1).
per_pageinteger20Results per page (1–100).

Response — 200 OK

{
  "history": [
    {
      "product_id": "8472910564",
      "field_name": "title",
      "old_value": "BLUE DRESS WOMENS SUMMER NEW",
      "new_value": "Women's Blue Summer Midi Dress",
      "shopify_response_code": 200,
      "status": "pushed",
      "pushed_at": "2026-05-02 18:21:07+00:00"
    }
  ],
  "total": 1640,
  "page": 1,
  "per_page": 20
}

Example

curl -G https://developer-clean-devID.region.run.app/api/v1/cleaner/history \
  -H "X-Merchant-ID: 12345" --data-urlencode "per_page=50"
const res = await fetch(
  'https://developer-clean-devID.region.run.app/api/v1/cleaner/history?per_page=50',
  { headers: { 'X-Merchant-ID': '12345' } },
);
import httpx

r = httpx.get(
    "https://developer-clean-devID.region.run.app/api/v1/cleaner/history",
    headers={"X-Merchant-ID": "12345"},
    params={"per_page": 50},
)

Roll back a product

POST /api/v1/cleaner/rollback/{product_id}

Restore a pushed product to its original Shopify values using the backup captured at push time, and reset its status to pending. Only valid for products currently in pushed status.

Response — 200 OK

{ "status": "rolled_back", "product_id": "8472910564" }

Errors

StatusResponse
400{ "detail": "Can only rollback pushed products" } or No backup data available for rollback
404{ "detail": "Product override not found" }
502{ "detail": "Shopify rollback failed: ..." }

Example

curl -X POST https://developer-clean-devID.region.run.app/api/v1/cleaner/rollback/8472910564 \
  -H "X-Merchant-ID: 12345"
await fetch(
  'https://developer-clean-devID.region.run.app/api/v1/cleaner/rollback/8472910564',
  { method: 'POST', headers: { 'X-Merchant-ID': '12345' } },
);
import httpx

httpx.post(
    "https://developer-clean-devID.region.run.app/api/v1/cleaner/rollback/8472910564",
    headers={"X-Merchant-ID": "12345"},
)

Get cleaner config

GET /api/v1/cleaner/config

Current automation settings for the merchant. If none are saved, the defaults shown below are returned.

Response — 200 OK

{
  "auto_approve": false,
  "auto_push": false,
  "clean_titles": true,
  "clean_descriptions": true,
  "clean_tags": true,
  "tag_push_mode": "skip",
  "min_improvement_score": 0.3,
  "max_title_length": 80
}

Example

curl https://developer-clean-devID.region.run.app/api/v1/cleaner/config \
  -H "X-Merchant-ID: 12345"
const res = await fetch(
  'https://developer-clean-devID.region.run.app/api/v1/cleaner/config',
  { headers: { 'X-Merchant-ID': '12345' } },
);
import httpx

r = httpx.get(
    "https://developer-clean-devID.region.run.app/api/v1/cleaner/config",
    headers={"X-Merchant-ID": "12345"},
)

Update cleaner config

POST /api/v1/cleaner/config

Update one or more settings. Only the fields you send are changed; omitted fields are left as-is. Creates the config row with sensible defaults if it doesn't exist yet.

Request body

FieldTypeDescription
auto_approvebooleanAuto-approve cleaned products above min_improvement_score.
auto_pushbooleanAuto-push approved products to Shopify.
clean_titlesbooleanEnrich product titles.
clean_descriptionsbooleanEnrich product descriptions.
clean_tagsbooleanEnrich product tags.
tag_push_modestringHow tags are written back: skip, replace, or append.
min_improvement_scorenumberMinimum score (0.0–1.0) required for auto-approve.
max_title_lengthintegerMaximum cleaned-title length.

Response — 200 OK

{ "status": "ok" }

Example

curl -X POST https://developer-clean-devID.region.run.app/api/v1/cleaner/config \
  -H "X-Merchant-ID: 12345" -H "Content-Type: application/json" \
  -d '{ "auto_approve": true, "min_improvement_score": 0.5, "tag_push_mode": "append" }'
await fetch('https://developer-clean-devID.region.run.app/api/v1/cleaner/config', {
  method: 'POST',
  headers: { 'X-Merchant-ID': '12345', 'Content-Type': 'application/json' },
  body: JSON.stringify({ auto_approve: true, min_improvement_score: 0.5 }),
});
import httpx

httpx.post(
    "https://developer-clean-devID.region.run.app/api/v1/cleaner/config",
    headers={"X-Merchant-ID": "12345"},
    json={"auto_approve": True, "min_improvement_score": 0.5},
)

Lifecycle endpoints

Two additional endpoints support the Shopify app install lifecycle and are called by the app, not typically by integrators: POST /api/v1/cleaner/revert-all/{merchant_id} bulk-reverts every pushed product (used pre-uninstall, while the access token is still valid), and POST /api/v1/cleaner/reset-reverted/{merchant_id} resets reverted products back to pending on reinstall.

Feeds and Visibility API

discoverGPT turns an enriched catalog into AI-discovery formats — the GEO (Generative Engine Optimization) layer. From one enriched catalog the pipeline produces five agentic-commerce formats; you download the four feed files, query the catalog over UCP, track how your catalog surfaces across AI assistants, check feed health, and (for non-Shopify merchants) ingest products and pull feeds over an API-key-authenticated endpoint.

Five formats are produced:

FormatMedia typeShape
ucpapplication/jsonUniversal Commerce Protocol Product records, queried (not downloaded) via the UCP Catalog query API — discovery profile + search / lookup / product.
acpapplication/jsonlOne ACP product object per line (Agentic Commerce Protocol).
jsonldapplication/ld+jsonA single Schema.org document with all products under @graph.
perplexityapplication/jsonA single JSON envelope wrapping every product (Perplexity Shopping shape).
llmstxttext/plainAn llms.txt Markdown summary of the catalog for AI assistants.

The four acp / jsonld / perplexity / llmstxt formats are downloadable feed files (the GET .../feeds/{format} routes below). ucp is different — it is queried, not downloaded; see UCP (Universal Commerce Protocol) Catalog.

The Feeds and Visibility API has two API surfaces:

  • Feed downloads — for Shopify merchants, authenticated with X-Merchant-ID (the same model as the Search API). Prefix /api/v1/feeds.
  • Public API — for non-Shopify merchants, authenticated with an X-API-Key you mint via generate-key. Prefix /api/v1/catalog.

Feeds and Visibility API base URL

All Feeds and Visibility API requests go to:

https://developer-catalog-devID.region.run.app

Feed downloads (Shopify)

These endpoints serve the compiled feed for the merchant identified by the X-Merchant-ID header.

HeaderRequiredValue
X-Merchant-IDyesYour merchant ID (integer)

Download ACP feed

GET /api/v1/feeds/acp

Returns the full ACP feed as JSONL (one JSON object per line), as a file download. 404 if no ACP feed has been compiled yet.

curl https://developer-catalog-devID.region.run.app/api/v1/feeds/acp \
  -H "X-Merchant-ID: 12345" -o acp_feed.jsonl
const res = await fetch(
  'https://developer-catalog-devID.region.run.app/api/v1/feeds/acp',
  { headers: { 'X-Merchant-ID': '12345' } },
);
const jsonl = await res.text();
import httpx

r = httpx.get(
    "https://developer-catalog-devID.region.run.app/api/v1/feeds/acp",
    headers={"X-Merchant-ID": "12345"},
)
open("acp_feed.jsonl", "w").write(r.text)

Download JSON-LD feed

GET /api/v1/feeds/jsonld

Returns a single Schema.org JSON-LD document with every product under @graph. 404 if none compiled.

{
  "@context": "https://schema.org",
  "@graph": [
    {
      "@type": "Product",
      "name": "Women's Blue Summer Midi Dress",
      "brand": { "@type": "Brand", "name": "Cove & Coast" },
      "offers": { "@type": "Offer", "price": "58.00", "priceCurrency": "USD", "availability": "https://schema.org/InStock" }
    }
  ]
}
curl https://developer-catalog-devID.region.run.app/api/v1/feeds/jsonld \
  -H "X-Merchant-ID: 12345" -o jsonld_feed.json
const res = await fetch(
  'https://developer-catalog-devID.region.run.app/api/v1/feeds/jsonld',
  { headers: { 'X-Merchant-ID': '12345' } },
);
const feed = await res.json();
import httpx

r = httpx.get(
    "https://developer-catalog-devID.region.run.app/api/v1/feeds/jsonld",
    headers={"X-Merchant-ID": "12345"},
)
graph = r.json()["@graph"]

Download Perplexity feed

GET /api/v1/feeds/perplexity

Returns the Perplexity Shopping feed as a single JSON envelope — every product wrapped under products with a top-level merchant_id and product_count. Served as a file download (Content-Disposition: attachment; filename=perplexity_feed.json). 404 if no feed has been compiled yet.

{
  "version": "1.0",
  "merchant_id": 12345,
  "product_count": 164,
  "products": [
    {
      "id": "10750448533814-54137246810422",
      "title": "Gift Card",
      "brand": "Cove & Coast",
      "price": "10.00 USD",
      "colour": "light mustard",
      "condition": "new",
      "availability": "in_stock",
      "link": "https://your-store.myshopify.com/products/gift-card?variant=54137246810422",
      "image_link": "https://cdn.shopify.com/s/files/.../gift_card.png",
      "seller_name": "Your Store",
      "seller_url": "https://your-store.myshopify.com",
      "item_group_id": "10750448533814",
      "product_category": "Gift Cards",
      "description": "Give the gift of shred-ready style...",
      "q_and_a": [
        { "q": "What can I buy with this gift card?", "a": "This gift card can be used to purchase any product..." }
      ],
      "enable_search": true,
      "enable_checkout": true
    }
  ]
}
curl https://developer-catalog-devID.region.run.app/api/v1/feeds/perplexity \
  -H "X-Merchant-ID: 12345" -o perplexity_feed.json
import httpx

r = httpx.get(
    "https://developer-catalog-devID.region.run.app/api/v1/feeds/perplexity",
    headers={"X-Merchant-ID": "12345"},
)
products = r.json()["products"]

Download llms.txt feed

GET /api/v1/feeds/llmstxt

Returns the merchant's llms.txt — a plain-text Markdown summary of the catalog that AI assistants can read to ground answers about your products. The body is text/plain, grouped by category, with one bullet per product (title, link, and a short description). 404 if none compiled.

# Your Store

> Product catalog for Your Store. 53 products available. This file helps AI
> assistants discover and reference the catalog.

## accessories > book stand

- [Kitchen Counter Cookbook Bookshelf](https://your-store.myshopify.com/products/kitchen-cooking-bookshelf): This charming wooden cooking bookshelf...
curl https://developer-catalog-devID.region.run.app/api/v1/feeds/llmstxt \
  -H "X-Merchant-ID: 12345" -o llms.txt

llms.txt is also served at the storefront root

For Shopify storefronts the same document is published, unauthenticated, at the App-Proxy path GET /llms.txt?shop={shop_domain} (e.g. for crawlers). The /api/v1/feeds/llmstxt route above is the merchant-authenticated equivalent. llmstxt is the one format not available through the API-key Public API download route — it is served Shopify-side only.

Feed status

GET /api/v1/feeds/status

Summary of the merchant's feed: product count, compile/push timestamps, which formats are enabled, and how many unresolved health issues exist.

{
  "total_products": 1240,
  "last_compiled": "2026-06-01 04:12:55+00:00",
  "last_modified": "2026-06-01 03:59:10+00:00",
  "last_pushed": "2026-06-01 04:15:02+00:00",
  "acp_enabled": true,
  "jsonld_enabled": true,
  "unresolved_issues": 7
}
curl https://developer-catalog-devID.region.run.app/api/v1/feeds/status \
  -H "X-Merchant-ID: 12345"

Feed health

GET /api/v1/feeds/health

Lists feed health issues (missing fields, invalid prices, etc.), newest first, capped at 200.

Query parameters

ParamTypeDescription
resolvedbooleanFilter by resolution state.
severitystringFilter by severity (e.g. error, warning).
{
  "issues": [
    {
      "id": 91,
      "product_id": "8472910564",
      "issue_type": "missing_narrative",
      "severity": "warning",
      "details": "The product has no descriptive narrative for AI assistants.",
      "resolved": false,
      "detected_at": "2026-06-01 04:12:55+00:00",
      "resolved_at": null
    }
  ],
  "count": 1
}
curl -G https://developer-catalog-devID.region.run.app/api/v1/feeds/health \
  -H "X-Merchant-ID: 12345" --data-urlencode "resolved=false" --data-urlencode "severity=error"

UCP (Universal Commerce Protocol) Catalog

UCP is the Universal Commerce Protocol — the open agentic-commerce standard backed by Google and Shopify. Where the feed downloads above hand an agent a file to fetch and parse, UCP lets an agent query your catalog directly: it reads a discovery profile, learns which capabilities you support and where they live, then calls those endpoints.

UCP is queried, not downloaded

Unlike the downloadable feeds (acp, jsonld, perplexity, llmstxt), UCP is a JSON query API with its own discovery profile and spec — an agent calls it rather than fetching a file. UCP is not a value for GET /v1/feed/{feed_format}; there is no ucp feed file to download.

Through the unified gateway (JWT + MCP)

The UCP catalog is also exposed on the unified gateway — same api.vairetail.com base and OAuth credential as the rest of /v1 — so you can query it with your agency token (merchant resolved from the JWT, no path id) or call it from an agent as MCP tools:

Gateway REST (api.vairetail.com)MCP toolUCP capability
GET /v1/ucp/profileget_ucp_profileDiscovery profile
POST /v1/ucp/catalog/searchucp_catalog_searchCatalog Search
POST /v1/ucp/catalog/lookupucp_catalog_lookupCatalog Lookup (by ids)
POST /v1/ucp/catalog/productucp_get_productGet Product

These forward to the per-merchant catalogGPT routes documented below; catalogGPT enforces the per-merchant ucp_enabled gate. Request bodies follow the UCP Catalog spec (the same shapes shown below, minus the {merchant_id} path segment). Example:

curl -s -X POST https://api.vairetail.com/v1/ucp/catalog/search \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"query":"linen dress","filters":{"categories":["Dresses"],"price":{"max":100}},"pagination":{"limit":10}}'

The raw per-merchant routes below are the public, unauthenticated surface a shopping agent follows from a merchant's discovery profile. Two things set them apart from the rest of the catalogGPT API:

  • Merchant is in the path, not a header. Each merchant's profile advertises its own merchant-scoped URLs, so agents follow them directly — no X-Merchant-ID header, no out-of-band merchant identification.
  • No API key. These endpoints are public by design; a shopping agent has to be able to reach them unauthenticated.

Discovery profile

GET /.well-known/ucp/{merchant_id}

The entry point. Returns the merchant's advertised UCP version and a capabilities map pointing at the live endpoint for each catalog capability. Agents should read this first and follow the endpoint URLs rather than hardcoding paths.

{
  "ucp": { "versions": ["2026-04-08"] },
  "merchant": { "id": "12345", "domain": null, "name": null },
  "capabilities": {
    "dev.ucp.shopping.catalog.search": [
      {
        "version": "2026-04-08",
        "endpoint": "https://developer-catalog-devID.region.run.app/api/v1/ucp/12345/catalog/search",
        "transport": "rest"
      }
    ],
    "dev.ucp.shopping.catalog.lookup": [{ "version": "2026-04-08", "endpoint": "...", "transport": "rest" }],
    "dev.ucp.shopping.catalog.product": [{ "version": "2026-04-08", "endpoint": "...", "transport": "rest" }]
  },
  "signing_keys": []
}

signing_keys is currently empty — response signing is a later follow-up.

curl https://developer-catalog-devID.region.run.app/.well-known/ucp/12345
POST /api/v1/ucp/{merchant_id}/catalog/search

Free-text search across the merchant's UCP catalog, with optional filters and cursor pagination.

FieldTypeDescription
querystringFree-text query. Omit to list the catalog.
filtersobjectcategories (array of strings) and/or price ({ "max": number }).
paginationobjectlimit (default 50, max 200) and cursor (the last id from a prior page).
context / signals / attributionobjectOptional UCP passthrough blocks; accepted and ignored today.

Every response carries a ucp envelope advertising the capability and version, alongside the results.

{
  "ucp": {
    "version": "2026-04-08",
    "capabilities": { "dev.ucp.shopping.catalog.search": [{ "version": "2026-04-08" }] }
  },
  "products": [{ "id": "SKU-1001", "title": "Relaxed Linen Button-Up", "...": "..." }],
  "pagination": { "cursor": "SKU-1001" }
}

Page forward by passing the returned pagination.cursor back as pagination.cursor; an empty string means there is nothing more to read.

curl -X POST https://developer-catalog-devID.region.run.app/api/v1/ucp/12345/catalog/search \
  -H "Content-Type: application/json" \
  -d '{"query":"linen shirt","filters":{"price":{"max":100.0}},"pagination":{"limit":20}}'

Catalog lookup

POST /api/v1/ucp/{merchant_id}/catalog/lookup

Fetch UCP Product records for a known set of IDs — the batch counterpart to search.

FieldTypeDescription
idsarray of stringRequired. 1–200 product IDs.
filtersobjectSame shape as search; applied to the results.
curl -X POST https://developer-catalog-devID.region.run.app/api/v1/ucp/12345/catalog/lookup \
  -H "Content-Type: application/json" \
  -d '{"ids":["SKU-1001","SKU-1002"]}'

Get product

POST /api/v1/ucp/{merchant_id}/catalog/product

Fetch a single UCP Product record by id. Returns the record under product rather than products.

curl -X POST https://developer-catalog-devID.region.run.app/api/v1/ucp/12345/catalog/product \
  -H "Content-Type: application/json" \
  -d '{"id":"SKU-1001"}'

The UCP Product record

Search, lookup, and get-product all return the same spec-conformant Product shape:

FieldDescription
idProduct identifier.
titleProduct title.
descriptionDescription object.
categoriesCategory path for the product.
price_rangeMin/max price across variants.
mediaProduct imagery.
optionsOption definitions (e.g. Size, Color) shared across variants.
variantsEach with id, title, description, price, availability, and options.
metadataNon-spec extras (e.g. brand, faq) kept out of the first-class UCP fields.

Prices are integer minor units

Every UCP price.amount is an integer in ISO 4217 minor units5800 is $58.00, not $5,800. This is a deliberate difference from the ACP and JSON-LD feeds, which express price as a decimal. Read price.currency alongside it.

AI Visibility and GEO

discoverGPT continuously checks whether your products surface inside AI assistants — ChatGPT, Perplexity, Google AI Overviews, and other engines — and scores your catalog's Generative Engine Optimization (GEO). These endpoints use the same X-Merchant-ID header as the feed downloads.

HeaderRequiredValue
X-Merchant-IDyesYour merchant ID (integer)

Visibility score

GET /api/v1/visibility/score

Overall AI-visibility score for the catalog with a per-platform breakdown. score values run 0–100; avg_rank is the mean position among visible results (null when nothing is visible); last_checked is null until the first check runs.

{
  "overall_score": 72.5,
  "products_checked": 120,
  "products_visible": 87,
  "total_checks": 480,
  "visible_checks": 348,
  "platforms": {
    "chatgpt":    { "total_checked": 120, "visible": 90, "score": 75.0, "avg_rank": 2.3 },
    "perplexity": { "total_checked": 120, "visible": 80, "score": 66.7, "avg_rank": 1.8 }
  },
  "last_checked": "2026-06-20 14:32:00+00:00"
}
curl https://developer-catalog-devID.region.run.app/api/v1/visibility/score \
  -H "X-Merchant-ID: 12345"
import httpx

r = httpx.get(
    "https://developer-catalog-devID.region.run.app/api/v1/visibility/score",
    headers={"X-Merchant-ID": "12345"},
)
print(r.json()["overall_score"])

Visibility products

GET /api/v1/visibility/products

Per-product visibility rows across platforms — which products were visible, their rank, and the query that surfaced them.

Query parameters

ParamTypeDefaultDescription
platformstringFilter to one platform (e.g. chatgpt, perplexity).
visible_onlybooleanfalseReturn only rows where the product was visible.
limitinteger50Page size.
offsetinteger0Page offset.
{
  "products": [
    {
      "product_id": "8412345678",
      "product_name": "Classic Linen Blazer",
      "brand": "Soleil Studio",
      "handle": "classic-linen-blazer",
      "platform": "chatgpt",
      "visible": true,
      "rank_position": 2,
      "query_used": "linen blazer women summer",
      "checked_at": "2026-06-20 14:32:00+00:00"
    }
  ],
  "count": 1,
  "total": 240,
  "limit": 50,
  "offset": 0
}
curl -G https://developer-catalog-devID.region.run.app/api/v1/visibility/products \
  -H "X-Merchant-ID: 12345" \
  --data-urlencode "platform=chatgpt" --data-urlencode "visible_only=true"
import httpx

r = httpx.get(
    "https://developer-catalog-devID.region.run.app/api/v1/visibility/products",
    headers={"X-Merchant-ID": "12345"},
    params={"platform": "chatgpt", "visible_only": True, "limit": 50},
)
GET /api/v1/visibility/trends

Daily visibility time-series for the catalog, optionally scoped to one platform.

Query parameters

ParamTypeDefaultDescription
daysinteger30Look-back window in days (1–365).
platformstringRestrict the series to one platform.
{
  "days": 30,
  "platform": null,
  "trends": [
    { "date": "2026-06-01", "total_checks": 480, "visible_count": 348, "score": 72.5, "avg_rank": 2.1 }
  ]
}
curl -G https://developer-catalog-devID.region.run.app/api/v1/visibility/trends \
  -H "X-Merchant-ID: 12345" --data-urlencode "days=7"
import httpx

r = httpx.get(
    "https://developer-catalog-devID.region.run.app/api/v1/visibility/trends",
    headers={"X-Merchant-ID": "12345"},
    params={"days": 7},
)

Visibility issues

GET /api/v1/visibility/issues

The products that are holding your AI-visibility score back, with the root cause and which capability resolves it. severity runs 1–5 (higher is worse); issues are returned worst-last (ascending severity). route tells you where to fix it: cleaner (improve the title/description via the data-quality pipeline) or catalog (re-compile the feed). Takes no parameters.

{
  "total": 53,
  "by_cause": { "weak_title": 18, "weak_description": 35 },
  "by_route": { "catalog": 0, "cleaner": 53 },
  "issues": [
    { "product_id": "10750467440950", "root_cause": "weak_title",       "route": "cleaner", "severity": 4, "status": "open" },
    { "product_id": "10750448533814", "root_cause": "weak_description", "route": "cleaner", "severity": 5, "status": "open" }
  ]
}
curl https://developer-catalog-devID.region.run.app/api/v1/visibility/issues \
  -H "X-Merchant-ID: 12345"

Visibility readiness

GET /api/v1/visibility/readiness

A fast, key-free AI-readiness check — does the catalog have the artifacts AI assistants need (JSON-LD, an llms.txt, and fresh feeds)? Unlike the visibility score, this does not call any external AI platform; it reads the latest readiness snapshot. available is false (and the score 0) until the first snapshot has been taken. Takes no parameters.

{
  "available": true,
  "readiness_score": 100.0,
  "jsonld_ok": true,
  "llms_txt_ok": true,
  "feeds_fresh": true,
  "products_total": 164,
  "products_with_feed": 164,
  "checked_at": "2026-06-20 11:26:32.795422"
}
curl https://developer-catalog-devID.region.run.app/api/v1/visibility/readiness \
  -H "X-Merchant-ID: 12345"

BYO visibility keys

The visibility score checks whether your products surface inside AI assistants. To run those checks against your own ChatGPT, Gemini, or Perplexity quota, supply your own provider API keys. Keys are stored encrypted and are never returned — responses only ever expose the last four characters, a validity flag, and a set / not_set status. All three platforms are always present in the response.

HeaderRequiredValue
X-Merchant-IDyesYour merchant ID (integer)

Valid platform values: chatgpt, gemini, perplexity. Note the trailing slash on the collection routes.

List keys (masked)

GET /api/v1/visibility-keys/
{
  "platforms": [
    { "platform": "chatgpt",    "status": "set",     "key_last4": "sk-9", "is_valid": true,  "validated_at": "2026-06-20 21:45:45.459101+00:00" },
    { "platform": "gemini",     "status": "not_set", "key_last4": null,   "is_valid": null,  "validated_at": null },
    { "platform": "perplexity", "status": "not_set", "key_last4": null,   "is_valid": null,  "validated_at": null }
  ]
}
curl https://developer-catalog-devID.region.run.app/api/v1/visibility-keys/ \
  -H "X-Merchant-ID: 12345"

Set or rotate a key

POST /api/v1/visibility-keys/

Stores (or replaces) the key for one platform — upserted on (merchant_id, platform), so posting again rotates it. On save, discoverGPT makes a best-effort validation call to the provider and records the result in is_valid (true / false, or null if the check couldn't run).

Request body

FieldTypeRequiredDescription
platformstringyeschatgpt, gemini, or perplexity.
api_keystringyesYour provider API key (stored encrypted).

The response is the same masked list as List keys, reflecting the new state.

curl -X POST https://developer-catalog-devID.region.run.app/api/v1/visibility-keys/ \
  -H "X-Merchant-ID: 12345" -H "Content-Type: application/json" \
  -d '{ "platform": "chatgpt", "api_key": "sk-..." }'
import httpx

httpx.post(
    "https://developer-catalog-devID.region.run.app/api/v1/visibility-keys/",
    headers={"X-Merchant-ID": "12345"},
    json={"platform": "chatgpt", "api_key": "sk-..."},
)

Delete a key

DELETE /api/v1/visibility-keys/{platform}/

Removes the stored key for one platform (discoverGPT falls back to its shared quota for that platform). Returns the same masked list, with the deleted platform back to not_set. There is no separate update route — re-POST to change a key.

curl -X DELETE https://developer-catalog-devID.region.run.app/api/v1/visibility-keys/chatgpt/ \
  -H "X-Merchant-ID: 12345"

Feed health detail

Beyond the issue list at /api/v1/feeds/health, these endpoints score how complete and enrichment-ready your catalog is. All use the X-Merchant-ID header. (A paginated version of the issue list is also available at GET /api/v1/health/issues, which adds limit/offset/total.)

Health summary

GET /api/v1/health/summary

Catalog-wide readiness: how many products are feed-complete (ACP and JSON-LD present) and the count of unresolved issues by severity.

{
  "total_products": 1240,
  "complete_products": 1108,
  "health_score": 89.4,
  "unresolved_errors": 2,
  "unresolved_warnings": 7,
  "unresolved_info": 0
}
curl https://developer-catalog-devID.region.run.app/api/v1/health/summary \
  -H "X-Merchant-ID: 12345"

Product health

GET /api/v1/health/product/{product_id}

Per-product readiness and enrichment-quality breakdown: which feed formats and enrichment artifacts exist, plus a weighted factor-by-factor score. readiness_score is the share of the five checks that pass.

{
  "product_id": "8412345678",
  "readiness_score": 100.0,
  "enrichment_quality_score": 75.0,
  "checks": {
    "has_acp": true, "has_jsonld": true,
    "has_narrative": true, "has_qa": true, "no_errors": true
  },
  "enrichment_factors": {
    "description_quality": { "score": 20, "max": 20, "detail": "142 chars" },
    "brand": { "score": 10, "max": 10, "detail": "Soleil Studio" },
    "qa_coverage": { "score": 8, "max": 15, "detail": "1 Q&A pair (needs more)" },
    "feed_completeness": { "score": 10, "max": 10, "detail": "ACP + JSON-LD" }
  },
  "compiled_at": "2026-06-20 10:00:00+00:00",
  "narrative_count": 1,
  "qa_count": 1,
  "unresolved_issues": []
}
curl https://developer-catalog-devID.region.run.app/api/v1/health/product/8412345678 \
  -H "X-Merchant-ID: 12345"

Enrichment quality

GET /api/v1/health/enrichment-quality

Ranked enrichment-quality scores across the catalog, with a band distribution (excellent ≥ 80, good ≥ 60, fair ≥ 40, poor < 40).

Query parameters

ParamTypeDefaultDescription
limitinteger50Page size.
offsetinteger0Page offset.
min_scorenumberOnly products at or above this score.
max_scorenumberOnly products at or below this score.
{
  "average_score": 68.4,
  "total_products": 1240,
  "score_distribution": { "excellent": 410, "good": 520, "fair": 230, "poor": 80 },
  "products": [
    {
      "product_id": "8412345678",
      "product_name": "Classic Linen Blazer",
      "enrichment_quality_score": 89.5,
      "factors": { "...": "same shape as Product health → enrichment_factors" }
    }
  ],
  "count": 50,
  "limit": 50,
  "offset": 0
}
curl -G https://developer-catalog-devID.region.run.app/api/v1/health/enrichment-quality \
  -H "X-Merchant-ID: 12345" --data-urlencode "min_score=80"
import httpx

r = httpx.get(
    "https://developer-catalog-devID.region.run.app/api/v1/health/enrichment-quality",
    headers={"X-Merchant-ID": "12345"},
    params={"min_score": 80, "limit": 50},
)

Health issues

GET /api/v1/health/issues

The paginated companion to /api/v1/feeds/health: the same feed health issues (missing fields, invalid prices, etc.), newest first, but with limit / offset / total so you can page through a large backlog. A catalog with no open issues returns an empty issues array.

Query parameters

ParamTypeDefaultDescription
resolvedbooleanFilter by resolution state.
severitystringFilter by severity (e.g. error, warning).
limitinteger100Page size.
offsetinteger0Page offset.
{
  "issues": [
    {
      "id": 91,
      "product_id": "8472910564",
      "issue_type": "missing_narrative",
      "severity": "warning",
      "details": "The product has no descriptive narrative for AI assistants.",
      "resolved": false,
      "detected_at": "2026-06-01 04:12:55+00:00",
      "resolved_at": null
    }
  ],
  "count": 1,
  "total": 1,
  "limit": 100,
  "offset": 0
}
curl -G https://developer-catalog-devID.region.run.app/api/v1/health/issues \
  -H "X-Merchant-ID: 12345" --data-urlencode "resolved=false" --data-urlencode "limit=50"

Public API (non-Shopify)

For merchants not on Shopify, the Public API accepts product data from any source and serves feeds — authenticated with an API key instead of X-Merchant-ID.

HeaderRequiredValue
X-API-KeyyesThe key returned by generate-key.

Generate an API key

POST /api/v1/catalog/generate-key

Mint (or rotate) the merchant's Public API key. This is the one Public-API endpoint authenticated with X-Merchant-ID — every other endpoint below uses the returned X-API-Key. Calling it again replaces the existing key.

HeaderRequiredValue
X-Merchant-IDyesYour merchant ID (integer)

Response — 200 OK

{ "api_key": "cgpt_xxxxx", "merchant_id": 12345 }
curl -X POST https://developer-catalog-devID.region.run.app/api/v1/catalog/generate-key \
  -H "X-Merchant-ID: 12345"
import httpx

r = httpx.post(
    "https://developer-catalog-devID.region.run.app/api/v1/catalog/generate-key",
    headers={"X-Merchant-ID": "12345"},
)
api_key = r.json()["api_key"]

Ingest products

POST /api/v1/catalog/ingest

Upsert products into the catalog from any platform. Products are matched on (merchant_id, product_id, variant_id); existing rows are updated. A product with no variants is stored as a single default variant.

HeaderRequiredValue
X-API-KeyyesYour Public API key.
Content-Typeyesapplication/json

Request body

FieldTypeRequiredDescription
productsProductIngest[]yesProducts to upsert.

Each ProductIngest:

FieldTypeRequiredDescription
product_idstringyesYour stable product identifier.
titlestringyesProduct title.
descriptionstringnoProduct description / details.
handlestringnoURL handle (defaults to product_id).
vendorstringnoBrand / vendor.
product_typestringnoCategory.
tagsstring[]noTags (stored comma-joined).
image_urlstringnoPrimary image URL.
variantsProductVariant[]noVariants; if omitted, one default is created.

Each ProductVariant: variant_id, sku, price, compare_at_price, option1, option2, option3 (all strings, optional), and available (boolean, default true).

Response — 200 OK

{
  "status": "ok",
  "products_processed": 2,
  "rows_upserted": 5,
  "errors": []
}
curl -X POST https://developer-catalog-devID.region.run.app/api/v1/catalog/ingest \
  -H "X-API-Key: cgpt_xxxxx" -H "Content-Type: application/json" \
  -d '{
    "products": [
      {
        "product_id": "SKU-1001",
        "title": "Relaxed-Fit Linen Button-Up",
        "vendor": "Cove & Coast",
        "product_type": "tops",
        "tags": ["linen", "summer"],
        "image_url": "https://cdn.example.com/linen.jpg",
        "variants": [{ "variant_id": "SKU-1001-M", "price": "69.00", "option1": "M" }]
      }
    ]
  }'
await fetch('https://developer-catalog-devID.region.run.app/api/v1/catalog/ingest', {
  method: 'POST',
  headers: { 'X-API-Key': 'cgpt_xxxxx', 'Content-Type': 'application/json' },
  body: JSON.stringify({
    products: [{ product_id: 'SKU-1001', title: 'Relaxed-Fit Linen Button-Up', variants: [{ price: '69.00' }] }],
  }),
});
import httpx

httpx.post(
    "https://developer-catalog-devID.region.run.app/api/v1/catalog/ingest",
    headers={"X-API-Key": "cgpt_xxxxx"},
    json={"products": [{"product_id": "SKU-1001", "title": "Relaxed-Fit Linen Button-Up"}]},
)

Download a feed

GET /api/v1/catalog/feed/{format}

Download the compiled feed in the given formatacp, jsonld, or perplexity (same payloads as the Shopify feed downloads), but authenticated by X-API-Key. The key may be passed as the X-API-Key header or an ?api_key= query parameter. (llmstxt is not served on this route — use the Shopify llms.txt route.)

Path paramValues
formatacp, jsonld, perplexity

Errors

StatusResponse
400{ "detail": "Format must be 'acp', 'jsonld', or 'perplexity'" }
401{ "detail": "Invalid API key" }
404{ "detail": "No ACP feed data" } (per format)
curl https://developer-catalog-devID.region.run.app/api/v1/catalog/feed/jsonld \
  -H "X-API-Key: cgpt_xxxxx" -o jsonld_feed.json
import httpx

r = httpx.get(
    "https://developer-catalog-devID.region.run.app/api/v1/catalog/feed/jsonld",
    headers={"X-API-Key": "cgpt_xxxxx"},
)

Get enriched product

GET /api/v1/catalog/product/{product_id}/enriched

Full enriched record for one product: base fields, the AI narrative, Q&A pairs, and the compiled ACP / JSON-LD feed records.

Response — 200 OK

{
  "product_id": "SKU-1001",
  "product": {
    "name": "Relaxed-Fit Linen Button-Up",
    "description": "A breezy linen button-up...",
    "brand": "Cove & Coast",
    "category": "tops",
    "color": "sand",
    "image_url": "https://cdn.example.com/linen.jpg",
    "handle": "relaxed-fit-linen-button-up",
    "price": "69.00",
    "tags": "linen,summer",
    "status": "active"
  },
  "narrative": {
    "description": "Cut from breathable linen, this relaxed button-up...",
    "model_used": "gemini-2.5-flash",
    "generated_at": "2026-06-01 04:10:22+00:00"
  },
  "qa": [
    { "question": "Is it machine washable?", "answer": "Yes, cold wash...", "source": "ai", "display_order": 1 }
  ],
  "feeds": {
    "acp": { "...": "ACP object" },
    "jsonld": { "...": "Schema.org object" },
    "compiled_at": "2026-06-01 04:12:55+00:00"
  }
}

Errors

StatusResponse
401{ "detail": "Invalid API key" }
404{ "detail": "Product not found" }
curl https://developer-catalog-devID.region.run.app/api/v1/catalog/product/SKU-1001/enriched \
  -H "X-API-Key: cgpt_xxxxx"
import httpx

r = httpx.get(
    "https://developer-catalog-devID.region.run.app/api/v1/catalog/product/SKU-1001/enriched",
    headers={"X-API-Key": "cgpt_xxxxx"},
)
print(r.json()["narrative"]["description"])

Gateway API (/v1)

The discoverGPT gateway exposes a unified /v1/* surface authenticated with Authorization: Bearer <jwt> (see Authentication). All endpoints below require a valid token. The gateway is served at:

https://api.vairetail.com

Unified surface overview

The complete /v1 surface covers all four capabilities:

EndpointMethodCapabilityNotes
/v1/searchPOSTSearchTrimodal search — text, image, color
/v1/uploads/signPOSTSearchPresigned upload URL for an image-search reference image
/v1/ingestPOSTPlatformPush products into the canonical catalog
/v1/enrichPOSTData cleaningTrigger enrichment for product IDs
/v1/quality/overviewGETData cleaningCatalog-quality overview
/v1/quality/product/{product_id}GETData cleaningPer-product quality detail
/v1/feed/statusGETFeeds & GEOFeed pipeline status
/v1/feed/{feed_format}GETFeeds & GEODownload jsonld / llmstxt / manifest
/v1/feed/product/{product_id}/acpGETFeeds & GEOPer-product ACP record
/v1/visibility/scoreGETFeeds & GEOAI-visibility score + per-platform breakdown
/v1/visibility/productsGETFeeds & GEOPer-product AI-visibility status
/v1/visibility/trendsGETFeeds & GEOAI-visibility trend data
/v1/visibility/geo-recommendationsGETFeeds & GEOGEO gap recommendations
/v1/vto/composePOSTVirtual try-onVirtual try-on image composition — usage-metered
/v1/vto/metricsGETVirtual try-onTry-on engagement & conversion metrics
/v1/billing/checkout-sessionPOSTPlatformStart a Stripe checkout
/v1/billing/portal-sessionPOSTPlatformOpen the Stripe billing portal
/v1/checkoutPOSTPlatformAgentic checkout — Preview
/v1/checkout/{checkout_id}GETPlatformCheckout status — Preview
/mcpSSEPlatformMCP transport

The search, ingest, enrich, and visibility endpoints map to the same underlying services described in the Search and Feeds & GEO sections — the gateway adds the OAuth layer and a unified host. The sections below document the billing, feed, quality, virtual try-on, checkout, and MCP surfaces in detail.

Billing

Billing endpoints let you initiate Stripe subscription flows on behalf of a user. All require Authorization: Bearer <jwt>.

Start a checkout session

POST /v1/billing/checkout-session

Creates a Stripe Checkout session for the specified tier and billing interval. Returns a redirect URL that you open in the user's browser.

Request body (CheckoutSessionBody):

FieldTypeRequiredDefaultDescription
tierstringyesTier name — case-sensitive: Growth or Pro. (Starter is free — no checkout needed; Platinum is sales-led — contact us.)
intervalstringnomonthBilling interval: month or year

Response — 200 OK:

{ "url": "https://checkout.stripe.com/pay/cs_live_..." }

Example:

curl -X POST https://api.vairetail.com/v1/billing/checkout-session \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "tier": "Growth", "interval": "month" }'

Redirect the user to the returned url to complete payment. On success, Stripe redirects back to your configured return URL.

Open the billing portal

POST /v1/billing/portal-session

Creates a Stripe Customer Portal session where the customer can view invoices, update payment methods, and manage their subscription.

Response — 200 OK:

{ "url": "https://billing.stripe.com/session/bps_live_..." }

Example:

curl -X POST https://api.vairetail.com/v1/billing/portal-session \
  -H "Authorization: Bearer $TOKEN"

Stripe webhook — not for partner calls

POST /stripe/webhook handles Stripe-to-platform event sync (subscription updates, payment failures, etc.). It is signature-verified and intended only for Stripe's servers, not for your integration code.

Feed

Feed endpoints expose discoverGPT's feed output via the gateway. All require Authorization: Bearer <jwt>. For the Shopify-authenticated feed downloads (/api/v1/feeds/*) see Feed downloads (Shopify).

Feed status

GET /v1/feed/status

Returns feed pipeline status — last compiled/pushed timestamps, issue count, and enabled formats.

Example:

curl https://api.vairetail.com/v1/feed/status \
  -H "Authorization: Bearer $TOKEN"

Plausible response shape (full schema is in the OpenAPI spec):

{
  "total_products": 1240,
  "last_compiled": "2026-06-20 04:12:55+00:00",
  "acp_enabled": true,
  "jsonld_enabled": true,
  "unresolved_issues": 7
}

Download a feed

GET /v1/feed/{feed_format}

Downloads the compiled feed in the given format. Used by the MCP get_product_details and get_merchant_brand_context tools internally.

Path paramValues
feed_formatjsonld, llmstxt, or manifest

Example:

curl https://api.vairetail.com/v1/feed/jsonld \
  -H "Authorization: Bearer $TOKEN" \
  -o jsonld_feed.json

Per-product ACP record

GET /v1/feed/product/{product_id}/acp

Returns the Agentic Commerce Protocol (ACP) record for a single product from the merchant catalog feed.

Example:

curl https://api.vairetail.com/v1/feed/product/SKU-1001/acp \
  -H "Authorization: Bearer $TOKEN"

Quality

Quality endpoints expose discoverGPT's data-quality assessments via the gateway. All require Authorization: Bearer <jwt>.

Catalog quality overview

GET /v1/quality/overview

Returns catalog-wide quality metrics: status counts, average scores, and quality-band distribution across all products.

Example:

curl https://api.vairetail.com/v1/quality/overview \
  -H "Authorization: Bearer $TOKEN"

Plausible response shape:

{
  "total_products": 1240,
  "avg_score": 0.74,
  "band_distribution": { "excellent": 410, "good": 520, "fair": 230, "poor": 80 },
  "status_counts": { "clean": 960, "pending": 180, "rejected": 100 }
}

Per-product quality detail

GET /v1/quality/product/{product_id}

Returns before/after content, scores, and synthesized quality issues for a single product.

Example:

curl https://api.vairetail.com/v1/quality/product/SKU-1001 \
  -H "Authorization: Bearer $TOKEN"

Plausible response shape:

{
  "product_id": "SKU-1001",
  "original": { "title": "BLUE DRESS", "description": "nice dress" },
  "clean": { "title": "Women's Blue Summer Midi Dress", "description": "A breezy blue midi dress..." },
  "scores": { "title": 0.82, "description": 0.71, "overall": 0.77 },
  "status": "pending"
}

Virtual Try-On

Virtual Try-On composes a shopper (or model) image wearing your garments — the one usage-metered discoverGPT capability. Both endpoints require Authorization: Bearer <jwt> and are scoped to the merchant in your token. The gateway derives merchant_id from the JWT and bridges it to the Virtual Try-On service internally; you never send a merchant ID or any Virtual Try-On credential yourself.

Metering — pay only for a live compose

A live (cache-miss) compose is metered at ~$0.18 and counts against your plan's VTO allotment / prepaid credits. Cache hits and subject-only lookups are free — repeating the same subject + garment combination returns the cached image at no cost. A content-filtered result (blocked: true) is not billed.

Compose a try-on image

POST /v1/vto/compose

Generates an image of the subject wearing the supplied garment(s) and returns a public URL to the composed image. Identify the subject and garments by their catalog IDs; you can override the image for any slot with a *_url, or reference a product *_handle. dress is mutually exclusive with top / bot.

Request body (ComposeRequest)

FieldTypeRequiredDefaultDescription
subjectstringyesModel/avatar base-image ID (or URL) to dress.
topstringnoTop garment ID.
botstringnoBottom garment ID.
dressstringnoDress garment ID (excludes top/bot).
shoestringnoShoe garment ID.
top_url, bot_url, dress_url, shoe_urlstringnoImage-URL override for that slot.
top_handle, bot_handle, dress_handle, shoe_handlestringnoProduct handle for that slot.
sourcestringnoEntry-point tag for try-on analytics: header, pdp, card, search, or standalone.
refreshbooleannofalseBypass the cache and force a fresh (metered) compose.

The body is strict — unknown fields (including a merchant_id) are rejected with 422.

Response — 200 OK

{
  "path": "https://storage.googleapis.com/.../vto/abc123.png",
  "cached": false,
  "latency_ms": 1234,
  "calls_made": 1,
  "source": "live",
  "model": "gemini-3.1-flash-image-preview",
  "credits_remaining": 9,
  "credits_window": "2026-06-30"
}
  • path — public URL of the composed image (always present).
  • cachedtrue for a free cache hit (then latency_ms and calls_made are 0); false for a metered live compose.
  • source"live" (metered), a cache source ("gemini-cache", "matrix", "outfit-cache"), or "subject" for a subject-only lookup.
  • credits_remaining / credits_window — appended only on a metered live success.

A content-filtered garment returns 200 with { "blocked": true, "reason": "content_filtered", "message": "Try-on preview isn't available for this item." } (not billed). If Virtual Try-On is not enabled for the merchant, the gateway returns 403 { "detail": { "error": "entitlement_denied", "message": "..." } }.

Example

curl -X POST https://api.vairetail.com/v1/vto/compose \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "subject": "model-aria", "dress": "SKU-1001", "source": "pdp" }'
import httpx

r = httpx.post(
    "https://api.vairetail.com/v1/vto/compose",
    headers={"Authorization": f"Bearer {token}"},
    json={"subject": "model-aria", "dress": "SKU-1001", "source": "pdp"},
)
print(r.json()["path"], "cached:", r.json()["cached"])

Try-on metrics

GET /v1/vto/metrics

Returns try-on engagement for the merchant over a rolling window: try-on counts (live vs cached, distinct shoppers), a breakdown by entry point, the most tried-on products, and the try-on → add-to-cart conversion rate. Read-only and not metered.

Query parameters

ParamTypeDefaultRangeDescription
window_hoursinteger7201–8784Look-back window. ≤ 48 yields hourly buckets, otherwise daily.

Response — 200 OK

{
  "entitled": true,
  "merchant_id": 12345,
  "metrics": {
    "windowHours": 720,
    "granularity": "day",
    "generated_at": "2026-06-30T12:00:00Z",
    "tryOns": {
      "total": 1840,
      "distinctShoppers": 1120,
      "live": 610,
      "cached": 1230,
      "byEntryPoint": { "header": 120, "pdp": 980, "card": 410, "search": 230, "standalone": 100, "unknown": 0 },
      "byDay": [ { "date": "2026-06-01", "count": 62 } ]
    },
    "topProducts": [ { "product_id": "gid://shopify/Product/8412345678", "count": 240 } ],
    "cart": {
      "totalAdds": 410,
      "distinctShoppers": 360,
      "byEntryPoint": { "pdp": 300, "card": 110 },
      "byDay": [ { "date": "2026-06-01", "count": 14 } ]
    },
    "conversion": { "shoppersTriedOn": 1120, "shoppersAddedToCart": 360, "tryOnToCartRate": 0.321 },
    "checkout": { "status": "pending", "note": "Checkout is a redirect to Shopify-hosted checkout; order attribution needs a Shopify order webhook, tracked separately." }
  }
}

A merchant without Virtual Try-On enabled returns 200 { "entitled": false, "merchant_id": 12345 }. The checkout block is pending — try-on → order attribution is tracked separately via a Shopify order webhook.

Example

curl -G https://api.vairetail.com/v1/vto/metrics \
  -H "Authorization: Bearer $TOKEN" \
  --data-urlencode "window_hours=168"

Checkout (Preview — not yet live)

Preview — not yet live

The checkout endpoints implement the Agentic Commerce Protocol (ACP) checkoutable-product flow. They are included in the API surface for forward-compatibility planning but are not yet live. Do not build production dependencies on these endpoints.

Create an agentic checkout

POST /v1/checkout

Create an agentic checkout session for a product (ACP-compliant flow).

Request body (CreateCheckoutBody):

FieldTypeRequiredDefaultDescription
product_idstringyesThe product to check out
variant_idstringnoSpecific variant (optional)
quantityintegerno1Quantity (minimum 1)

Example:

curl -X POST https://api.vairetail.com/v1/checkout \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "product_id": "SKU-1001", "quantity": 1 }'

Get checkout status

GET /v1/checkout/{checkout_id}

Returns the status of a checkout session created by POST /v1/checkout.

Example:

curl https://api.vairetail.com/v1/checkout/chk_abc123 \
  -H "Authorization: Bearer $TOKEN"

MCP transport

discoverGPT exposes an MCP (Model Context Protocol) transport over Server-Sent Events (SSE) at:

https://api.vairetail.com/mcp/sse

MCP-aware hosts (Claude, ChatGPT with MCP support, Cursor, custom agents) connect to this endpoint and authorize once through a browser consent screen — there is no static token to paste or rotate (see Authorization below). The host then discovers the full tool set automatically. The server currently exposes 24 tools:

ToolWhat it does
search_products_trimodalTrimodal product search — text + image + color — over the merchant catalog
browse_catalogFacet-driven catalog browse (category, brand, price range, …)
get_product_detailsComposed discovery + transaction + JSON-LD detail for one product
recommend_complementary_productsComplementary / cross-sell products for a given product
get_merchant_brand_contextBrand voice + catalog manifest to ground downstream tool calls
get_visibility_scoreAI-visibility score + per-platform breakdown for the catalog
get_visibility_productsPer-product AI-visibility status across platforms
get_visibility_trendsAI-visibility trend data over a configurable lookback window
get_geo_recommendationsGeo-gap recommendations from not-visible products across platforms
get_feed_statusFeed pipeline status — last compiled/pushed, issue count, enabled formats
get_product_acpPer-product ACP (agentic-commerce) records from the catalog feed
get_catalog_quality_overviewCatalog-quality overview — status counts, average scores, quality bands
get_product_qualityPer-product quality — before/after content, scores, and issues
create_checkoutCreate an agentic checkout for a product (preview — not yet live)
get_checkout_statusStatus of a checkout session (preview — not yet live)
virtual_try_onVirtual try-on — dress a subject image in catalog garments (50 free per agency, then $0.18 pay-per-comp)
get_catalog_feedExport the full catalog feed in a format — jsonld / acp / gmc / perplexity / llmstxt
get_vto_creditsThe agency's virtual-try-on allowance — free used/remaining, prepaid credits, pay-per-comp
get_vto_metricsVirtual-try-on analytics over a configurable look-back window
get_ucp_profileUCP (Universal Commerce Protocol) discovery profile for the merchant
ucp_catalog_searchUCP Catalog Search — spec-conformant product search over the catalog
ucp_catalog_lookupUCP Catalog Lookup — UCP Product records for a list of ids
ucp_get_productUCP Catalog Get Product — a single UCP Product record by id
healthLiveness probe for the MCP transport

query_image_url dominates

On search_products_trimodal, a non-null query_image_url selects image-similarity search and ignores query_text / query_color in the same call. Run separate calls to combine modes. See Demo 2.

Authorization — connect once

The MCP endpoint uses the OAuth 2.1 "authorize once" flow from the MCP Authorization spec — not a static Bearer token. You never paste a client_secret for MCP: the host registers itself, opens a browser consent screen, and the connection refreshes itself from then on.

What happens on first connect:

  1. The host hits /mcp/sse, gets a 401 pointing at the gateway's OAuth discovery metadata, and reads .well-known/oauth-authorization-server.
  2. It registers itself automatically — OAuth Dynamic Client Registration (DCR); no pre-provisioned MCP client id.
  3. Your browser opens the consent screen at developer.vairetail.com/authorize. You sign in, choose which merchant to grant access to, and approve.
  4. The host exchanges the grant (PKCE) for an access token and a refresh token, then connects.
  5. When the short-lived access token expires, the host refreshes silently — you authorize once and stay connected.

One-command setup (Claude Code):

claude mcp add --transport sse discovergpt https://api.vairetail.com/mcp/sse

Then run /mcp in Claude Code → discovergpt → Authenticate, and complete the browser consent (pick the demo merchant 98906636598 or one of your own). The tools appear and stay authorized. (If the server was just added, restart Claude Code so it loads before you authenticate.)

Generic MCP host config (Claude Desktop, Cursor, and other mcpServers hosts):

{
  "mcpServers": {
    "discovergpt": {
      "transport": "sse",
      "url": "https://api.vairetail.com/mcp/sse"
    }
  }
}

No Authorization header or API key here — the host runs the browser OAuth flow above on first connect.

MCP vs. REST auth

MCP uses the authorize-once OAuth flow above (browser consent + silent refresh). Direct REST calls to /v1/* use the client-credentials grant with your client_id / client_secret — see Authentication. Both bill through the same account, tiers, and quotas, and both can use the demo merchant 98906636598.

Demos

Three copy-paste POCs you can run today against the shared demo catalog — no catalog upload, no production access required. Every new account is auto-authorized for demo merchant 98906636598 (read-only, real products), so these calls return live results on your first request.

Try it live: the interactive search demo runs real search_products_trimodal queries against the shared demo catalog — type words, pick a precise color, or paste an image URL and get live product results (no token needed). A self-contained Artifact version shows the same widget as a shareable, offline preview.

What you need

A dev client_id / client_secret (see Authentication) and a 15-minute access token scoped to the demo merchant:

curl -s -X POST https://api.vairetail.com/oauth/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d grant_type=client_credentials \
  -d "client_id=$CLIENT_ID" \
  -d "client_secret=$CLIENT_SECRET" \
  -d "scope=merchant:98906636598" | jq -r .access_token
DemoWhat it showsTransport
1 — NL shopping agentAn agent that answers shopping questions over your catalogMCP (agent-callable)
2 — Image similarity"Find products like this photo"MCP + presigned upload, or direct REST
3 — Search widgetA drop-in trimodal search box for a storefrontStarter-kit React component

Demo 1 — Natural-language shopping agent (MCP)

Point any MCP-aware host at discoverGPT and it discovers ready-to-use catalog tools — no glue code. This demo uses Claude Desktop; the same tools work in any MCP host.

Setup — add discoverGPT to claude_desktop_config.json (~/Library/Application Support/Claude/ on macOS):

{
  "mcpServers": {
    "discovergpt": {
      "url": "https://api.vairetail.com/mcp/sse",
      "transport": "sse"
    }
  }
}

Restart Claude Desktop. On first use it opens the browser consent screen — sign in, pick the demo merchant 98906636598, and approve (the authorize-once flow; no token to paste). tools/list then surfaces search_products_trimodal, browse_catalog, get_product_details, recommend_complementary_products, and get_merchant_brand_context.

Example prompts to build the agent's behavior

  • "Use search_products_trimodal to find linen dresses under $100. Show the first three as a table with name, price, and product_id."
  • "For that first product, call get_product_details and tell me the material and whether it's machine washable."
  • "Call recommend_complementary_products on that product_id — what pairs with it?"
  • "Ground yourself first: call get_merchant_brand_context, then recommend an outfit in the brand's voice."

The model invokes the tools and renders the discovery field group — the same UCP + Schema.org product records the Search API returns. (A parallel transaction field group for agentic checkout is a preview capability, not live yet.)

Auth modes supported

The MCP connection authorizes once via browser consent — no token to paste (see MCP transport). The direct REST calls in Demo 2 and the widget in Demo 3 use a short-lived Bearer JWT from the client-credentials grant.

"Find products like this photo." Two ways to run it.

2a — Agent / MCP path. First turn a local file into a public URL with the presigned-upload endpoint, then pass that URL as query_image_url.

TOKEN=<15-min token>
# 1. get a presigned PUT URL
SIGN=$(curl -s -X POST https://api.vairetail.com/v1/uploads/sign \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{"content_type":"image/jpeg","filename":"ref.jpg"}')
UPLOAD_URL=$(echo "$SIGN" | jq -r .upload_url)
PUBLIC_URL=$(echo "$SIGN" | jq -r .public_url)
# 2. upload the bytes straight to GCS (never touches our server)
curl -s -X PUT "$UPLOAD_URL" -H "Content-Type: image/jpeg" --data-binary @ref.jpg
# 3. search by image
curl -s -X POST https://api.vairetail.com/v1/search \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d "{\"query_image_url\":\"$PUBLIC_URL\",\"limit\":8}" | jq '.discovery'

In an agent, the same query_image_url goes straight into search_products_trimodal.

Image search does not combine with text or color

When query_image_url is present it drives the search on its own — any query_text or query_color in the same call is ignored (image dominates). discoverGPT does not fuse image + text in a single request. To combine intents, run the modes as separate calls and merge the results client-side.

2b — Direct REST POC (server-side, not agent-callable). If you already have the image bytes server-side, the legacy multipart endpoint skips the upload step entirely:

curl -s -X POST https://developer-search-devID.region.run.app/api/v1/product/search/image \
  -H "X-Merchant-ID: 98906636598" \
  -F "file=@ref.jpg" -F "limit=8" | jq '.results'

This is a different mechanism — a direct product-service call using the X-Merchant-ID header, not the gateway Bearer token, and not exposed as an MCP tool. Use it for quick server-side POCs; use 2a for anything agent-facing.

Demo 3 — Drop-in search widget

The starter kit ships a React <TrimodalSearch /> component that renders a text + color + image search box and calls the MCP tools for you.

Setup (clone → 3 env vars → running in ~15 min):

git clone <your fork of vairetail-app-starter-kit>
cd vairetail-app-starter-kit
cp .env.example .env
# set:
#   VITE_MERCHANT_ID=98906636598
#   VITE_AGENCY_CLIENT_ID=<your client_id>
#   VITE_MCP_ENDPOINT_URL=https://api.vairetail.com/mcp/sse
#   VITE_PRESIGNED_UPLOAD_URL=https://api.vairetail.com/v1/uploads/sign
#   VITE_DEV_BEARER_TOKEN=<15-min token, dev only>
npm install
npm run dev

Open http://localhost:5173. Type a query, optionally pick a color or upload a reference image — the image upload uses the same /v1/uploads/sign flow as Demo 2, so the same "image dominates" rule applies.

Where the component lives

<TrimodalSearch /> is exported from @vairetail/apps-sdk-iframe; the starter kit wires it into an Apps-SDK-compatible iframe. Customize the stubs in src/components/ freely.

Terms of Service

Pre-GA draft

These terms are published ahead of discoverGPT general availability and may be updated before launch. The version in effect at the time you access the API governs your use. Material changes will be announced in the Changelog.

Effective date: 2026-06-26

These Terms of Service ("Terms") govern access to and use of the discoverGPT API, MCP transport, dashboards, SDKs, sample code, and related services (collectively, the "Services") provided by visualAI retail solutions, inc, a California corporation ("visualAI", "we", "us"). By requesting credentials, calling the Services, or otherwise accessing them, you and the organization you represent ("you", "Partner") agree to these Terms. If you do not agree, do not use the Services.

1. Accounts and credentials

Credentials (client_id / client_secret, Bearer tokens, and any legacy X-Merchant-ID access) are issued per agency partner during onboarding. You are responsible for keeping credentials confidential, for all activity under your account, and for the acts and omissions of every developer, store, and end user you grant access to. Notify enterprise@vairetail.com immediately if you suspect credentials have been compromised. One billing account is intended to represent one agency or organization; provisioning multiple accounts to split usage and evade tier limits is prohibited (see Section 4).

2. License grant

Subject to your compliance with these Terms and payment of applicable fees, visualAI grants you a limited, non-exclusive, non-transferable, non-sublicensable, revocable license to access the Services and to integrate their outputs into your own applications and into the storefronts of merchants you are authorized to act for. All rights not expressly granted are reserved by visualAI.

3. Ownership and data rights

  • Your catalog data. As between the parties, you and your merchants retain all rights to the product catalog data you submit ("Partner Data"). You grant visualAI a worldwide license to host, process, and transform Partner Data solely to provide, secure, and support the Services.
  • The engine. visualAI retains all right, title, and interest in the Services, including the underlying models, embeddings, rankings, indexes, software, and documentation, and in all improvements to them. The Services are licensed, not sold.
  • Outputs. You may use search results, recommendations, feeds, enriched fields, and composed images returned by the Services ("Outputs") for the authorized merchant storefronts and applications described in Section 2. visualAI retains all intellectual property rights in the Services that produce those Outputs.
  • Usage data. visualAI may collect and use aggregated, de-identified usage and performance data (for example, query volumes, latencies, and relevance signals) to operate, secure, and improve the Services. visualAI will not publish Partner Data or merchant-identifying information.

4. Fees, tiers, and metering

Access is gated by aggregate SKUs at the billing-account level and by the plan tiers described in Plans & Pricing. Virtual Try-On compose calls are usage-metered. You agree not to circumvent, disable, or artificially inflate or split SKU counts, rate limits, quotas, or fair-use caps. We may meter usage, throttle, or require an upgrade when limits are exceeded. Fees are non-refundable except where required by law.

5. Acceptable use and restrictions

Your use of the Services is subject to the Acceptable Use & Restrictions section below, which is incorporated into these Terms by reference. Violation of that section is a material breach.

6. Suspension and termination

We may suspend or terminate access, with or without notice, if you breach these Terms, if your use poses a security, legal, or operational risk, or for non-payment. You may stop using the Services at any time. On termination, the license in Section 2 ends and you must cease using the Services and Outputs, except that Outputs already delivered to and displayed on an authorized merchant storefront may remain in place. Sections 3, 5, 7, 8, and 9 survive termination.

7. Confidentiality

Non-public information disclosed in connection with the Services, including credentials, pricing, and unreleased features, is confidential and may be used only to exercise rights under these Terms.

8. Disclaimers and limitation of liability

The Services are provided "as is" and "as available", without warranties of any kind, express or implied, including merchantability, fitness for a particular purpose, and non-infringement. visualAI does not warrant that the Services will be uninterrupted, error-free, or that Outputs will be accurate or complete. To the maximum extent permitted by law, visualAI's aggregate liability arising out of or related to the Services is limited to the fees you paid in the three months preceding the claim, and visualAI is not liable for indirect, incidental, special, consequential, or exemplary damages.

9. Governing law and changes

These Terms are governed by the laws of the State of California, without regard to conflict-of-laws rules. We may update these Terms; the version in effect when you access the Services governs, and material changes will be posted to the Changelog. Questions: enterprise@vairetail.com.

Acceptable Use & Restrictions

This Acceptable Use & Restrictions section is part of the Terms of Service. It defines what you may and may not do with the discoverGPT API, the MCP transport, and any Outputs.

Permitted use

The Services are built for agencies and developers to embed visualAI's capabilities into their own applications and into the storefronts of merchants they are authorized to serve. You may:

  • Integrate search, discovery, recommendations, enrichment, feeds, GEO, and virtual try-on into merchant storefronts and your own merchant-facing tooling.
  • Build AI commerce and merchandising agents on top of the REST API and MCP tools for those merchants.
  • Operate across unlimited stores and seats within your tier, subject to the aggregate SKU gate.

Prohibited use

You may not, directly or indirectly, and may not permit any third party to:

  1. Build or operate a competing service. Use the Services, the MCP tools, or any Outputs to develop, train, market, or operate a product or service that competes with discoverGPT or any visualAI capability, including visual or image search, product discovery, catalog data enrichment, AI-discovery or agent feeds, AI-visibility / GEO, or virtual try-on.
  2. Resell or white-label the engine. Resell, sublicense, rent, lease, time-share, or otherwise make the Services or their Outputs available to third parties as a standalone search, discovery, enrichment, feed, or try-on service, or expose the Services under your own or a third party's brand as a competing API or widget. Embedding Outputs into an authorized merchant's storefront is permitted; offering the engine itself to others as a service is not.
  3. Train competing models. Use Outputs, embeddings, vectors, rankings, or responses to train, fine-tune, distill, or benchmark any machine-learning model intended to replicate or compete with the Services.
  4. Extract or reconstruct the system. Scrape, bulk-export, harvest, or reconstruct the catalog index, embeddings, ranking logic, or model behavior; or reverse engineer, decompile, or disassemble any part of the Services, except to the limited extent that restriction is prohibited by applicable law.
  5. Circumvent limits. Evade or attempt to evade SKU tiers, rate limits, quotas, fair-use caps, or authentication, including by registering multiple accounts to split or disguise usage.
  6. Misuse or abuse the Services. Submit unlawful, infringing, or rights-violating content; probe or breach security; introduce malware; or use the Services in violation of applicable law or third-party rights.

High-volume, reseller, and platform use

Reseller, white-label, OEM, platform-embedding, and other high-volume arrangements are available only under a separate written agreement (the sales-led Platinum tier). visualAI reviews such use cases individually and may approve, condition, or decline provisioning at its discretion. Self-serve and standard-tier credentials do not grant reseller or white-label rights, and continued self-serve use does not create them by default. To discuss a reseller or platform arrangement, contact enterprise@vairetail.com.

Agency use vs. reselling the engine

The line is simple: using discoverGPT to power the storefronts and applications of merchants you serve is the intended use. Repackaging discoverGPT's search, discovery, enrichment, feeds, or try-on as your own competing service for third parties is not, and requires a separate written agreement.

Enforcement

We may investigate suspected violations and may suspend or terminate access under Section 6 of the Terms of Service. We may also require you to certify compliance with this section as a condition of continued or expanded access.

Changelog

2026-07-17 — UCP Catalog; GMC feed retired

  • Added the UCP (Universal Commerce Protocol) Catalog query API — a per-merchant discovery profile (GET /.well-known/ucp/{merchant_id}) plus search / lookup / product endpoints. Unlike the downloadable feeds, agents query UCP directly rather than fetching a file.
  • Retired the GMC (Google Merchant Center) feed. Shopify's native Google & YouTube sales channel syncs products to Merchant Center directly, so the feed was redundant. GET /api/v1/feeds/gmc and format=gmc are removed, and feed completeness now scores ACP + JSON-LD. catalogGPT's focus is the AI/agentic formats (ACP, JSON-LD, UCP, Perplexity, llms.txt).

2026-06-30 — Virtual Try-On, more catalogGPT feeds & synonyms

2026-06-26 — Terms of Service & Acceptable Use

  • Published the Terms of Service and Acceptable Use & Restrictions, covering license scope, data and engine ownership, tier/metering anti-circumvention, the no-competing-service and no-white-label-resale restrictions, and the sales-led path for reseller / platform use.

2026-06-24 — discoverGPT reframe + Gateway API (/v1)

  • Reframed docs as discoverGPT platform docs (four capabilities: shopperGPT, cleanerGPT, catalogGPT, studioGPT).
  • Added Introduction with full value proposition, what's included at every tier, and pricing table (Starter/Growth/Pro/Platinum).
  • Rewrote Authentication — OAuth 2.0 client-credentials is now the primary method; legacy X-Merchant-ID preserved as "Direct product API (legacy)".
  • Rewrote Quickstart around the OAuth token → POST /v1/search flow; legacy quickstart retained as a sub-section.
  • Added Gateway API (/v1) section: Billing (checkout-session, portal-session), Feed (status, download, per-product ACP), Quality overview + per-product, Checkout Preview, and MCP transport at /mcp.

2026-06-22 — Visibility, Analytics, Synonyms & more

2026-06-03 — cleanerGPT & catalogGPT APIs

  • Added a Platform overview and dedicated cleanerGPT API and catalogGPT API references.
  • Documented the cleanerGPT review workflow — overview, products, approve, reject, edit, undo, push, push status/history, rollback, and config.
  • Documented catalogGPT feed downloads (ACP, JSON-LD, GMC) and the API-key-authenticated Public API (generate-key, ingest, feed, enriched).

2026-04-10 — Initial public docs

  • Launched docs.vairetail.com.
  • Documented 20 in-service endpoints across Search, User, Pipeline, Merchant, and Utilities.
  • Published the canonical Data Model and accepted CSV / NDJSON formats.
  • Shipped the client-side Column Mapper.

Coming soon

  • Per-request outcome selection for catalog feed compile and AI visibility (GEO) on POST /v1/ingest — natural-language search, color search, image search, clean data, generate narratives, and generate Q&A are all selectable today.
  • API key authentication alongside X-Merchant-ID.
  • Non-Shopify merchant onboarding self-service.

Support

Email

enterprise@vairetail.com — integration questions, onboarding, API keys, bug reports.

When reporting an issue, include:

  1. The X-Request-ID header from the failing response (every response includes one).
  2. The full request body you sent (with PII redacted).
  3. The response status code and body.
  4. Your merchant ID.

Marketing site

vairetail.com — product overview, case studies, and scheduling a demo.