# 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)} />
{results.map((p) => (
{p.name} — {p.brand}
))}
>
);
}
```
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 `