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 pay-as-you-go, down to $0.10 at volume with prepaid credits — see Virtual Try-On.

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 pay-as-you-go, or prepaid credit packs down to $0.10/compose at volume. Each paid tier includes a monthly allotment — see Virtual Try-On for the full pricing ladder.
  • 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

Default to the unified gateway — direct endpoints aren't publicly reachable yet

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, not real, resolvable URLs. Real per-service hostnames exist internally today but aren't published, and won't be until GA. If you don't have a specific reason to call a direct endpoint, use the unified gateway's POST /v1/* paths instead — most direct-endpoint capabilities (search, quality, feeds, visibility) already have a gateway equivalent, listed in the table above. If your integration genuinely needs a direct endpoint before GA, email enterprise@vairetail.com for the real host.

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. The direct-endpoint sections below (Quality, Feeds and Visibility, etc.) document that lower-level contract for reference — they are not a second way to reach the same data with today's published hostnames.

Not just the API — the same pipeline code runs our own Shopify apps

The ingest + enrichment pipeline behind discoverGPT's four capability APIs isn't discoverGPT-only infrastructure. Our Shopify apps for search, data-cleaning, and catalog syndication each already run their own dedicated orchestration service today; discoverGPT's push ingest — for catalogs with no Shopify install at all — is being moved onto its own dedicated service the same way (in progress). None of them reimplement the pipeline to get there: every service runs on the same underlying job code, tables, and Foundation/Outcomes model, not a parallel implementation built to look similar. Independent, separately deployable services; one shared pipeline underneath.

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. Some of these direct endpoints authenticate with the X-Merchant-ID header; others — most of data cleaning, and part of feeds & GEO — require a verified caller identity instead (see Endpoints that require more than X-Merchant-ID for exactly which). 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 35 moreFeeds & GEO · Data cleaning · Virtual try-on · UCPAI-visibility (score / products / trends / geo), feed status + ACP + full feed export, catalog + product quality plus the full review-and-publish workflow (list / approve / reject / undo / edit / push / config / history), checkout (cart + session management, preview), virtual try-on (+ credits / metrics), and UCP (Universal Commerce Protocol) catalog search / lookup / product + checkout session — 40 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 98334572911). Scope your token to it and search immediately.

The demo catalog is read-only — it is shared across every account, so no tier can write to it. To ingest your own products, create your own merchant: in the dashboard open Merchants → Create merchant. It provisions one under your organization and authorizes it for your existing API keys immediately, with no waiting on us. Then scope your token to that merchant id instead. See Create a merchant.

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="98334572911"   # 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:98334572911"
}

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:98334572911",
}).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:98334572911",
  }),
});
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 98334572911, 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 self-serve: sign up at developer.vairetail.com, then open API Keys → Create key in the dashboard (see Quickstart step 2). Keys are issued per agency, not per store. Email enterprise@vairetail.com only if you need something the dashboard cannot do — a custom plan, or an existing Shopify store adopted into your organization.

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 Search API direct-service endpoint requires the header.

Endpoints that require more than X-Merchant-ID

A bare X-Merchant-ID is sufficient for the Search API above and for a few other direct endpoints (pipeline/status, synonyms/*). It is not sufficient for the following route families — a security fix closed the gap where a client-suppliable Shopify store ID (not a secret) was trusted as identity on its own:

Route family (service)Endpoints
Feed downloads (catalogGPT)acp, jsonld, perplexity, llmstxt, status, health — see Feed downloads
AI Visibility and GEO (catalogGPT)score, products, trends, issues, readiness — see AI Visibility and GEO
BYO visibility keys (catalogGPT)list / set / delete — see BYO visibility keys
Feed health detail (catalogGPT)summary, product/{id}, enrichment-quality, issues — see Feed health detail
Generate/rotate a Public API key (catalogGPT)POST /api/v1/catalog/generate-key — see Generate an API key
Quality API, all 13 endpoints (cleanerGPT)overview, products, product detail, approve, reject, undo, edit, push, push status, history, rollback, config (get/update) — see Quality API

These require Authorization: Bearer <session token> — the Shopify App Bridge session token the discoverGPT dashboard attaches automatically inside the embedded app — instead of, or in addition to, X-Merchant-ID. It is not something a third-party integration can construct standalone. Sent only X-Merchant-ID, these now return 401 with "Missing or invalid caller identity" (catalogGPT), "Missing or invalid credentials" (cleanerGPT), or "Missing session token" (generate-key specifically). Where the same data is reachable through the OAuth gateway instead, the linked section says so — most of these route families are not.

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.
401Unauthorized. A verified caller identity is missing or invalid on a route that requires one instead of a bare X-Merchant-ID — see Endpoints that require more than X-Merchant-ID.
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." }

401 — missing verified caller identity

Sent to a route that requires more than a bare X-Merchant-ID (see Endpoints that require more than X-Merchant-ID) without a valid Authorization: Bearer <session token>. The exact message depends on which service and dependency rejected the request:

{ "detail": "Missing or invalid caller identity" }
{ "detail": "Missing or invalid credentials" }
{ "detail": "Missing session token" }

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 — any vertical (tops, dresses, lighting, wall art, skincare, …)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 fills this from product_name / product_details if missing; non-apparel products default to unisex. Kept for gender-filtered search — richer per-vertical attributes live in metadata_json.
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. Enrichment also writes a per-vertical attribute bag here for non-apparel products (e.g. material/style for home goods, medium/orientation for art) — your own keys are preserved alongside it.

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). Enrichment is vertical-aware — it classifies any product type, not just apparel; gender defaults to unisex outside apparel, and non-apparel products get a per-vertical attribute bag (material, style, room, medium, and similar, depending on category) written to metadata_json. 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.

Reliability & operational visibility

What actually happens when something fails, and how you'd find out — stated plainly rather than as a general resilience claim.

Retrying is safe. Every pipeline step is claimed exactly once per merchant before it runs — a duplicate webhook delivery, a retried request, or re-running ingest against a catalog you've already sent cannot cause a step to double-execute. Re-requesting an outcome "fills only the gaps" (above) for the same reason: the platform checks what's already done before doing anything.

Keeping data current is a re-sync, not a wait. For Shopify-installed apps this is automatic — see Webhook delivery & retry behavior above. For catalogs you push yourself, there's no background process that notices your source data changed on its own; call ingest again with the changed rows (or the outcomes you want refreshed) and only the gap gets processed. We'd rather say that plainly than claim a uniform automatic-freshness guarantee across every ingest path — explicit re-sync is the reliable way to keep push-ingested data current today.

Monitoring is pull, not push, today. GET /api/v1/pipeline/status/{merchant_id} is the source of truth for running / completed / failed and failed_steps — poll it after ingest, the same way the dashboard itself does. There's no email or in-app alert on pipeline failure yet and no outbound webhook to notify your systems (see the callout above) — if you need proactive alerting, build it on top of polling this endpoint for now.

Deletions require an explicit call. Removing a product from your source doesn't remove it from the canonical catalog on its own — see deletion-sync for the exact behavior and DELETE /api/v1/merchant/{merchant_id}/data for full removal.

Rate limits are usage-tier, not request-per-minute. See Plans & Pricing — every tier runs under a fair-use cap rather than a hard per-minute limit; exceeding it prompts an upgrade rather than throttling mid-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.

Webhook delivery & retry behavior

Webhook processing retries automatically on failure with exponential backoff — starting at 1 second, doubling each attempt, capped at 5 minutes between tries. A transient failure on our side (a deploy, a database blip) does not lose the update; it's picked up on the next successful attempt.

No outbound webhooks yet

This is inbound delivery only — Shopify notifying us of changes to your store. There is no outbound webhook subscription today for us to notify your systems of pipeline events (e.g. "enrichment finished for product X"). If you need to react to pipeline state, poll pipeline status — see Reliability & operational visibility for the honest current state of this and what to expect if you're building around it.

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 — non-apparel products default to unisex rather than a fabricated value. Color is extracted from image1 if not present in tags. Non-apparel products also get a per-vertical attribute bag (material, style, room, medium, and similar, depending on category) written to metadata_json — Shopify sells far more than apparel, and enrichment classifies whatever you send it.

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>

Scope your token to your own merchant

Your token's scope must name a merchant you own. A token scoped to the shared demo catalog (98334572911) is valid and reads fine, but every catalog-write route rejects it with 403 { "error": "demo_merchant_read_only" }. The demo catalog is read-only for everyone — that's not a plan or entitlement limit, and no tier changes it.

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": "Handle",
    "variant_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.
403{ "error": "demo_merchant_read_only", "message": "the shared demo catalog is read-only; create your own merchant to ingest data" } — your token is scoped to the shared demo catalog (98334572911). Re-scope it to one of your own merchants. Not a plan limit; no tier can write to the demo catalog.
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"

Create a merchant

Every account starts with only the shared demo catalog (98334572911), which is read-only. Before you can ingest anything you need a merchant of your own.

It is self-serve and takes one click:

  1. Open Merchants in the dashboard
  2. Click Create merchant
  3. Copy the merchant id it returns

The new merchant is authorized for your existing API keys immediately — no new credentials, no waiting on us. From then on, scope your token to that id (scope=merchant:<your merchant id>) instead of the demo catalog, and every route in this reference works against your own catalogue.

Which merchant should I ingest into?

Use your own merchant for anything real. Keep using the demo catalog for read-only experimentation — it stays authorized alongside your own merchants, so you can compare behaviour against a populated catalogue while yours is still filling up.

If you already run a Shopify store that should become the merchant, email enterprise@vairetail.com — adopting an existing store is not self-serve yet.

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, and create a merchant if you have not already — the demo catalog cannot be written to.

Ingest needs your own merchant — not the demo catalog

The shared demo catalog (98334572911) is read-only. It's auto-authorized on every new account for search, but every catalog-write route rejects it:

{ "error": "demo_merchant_read_only",
  "message": "the shared demo catalog is read-only; create your own merchant to ingest data" }

That's an HTTP 403 on POST /v1/ingest and POST /v1/ingest/file, and it is not a plan or entitlement limit — no tier can write to the demo catalog. Use one of your own merchants below. If your organization doesn't have one yet, create one — it's self-serve, one click in the dashboard, and it's authorized for your existing keys straight away.

1. Mint a token scoped to your merchant

export CLIENT_ID="YOUR_CLIENT_ID"
export CLIENT_SECRET="YOUR_CLIENT_SECRET"
export MERCHANT_ID="YOUR_MERCHANT_ID"   # one of your own merchants — NOT the demo catalog

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": "Handle", "variant_id": "SKU", "product_name": "Title", "price": "Price (USD)" },
  "estimated_rows": 4821,
  "sample_rows": [{ "product_id": "shirt-relaxed-linen", "product_name": "Relaxed Linen Button-Up" }],
  "headers": ["Handle", "SKU", "Title", "Price (USD)"]
}

A file whose only ID column is SKU needs an explicit mapping

Auto-detection maps a SKU column to variant_id, never to product_id. So a flat export with no separate product-id column returns 400:

{ "detail": "required field 'product_id' is not mapped to any column" }

That is deliberate, not a gap. product_id identifies the product; variant_id identifies one variant of it. In a file with several rows per product — one per size or colour, each with its own SKU — silently treating SKU as the product id would turn one product into N separate products and lose the variant grouping. We would rather reject the upload than corrupt your catalogue.

If SKU genuinely is your product identifier (one row per product), say so explicitly and it will be accepted:

curl -s -X POST https://api.vairetail.com/v1/ingest/file \
  -H "Authorization: Bearer $TOKEN" \
  -F "file=@catalog.csv" \
  -F 'mapping={"product_id":"SKU","product_name":"Title","price":"Price (USD)"}'

Better still, add a product-id column (Handle, Product ID, Item Number, Style Number, UPC and GTIN are all auto-detected) so variants group correctly without any mapping at all.

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

Two ways to call this. The OAuth gatewayPOST /v1/search, Bearer JWT — is the recommended server-to-server path and is live today. This direct /api/v0 endpoint is Shopify-embedded only (CORS-enforced, X-Merchant-ID header) and is retained for backward compatibility.

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.

Supported query languages

Queries are interpreted in English today. Non-English queries — including Arabic and other non-Latin scripts — aren't supported yet: they embed poorly and come back as loosely-related nearest matches rather than an accurate result. Your catalog data can be in any language; it's the query interpretation that's English-only for now. Broader language support is on the roadmap — flag it to your account contact if it matters for your market.

How ranking works — and why a query never returns zero results

Text search is semantic (vector) similarity, not keyword or exact-title matching. It always returns the nearest products in your catalog, ranked by distance (lower is better) — even when nothing is a genuine match. There is no server-side "no results": an off-catalog query (e.g. searching electronics against an apparel catalog) still returns your closest apparel. If you want a "nothing found" state, threshold on distance client-side and drop results above a cutoff you tune against your own catalog.

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

Auth modes supported

Two ways to call this. The OAuth gatewayPOST /v1/search, Bearer JWT — is the recommended server-to-server path and is live today. This direct /api/v0 endpoint is Shopify-embedded only (CORS-enforced, X-Merchant-ID header) and is retained for backward compatibility.

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

Two ways to call this. The OAuth gatewayPOST /v1/search, Bearer JWT — is the recommended server-to-server path and is live today. This direct /api/v0 endpoint is Shopify-embedded only (CORS-enforced, X-Merchant-ID header) and is retained for backward compatibility.

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

Two ways to call this. The OAuth gatewayPOST /v1/search, Bearer JWT — is the recommended server-to-server path and is live today. This direct /api/v0 endpoint is Shopify-embedded only (CORS-enforced, X-Merchant-ID header) and is retained for backward compatibility.

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

Prefer the gateway

This host is a placeholder (see Base URL) — GET /v1/quality/* on the unified gateway reaches the same data today. Use this section as the direct-endpoint reference contract, not as a callable URL.

Unlike the Search API, the Quality API does not trust a bare X-Merchant-ID header — see Authentication. Every endpoint below requires a verified caller identity: the merchant's own embedded-app session, or a trusted server-to-server caller.

HeaderRequiredValue
AuthorizationyesBearer <session token> — minted by the embedded dashboard, not something you construct yourself
Content-Typeon POSTapplication/json

Not directly callable by third-party integrators today

The X-Merchant-ID-only examples below illustrate the request/response shape — sent alone, X-Merchant-ID now returns 401 { "detail": "Missing or invalid credentials" }. Only this direct, first-party service is gated this way. Twelve of the thirteen endpoints have a working gateway equivalent at Authorization: Bearer <jwt> instead — the same self-serve OAuth token every other /v1/* route uses, no embedded-app session required: GET /v1/quality/overview, GET /v1/quality/product/{product_id}, GET /v1/quality/products (the paginated review list), GET /v1/quality/push-status, GET/POST /v1/quality/config, GET /v1/quality/history, and POST /v1/quality/approve / reject / undo / edit / push — a verbatim proxy of the same backend this direct API and the merchant's own dashboard both call. push writes live to the merchant's connected Shopify store; there is no undo once it succeeds. Only rollback (POST /rollback/{product_id}, POST /rollback-all, GET /rollback-status) has no gateway path — that one really is reachable only from the merchant's own embedded-app session. Need it for an integration? Email enterprise@vairetail.com.

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, AI Visibility, BYO keys, and feed health — for Shopify merchants, gated behind a verified caller identity (embedded-app session or a trusted server-to-server caller) — not the bare X-Merchant-ID model the Search API uses. See Authentication and the trust-model note at the top of each subsection below. Prefixes /api/v1/feeds, /api/v1/visibility, /api/v1/visibility-keys, /api/v1/health.
  • Public API — for non-Shopify merchants, authenticated with an X-API-Key you mint via generate-key (itself gated behind a verified embedded-app session, not X-Merchant-ID). 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

Prefer the gateway

This host is a placeholder (see Base URL) — GET /v1/feed/* and GET /v1/visibility/* on the unified gateway reach the same data today. Use this section as the direct-endpoint reference contract, not as a callable URL. The public, no-auth UCP endpoints below (GET /.well-known/ucp/{id} and its search/lookup/ product siblings) are the one part of this section already reachable via the gateway under /v1/ucp/* — see UCP Catalog.

Feed downloads (Shopify)

These endpoints serve the compiled feed for the merchant identified by the caller's verified identity — not a bare X-Merchant-ID header. Every route below (acp, jsonld, perplexity, llmstxt, status, health) requires Authorization: Bearer <session token> (the embedded-app session) or a trusted internal caller; sent with only X-Merchant-ID, each now returns 401 { "detail": "Missing or invalid caller identity" }. See Endpoints that require more than X-Merchant-ID for the full picture.

HeaderRequiredValue
AuthorizationyesBearer <session token> — minted by the embedded dashboard

Gateway equivalents

status, jsonld, and llmstxt below mirror GET /v1/feed/status and GET /v1/feed/{jsonld,llmstxt} in the Gateway API's Feed section (Authorization: Bearer <jwt>). acp has only a per-product equivalent there — GET /v1/feed/product/{product_id}/acp — not a full-feed download. perplexity and health have no gateway path yet.

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 require the same verified caller identity as the feed downloads above — not a bare X-Merchant-ID header; sent alone it now returns 401 { "detail": "Missing or invalid caller identity" }.

HeaderRequiredValue
AuthorizationyesBearer <session token> — minted by the embedded dashboard

Gateway equivalents

score, products, and trends below mirror GET /v1/visibility/score, GET /v1/visibility/products, and GET /v1/visibility/trends on the gateway (Authorization: Bearer <jwt>). issues and readiness have no gateway equivalent — GET /v1/visibility/geo-recommendations is a different, derived dataset (missing-platform groupings computed from products), not a mirror of issues.

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
AuthorizationyesBearer <session token> — minted by the embedded dashboard

Not directly callable by third-party integrators today

Like the rest of this section's AI Visibility endpoints, these require a verified embedded-app session — a bare X-Merchant-ID (shown in the examples below for the request shape only) now returns 401 { "detail": "Missing or invalid caller identity" }. There is no gateway equivalent for BYO visibility keys yet.

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. Like the rest of this section, all four require a verified caller identity — Authorization: Bearer <session token> — not a bare X-Merchant-ID; see the trust-model note above. There is no gateway equivalent for any of them yet. (A paginated version of the issue list is also available at GET /api/v1/health/issues, which adds limit/offset/total, same auth.)

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. Every other endpoint below uses the returned X-API-Key. Calling it again replaces the existing key.

Requires the merchant's own embedded-app session

This is a merchant self-service action — it mints a new secret for that merchant's catalog — so it authenticates differently from the rest of this page: Authorization: Bearer <session token>, the Shopify App Bridge session token the discoverGPT dashboard attaches automatically, never a bare X-Merchant-ID. Sent alone, X-Merchant-ID now returns 401 { "detail": "Missing session token" }. There is no API-key or gateway path to mint a key on a merchant's behalf — a human must be signed into that merchant's dashboard.

HeaderRequiredValue
AuthorizationyesBearer <session token> — minted by the embedded dashboard

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/quality/productsGETData cleaningPaginated review list (the dashboard's own queue)
/v1/quality/approvePOSTData cleaningApprove one or more products
/v1/quality/rejectPOSTData cleaningReject one or more products
/v1/quality/undoPOSTData cleaningUndo a prior approve/reject
/v1/quality/editPOSTData cleaningEdit a product's clean title/description before push
/v1/quality/pushPOSTData cleaningPush approved products live to Shopify — writes real store data, no undo
/v1/quality/push-statusGETData cleaningPoll the status of an in-flight push
/v1/quality/configGET/POSTData cleaningRead or update cleaning config
/v1/quality/historyGETData cleaningPast push history
/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 pay-as-you-go 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.

Prepay a credit bucket and the per-compose rate drops — the pay-as-you-go $0.18 rate is the ceiling, not the only option:

BucketPricePer composition
100 credits$16.00$0.16
500 credits$70.00$0.14
1,000 credits$120.00$0.12
10,000 credits$1,000.00$0.10

Credits never expire and stack with your plan's monthly allotment; draw-down order is free allotment → prepaid credits → the $0.18 metered rate.

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 (null on a not_found result — see below).
  • 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"), "subject" for a subject-only lookup, or "not_found" (see below).
  • 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 the subject or a garment (top/bot/dress/shoe, however identified — id, *_url, or *_handle) has no image virtual try-on can load, the response is 200 with { "path": null, "cached": true, "calls_made": 0, "source": "not_found", "found": false, "reason": "subject_not_found" | "product_not_found", "message": "..." } (not billed) — the same product_not_found vocabulary the UCP Catalog Get product route uses for an unresolvable catalog id. This is expected for a product that hasn't been staged for virtual try-on yet; it is not an error condition to retry.

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 40 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
list_quality_productsList products in the review queue, filterable by status / score
approve_quality_productsApprove one or more products' AI-cleaned content for publish
reject_quality_productsReject one or more products' AI-cleaned content, with a reason
undo_quality_productsRevert a prior approve/reject decision
edit_quality_productEdit a product's cleaned content before approving it
push_quality_productsPush approved product content live to the merchant's connected store
get_quality_push_statusStatus of a previously started push job
get_quality_configThe merchant's quality-review configuration (thresholds, auto-approve rules)
update_quality_configUpdate the merchant's quality-review configuration
get_quality_historyAudit history of quality-review decisions for a product or the catalog
create_checkoutCreate an agentic checkout for a product (preview — not yet live)
add_to_cartBuild a real multi-item Shopify cart link on the merchant's storefront for the shopper
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, down to $0.10 at volume — see Virtual Try-On)
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
ucp_create_checkoutUCP Create Checkout Session (preview — explanatory session, not yet a live checkout)
ucp_get_checkoutUCP Get Checkout Session — read a session's current state
ucp_update_checkoutUCP Update Checkout Session (preview — explanatory session, not yet a live checkout)
ucp_complete_checkoutUCP Complete Checkout Session (preview — no live payment handler wired yet)
ucp_cancel_checkoutUCP Cancel Checkout Session
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 98334572911 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.)

Claude.ai / Claude Desktop — no config file, add it from the UI:

On Free, Pro, and Max plans: Customize → Connectors → Add custom connector, paste https://api.vairetail.com/mcp/sse, then Add. On Team and Enterprise plans, an org owner adds it once (Organization settings → Connectors → Add → Custom — choose Web if asked for a connector type — paste the URL, Add) and members then connect it themselves under Customize → Connectors.

Leave the OAuth Client ID/Secret fields blank — discoverGPT supports Dynamic Client Registration, so Claude registers itself and runs the same browser consent as every other host here. Full walkthrough: Third-party connectors with remote MCP.

Cursor — one-click install:

Add discoverGPT to Cursor →

Or add it by hand in Settings → Tools & MCP → New MCP Server, pointing at https://api.vairetail.com/mcp/sse. Cursor runs the same browser consent on first connect.

VS Code — add to .vscode/mcp.json in your workspace (or your user mcp.json):

{
  "servers": {
    "discovergpt": {
      "type": "sse",
      "url": "https://api.vairetail.com/mcp/sse"
    }
  }
}

Or one line from the terminal:

code --add-mcp '{"name":"discovergpt","type":"sse","url":"https://api.vairetail.com/mcp/sse"}'

VS Code prompts for the browser consent the first time an agent calls a tool.

Codex CLIcodex mcp add registers stdio servers only, so bridge the remote SSE endpoint with mcp-remote:

codex mcp add discovergpt -- npx -y mcp-remote https://api.vairetail.com/mcp/sse

mcp-remote opens the authorize-once browser flow and keeps the token refreshed — no client_secret in your config.toml.

Gemini CLIgemini mcp add supports SSE directly; the transport flag must be sse (not http, which is a separate streamable-HTTP transport):

gemini mcp add --transport sse --scope user discovergpt https://api.vairetail.com/mcp/sse

Then run /mcp auth discovergpt and complete the browser consent. Check status any time with gemini mcp list or /mcp.

Generic MCP host config (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 98334572911.

Demos

Five demos you can run today against the shared demo catalog — three copy-paste POCs, a hosted no-setup virtual try-on, and a step-by-step build tutorial — with no catalog upload and no production access required. Every new account is auto-authorized for demo merchant 98334572911 (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 upload an image and get live product results (no token needed). Prefer a conversation? The shopping-agent demo runs the same search as a chat, with a “more like this” visual-similarity on any result. A self-contained Artifact version shows the search 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:98334572911" | 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
4 — Virtual try-onThe full search → try-on → shop flow, live and hosted (no setup)Hosted demo widget
5 — Build your own with MCP toolsStep-by-step tutorial: connect Claude, vibe-code a text+color widget, inject it into a real storefrontMCP (build-time) + REST (runtime)

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 98334572911, 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: 98334572911" \
  -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=98334572911
#   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.

Demo 4 — Virtual try-on (hosted)

No setup — just open (or share) the link. The full search → try-on → shop flow runs live on the shared demo catalog (merchant 98334572911):

vairetail.com/demos/vto

Search the catalog, build a try-on avatar (or upload a photo), pick a garment, and get an AI-composed on-model image — then open product detail and add to cart. It's the same virtual-try-on capability you call from your own code as the virtual_try_on MCP tool and POST /v1/vto/compose; this page just wraps it in a shareable storefront widget so you can see the end-to-end experience before you build it.

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-08-11 — Enrichment is vertical-aware, not apparel-only

  • Corrected products schema, Foundation, and Shopify field mapping to reflect that enrichment classifies any product vertical, not just apparel: category examples now span verticals, gender is documented as defaulting to unisex outside apparel (kept for existing gender-filtered search) rather than being fabricated, and metadata_json is now documented as also carrying a per-vertical attribute bag (material/style/room/medium, depending on category) that enrichment writes for non-apparel products, alongside any fields you send in.

2026-08-10 — Platform-level orchestration framing; reliability & webhook docs

  • Added a callout to Platform overview stating that the ingest + enrichment pipeline is shared, not discoverGPT-only plumbing: our Shopify apps for search, data-cleaning, and catalog syndication each run their own dedicated orchestration service, and discoverGPT's push ingest is being moved onto its own the same way — independent services, built on the same underlying pipeline code rather than separate reimplementations.
  • Added Webhook delivery & retry behavior under Shopify (Pull): real retry/backoff numbers for inbound webhook processing, and an explicit statement that outbound webhook subscriptions don't exist yet.
  • Added Reliability & operational visibility: why retries and re-ingests are safe (exactly-once step claiming), the honest current state of keeping push-ingested data current (explicit re-sync, not an automatic background refresh), that pipeline monitoring is poll-based today with no proactive alerting, and pointers to the existing deletion-sync and rate-limit documentation rather than new claims layered on top.

2026-08-10 — MCP client coverage: Claude.ai/Desktop, Gemini CLI

  • Added Claude.ai / Claude Desktop connector-UI setup (Customize → Connectors, no config file) alongside the existing Claude Code CLI instructions — previously it only had the raw mcpServers JSON path.
  • Added Gemini CLI (gemini mcp add --transport sse) setup and its /mcp auth step.
  • Split the generic mcpServers JSON block off from Claude Desktop, which now has its own guided path.

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.