# discoverGPT API Docs (/docs) 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 [#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 [#whats-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](#how-ingestion-and-orchestration-works). What you can build [#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](#ucp-universal-commerce-protocol-catalog) 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](#virtual-try-on). Agent-native: built on MCP [#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 endpoint — **Streamable HTTP** (`/mcp/http`) or **SSE** (`/mcp/sse`) — and your catalog becomes a set of ready-to-use agent tools, with **no glue code**. See [Model Context Protocol (MCP)](#model-context-protocol-mcp) for how it works and why it matters. Platform-neutral by design [#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](#products) and [Ingesting Your Data](#ingesting-your-data) sections for the canonical schema and available ingest paths. 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 [#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. | Tier | SKU limit (account total) | Monthly | Annual | | ------------ | ------------------------- | ------------------------------------------------------ | ------------------------ | | **Starter** | ≤ 500 | **Free 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) | | **Platinum** | 25,000+ | Custom (sales-led) | Custom | 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](#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. 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](#acceptable-use--restrictions). To get provisioned as an agency partner, email **[enterprise@vairetail.com](mailto:enterprise@vairetail.com)**. Credentials (`client_id` / `client_secret`) are issued per agency during onboarding. Where to start [#where-to-start] Base URL [#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 [#support] Questions about integration, enterprise access, or data migration? Email **[enterprise@vairetail.com](mailto:enterprise@vairetail.com)** or visit [vairetail.com](https://vairetail.com). Platform overview [#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 [#the-unified-gateway] | Capability | What it does | Gateway path | Auth | | ------------------ | ------------------------------------------------------------ | ---------------------------------------------- | ---------- | | **Search** | Natural-language, precise-color, and image-similarity search | `POST /v1/search` | Bearer JWT | | **Data cleaning** | AI cleaning & enrichment, quality scoring | `POST /v1/enrich` · `GET /v1/quality/*` | Bearer JWT | | **Feeds & GEO** | AI-discovery feeds + GEO visibility | `GET /v1/feed/*` · `GET /v1/visibility/*` | Bearer JWT | | **Virtual try-on** | Virtual try-on image composition | `POST /v1/vto/compose` · `GET /v1/vto/metrics` | Bearer JWT | 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](mailto: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](#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. 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](#how-ingestion-and-orchestration-works), not a parallel implementation built to look similar. Independent, separately deployable services; one shared pipeline underneath. How they fit together [#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](#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. Most sections below document each capability's **direct service API** — **search**, **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](#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 [#jump-to-a-capability] Model Context Protocol (MCP) [#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 [#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 [#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 [#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/http` (Streamable HTTP — or `/mcp/sse` for SSE) and **authorizes once** through a browser consent screen — no API keys to copy or rotate (see [MCP transport](#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](#search-api)). What it makes easier [#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 [#tools-exposed-over-mcp] | Tool | Capability | What it does | | ---------------------------------- | -------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `search_products_trimodal` | Search | Natural-language + precise-color + image-similarity search | | `browse_catalog` | Search | Faceted catalog browse | | `get_product_details` | Search · Feeds & GEO | Full product detail (discovery + transaction shapes) | | `recommend_complementary_products` | Search | "Goes well with" recommendations | | `get_merchant_brand_context` | Feeds & GEO | Brand summary / `llms.txt` grounding for the agent | | …and 35 more | Feeds & GEO · Data cleaning · Virtual try-on · UCP | AI-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](#mcp-transport) | Connection details — endpoint, transport, and auth — are in [MCP transport](#mcp-transport) under the Gateway API. Building a Claude-native shopping agent rather than your own MCP host? **[discovergpt-storefront-backend](/docs/storefront-backend)** is an open-source Python package that implements Anthropic's `StorefrontBackend` interface — the storefront adapter contract for Claude's commerce-agents blueprint — on top of these same REST endpoints, so any agent built against that interface gets a working discoverGPT storefront with no custom integration code. Quickstart [#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. 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](#create-a-merchant). 1\. Sign up [#1-sign-up] Create your account at **[developer.vairetail.com](https://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 [#2-create-an-api-key] In the dashboard, open **API Keys → Create key**. You'll get a `client_id` and a `client_secret`. 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. ```bash 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 [#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}`. ```bash 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: ```json { "access_token": "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9...", "token_type": "Bearer", "expires_in": 900, "scope": "merchant:98334572911" } ``` Tokens expire after **15 minutes** (re-mint on `401`). Grab the token: ```bash export TOKEN="eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9..." ``` 4\. Run a text + color search [#4-run-a-text--color-search] Call `POST /v1/search`. This searches for a **midi dress** in a deep red. `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]}`. ```bash 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** ```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** ```js 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: ```json { "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 [#5-next-steps] 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) [#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: ```bash 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)](#direct-product-api-legacy) for full details. Wiring search into your storefront [#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. ```js // 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 ?? []); } ``` ```jsx // 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 ( <> e.key === "Enter" && onSubmit(e.target.value)} /> ); } ``` Want to try queries before writing code? Use the live [search demo](/demo) (no token needed), or the [playground](/playground) to hit `/v1/search` against the demo merchant with your own token. Authentication [#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) [#oauth-20-client-credentials-primary] All gateway endpoints under `/v1/*` require a Bearer JWT obtained via the OAuth token endpoint. Step 1 — request a token [#step-1--request-a-token] ```http 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:** | Field | Required | Value | | --------------- | -------- | --------------------------------------------------------------------------------------------------------------------- | | `grant_type` | yes | `client_credentials` | | `client_id` | yes | Issued during onboarding | | `client_secret` | yes | Issued during onboarding | | `scope` | yes | `merchant:{merchant_id}` — the merchant you act for (one token = one merchant; your agency must be authorized for it) | **Response — 200 OK:** ```json { "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 [#step-2--send-bearer-on-every-request] ```http 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 [#how-to-get-credentials] Credentials (`client_id` / `client_secret`) are self-serve: sign up at [developer.vairetail.com](https://developer.vairetail.com), then open **API Keys → Create key** in the dashboard (see [Quickstart step 2](#2-create-an-api-key)). Keys are issued per agency, not per store. Email **[enterprise@vairetail.com](mailto:enterprise@vairetail.com)** only if you need something the dashboard cannot do — a custom plan, or an existing Shopify store adopted into your organization. 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) [#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. ```http 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: ```json { "detail": "Missing or invalid X-Merchant-ID header" } ``` with HTTP status **400**. Endpoints that do NOT require `X-Merchant-ID` [#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` [#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](#feed-downloads-shopify) | | AI Visibility and GEO (catalogGPT) | `score`, `products`, `trends`, `issues`, `readiness` — see [AI Visibility and GEO](#ai-visibility-and-geo) | | BYO visibility keys (catalogGPT) | list / set / delete — see [BYO visibility keys](#byo-visibility-keys) | | Feed health detail (catalogGPT) | `summary`, `product/{id}`, `enrichment-quality`, `issues` — see [Feed health detail](#feed-health-detail) | | Generate/rotate a Public API key (catalogGPT) | `POST /api/v1/catalog/generate-key` — see [Generate an API key](#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](#quality-api-base-url) | These require `Authorization: Bearer ` — 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](#gateway-api-v1) instead, the linked section says so — most of these route families are not. How merchant IDs are issued [#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](mailto:enterprise@vairetail.com)**. Trust model [#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 [#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 [#errors] discoverGPT uses conventional HTTP status codes and returns all error details in a JSON body with a `detail` field. Error shape [#error-shape] ```json { "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](#validation-errors) below. Status codes [#status-codes] | Status | Meaning | | ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 200 | Success. | | 400 | Bad request. Missing or malformed `X-Merchant-ID`, missing required body fields, invalid color input, or a search body with neither text nor color. | | 401 | Unauthorized. 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](#endpoints-that-require-more-than-x-merchant-id). | | 403 | Forbidden. The authenticated merchant does not have access to the target resource (e.g. calling another merchant's pipeline status). | | 404 | Not found. A referenced user profile, pipeline status, or resource does not exist for this merchant. | | 422 | Unprocessable entity. FastAPI schema validation failed (wrong field types, missing required fields). | | 500 | Internal server error. A bug or downstream failure. Retry with backoff and contact support if it persists. | | 502 | Bad gateway. The upstream service a utility endpoint depends on (e.g. `proxy-image`) was unreachable. | Common error examples [#common-error-examples] 400 — missing merchant ID [#400--missing-merchant-id] ```http HTTP/1.1 400 Bad Request Content-Type: application/json {"detail": "Missing or invalid X-Merchant-ID header"} ``` 400 — empty search body [#400--empty-search-body] Sent to `POST /api/v0/product/search/textandcolor` with neither a `query_str` nor a `color`: ```json { "detail": "At least one of 'text' or 'color' must be provided." } ``` 401 — missing verified caller identity [#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](#endpoints-that-require-more-than-x-merchant-id)) without a valid `Authorization: Bearer `. The exact message depends on which service and dependency rejected the request: ```json { "detail": "Missing or invalid caller identity" } ``` ```json { "detail": "Missing or invalid credentials" } ``` ```json { "detail": "Missing session token" } ``` 403 — cross-merchant access [#403--cross-merchant-access] Attempting to read another merchant's pipeline status: ```json { "detail": "Access denied to this merchant's pipeline status" } ``` 404 — resource not found [#404--resource-not-found] ```json { "detail": "User preferences not found" } ``` 422 — validation error [#422--validation-error] ```json { "detail": [ { "loc": ["body", "query_str"], "msg": "field required", "type": "value_error.missing" } ] } ``` Retries [#retries] The search endpoints are **idempotent** — identical `POST`s 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 [#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](mailto:enterprise@vairetail.com)**. ```http HTTP/1.1 200 OK X-Request-ID: f2c8e9d1-0b3a-4a74-9cc6-b1d3a2a0f0ab ``` Data Model [#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] `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 [#fields-you-provide] | Field | Type | Required | Constraints | Notes | | -------------------------- | ------- | ----------- | ----------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `product_id` | string | **yes** | Unique per merchant, ≤ 2048 chars | Opaque stable ID from your source system — SKU, UUID, slug, or numeric ID, your choice. Shared across a product's variants. | | `variant_id` | string | optional | Unique within a `product_id` | Per-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_name` | string | **yes** | Plaintext | Shopify `title`, Woo `name`, custom `productName`, etc. | | `handle` | string | recommended | URL-safe slug, lowercase, hyphenated | Storefront URL slug. | | `product_details` | string | recommended | Plaintext or HTML (HTML is stripped during enrich) | Long description. Drives semantic text search. | | `brand` | string | optional | Plaintext | Shopify `vendor`. | | `category` | string | recommended | Free text — any vertical (`tops`, `dresses`, `lighting`, `wall art`, `skincare`, …) | Enrichment fills this in if missing. | | `sub_category` | string | optional | Free text | | | `color` | string | optional | Free text (`navy blue`) or hex (`#0a1f44`) | The color pipeline will derive color from `image1` if this is missing. | | `tags` | string | optional | Comma-separated | | | `price` | string | **yes** | Currency-formatted string, e.g. `"29.99"` | Display field. Must agree with `price_numeric`. | | `price_numeric` | float | **yes** | Non-negative decimal | Used for range filters, min/max price queries, and price-aware ranking. | | `variant_compare_at_price` | string | optional | Currency string | Original / MSRP price for sale display. | | `option1_name` | string | optional | e.g. `"Size"`, `"Color"` | First variant option label (same across a product's variants). | | `option1_value` | string | optional | e.g. `"M"`, `"Navy"` | This variant's value for `option1_name`. | | `option2_name` | string | optional | e.g. `"Color"` | Second variant option label. | | `option2_value` | string | optional | Plaintext | This variant's value for `option2_name`. | | `option3_name` | string | optional | Plaintext | Third variant option label. | | `option3_value` | string | optional | Plaintext | This variant's value for `option3_name`. | | `variant_sku` | string | optional | Plaintext | Variant-specific SKU/stock code. | | `variant_image` | string | optional | Publicly reachable HTTPS URL | Variant-specific image, if it differs from `image1`. | | `inventory_quantity` | integer | optional | Whole number ≥ 0 | Per-variant stock on hand. Persisted verbatim; omit or leave blank for untracked inventory. | | `gender` | enum | optional | `mens` \| `womens` \| `unisex` \| `kids` | Enrichment 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`. | | `image1` | string | **yes** | Publicly reachable HTTPS URL, ≤ 10 MB, CORS-readable | The primary product image. All image embeddings and color extraction are computed from this URL. | | `image1_description` | string | optional | Plaintext | If absent, enrichment generates a description via a vision model. | | `status` | enum | optional | `active` \| `draft` \| `archived` (default `active`) | Only `active` rows are searchable. | | `display_flag` | boolean | optional | Default `true` | Soft-hide: set to `false` to exclude from search without deleting. | | `metadata_json` | object | optional | Arbitrary JSON | Free-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. | 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 [#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. | Field | Populated by | | -------------------------- | ---------------------------------------------------------------------------------------- | | `tags1` | Enrichment (Gemini) — 5–15 generated keyword tags. Distinct from the `tags` you provide. | | `text_embedding` | Text embedding pipeline (`create_text_embeddings`) | | `text_embedding_1` | Text embedding pipeline | | `img_embedding` | Image embedding pipeline (`create_img_embeddings`) | | `image_desc_embed` | Vision description pipeline | | `color_embedding` | Color pipeline (`create_color_embeddings`) | | `color_embedding_1..3` | Color pipeline | | `color_hsv_embedding_1..3` | Color pipeline | | `percent_1..3` | Color pipeline (dominant-color coverage percentages) | | `merchant_id` | Set server-side from the authenticated identity. | | `last_updated` | Database-managed timestamp. | | `id` | Database-managed primary key. | Uniqueness & upserts [#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.) Related reading [#related-reading] * [CSV format](#csv-format) — headers, sample file, type rules. * [JSON / NDJSON format](#json-format) — shapes for HTTP ingest. * [Column Mapper](/mapper) — paste your source headers, get a canonical mapping. User profiles [#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](#user-preferences) or personalize results over time. Fields [#fields] | Field | Type | Required | Notes | | -------------- | ------- | -------- | ---------------------------------------------- | | `id` | integer | server | Auto-generated primary key. | | `user_name` | string | **yes** | Display name (first name is acceptable). | | `first_name` | string | **yes** | First name. | | `last_name` | string | **yes** | Last name. | | `email_id` | string | **yes** | Email address. Unique within a merchant. | | `profile_name` | string | optional | A label for the profile (e.g. "Work", "Home"). | Uniqueness [#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`](#create-user-profile). Related endpoints [#related-endpoints] * [`POST /api/v1/user/profile`](#create-user-profile) * [`POST /api/v1/user/profile/email`](#get-profile-by-email) User preferences [#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`](#user-profiles) 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 [#fields-1] | Field | Type | Required | Notes | | ------------------------- | ----------------- | -------- | ------------------------------------------------------------------------------ | | `id` | integer | server | Auto-generated primary key. | | `user_profile_id` | integer | **yes** | Foreign key to `user_profile.id`. Unique — one preferences row per user. | | `conversational_style` | string | optional | Free text (`concise`, `friendly`, `technical`, …). Biases recommendation tone. | | `personal_fashion_style` | string | optional | Free text (`minimalist`, `streetwear`, `classic`, …). | | `favorite_colors` | array of strings | optional | E.g. `["navy", "olive"]`. Influences color-aware ranking. | | `fashion_material_prefer` | array of strings | optional | E.g. `["linen", "cotton"]`. | | `body_type` | string | optional | Free text. | | `fashion_goals` | string | optional | Free text (`"dress for job interviews"`, `"beach vacation outfits"`). | | `age_group` | string | optional | Free text. | | `is_modified` | boolean | server | `true` after a user has explicitly edited their preferences. | | `product_ids` | array of integers | server | Internal — used by the randomdata endpoint to seed cold-start results. | How preferences are used [#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. Related endpoints [#related-endpoints-1] * [`POST /api/v1/user/preferences`](#create-preferences) * [`POST /api/v1/user/preferences/update`](#update-preferences) * [`GET /api/v1/user/preferences/{user_profile_id}`](#get-preferences) * [`POST /api/v1/user/preferences/randomdata`](#random-data-by-preferences) Pipeline status [#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`](#create-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}`](#get-pipeline-status). Fields [#fields-2] | Field | Type | Notes | | ----------------- | --------------- | ---------------------------------------------------------------------------- | | `merchant_id` | integer | The merchant this pipeline run belongs to. | | `shop_domain` | string | Optional source-platform domain (e.g. `store.myshopify.com`). Informational. | | `current_step` | string | The step currently running. See [Pipeline steps](#pipeline-steps) below. | | `completed_steps` | array of string | Steps that have completed successfully, in order. | | `failed_steps` | array of string | Steps that failed. Parallel to `completed_steps`. | | `status` | string | `running` \| `completed` \| `failed`. | | `started_at` | datetime (UTC) | When the run started. | | `completed_at` | datetime (UTC) | When the run finished. `null` while running. | | `last_updated` | datetime (UTC) | When the row was most recently touched. | | `error_message` | string | Populated when `status = failed`. | | `config` | object | Source-specific configuration carried through the pipeline. | Response-only fields [#response-only-fields] The `PipelineStatusResponse` returned by the [API endpoints](#get-pipeline-status) adds two computed fields that are not stored in the database: | Field | Type | Notes | | ---------------------- | -------------- | -------------------------------------------------- | | `progress_percentage` | float (0–100) | `completed_steps.length / total_steps * 100`. | | `estimated_completion` | datetime (UTC) | Heuristic estimate based on average step duration. | Pipeline steps [#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 [#csv-format] discoverGPT accepts UTF-8 CSV files whose columns map to the canonical [`products`](#products) fields. Requirements [#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](/mapper) 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 [#column-mapping] If your source system uses different column names — and it almost certainly does — use the [Column Mapper](/mapper) to paste your headers and produce a `mapping.json` file. You can then either: 1. Run the mapping yourself ([Node](#apply-a-mapping-nodejs) or [Python](#apply-a-mapping-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 [#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](#products#fields-you-provide). Column order doesn't matter; header names do (case-insensitive, matched to canonical names or [mapped](#column-mapping)). 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): ```csv 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`](/samples/products.csv) Validation rules [#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 [#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](#push-ingest) for the exact accepted fields. NDJSON (preferred for large catalogs) [#ndjson-preferred-for-large-catalogs] One JSON object per line. No array brackets, no trailing commas. UTF-8. Content-type `application/x-ndjson`. ```jsonl {"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`](/samples/products.ndjson) Array-of-objects (small batches) [#array-of-objects-small-batches] For small imports (under a few thousand products), a standard JSON array works fine. Content-type `application/json`. ```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 [#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 [#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](#products#fields-we-compute). How ingestion and orchestration works [#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 [#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: | Outcome | What it enables | Jobs it runs (after foundation) | Selectable per push request? | | --------------------------- | ---------------------------------------------------------------- | ----------------------------------------------------- | ---------------------------------------------------------- | | **Natural-language search** | Semantic text search | text embeddings | Yes | | **Precise-color search** | Color-aware filtering & search | color embeddings → color names | Yes | | **Image-similarity search** | "More like this" by image | image embeddings | Yes | | **Clean product data** | Normalized titles/descriptions & sellability | data cleaning | Yes | | **Generate narratives** | AI-written product narratives, for agentic/LLM shopping surfaces | narrative generation | Yes | | **Generate Q\&A** | AI-generated product Q\&A pairs | narrative generation (prerequisite) → Q\&A generation | Yes | | **Catalog feed** | ACP / Schema.org JSON-LD / UCP feeds | feed compile | Onboarding flow only — not yet selectable per push request | | **AI visibility (GEO)** | Presence across AI answer engines | visibility 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 [#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 [#you-wont-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 [#dos-and-donts] * **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](#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. `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 [#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](#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}`](#get-pipeline-status) 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](#check-ingest-job-status) for the exact behavior and [`DELETE /api/v1/merchant/{merchant_id}/data`](#delete-merchant-data) for full removal. **Rate limits are usage-tier, not request-per-minute.** See [Plans & Pricing](#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 [#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](#how-ingestion-and-orchestration-works). Shopify (Pull) [#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 [#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`](#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](#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-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. 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](#pipeline-status) — see [Reliability & operational visibility](#reliability--operational-visibility) for the honest current state of this and what to expect if you're building around it. Field mapping (Shopify → canonical) [#field-mapping-shopify--canonical] | Shopify field (Admin API) | Canonical `products` field | | ------------------------------ | ------------------------------------------- | | `id` | `product_id` | | `title` | `product_name` | | `handle` | `handle` | | `body_html` | `product_details` (HTML stripped on enrich) | | `vendor` | `brand` | | `product_type` | `category` | | `tags` | `tags` | | `variants[0].price` | `price` / `price_numeric` | | `variants[0].compare_at_price` | `variant_compare_at_price` | | `options[0].name` | `option1_name` | | `images[0].src` | `image1` | | `status` | `status` | 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 [#onboarding-status] Pipeline progress (`shopify_etl_dev → enrichment_dev → ... → deploy_shoppergpt_extension`) is tracked in [`pipeline_status`](#pipeline-status) and can be read via [`GET /api/v1/pipeline/status/{merchant_id}`](#get-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 [#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`](#delete-merchant-data). Push ingest [#push-ingest] `POST /v1/ingest` is live on `https://api.vairetail.com`. Every push request selects one or more `outcomes` — see [How ingestion works](#how-ingestion-and-orchestration-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 [#contract] ```http POST /v1/ingest HTTP/1.1 Host: api.vairetail.com Content-Type: application/json Authorization: Bearer ``` 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](#the-two-tiers) 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 [#response] ```json { "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}`](#check-push-run-status) (below) or [pipeline status](#pipeline-status). See [How ingestion works](#how-ingestion-and-orchestration-works) for what each outcome runs. 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}`](#check-push-run-status) reports `nl_search: "ready"` (or poll [pipeline status](#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 [#check-push-run-status] ```http GET /v1/ingest/runs/{run_id} ``` ```json { "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 [#running-a-mapping-client-side] If you'd rather produce the canonical `{"products":[...]}` JSON body yourself before calling the ingest endpoint, see: * [Using the mapper output (Node)](#apply-a-mapping-nodejs) * [Using the mapper output (Python)](#apply-a-mapping-python) Example [#example] ```bash curl -X POST https://api.vairetail.com/v1/ingest \ -H "Authorization: Bearer " \ -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 [#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](/mapper) to map your source columns to the canonical [`products`](#products) schema. 3. Apply the mapping to produce a canonical file ([Node](#apply-a-mapping-nodejs) or [Python](#apply-a-mapping-python) snippets). 4. Send it to us via [push ingest](#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] | BigCommerce field | Canonical `products` field | | ---------------------------- | -------------------------- | | `id` or `sku` | `product_id` | | `name` | `product_name` | | `custom_url.url` | `handle` | | `description` | `product_details` | | `brand_name` | `brand` | | `categories` | `category` | | `price` | `price_numeric` | | `retail_price` | `variant_compare_at_price` | | `primary_image.url_standard` | `image1` | | `is_visible` | `display_flag` | WooCommerce [#woocommerce] | WooCommerce field | Canonical `products` field | | -------------------- | -------------------------- | | `id` or `sku` | `product_id` | | `name` | `product_name` | | `slug` | `handle` | | `description` | `product_details` | | *(attribute)* brand | `brand` | | `categories[0].name` | `category` | | `price` | `price_numeric` | | `regular_price` | `variant_compare_at_price` | | `tags[].name` | `tags` (comma-joined) | | `images[0].src` | `image1` | | `status` | `status` | Magento [#magento] | Magento field | Canonical `products` field | | --------------------------------- | -------------------------- | | `sku` | `product_id` | | `name` | `product_name` | | `url_key` | `handle` | | `description` | `product_details` | | `brand` *(custom attr)* | `brand` | | `category_names` | `category` | | `price` | `price_numeric` | | `base_image` | `image1` | | `status` | `status` | Custom PIM / flat export [#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](#csv-format) for the required columns. If you can't rename columns at the source, use the [Column Mapper](/mapper) — paste your headers, accept the auto-suggestions, download `mapping.json`, and run it through the [Node](#apply-a-mapping-nodejs) or [Python](#apply-a-mapping-python) transformer. File ingest API (CSV and XLSX) [#file-ingest-api-csv-and-xlsx] Unlike [Push ingest](#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 ` — see [Authentication](#authentication) for how to mint one. * 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 [#upload-a-file] ```http 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. `handle` → `product_id`, `sku` → `variant_id`, `title` → `product_name`, `msrp` → `price`); 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`/`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 [#headers] | Header | Required | Value | | --------------- | -------- | --------------------------------------------------------------------- | | `Authorization` | yes | `Bearer ` — see [Authentication](#authentication) | | `Content-Type` | yes | `multipart/form-data` (set automatically when you attach form fields) | Request body (multipart form fields) [#request-body-multipart-form-fields] | Field | Type | Required | Description | | ------------- | ------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `file` | file | **yes** | The `.csv` or `.xlsx` file to ingest. | | `outcomes` | string (JSON array) | **yes** | JSON-array-as-string of one or more outcome values — see [Outcomes](#the-two-tiers) for the full list and what each enables. E.g. `'["nl_search","clean_data"]'`. | | `mapping` | string (JSON) | no | Explicit 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_id` | string (UUID) | no | ID of a saved mapping template (see [Save a mapping template](#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 [#response--202-accepted] ```json { "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 [#example-1] ```bash 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"}' ``` ```python 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"] ``` ```js 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 [#errors-1] | Status | Response | | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 400 | Unsupported 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. | | 404 | `template_id` doesn't exist, or belongs to a different agency — both return the same detail so a foreign template can't be probed. | | 413 | File exceeds the 20 MiB cap. | | 429 | `{ "error": "quota_exceeded", "bucket": "shared", "limit": ..., "counted_units": ... }` — plan quota exhausted. | Check ingest job status [#check-ingest-job-status] ```http GET /v1/ingest/file/{job_id} ``` 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 [#response--200-ok] ```json { "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": {} } ``` * `state` — `queued` → `running` → 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}`](#check-push-run-status). `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 [#example-2] ```bash curl https://api.vairetail.com/v1/ingest/file/$JOB_ID \ -H "Authorization: Bearer $TOKEN" ``` Errors [#errors-2] | Status | Response | | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | 404 | Unknown 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 [#save-a-mapping-template] ```http 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](#upload-a-file). Templates are agency-wide (not merchant-specific). Request body [#request-body] | Field | Type | Required | Description | | --------- | ------ | -------- | ---------------------------------------------------------- | | `name` | string | **yes** | 1–120 chars. Must be unique within your agency. | | `mapping` | object | **yes** | `{ "target_field": "source_column" }`, at least one entry. | Response — 201 Created [#response--201-created] ```json { "id": "9a4e2f10-6b3c-4a1d-8e7f-1234567890ab" } ``` Example [#example-3] ```bash 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 [#errors-3] | Status | Response | | ------ | ------------------------------------------------------- | | 409 | A template named `name` already exists for your agency. | List mapping templates [#list-mapping-templates] ```http GET /v1/ingest/mappings ``` Response — 200 OK [#response--200-ok-1] ```json { "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 [#example-4] ```bash curl https://api.vairetail.com/v1/ingest/mappings \ -H "Authorization: Bearer $TOKEN" ``` Create a merchant [#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](https://developer.vairetail.com/dashboard/merchants) 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:`) instead of the demo catalog, and every route in this reference works against your own catalogue. 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](mailto:enterprise@vairetail.com)** — adopting an existing store is not self-serve yet. Ingest quickstart [#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](#quickstart) first, and [create a merchant](#create-a-merchant) if you have not already — the demo catalog cannot be written to. 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: ```json { "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](#create-a-merchant) — 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 [#1-mint-a-token-scoped-to-your-merchant] ```bash 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" ``` ```bash export TOKEN="eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9..." # access_token from the response ``` 2\. Upload your CSV [#2-upload-your-csv] ```bash curl -s -X POST https://api.vairetail.com/v1/ingest/file \ -H "Authorization: Bearer $TOKEN" \ -F "file=@catalog.csv" ``` ```json { "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)"] } ``` 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**: ```json { "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: ```bash 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](#save-a-mapping-template) and pass `template_id`) rather than proceeding with a bad mapping. ```bash export JOB_ID="6f1b1a2e-8c3d-4e9a-9d1a-2b7c9e6f1234" # job_id from the response ``` 3\. Poll until the job is terminal [#3-poll-until-the-job-is-terminal] ```python 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: ```bash curl -s https://api.vairetail.com/v1/ingest/file/$JOB_ID \ -H "Authorization: Bearer $TOKEN" ``` 4\. Confirm the result [#4-confirm-the-result] ```json { "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 [#column-mapping--templates] This section covers the mapping engine behind [Upload a file](#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`](#products) table used by [Push ingest](#push-ingest) and [CSV format](#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 [#canonical-target-schema] | Target field | Required | Notes | | | ----------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | | `product_id` | **yes** | The 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](#check-ingest-job-status)). | | | `product_name` | no | | | | `description` | no | | | | `price` | no | Coerced to a number where possible — see [Price coercion](#price-coercion). | | | `currency` | no | | | | `image_url` | no | | | | `additional_image_urls` | no | \` | \`-delimited in the source column — see [Additional image URLs](#additional-image-urls). | | `product_url` | no | | | | `category` | no | | | | `brand` | no | | | 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 [#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 field | Synonyms (normalized) | | ----------------------- | --------------------------------------------------------------------------------- | | `product_id` | `sku`, `id`, `handle`, `product id`, `item number`, `style number`, `upc`, `gtin` | | `product_name` | `title`, `name`, `product`, `product name`, `item name` | | `description` | `description`, `desc`, `details`, `body`, `body html` | | `price` | `price`, `cost`, `msrp`, `retail price`, `list price`, `amount` | | `currency` | `currency`, `currency code` | | `image_url` | `image`, `image url`, `photo`, `img`, `main image`, `image src`, `featured image` | | `additional_image_urls` | `images`, `additional images`, `gallery` | | `product_url` | `url`, `product url`, `link`, `permalink` | | `category` | `category`, `type`, `product type`, `department`, `collection` | | `brand` | `brand`, `vendor`, `manufacturer`, `make` | Mapping precedence [#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. 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-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](#check-ingest-job-status)). Additional image URLs [#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 [#mapping-templates] Save a mapping you like as a reusable, agency-scoped template with [`POST /v1/ingest/mappings`](#save-a-mapping-template), then apply it on any later upload with `template_id` instead of resending the JSON blob — see [Save a mapping template](#save-a-mapping-template) and [List mapping templates](#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](#ingest-from-the-dashboard) below is the easiest way to build and save one interactively. Ingest from the dashboard [#ingest-from-the-dashboard] The dashboard wraps [File ingest API](#file-ingest-api-csv-and-xlsx) 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 [#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](#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. {/* screenshot: upload step */} 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`](#upload-a-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](#check-ingest-job-status)'s `run_id`). 2\. Review the detected mapping [#2-review-the-detected-mapping] {/* screenshot: map step */} The map step shows every canonical field next to a ` setText(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && run()} placeholder="something for a late-summer wedding under $150" aria-label="Search query" className="w-full rounded-lg border border-slate-200 bg-slate-50 px-3 py-2.5 text-[15px] text-slate-900 outline-none dark:border-slate-700 dark:bg-slate-800 dark:text-slate-100" /> {colorOn && (
{ dragging.current = true; e.currentTarget.setPointerCapture(e.pointerId); pick(e); }} onPointerMove={(e) => dragging.current && pick(e)} onPointerUp={() => (dragging.current = false)} className="h-[160px] w-[160px] cursor-crosshair touch-none rounded-full ring-1 ring-black/10" aria-label="Hue and saturation wheel" />
hsv {Math.round(hue)}, {Math.round(sat)}, {Math.round(val)}
)} {error &&

{error}

} {!loading && products.length > 0 && (
{products.map((p, i) => (
{p.image && ( // eslint-disable-next-line @next/next/no-img-element {p.name} )}
{p.brand &&
{p.brand}
}
{p.name}
{p.price_cents != null &&
{price(p.price_cents)}
}
))}
)} {!loading && ran && products.length === 0 && !error && (

No matches — try different words or a color.

)} ); } ``` 4\. Wire it to a backend proxy [#4-wire-it-to-a-backend-proxy] The component can't call `api.vairetail.com` directly from the browser — that would mean putting a `client_secret` in client-side JavaScript, readable by anyone who opens dev tools. Instead it calls a same-origin route, `/api/demo-search`, which mints a short-lived token server-side (never exposed to the browser) and forwards the search. This is the identical pattern the [Search API](/docs#search-api) section documents for any REST integration — a server-side token mint, then the real call. 5\. See it live [#5-see-it-live] This is that exact widget, running against the real demo catalog, right now: Type a query, try a color, or both — every result on this page is a live `POST /v1/search` call, the same thing `search_products_trimodal` does over MCP. 6\. Inject it into a real storefront [#6-inject-it-into-a-real-storefront] The search widget above is running inside this docs page. To prove the same approach works on an actual commerce site, here it is dropped into `discovergpt.myshopify.com`'s theme — the same shared demo catalog, a real Shopify store. That storefront sits behind Shopify's visitor password gate (normal for a dev store) — the password is `demo-store!` if you want to browse the general catalog. The injected widget itself, though, lives on a new, unpublished preview theme rather than the live site, so you won't see it there even with the password — the screenshot below is the real evidence for that specific piece. Since a Shopify theme doesn't run a React/Node backend of its own, this uses a plain-JavaScript version of the same two calls — mint a token, then search — added directly into the theme's `layout/theme.liquid`, right before ``. It's still multimodal: a native color swatch sits next to the text input, and when "Filter by color" is checked its value gets converted to HSV and sent alongside `query_text` in the same `query_color` shape `search_products_trimodal` uses over MCP. ```html ``` `discovergpt-search.vercel.app/api/dev-token` mints a real (short-lived, single-merchant, read-only) token, scoped only to the shared demo catalog. It exists for exactly this kind of walkthrough. For your own storefront, deploy your own copy of this token-minting proxy with your own `client_id` / `client_secret`, scoped to your own merchant — see [Authentication](/docs#authentication) for the credential flow, and reuse this same two-call shape (mint, then search) against your own endpoint. The snippet above is quoted verbatim from what's actually deployed — it was inserted into `layout/theme.liquid` and pushed as a new, unpublished preview theme (`discoverGPT MCP tutorial widget`) on `discovergpt.myshopify.com` via the Shopify CLI, without touching the live Dawn theme. The store's browser-based theme code editor had a reproducible Shopify admin bug at the time — the sections sidebar and toolbar stuck loading — so the snippet was pushed with `theme pull` / `theme push --unpublished` instead of pasting it in directly. The screenshot below is from that live preview: a real multimodal search — text query "mini dress" combined with the red color swatch (`query_color` from the same picker, converted to HSV) — returning real images and product names from the shared demo catalog, with the network tab confirming 200s from `discovergpt-search.vercel.app` and no CORS errors. Real results for "mini dress" combined with a red color filter — six real products with images and names, returned live from discovergpt-search.vercel.app