discoverGPT API Docs
Developer documentation for discoverGPT — the unified REST API + MCP gateway exposing search, data cleaning, AI-discovery feeds & GEO, and virtual try-on to agency partners and developers. One set of credentials, one canonical catalog, four AI capabilities.
Welcome to the developer documentation for discoverGPT — visualAI's unified REST API + MCP gateway for agency partners and developers. One platform, one set of credentials, four AI capabilities: search, data cleaning, feeds & GEO, and virtual try-on. This entire site is a single scrollable document — use the left sidebar to jump to any section.
Introduction
discoverGPT is a unified REST API and MCP gateway that exposes visualAI's four AI capabilities to agency partners and developers through one set of credentials and one canonical catalog.
What's included at every tier
Search
Natural-language, precise-color, and image-similarity search. POST /v1/search.
Data quality
AI product-data cleaning, enrichment, and quality scoring. POST /v1/enrich.
Feeds & GEO
AI-discovery feeds (Schema.org JSON-LD, llms.txt, ACP, Perplexity), the UCP catalog query API, plus AI-visibility / GEO. GET /v1/feed/{feed_format}.
Virtual try-on
AI image composition — the one usage-metered capability. POST /v1/vto/compose.
Onboard once: shared ingest and enrichment mean every product you load flows into all four capabilities without per-capability setup. Each capability becomes available once its pipeline finishes — see How ingestion works.
What you can build
Search:
- Natural-language product search. Convert free-text queries like "something comfortable for a beach wedding under $200" into ranked product results.
- Precise-color search. Mix a text query with an RGB or named color and rank products by both semantic and precise-color similarity.
- Image-similarity search. Upload a reference image and return the closest matching products from your catalog.
- Personalized results. Layer optional shopper preferences — style, colors, size, budget — over any of the above.
Data cleaning:
- Batch enrichment. Submit raw products and get back cleaned titles, rewritten descriptions, and normalized attributes, ready to publish.
- Quality scoring. Surface thin, duplicated, or low-quality records before they reach your storefront or your feeds.
Feeds & GEO:
- AI-readable feeds. Generate Schema.org JSON-LD, an
llms.txtsummary, plus ACP and Perplexity Shopping feeds from one enriched catalog. The same catalog also backs a UCP (Universal Commerce Protocol) query API for agents that discover and query rather than download. - Multi-protocol syndication. Distribute to ChatGPT, Gemini, Perplexity, and MCP-aware agents without betting on a single protocol.
- GEO dashboards. Track which products surface inside AI assistants and get recommendations to improve visibility.
Virtual try-on:
- AI image composition. Generate on-model and lifestyle images from product photos — no photoshoot required. Each compose call costs ~$0.18; credits are prepaid or metered per tier.
Agent-native: built on MCP
Beyond REST, every capability above is also exposed through the Model Context Protocol (MCP) — the open standard that lets AI agents discover and call tools in a uniform way. Point an MCP-aware host (Claude, ChatGPT, Cursor, or your own agent) at discoverGPT's /mcp/sse endpoint and your catalog becomes a set of ready-to-use agent tools, with no glue code. See Model Context Protocol (MCP) for how it works and why it matters.
Platform-neutral by design
discoverGPT does not require Shopify. Every capability operates on a single canonical products table, and Shopify is one of several supported ways to populate it.
If your data lives in BigCommerce, WooCommerce, Magento, a custom PIM, a flat CSV export, or an S3 data lake, you can still use the API — see the Data Model and Ingesting Your Data sections for the canonical schema and available ingest paths.
Shopify is an example integration
The most common way to get started today is via the Shopify app, which handles ingest automatically. The underlying API is not Shopify-specific — every endpoint on this site works against any merchant whose catalog matches the canonical data model.
Plans & Pricing
discoverGPT is gated by aggregate SKUs at the billing-account (agency) level — not per store. Stores, seats, and all four capabilities are unlimited at every tier.
| 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 |
Founding offer — Starter free for life
The first 500 organizations to sign up keep Starter free for life — no card required. After the first 500, Starter is $20/mo with a 14-day free trial. Growth, Pro, and Platinum are unaffected.
Rules:
- The full platform is included at every tier, including Starter — every API and MCP tool: trimodal search, data enrichment, universal data feeds for agentic discovery, and virtual try-on.
- Every variant counts as a SKU. A product with 20 size/color variations is 20 SKUs — each variant is individually enriched, embedded, and searchable. Your SKU count is your total variant count across all connected stores.
- Unlimited stores on paid tiers (Growth and up); Starter includes 1 store. The SKU gate is always aggregate across all your stores, not per store.
- Unlimited seats.
- Virtual try-on (VTO) compose is the one usage meter: ~$0.18/compose, sold as prepaid credit packs or metered overage. Each paid tier includes a monthly allotment.
- Search, cleaning, feeds, and GEO run flat under a generous fair-use cap — exceeding it triggers an upgrade prompt, never a surprise invoice.
Fair use vs. reselling the engine
Every tier is licensed for you to power the storefronts and applications of merchants you serve. Repackaging discoverGPT's search, discovery, enrichment, feeds, or try-on as your own standalone or white-label service for third parties is not included in self-serve tiers — that requires the sales-led Platinum tier under a separate written agreement. See Acceptable Use & Restrictions.
No self-serve key UI yet
To get provisioned as an agency partner, email enterprise@vairetail.com. Credentials (client_id / client_secret) are issued per agency during onboarding.
Where to start
Quickstart
Get a token and make your first search request in under two minutes.
Authentication
OAuth 2.0 client-credentials: get a token, send Bearer on every request.
Gateway API (/v1)
Billing, feed, quality, checkout, and MCP transport — the unified surface.
API Reference
Every product-API endpoint, with request/response schemas and code samples.
Base URL
All API requests go to:
https://api.vairetail.comThis host is a placeholder — the final production endpoint is TBD and will be published here before GA.
Support
Questions about integration, enterprise access, or data migration? Email enterprise@vairetail.com or visit vairetail.com.
Platform overview
discoverGPT is one 34-claim patent-pending engine with four capability APIs in front of it. All four run on the same canonical products data model and the same ingest + enrichment pipeline — so a catalog you onboard once is usable by every capability.
The unified gateway
| 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 |
Base URLs are placeholders
The product-specific hostnames elsewhere on this page — developer-search-devID.region.run.app, developer-clean-devID.region.run.app, and developer-catalog-devID.region.run.app — are placeholders for the individual product services, retained for reference. The primary external surface is the unified gateway at https://api.vairetail.com.
Each capability also maintains a direct service endpoint with its own base URL, sharing the same JSON conventions, error shapes, and — for direct endpoints — the X-Merchant-ID header. See Authentication for both auth modes.
How they fit together
Used as a pipeline:
- Ingest a catalog — Shopify pull, push ingest, CSV/JSON, or a custom
source — into the canonical
productsmodel. See Ingesting Your Data. - Clean & enrich it: review AI-rewritten titles, descriptions, and attributes, then push approved changes back to the store.
- Search it, syndicate it with feeds so AI agents can discover and quote your products, and compose hero imagery.
Used independently, each capability is a standalone HTTP API — adopt only what you need.
Direct service APIs vs. the unified gateway
Most sections below document each capability's direct service API —
search, data cleaning, and feeds & GEO each have their own —
which authenticate with the X-Merchant-ID header. The Gateway API (/v1)
section documents the unified, OAuth-authenticated surface: billing, feed,
quality, checkout preview, and MCP transport.
Jump to a capability
Search API
Natural-language, precise-color, and image-similarity search over your catalog.
Data API
Review and publish AI-cleaned product data.
Feeds & GEO
Generate AI-discovery feeds; track GEO visibility.
Virtual try-on
AI image composition — the one usage-metered capability.
Model Context Protocol (MCP)
discoverGPT speaks two protocols over the same engine: a REST API for your own code, and MCP for AI agents. You don't have to choose — both run on the same account and canonical catalog (REST authenticates with client-credentials; MCP authorizes once via browser consent).
What MCP is
MCP (Model Context Protocol) is an open standard for connecting AI agents to tools and data — a universal adapter for AI. Instead of every agent author writing custom integration code against every API, an MCP-aware host — Claude, ChatGPT, Cursor, or a custom agent — connects to an MCP server, discovers its tools automatically, and calls them as the conversation requires.
Why we offer it
A growing share of shopping and merchandising will happen through AI agents. Without MCP, every agency building such an agent would hand-write REST calls, paginate results, reshape responses into something a model can reason over, and repeat that work for every AI host. With MCP, none of that is necessary — discoverGPT hands the agent ready-to-use tools backed by the same canonical catalog as the REST API.
How it works
┌───────────────────────────┐ ┌──────────────────────────────┐
│ MCP-aware host │ MCP │ discoverGPT · /mcp (SSE) │
│ Claude · ChatGPT · │ ◄─────► │ one OAuth credential │
│ Cursor · your own agent │ tools │ │
└───────────────────────────┘ └───────────────┬──────────────┘
discover + call tools │ tools run on the
▼ same engine as REST
┌──────────────────────────────────────────────────────┐
│ one canonical catalog → │
│ Search · Cleaning · Feeds · Try-on │
└──────────────────────────────────────────────────────┘- Your agent's host connects to
https://api.vairetail.com/mcp/sse(SSE transport) and authorizes once through a browser consent screen — no API keys to copy or rotate (see MCP transport). - The host discovers discoverGPT's tools and exposes them to the model.
- 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+transactionshapes documented under Search API).
What it makes easier
For developers
- Zero glue code. The agent discovers and calls tools; you don't hand-write requests, pagination, or response reshaping.
- Agent-ready data. Tools return structured results designed for model reasoning and agentic checkout (the ACP
transactionshape) — 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
| 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 19 more | Feeds & GEO · Data cleaning · Virtual try-on · UCP | AI-visibility (score / products / trends / geo), feed status + ACP + full feed export, catalog + product quality, checkout (preview), virtual try-on (+ credits / metrics), and UCP (Universal Commerce Protocol) catalog search / lookup / product — 24 tools total; full list under MCP transport |
Connection details — endpoint, transport, and auth — are in MCP transport under the Gateway API.
Quickstart
Go from zero to a live text + color search in under five minutes. Every new account starts on the free Starter tier with a read-only demo merchant already authorized for your keys — so the calls below return real results on your very first request, no catalog upload required.
Free Starter tier + demo merchant
Signing up provisions your organization on the free Starter tier and auto-authorizes a shared demo catalog (merchant 98906636598). Scope your token to it and search immediately. For your own merchants or production access, email enterprise@vairetail.com — the interim onboarding path while self-serve merchant provisioning rolls out.
1. Sign up
Create your account at developer.vairetail.com — the free Starter tier, no card. Self-serve signup provisions your org and the demo merchant automatically.
2. Create an API key
In the dashboard, open API Keys → Create key. You'll get a client_id and a client_secret.
Copy your secret now
The client_secret is shown once. Store it in a secrets manager or .env. If you lose it, revoke the key and create a new one.
export CLIENT_ID="YOUR_CLIENT_ID"
export CLIENT_SECRET="YOUR_CLIENT_SECRET"
export MERCHANT_ID="98906636598" # the shared demo catalog3. Exchange credentials for a token
Mint a short-lived Bearer token with the OAuth 2.0 client_credentials grant. The request is form-encoded; scope names the merchant you act on as merchant:{id}.
curl -s -X POST https://api.vairetail.com/oauth/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d grant_type=client_credentials \
-d client_id="$CLIENT_ID" \
-d client_secret="$CLIENT_SECRET" \
-d scope="merchant:$MERCHANT_ID"Response:
{
"access_token": "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "Bearer",
"expires_in": 900,
"scope": "merchant:98906636598"
}Tokens expire after 15 minutes (re-mint on 401). Grab the token:
export TOKEN="eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9..."4. Run a text + color search
Call POST /v1/search. This searches for a midi dress in a deep red.
Color is structured JSON, never a name
query_color must be an HSV or RGB object — the gateway forwards it verbatim and does not interpret color words ("red" will not filter by color).
- HSV —
{"space": "hsv", "values": [h, s, v]}·h0–360,s/v0–100 - RGB —
{"rgb": [r, g, b]}· each channel 0–255
Both describe the same red: {"space": "hsv", "values": [0, 90, 85]} ≈ {"rgb": [217, 22, 22]}.
curl -s -X POST https://api.vairetail.com/v1/search \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"query_text": "midi dress",
"query_color": { "space": "hsv", "values": [0, 90, 85] },
"limit": 5
}'Python
import requests
BASE = "https://api.vairetail.com"
tok = requests.post(f"{BASE}/oauth/token", data={
"grant_type": "client_credentials",
"client_id": "YOUR_CLIENT_ID",
"client_secret": "YOUR_CLIENT_SECRET",
"scope": "merchant:98906636598",
}).json()
res = requests.post(f"{BASE}/v1/search",
headers={"Authorization": f"Bearer {tok['access_token']}"},
json={"query_text": "midi dress",
"query_color": {"space": "hsv", "values": [0, 90, 85]},
"limit": 5}).json()
for p in res["discovery"]:
price = p["offers"][0]["price_cents"] / 100 if p["offers"] else None
print(p["product_id"], p["name"], f"${price:.2f}" if price else "—")JavaScript
const BASE = "https://api.vairetail.com";
const tokenRes = await fetch(`${BASE}/oauth/token`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "client_credentials",
client_id: "YOUR_CLIENT_ID",
client_secret: "YOUR_CLIENT_SECRET",
scope: "merchant:98906636598",
}),
});
const { access_token } = await tokenRes.json();
const searchRes = await fetch(`${BASE}/v1/search`, {
method: "POST",
headers: { Authorization: `Bearer ${access_token}`, "Content-Type": "application/json" },
body: JSON.stringify({
query_text: "midi dress",
query_color: { space: "hsv", values: [0, 90, 85] },
limit: 5,
}),
});
const { discovery } = await searchRes.json();
for (const p of discovery) {
const price = p.offers[0] ? (p.offers[0].price_cents / 100).toFixed(2) : "—";
console.log(p.product_id, p.name, `$${price}`);
}A successful call returns a SearchResponse with discovery and transaction arrays:
{
"discovery": [
{
"product_id": "8471290374",
"name": "Satin Wrap Midi Dress",
"brand": "Trendsi",
"category": "Dresses",
"images": [{ "url": "https://cdn.shopify.com/s/files/ruby-wrap-midi.jpg" }],
"offers": [{ "price_cents": 6699, "currency": "USD", "availability": { "in_stock": true } }]
}
],
"transaction": [],
"total": 5
}That's it — you minted a token and ran a live text + color search against the demo catalog. Each discovery hit is an agent-ready product shape (product_id, name, images, offers).
5. Next steps
Authentication
OAuth client-credentials, token refresh, and the legacy X-Merchant-ID header.
Ingesting Your Data
Bring your own catalog — the Shopify connector, or map any platform to /v1/ingest.
Search API
Full trimodal search: text, precise color, and image similarity.
401 on every request?
Your token has a 15-minute TTL. Fetch a new one via POST /oauth/token and retry. If you keep getting 401s, confirm your client_id / client_secret and that your token's scope names a merchant you're authorized for (the demo 98906636598, or one of yours).
Legacy quickstart (direct product API)
If you integrated before the unified gateway launched, the direct search service is still active. Replace steps 1–3 above with:
curl -X POST \
https://developer-search-devID.region.run.app/api/v0/product/search/text \
-H "X-Merchant-ID: 12345" \
-H "Content-Type: application/json" \
-d '{
"query_str": "comfortable linen shirt under $80",
"limit": 5
}'See Authentication → Direct product API (legacy) for full details.
Wiring search into your storefront
vairetail gives you a search API, not a drop-in widget — you own the UX. A minimal search bar calls POST /v1/search from your backend (never expose your token to the browser) and renders the discovery array it returns.
// server route: POST /api/search { q: string }
export async function search(req, res) {
const r = await fetch("https://api.vairetail.com/v1/search", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.VAIRETAIL_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ query_text: req.body.q, limit: 20 }),
});
const data = await r.json();
res.json(data.discovery ?? []);
}// client: a search bar that renders results
function SearchBar() {
const [results, setResults] = useState([]);
async function onSubmit(q) {
const r = await fetch("/api/search", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ q }),
});
setResults(await r.json());
}
return (
<>
<input onKeyDown={(e) => e.key === "Enter" && onSubmit(e.target.value)} />
<ul>
{results.map((p) => (
<li key={p.product_id}>{p.name} — {p.brand}</li>
))}
</ul>
</>
);
}Want to try queries before writing code? Use the live search demo (no token needed), or the playground to hit /v1/search against the demo merchant with your own token.
Authentication
discoverGPT supports two authentication modes: OAuth 2.0 client-credentials (the primary mode for all gateway /v1/* endpoints) and the legacy X-Merchant-ID header (direct product-service endpoints, retained for backward compatibility).
OAuth 2.0 client-credentials (primary)
All gateway endpoints under /v1/* require a Bearer JWT obtained via the OAuth token endpoint.
Step 1 — request a token
POST /oauth/token HTTP/1.1
Host: api.vairetail.com
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials&client_id=YOUR_CLIENT_ID&client_secret=YOUR_CLIENT_SECRET&scope=merchant:YOUR_MERCHANT_IDFields:
| 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:
{
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "bearer",
"expires_in": 900
}The access_token is a JWT with a 15-minute TTL. Re-request a new token before it expires; do not cache it beyond expires_in seconds.
Step 2 — send Bearer on every request
POST /v1/search HTTP/1.1
Host: api.vairetail.com
Content-Type: application/json
Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...Every /v1/* endpoint requires this header. A missing or expired token returns 401 Unauthorized.
How to get credentials
Credentials (client_id / client_secret) are issued per agency partner during onboarding. There is no self-serve key UI yet — email enterprise@vairetail.com to get provisioned.
Credentials are per-agency, not per-store
One set of credentials covers all stores and all seats in your agency account. You do not need separate credentials per merchant or per developer on your team.
Direct product API (legacy)
The individual backend services accept an integer X-Merchant-ID header identifying your catalog. This mode is retained for backward compatibility and is documented here so integrations written against it remain valid.
POST /api/v0/product/search/text HTTP/1.1
Host: developer-search-devID.region.run.app
Content-Type: application/json
X-Merchant-ID: 12345If the header is missing or non-integer, the server returns:
{ "detail": "Missing or invalid X-Merchant-ID header" }with HTTP status 400.
Endpoints that do NOT require X-Merchant-ID
GET /health— platform health checkGET /api/v1/proxy-image— public image proxy
Every other direct-service endpoint requires the header.
How merchant IDs are issued
- Shopify merchants: your merchant ID is provisioned automatically during
the Shopify app install flow and stored in the theme extension's
local storage as
merchant_id. - Non-Shopify merchants: merchant IDs are issued by the visualAI team during onboarding — contact enterprise@vairetail.com.
Trust model
The direct-service production API accepts requests only from approved origins via a CORS allow-list. The X-Merchant-ID header identifies which catalog to operate on; it does not itself authenticate the caller. This model is safe for the current browser-only deployment surface but is not suitable for server-to-server or cross-organization use. For server-to-server integrations, use the OAuth gateway.
Decoupling from Shopify
Today, a merchant's ID happens to equal their Shopify Shop ID for Shopify installs. This is an implementation detail and will change — new non-Shopify merchants already receive IDs from a separate range. Always treat the merchant ID as an opaque integer.
Errors
discoverGPT uses conventional HTTP status codes and returns all error details
in a JSON body with a detail field.
Error shape
{
"detail": "Missing or invalid X-Merchant-ID header"
}detail is a human-readable string. In some validation errors it is a list
of FastAPI validation objects — see Validation errors
below.
Status codes
| 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. |
| 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
400 — missing merchant ID
HTTP/1.1 400 Bad Request
Content-Type: application/json
{"detail": "Missing or invalid X-Merchant-ID header"}400 — empty search body
Sent to POST /api/v0/product/search/textandcolor with neither a query_str
nor a color:
{ "detail": "At least one of 'text' or 'color' must be provided." }403 — cross-merchant access
Attempting to read another merchant's pipeline status:
{ "detail": "Access denied to this merchant's pipeline status" }404 — resource not found
{ "detail": "User preferences not found" }422 — validation error
{
"detail": [
{
"loc": ["body", "query_str"],
"msg": "field required",
"type": "value_error.missing"
}
]
}Retries
The search endpoints are idempotent — identical POSTs return identical
results (and often a cached payload). Safe to retry on 5xx with exponential
backoff.
Mutation endpoints (POST /api/v1/user/profile, POST /api/v1/user/preferences,
PUT /api/v1/pipeline/status/{merchant_id}) are also safe to retry with the
same body — they upsert based on their natural keys.
The DELETE /api/v1/merchant/{merchant_id}/data endpoint is not
automatically retried and requires an explicit environment flag on the server;
do not retry on a 403 for this endpoint.
Request ID
Every response includes an X-Request-ID header containing a UUID that
uniquely identifies the request on the server side. Include this when
reporting issues to enterprise@vairetail.com.
HTTP/1.1 200 OK
X-Request-ID: f2c8e9d1-0b3a-4a74-9cc6-b1d3a2a0f0abData Model
discoverGPT operates on a small, fixed set of canonical tables. Every search, every recommendation, every preference query runs against these tables regardless of where your data originated.
Products
products is the single table every search endpoint reads. The model is
variant-centric: it holds one row per variant (identified by
variant_id), so a product with three sizes is three rows sharing a
product_id. A product with no variants is a single row whose variant_id
defaults to its product_id. Regardless of whether your data came from
Shopify, a CSV export, or a custom PIM, discoverGPT reads it here.
Rows are scoped by merchant_id — multi-tenant isolation is enforced at the
database level via row-level security.
Fields you provide
| 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 (tops, dresses, accessories) | 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 will fill this from product_name / product_details if missing. |
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. |
Required minimum
At an absolute minimum, a product row must have product_id, product_name,
price, price_numeric, and image1. Everything else is optional but
strongly recommended — enrichment can fill gaps but the best results come
from the richest input.
Fields we compute
These columns are populated by the discoverGPT ETL pipeline during ingest and enrichment. Do not provide them — sending them on ingest is an error.
| 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
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
- CSV format — headers, sample file, type rules.
- JSON / NDJSON format — shapes for HTTP ingest.
- Column Mapper — paste your source headers, get a canonical mapping.
User profiles
user_profile holds optional per-shopper identity data. Every profile is
scoped to a single merchant (merchant_id) and keyed by email within that
merchant.
User profiles are optional. Anonymous search works perfectly well — you only need profiles when you want to attach preferences or personalize results over time.
Fields
| 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
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.
Related endpoints
User preferences
user_preferences holds optional per-shopper preferences that search and
recommendation endpoints use to personalize results. Each row is attached to
a user_profile by user_profile_id, and
has a one-to-one relationship with it.
Preferences are optional and independent of the rest of the catalog — you can ship discoverGPT with no preferences at all and still get a great baseline search experience.
Fields
| 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
When a search request is made with preferences: true and a user_profile_id,
the API loads that profile's preferences and:
- Feeds
conversational_styleinto the recommendation LLM. - Adds
favorite_colorsandfashion_material_preferas soft ranking signals — products matching preferred attributes are nudged up. - Uses
body_type,age_group, andpersonal_fashion_styleas context in the recommendation text.
Preferences are never used as a hard filter — they influence ranking, they don't exclude products.
Related endpoints
POST /api/v1/user/preferencesPOST /api/v1/user/preferences/updateGET /api/v1/user/preferences/{user_profile_id}POST /api/v1/user/preferences/randomdata
Pipeline status
pipeline_status tracks the progress of a merchant's ingest and enrichment
pipeline — from initial catalog import all the way through embedding
generation and extension deployment. It is populated by discoverGPT's internal
ETL workers and read by admin UIs to display onboarding progress.
You don't usually write to this table directly — the
POST /api/v1/pipeline/status endpoint
initializes a row, and the internal pipeline workers update it as each step
completes. Your admin UI reads it via
GET /api/v1/pipeline/status/{merchant_id}.
Fields
| 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 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
The PipelineStatusResponse returned by the API endpoints
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
The default dev pipeline has these steps, in order:
shopify_etl_dev— initial catalog import (Shopify merchants only today)enrichment_dev— description enrichment, category classification, gender inferencecreate_color_embeddings— extract and embed dominant colorsadd_color_names— map color embeddings to human-readable color namescreate_text_embeddings— generate semantic text embeddingscreate_img_embeddings— generate image similarity embeddingsdeploy_shoppergpt_extension— deploy the storefront extension (Shopify only)
Non-Shopify merchants use a modified pipeline that skips shopify_etl_dev
and deploy_shoppergpt_extension.
CSV format
discoverGPT accepts UTF-8 CSV files whose columns map to the canonical
products fields.
Requirements
- Encoding: UTF-8 (no BOM required, but accepted).
- Delimiter: comma (
,). - Header row: required. Column names must match the canonical field names exactly, or a mapping must be supplied.
- Quoting: RFC 4180 — wrap any field containing commas, quotes, or
newlines in double quotes, with interior quotes doubled (
""). - Row order: unimportant. Rows will be imported in the order given.
- Max size: 100 MB per file for direct upload. For larger catalogs, split into multiple files or use NDJSON streaming.
Column mapping
If your source system uses different column names — and it almost certainly
does — use the Column Mapper to paste your headers and produce a
mapping.json file. You can then either:
- Run the mapping yourself (Node or Python snippets) to produce a canonical CSV, or
- Ship
mapping.jsonalongside the raw CSV (once the push-ingest endpoint is GA).
Sample file
The template is a UTF-8 CSV with a header row; one row per product variant.
Each column maps to a canonical products field — for the complete
column-by-column reference (type, required/optional, allowed values, and data
formats for every field) see
Products → Fields you provide. Column order
doesn't matter; header names do (case-insensitive, matched to canonical names
or mapped). Data-format essentials:
- Required in every row:
product_id,product_name,price,price_numeric,image1. - Prices:
priceis a currency-formatted string ("69.00");price_numericis 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 ownvariant_idandoption*_value. Omitvariant_idfor a no-variant product and it defaults toproduct_id. - Lists:
tagsis comma-separated inside one quoted cell.
A minimal canonical CSV — note SKU-1001 appears twice (two size variants),
and SKU-1002 omits variant_id (single-variant product):
product_id,variant_id,product_name,handle,product_details,brand,category,color,tags,price,price_numeric,option1_name,option1_value,gender,image1,status
SKU-1001,SKU-1001-M,Relaxed Linen Button-Up,relaxed-linen-button-up,"Breezy 100% linen with a relaxed cut.",Cove & Coast,tops,white,"linen,summer",69.00,69.00,Size,M,mens,https://cdn.example.com/images/relaxed-linen.jpg,active
SKU-1001,SKU-1001-L,Relaxed Linen Button-Up,relaxed-linen-button-up,"Breezy 100% linen with a relaxed cut.",Cove & Coast,tops,white,"linen,summer",69.00,69.00,Size,L,mens,https://cdn.example.com/images/relaxed-linen.jpg,active
SKU-1002,,Classic Chinos,classic-chinos,"Midweight cotton chinos with a tailored leg.",Cove & Coast,bottoms,khaki,"cotton,classic",89.00,89.00,Size,32,mens,https://cdn.example.com/images/classic-chinos.jpg,activeDownload the full sample: products.csv
Validation rules
During ingest discoverGPT applies the following rules to every row:
product_id,product_name,price,price_numeric, andimage1must be present and non-empty.price_numericmust parse as a non-negative float.image1must be a valid HTTPS URL.status, if provided, must be one ofactive,draft,archived.display_flag, if provided, must betrueorfalse(case-insensitive).gender, if provided, must be one ofmens,womens,unisex,kids.- Any additional columns not in the canonical schema are placed into
metadata_jsonas string values.
Rows that fail validation are skipped with a line number and a reason in the ingest log; valid rows in the same file still import.
JSON format
This section covers file ingest / bulk upload shapes only — not the
POST /v1/ingest push body. For bulk file uploads, discoverGPT accepts two
JSON shapes: newline-delimited JSON (NDJSON) and an array of objects. For any
catalog over a few thousand rows, prefer NDJSON — it streams and doesn't
require the whole catalog to live in memory on either side.
The push endpoint (POST /v1/ingest) does not accept NDJSON or a bare array;
its body is a single {"products": [...]} JSON object. See
Push ingest → Contract for the exact accepted fields.
NDJSON (preferred for large catalogs)
One JSON object per line. No array brackets, no trailing commas. UTF-8.
Content-type application/x-ndjson.
{"product_id":"SKU-1001","variant_id":"SKU-1001-M","product_name":"Relaxed Linen Button-Up","handle":"relaxed-linen-button-up","product_details":"Breezy 100% linen with a relaxed cut.","brand":"Cove & Coast","category":"tops","color":"white","tags":"linen,summer","price":"69.00","price_numeric":69.0,"option1_name":"Size","option1_value":"M","gender":"mens","image1":"https://cdn.example.com/images/relaxed-linen.jpg","status":"active"}
{"product_id":"SKU-1001","variant_id":"SKU-1001-L","product_name":"Relaxed Linen Button-Up","handle":"relaxed-linen-button-up","product_details":"Breezy 100% linen with a relaxed cut.","brand":"Cove & Coast","category":"tops","color":"white","tags":"linen,summer","price":"69.00","price_numeric":69.0,"option1_name":"Size","option1_value":"L","gender":"mens","image1":"https://cdn.example.com/images/relaxed-linen.jpg","status":"active"}Download the full sample: products.ndjson
Array-of-objects (small batches)
For small imports (under a few thousand products), a standard JSON array
works fine. Content-type application/json.
[
{
"product_id": "SKU-1001",
"product_name": "Relaxed Linen Button-Up",
"handle": "relaxed-linen-button-up",
"product_details": "Breezy 100% linen with a relaxed cut.",
"brand": "Cove & Coast",
"category": "tops",
"color": "white",
"tags": "linen,summer",
"price": "69.00",
"price_numeric": 69.0,
"gender": "mens",
"image1": "https://cdn.example.com/images/relaxed-linen.jpg",
"status": "active"
},
{
"product_id": "SKU-1002",
"product_name": "Classic Chinos",
"handle": "classic-chinos",
"product_details": "Midweight cotton chinos with a tailored leg.",
"brand": "Cove & Coast",
"category": "bottoms",
"color": "khaki",
"tags": "cotton,classic",
"price": "89.00",
"price_numeric": 89.0,
"gender": "mens",
"image1": "https://cdn.example.com/images/classic-chinos.jpg",
"status": "active"
}
]Type coercion
- Strings vs numbers:
pricemust be a JSON string;price_numericmust 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_flagmust be a JSON boolean, not"true"as a string. - Arrays:
tagsmust be a string (comma-separated). It is stored that way in the database and split at query time only when needed. - Unknown fields: any field not in the canonical schema is written to
metadata_json.
Fields you must not include
Never include these computed fields on ingest — the server will reject them:
merchant_id(set server-side from auth)id,last_updated(database-managed)text_embedding,text_embedding_1,img_embedding,image_desc_embedcolor_embedding,color_embedding_1,color_embedding_2,color_embedding_3color_hsv_embedding_1,color_hsv_embedding_2,color_hsv_embedding_3percent_1,percent_2,percent_3
See products → Fields we compute.
How ingestion and orchestration works
Your catalog moves through a two-tier pipeline. You only ever choose outcomes — the platform runs the right jobs, in the right order, and skips anything already done.
The two tiers
Foundation (runs once per store, then reused). When a catalog first arrives, two foundation steps run: import (your products land in the catalog) and enrichment (category, gender, sub-category, enriched tags, and an AI vision description of each product image). Foundation runs a single time per store — if you use more than one vairetail product, the second and third reuse the first's foundation instead of re-importing or re-enriching.
Outcomes (you choose these). On top of the foundation, you turn on the capabilities you want. Each maps to a set of jobs:
| 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
If you enable natural-language search together with precise-color search, color runs first and NL runs last — on purpose. The natural-language index embeds each product's color name (produced by the color step) directly into the searchable text, so a query like "navy blazer" matches on the color word. Enable color before (or with) NL and the platform orders it for you; enable NL alone and it still works, just without color words baked into the text index. Image search is independent — it lives in its own vector space and does not change NL quality. Enrichment (including the vision description that also strengthens NL) always runs first, underneath everything.
You won't step on your own data
Requesting an outcome runs only the work that's missing. Foundation and any enrichment another vairetail product already produced are reused, never re-run, and never overwritten. Re-requesting an outcome later safely backfills only what isn't done yet. You never have to sequence jobs yourself or worry about clobbering existing results.
Dos and don'ts
- Do ask for outcomes, not jobs. Don't try to trigger individual pipeline steps.
- Do expect a short pending window — an outcome is ready once its jobs finish, not the instant ingest returns. Poll pipeline status.
- Do turn on color search before or with natural-language search if you want color-aware text results.
- Don't assume a product is color- or image-searchable just because text search works — each outcome is independent (the one link is color → richer NL).
- Re-requesting is safe — it fills only the gaps.
What runs today via push
POST /v1/ingest and POST /v1/ingest/file are live and support natural-language search, precise-color search, image-similarity search, clean product data, generate narratives, and generate Q&A as per-request outcomes — request any combination on a single push. Catalog feed and AI visibility (GEO) are still produced only through the product onboarding flow, not yet selectable per push request.
Ingesting Your Data
discoverGPT supports two ingest models: pull (discoverGPT reaches into your commerce platform on a schedule) and push (you send us your catalog). Pull is available today for Shopify; push is live today with six selectable outcomes — see How ingestion works.
Shopify (Pull)
The Shopify integration is the most common ingest path and the one available today. It is also the only ingest path that requires zero custom code on your side.
What it does
- You install the discoverGPT app from the Shopify App Store.
- You grant the app read access to your products and storefront.
- discoverGPT pulls your product catalog via the Shopify Admin API and
populates the canonical
productstable. - The enrichment pipeline runs: categories, color extraction, embeddings.
- 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.
- Ongoing updates stream in via Shopify webhooks — product creates, updates, and deletes are reflected in the canonical table within seconds.
Field mapping (Shopify → canonical)
| Shopify field (Admin API) | Canonical products field |
|---|---|
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. Color is extracted from image1 if not present in tags.
Onboarding status
Pipeline progress (shopify_etl_dev → enrichment_dev → ... → deploy_shoppergpt_extension)
is tracked in pipeline_status and can be
read via GET /api/v1/pipeline/status/{merchant_id}.
Merchant ID
Your discoverGPT merchant_id is assigned during install and happens to equal
your Shopify Shop ID for today's installs. Always treat it as an opaque
integer — future non-Shopify merchant IDs will come from a separate range.
Uninstall
Uninstalling the Shopify app removes the theme extension and stops the webhook stream. Product rows remain in the canonical table by default (so reinstalling a week later doesn't require a full re-import).
For full data deletion on uninstall, see
DELETE /api/v1/merchant/{merchant_id}/data.
Push ingest
Status: Live
POST /v1/ingest is live on https://api.vairetail.com. Every push request
selects one or more outcomes — see
How ingestion works for the full
outcome model and which ones are selectable today.
Push ingest lets you send catalog rows to discoverGPT from any source — BigCommerce, WooCommerce, Magento, a custom PIM, a Python script reading from Snowflake, whatever.
Contract
POST /v1/ingest HTTP/1.1
Host: api.vairetail.com
Content-Type: application/json
Authorization: Bearer <access_token>Body: a single JSON object with products (array, 1–1000 items) and
outcomes (array of strings, at least one — see
Outcomes for the valid values). The top-level object
rejects unknown keys — there are no query parameters or form fields, only
this JSON body.
Each product requires product_id (string). Recognized fields:
variant_id (distinguishes variants of the same product; defaults to
product_id if omitted — one row per product), handle, product_name,
description, image_url, category, sub_category, brand, tags.
Note the names — it's description, not product_details; image_url,
not image1. Extra fields beyond these are accepted and passed through,
but only the fields above are guaranteed to map to the canonical catalog.
Response
{
"job_id": "sgpt-upsert-12345-4810",
"accepted_product_count": 4810,
"run_id": "push-8f3d2e1a-....",
"outcome_errors": {}
}Ingest returns once your rows are accepted (written synchronously); the
outcomes you requested then run asynchronously. Track progress via
GET /v1/ingest/runs/{run_id} (below) or
pipeline status. See
How ingestion works for what each
outcome runs.
New products aren't searchable the instant ingest returns
A 200 from POST /v1/ingest means your rows were written, not yet
indexed. Products become searchable only once the nl_search outcome
finishes embedding — typically a minute or two after a successful push. A
search run immediately after ingest can return zero results; that's expected,
not an error. Wait until
GET /v1/ingest/runs/{run_id} reports
nl_search: "ready" (or poll pipeline status) before
treating an empty result set as a problem.
outcome_errors maps an outcome to a failure reason if the gateway
couldn't forward your products to that outcome's backend — an outcome
listed here was never dispatched, but every other requested outcome still
processes normally.
Rows that fail validation are rejected and do not count toward
accepted_product_count. dry_run (validate without writing) is not yet
supported — planned, but not accepted today.
Check push-run status
GET /v1/ingest/runs/{run_id}{
"run_id": "push-8f3d2e1a-....",
"status": "running",
"outcomes": {
"nl_search": "ready",
"clean_data": "running"
}
}Each outcome you requested reports its own status: queued, running,
ready, or failed. status at the top level reflects the run as a
whole.
Running a mapping client-side
If you'd rather produce the canonical {"products":[...]} JSON body
yourself before calling the ingest endpoint, see:
Example
curl -X POST https://api.vairetail.com/v1/ingest \
-H "Authorization: Bearer <access_token>" \
-H "Content-Type: application/json" \
-d '{"outcomes":["nl_search"],"products":[{"product_id":"SKU-1","variant_id":"SKU-1-blue","product_name":"Aurora Linen Tee","description":"Lightweight linen tee","image_url":"https://example.com/tee.jpg","category":"Apparel","brand":"Aurora"}]}'Other platforms
If your catalog lives somewhere other than Shopify, the path today is:
- Export your catalog to CSV or NDJSON.
- Use the Column Mapper to map your source columns to the
canonical
productsschema. - Apply the mapping to produce a canonical file (Node or Python snippets).
- Send it to us via push ingest or share it with the visualAI team during onboarding.
Below are the typical field mappings for the common platforms. These are seeded into the Column Mapper's auto-suggest.
BigCommerce
| BigCommerce 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 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 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
If you own the source format, the fastest path is to export a CSV whose headers already match the canonical schema. See CSV format for the required columns.
If you can't rename columns at the source, use the
Column Mapper — paste your headers, accept the auto-suggestions,
download mapping.json, and run it through the
Node or
Python transformer.
File ingest API (CSV and XLSX)
Unlike Push ingest above, which sends canonical JSON, file
ingest lets you upload a .csv or .xlsx file directly and the
gateway auto-detects your column mapping, runs the import as an async job,
and reports per-row results — no NDJSON transform required. All endpoints
below require Authorization: Bearer <jwt> — see
Authentication for how to mint one.
Limits
- Max upload size: 20 MiB (
413above that). - Rows are ingested in batches of 1,000.
- Up to 100 row errors are returned inline on the status response; once
the job finishes, the full list is written to
error_report_uri.
Upload a file
POST /v1/ingest/fileStarts 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 alone no longer maps to product_id
sku/id-style headers map to variant_id, not product_id — a catalog
with a Handle (or similar product-grouping) column resolves both
correctly; a catalog with only a SKU-style column and nothing else that
looks like a product id now gets a 400 (product_id unmapped) instead of
silently treating SKU as the product id. Map product_id explicitly (e.g.
onto the same SKU column) if your catalog genuinely has no separate
product-grouping field.
Headers
| Header | Required | Value |
|---|---|---|
Authorization | yes | Bearer <jwt> — see Authentication |
Content-Type | yes | multipart/form-data (set automatically when you attach 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 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) to apply instead of auto-detection. |
Target fields: product_id (required — the upload is rejected with
400 if it can't be resolved by explicit mapping, template, or
auto-detection), variant_id (distinguishes variants of the same product;
defaults to product_id if omitted), product_name, description,
price, currency, image_url, additional_image_urls (|-delimited in
the source column), product_url, category, sub_category, brand,
tags. Any source column not consumed by the mapping is passed through
under its normalized header name.
Response — 202 Accepted
{
"job_id": "6f1b1a2e-8c3d-4e9a-9d1a-2b7c9e6f1234",
"detected_mapping": {
"product_id": "SKU",
"product_name": "Title",
"price": "Price (USD)",
"image_url": "Image URL"
},
"estimated_rows": 4821,
"sample_rows": [
{
"product_id": "SKU-1001",
"product_name": "Relaxed Linen Button-Up",
"price": 69.0,
"image_url": "https://cdn.example.com/images/relaxed-linen.jpg"
}
],
"headers": ["SKU", "Title", "Price (USD)", "Image URL"],
"outcome_errors": {}
}detected_mapping— the mapping that will actually be used, keyed by canonical target field.sample_rows— up to 20 transformed preview rows. A row that would be skipped by the real import (e.g. an emptyproduct_id) is simply omitted from the preview.headers— the raw column headers read from the file — handy for building your own mapping UI on top of this response.estimated_rows— total data rows parsed from the file.
Example
curl -X POST https://api.vairetail.com/v1/ingest/file \
-H "Authorization: Bearer $TOKEN" \
-F "file=@catalog.csv" \
-F 'outcomes=["nl_search"]' \
-F 'mapping={"product_id":"SKU","product_name":"Title"}'import httpx
with open("catalog.csv", "rb") as f:
r = httpx.post(
"https://api.vairetail.com/v1/ingest/file",
headers={"Authorization": f"Bearer {token}"},
files={"file": ("catalog.csv", f, "text/csv")},
data={
"outcomes": '["nl_search"]',
"mapping": '{"product_id":"SKU","product_name":"Title"}',
},
)
job_id = r.json()["job_id"]const form = new FormData();
form.append('file', fileInput.files[0]);
form.append('outcomes', JSON.stringify(['nl_search']));
form.append('mapping', JSON.stringify({ product_id: 'SKU', product_name: 'Title' }));
const res = await fetch('https://api.vairetail.com/v1/ingest/file', {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
body: form,
});
const { job_id } = await res.json();Errors
| 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. |
| 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
GET /v1/ingest/file/{job_id}Durable, poll-to-terminal processing
Ingest jobs run out-of-band and survive a gateway restart — poll this
endpoint until state reaches a terminal value. A bad row never aborts the
whole file: it's skipped, recorded in errors, and processing continues, so
a file with a few invalid rows finishes partial rather than failed. Rows
are upserted by product_id, so re-running the same file is safe.
Deletion-sync (removing products absent from a later file) is not
performed — a documented gap, not a bug.
Response — 200 OK
{
"job_id": "6f1b1a2e-8c3d-4e9a-9d1a-2b7c9e6f1234",
"state": "partial",
"processed": 4821,
"total": 4821,
"skipped": 3,
"accepted_product_count": 4818,
"errors": [
{ "row": 142, "reason": "empty product_id" }
],
"error_report_uri": "https://storage.googleapis.com/.../ingest/6f1b1a2e.../errors.json",
"run_id": "push-8f3d2e1a-....",
"outcome_errors": {}
}state—queued→running→ terminal:succeeded(no skipped rows),partial(some rows skipped), orfailed(the job itself errored).errors— up to 100 entries inline as{ row, reason }.error_report_uriholds the complete list once the job finishes.run_id— identifies the push-run for your requestedoutcomes; poll it viaGET /v1/ingest/runs/{run_id}.nulluntil at least one product has been accepted, and permanentlynullfor afailedjob or one where every row was skipped.outcome_errors— maps an outcome to a failure reason if the gateway couldn't forward your accepted products to that outcome's backend; every other requested outcome still processes normally.
Example
curl https://api.vairetail.com/v1/ingest/file/$JOB_ID \
-H "Authorization: Bearer $TOKEN"Errors
| 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
POST /v1/ingest/mappingsPersists a reusable, agency-scoped column mapping so you don't have to resend
an explicit mapping JSON blob on every upload — pass its id as
template_id on Upload a file. Templates are agency-wide
(not merchant-specific).
Request body
| 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
{ "id": "9a4e2f10-6b3c-4a1d-8e7f-1234567890ab" }Example
curl -X POST https://api.vairetail.com/v1/ingest/mappings \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "Acme PIM export",
"mapping": { "product_id": "SKU", "product_name": "Title", "price": "Price (USD)" }
}'Errors
| Status | Response |
|---|---|
| 409 | A template named name already exists for your agency. |
List mapping templates
GET /v1/ingest/mappingsResponse — 200 OK
{
"templates": [
{
"id": "9a4e2f10-6b3c-4a1d-8e7f-1234567890ab",
"name": "Acme PIM export",
"mapping": { "product_id": "SKU", "product_name": "Title", "price": "Price (USD)" },
"created_at": "2026-07-01T12:00:00Z"
}
]
}Example
curl https://api.vairetail.com/v1/ingest/mappings \
-H "Authorization: Bearer $TOKEN"Ingest quickstart
Zero to ingested: mint a token, upload a CSV, poll the job, and confirm the result. If you haven't created an API key yet, do Quickstart steps 1–2 first.
1. Mint a token scoped to your merchant
export CLIENT_ID="YOUR_CLIENT_ID"
export CLIENT_SECRET="YOUR_CLIENT_SECRET"
export MERCHANT_ID="98906636598" # or your own merchant
curl -s -X POST https://api.vairetail.com/oauth/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d grant_type=client_credentials \
-d client_id="$CLIENT_ID" \
-d client_secret="$CLIENT_SECRET" \
-d scope="merchant:$MERCHANT_ID"export TOKEN="eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9..." # access_token from the response2. Upload your CSV
curl -s -X POST https://api.vairetail.com/v1/ingest/file \
-H "Authorization: Bearer $TOKEN" \
-F "file=@catalog.csv"{
"job_id": "6f1b1a2e-8c3d-4e9a-9d1a-2b7c9e6f1234",
"detected_mapping": { "product_id": "SKU", "product_name": "Title", "price": "Price (USD)" },
"estimated_rows": 4821,
"sample_rows": [{ "product_id": "SKU-1001", "product_name": "Relaxed Linen Button-Up" }],
"headers": ["SKU", "Title", "Price (USD)"]
}Check detected_mapping before moving on. If it's missing a field you care
about or mapped one wrong, re-upload with an explicit mapping JSON blob (or
save a template and pass template_id) rather
than proceeding with a bad mapping.
export JOB_ID="6f1b1a2e-8c3d-4e9a-9d1a-2b7c9e6f1234" # job_id from the response3. Poll until the job is terminal
import time
import httpx
BASE = "https://api.vairetail.com"
headers = {"Authorization": f"Bearer {token}"}
while True:
status = httpx.get(f"{BASE}/v1/ingest/file/{job_id}", headers=headers).json()
print("state:", status["state"], "processed:", status["processed"], "/", status["total"])
if status["state"] in ("succeeded", "partial", "failed"):
break
time.sleep(2)Or poll by hand with curl every couple of seconds:
curl -s https://api.vairetail.com/v1/ingest/file/$JOB_ID \
-H "Authorization: Bearer $TOKEN"4. Confirm the result
{
"job_id": "6f1b1a2e-8c3d-4e9a-9d1a-2b7c9e6f1234",
"state": "succeeded",
"processed": 4821,
"total": 4821,
"skipped": 0,
"accepted_product_count": 4821,
"errors": [],
"error_report_uri": null
}state: "succeeded" with skipped: 0 means every row was accepted. A
partial state with skipped > 0 isn't a failure — inspect errors (or
error_report_uri for the full list), fix the source rows, and re-upload;
the upsert-by-product_id semantics make re-running safe.
Column Mapper
Interactively map non-standard column headers to the canonical schema before you upload.
File ingest API
Full reference: mapping precedence, saved templates, limits, and error codes.
Column mapping & templates
This section covers the mapping engine behind Upload a file
— the auto-detect logic, override precedence, and per-field coercion rules
that decide what detected_mapping looks like on every upload. It's a
different (smaller) schema than the full products table used
by Push ingest and CSV format: file ingest
doesn't require your columns to match canonical names at all — anything it
doesn't recognize is passed through unchanged rather than rejected.
Canonical target schema
| Target 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). |
product_name | no | |
description | no | |
price | no | Coerced to a number where possible — see Price coercion. |
currency | no | |
image_url | no | |
additional_image_urls | no | ` |
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
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
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_idcomes from auto-detect ("SKU"— nothing else set it).product_namecomes from the explicit mapping ("SKU"), even though the template also set it — explicit always wins per field.
Ghost columns are a 400, not a silent no-op
If an explicit mapping or a saved template names a source column that
isn't in the file's actual headers, the whole upload is rejected with 400
and the response names every missing column, e.g. mapped column(s) not found in headers: ghost_col. Re-check header spelling (including case and
punctuation) if you hit this after editing a template.
Price coercion
price values are coerced to a number: a leading $ and thousands-separator
commas are stripped, then the remainder is parsed as a float
("$1,299.00" → 1299.0). If the cleaned value doesn't parse as a number,
the original string is kept as-is rather than the row being dropped or
flagged as an error — a bad price never causes a row to be skipped. Only an
empty product_id does that (recorded in the job's errors, see
Check ingest job status).
Additional image URLs
additional_image_urls maps from a single source column whose value is
|-delimited — "a.jpg|b.jpg|c.jpg" becomes
["a.jpg", "b.jpg", "c.jpg"]. The delimiter is fixed at | and isn't
configurable via the API today.
If your source spreads gallery images across separate columns instead
(image2, image3, … imageN) rather than one delimited column, those
aren't auto-merged — none of them match the additional_image_urls
synonyms above, so each passes through individually under its own
normalized header rather than collapsing into additional_image_urls.
Concatenate them into one |-delimited column before uploading (or map one
of them explicitly) if you want a single additional_image_urls array.
Mapping templates
Save a mapping you like as a reusable, agency-scoped template with
POST /v1/ingest/mappings, then apply it on any
later upload with template_id instead of resending the JSON blob — see
Save a mapping template and
List mapping templates for full request/response
shapes. Template names must be unique within your agency (409 on a
duplicate); templates are agency-scoped, not merchant-scoped, so the
same template applies across every merchant your agency ingests for. The
dashboard wizard below is the easiest way to
build and save one interactively.
Ingest from the dashboard
The dashboard wraps File ingest API in a
three-step wizard — upload → review mapping → confirm & track — for
teams who'd rather not script it. It's not a separate ingest path: uploads
go through token-gated /internal/ingest/* routes that call the exact same
mapping/job machinery as POST /v1/ingest/file, so they're metered
identically to the API — there's no quota advantage to using the UI.
1. Upload
Open Ingest in the dashboard sidebar (/dashboard/ingest), pick one of
your organization's authorized merchants from the dropdown (authorize one on
the Merchants page first if the list is empty), choose a .csv or .xlsx
file, and check at least one of Natural-language search, Color
search, or Image search — these map directly to the API's outcomes
field (see Upload a file) and control which search
capabilities your catalog gets indexed for once the job completes. Upload
& preview stays disabled until a merchant, a file, and at least one
outcome are all set.
4 MB dashboard cap
The dashboard enforces a client-side 4 MB limit and rejects larger files
before they're sent — the underlying gateway endpoint's cap is 20 MiB.
For larger catalogs, call POST /v1/ingest/file directly.
Clicking Upload & preview creates the ingest job immediately, using
whatever mapping the gateway auto-detects from your file's headers, and
publishes a push-run for your chosen outcomes once the job finishes
accepting at least one product (see Check ingest job
status's run_id).
2. Review the detected mapping
The map step shows every canonical field next to a <select> pre-filled
with the auto-detected source column, a preview of the first 20 transformed
rows in canonical shape, and quick stats (e.g. "92% of sampled rows have a
price"). You can:
- Change any field's source column from the dropdown of your file's raw headers.
- Load a saved template from the template selector — it merges the template's mapping into your current selections.
- Save the current selections as a new template by naming it and
clicking "Save as template" (agency-scoped; a duplicate name returns a
409, surfaced inline).
Phase 3 limitation: editing here doesn't retarget the existing job
The job was already created — with the auto-detected mapping — the moment
you clicked Upload & preview in step 1. Editing the selects on this screen
only changes what gets saved as a template; it does not retroactively
change how that job's rows get transformed. To actually apply an edited
mapping to the ingest itself, click "Re-upload with this mapping"
(re-sends the same file with your edits as an explicit mapping override,
creating a new job), or save it as a template and select it before
uploading next time.
3. Confirm & track progress
Confirm & track progress is disabled until product_id is mapped. Once
confirmed, the wizard polls the job's status every 3 seconds until it
reaches a terminal state, showing:
- A progress bar (
processed/totalrows). - A state pill (
queued,running,succeeded,partial,failed). - On completion: an accepted/skipped/total summary and, if any rows were
skipped, the
error_report_uriand a table of the first 100 row errors ({ row, reason }) — same data as Check ingest job status.
If status checks fail repeatedly (five in a row) — or the job disappears
entirely (404) — polling stops and shows a Retry button. That only
pauses the live view; the ingest job itself keeps running server-side
regardless. Once the job reaches a terminal state, "Start a new upload"
resets the wizard to step 1.
Ingest quickstart
Do the same upload → poll → confirm flow with curl and a script, no dashboard required.
Column mapping & templates
Auto-detect synonyms, override precedence, price coercion, and template CRUD in full.
File ingest API
The raw endpoint the dashboard wizard calls under the hood.
Column Mapper
The Column Mapper is an interactive tool that maps your source catalog columns to the canonical products schema. It runs entirely in your browser — nothing is uploaded. Once you've exported a mapping.json, use one of the recipes below to transform your raw CSV into a canonical {"products": [...]} payload.
Apply a mapping (Node.js)
This snippet takes a mapping.json file (downloaded from the
Column Mapper) and uses it to convert a raw source CSV into a
canonical {"products": [...]} payload ready for push ingest.
Install dependencies
npm install csv-parseScript
import fs from 'node:fs';
import { parse } from 'csv-parse/sync';
// 1. Load the mapping file produced by the Column Mapper.
const mappingConfig = JSON.parse(fs.readFileSync('mapping.json', 'utf8'));
const { mapping } = mappingConfig;
// 2. Read the raw CSV.
const rawCsv = fs.readFileSync('raw-products.csv', 'utf8');
const rows = parse(rawCsv, { columns: true, skip_empty_lines: true });
// 3. Transform each row into the canonical shape.
const canonical = rows.map((row) => {
const out = {};
for (const [canonicalField, spec] of Object.entries(mapping)) {
if (typeof spec === 'string') {
out[canonicalField] = row[spec];
} else {
const value = row[spec.source];
out[canonicalField] = applyTransform(value, spec.transform);
}
}
return out;
});
// 4. Wrap in the push-ingest request body and write as JSON.
const payload = { products: canonical };
fs.writeFileSync('products.json', JSON.stringify(payload));
console.log(`Wrote ${canonical.length} canonical products`);
function applyTransform(value, transform) {
if (value == null || value === '') return null;
switch (transform) {
case 'parse_float': {
const n = parseFloat(String(value).replace(/[^0-9.-]/g, ''));
return Number.isFinite(n) ? n : null;
}
case 'trim':
return String(value).trim();
case 'uppercase':
return String(value).toUpperCase();
case 'lowercase':
return String(value).toLowerCase();
case 'strip_html':
return String(value).replace(/<[^>]*>/g, '');
default:
return value;
}
}Output
products.json will contain a single JSON object, {"products": [...]},
ready to be POSTed to the push-ingest endpoint.
Rows that fail validation are rejected and do not count toward
accepted_product_count — dry_run is not yet supported (planned).
Apply a mapping (Python)
This snippet takes a mapping.json file (downloaded from the
Column Mapper) and uses it to convert a raw source CSV into a
canonical {"products": [...]} payload, ready for push ingest.
Standard library only — no dependencies.
Script
import csv
import json
import re
from pathlib import Path
def apply_transform(value, transform):
if value is None or value == "":
return None
if transform == "parse_float":
cleaned = re.sub(r"[^0-9.\-]", "", str(value))
try:
return float(cleaned)
except ValueError:
return None
if transform == "trim":
return str(value).strip()
if transform == "uppercase":
return str(value).upper()
if transform == "lowercase":
return str(value).lower()
if transform == "strip_html":
return re.sub(r"<[^>]*>", "", str(value))
return value
def transform_row(row, mapping):
out = {}
for canonical_field, spec in mapping.items():
if isinstance(spec, str):
out[canonical_field] = row.get(spec)
else:
value = row.get(spec["source"])
out[canonical_field] = apply_transform(value, spec.get("transform"))
return out
def main():
mapping_config = json.loads(Path("mapping.json").read_text())
mapping = mapping_config["mapping"]
with open("raw-products.csv", newline="", encoding="utf-8") as src:
reader = csv.DictReader(src)
canonical = [transform_row(row, mapping) for row in reader]
payload = {"products": canonical}
Path("products.json").write_text(json.dumps(payload))
print(f"Wrote {len(canonical)} canonical products")
if __name__ == "__main__":
main()Output
products.json will contain a single JSON object, {"products": [...]},
ready to be POSTed to the push-ingest endpoint.
Rows that fail validation are rejected and do not count toward
accepted_product_count — dry_run is not yet supported (planned).
Search API
Query response
POST /api/v0/product/search/queryresponseGenerates an LLM-driven natural-language recommendation for a shopper's query — the conversational text shown above product results. This endpoint does not return products; it returns a single recommendation string.
The theme extension calls this endpoint first, then calls
/api/v0/product/search/text or
/api/v0/product/search/textandcolor to
fetch the actual matching products.
Results are cached per (user_profile_id, normalized_query[, color]) so
repeat queries return the same recommendation instantly.
Auth modes supported
Today: Shopify-embedded only (CORS-enforced, X-Merchant-ID header). API
key support coming soon.
Headers
| Header | Required | Value |
|---|---|---|
X-Merchant-ID | yes | Your merchant ID (integer) |
Content-Type | yes | application/json |
Request body
| Field | Type | Required | Description |
|---|---|---|---|
query_str | string | yes | The shopper's search query in plain language. |
preferences | boolean | no | If true, merge the user's stored preferences into the prompt. |
userPrefsNotPresent | boolean | no | Hint that the user profile has no preferences yet. |
current_hour | integer | no | Local hour (0–23) — used to pick a time-of-day greeting. |
user_name | string | no | Shopper first name. |
color_input | string or object | no | Optional color (named or hex). When set, the recommendation is color-aware. |
user_profile_id | integer | no | Attach this query to a known user profile. |
conversational_style | string | no | Style hint for the LLM (friendly, concise, technical, …). |
personal_fashion_style | string | no | Fashion style hint. |
favorite_colors | string[] | no | Soft color preferences. |
fashion_material_prefer | string[] | no | Preferred materials. |
body_type | string | no | |
fashion_goals | string | no | |
age_group | string | no | |
session_id | string | no | Opaque session identifier for analytics. |
client_ip | string | no | Client IP (otherwise inferred server-side). |
location | string | no | City/region string for locale hints. |
Response — 200 OK
{
"results": {
"recommendation": "Here are a few breezy linen shirts under $80 that should feel great all day."
}
}Example
curl -X POST \
https://developer-search-devID.region.run.app/api/v0/product/search/queryresponse \
-H "X-Merchant-ID: 12345" \
-H "Content-Type: application/json" \
-d '{
"query_str": "comfortable linen shirt under $80",
"user_name": "Alex",
"current_hour": 14
}'const res = await fetch(
'https://developer-search-devID.region.run.app/api/v0/product/search/queryresponse',
{
method: 'POST',
headers: {
'X-Merchant-ID': '12345',
'Content-Type': 'application/json',
},
body: JSON.stringify({
query_str: 'comfortable linen shirt under $80',
user_name: 'Alex',
}),
},
);
const data = await res.json();
console.log(data.results.recommendation);import httpx
r = httpx.post(
"https://developer-search-devID.region.run.app/api/v0/product/search/queryresponse",
headers={"X-Merchant-ID": "12345", "Content-Type": "application/json"},
json={"query_str": "comfortable linen shirt under $80", "user_name": "Alex"},
)
print(r.json()["results"]["recommendation"])Errors
| Status | Response |
|---|---|
| 400 | { "detail": "Missing or invalid X-Merchant-ID header" } |
| 422 | Validation error — usually a missing query_str. |
| 500 | Internal error reaching the LLM. Safe to retry with backoff. |
Text search
POST /api/v0/product/search/textThe workhorse search endpoint. Takes a natural-language query, runs a semantic-similarity search over the merchant's catalog, filters by price, and returns ranked products plus a conversational recommendation.
Supports natural-language price constraints in the query string — "linen shirt under $80", "dress between $50 and $120", "sneakers around $200" — which are parsed out and applied as numeric filters.
Results are cached for 7 days, keyed on the normalized query plus any relevant preference fields.
Auth modes supported
Today: Shopify-embedded only (CORS-enforced, X-Merchant-ID header). API
key support coming soon.
Headers
| Header | Required | Value |
|---|---|---|
X-Merchant-ID | yes | Your merchant ID (integer) |
Content-Type | yes | application/json |
Request body
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
query_str | string | yes | — | The shopper's search query. May contain price phrases like "under $80". |
limit | integer | no | 10 | Maximum number of results. |
preferences | boolean | no | false | If true, blend the user's stored preferences into ranking. |
user_profile_id | integer | no | — | Attach results to a user profile for analytics. |
user_name | string | no | — | Shopper first name (used in the recommendation). |
conversational_style | string | no | — | Style hint for the recommendation LLM. |
personal_fashion_style | string | no | — | Fashion style hint. |
favorite_colors | string[] | no | — | Soft color preferences — nudge ranking toward matching colors. |
fashion_material_prefer | string[] | no | — | Preferred materials. |
body_type | string | no | — | |
fashion_goals | string | no | — | |
age_group | string | no | — | |
gender | string[] | no | — | Filter: any of mens, womens, unisex, kids. |
min_price | number | no | — | Explicit minimum price filter (takes precedence over in-query price phrases). |
max_price | number | no | — | Explicit maximum price filter. |
session_id, client_ip, location | string | no | — | Analytics fields. |
Response — 200 OK
{
"results": {
"data": [
{
"product_id": "SKU-1001",
"product_name": "Relaxed-Fit Linen Button-Up",
"handle": "relaxed-fit-linen-button-up",
"brand": "Cove & Coast",
"category": "tops",
"price": "69.00",
"price_numeric": 69.0,
"image1": "https://cdn.example.com/images/relaxed-linen.jpg",
"distance": 0.213
}
]
},
"recommendation": "Here are a few breezy linen shirts under $80 that should feel great all day.",
"success": true
}distance is the similarity score between the query and the product's
embedding — lower is better. Each product also carries every canonical
products field that was populated for the
row.
Example
curl -X POST \
https://developer-search-devID.region.run.app/api/v0/product/search/text \
-H "X-Merchant-ID: 12345" \
-H "Content-Type: application/json" \
-d '{
"query_str": "comfortable linen shirt under $80",
"limit": 10,
"gender": ["mens", "unisex"]
}'const res = await fetch(
'https://developer-search-devID.region.run.app/api/v0/product/search/text',
{
method: 'POST',
headers: {
'X-Merchant-ID': '12345',
'Content-Type': 'application/json',
},
body: JSON.stringify({
query_str: 'comfortable linen shirt under $80',
limit: 10,
}),
},
);
const data = await res.json();
console.log(`${data.results.data.length} products`);import httpx
r = httpx.post(
"https://developer-search-devID.region.run.app/api/v0/product/search/text",
headers={"X-Merchant-ID": "12345", "Content-Type": "application/json"},
json={"query_str": "comfortable linen shirt under $80", "limit": 10},
)
print(len(r.json()["results"]["data"]))Errors
| Status | Response |
|---|---|
| 400 | { "detail": "Missing or invalid X-Merchant-ID header" } |
| 422 | Validation error — usually a missing query_str. |
| 500 | { "detail": "An error occurred during the search." } |
Text and color search
POST /api/v0/product/search/textandcolorRuns a combined text and color search. Either query_str or color can be
omitted individually, but at least one must be provided. The endpoint
determines its operation_type automatically:
- Both text and color →
textandcolor - Color only →
color - Text only →
text
Natural-language price constraints in query_str are parsed and applied,
just like text search. Results are cached for
7 days on a normalized query|color cache key.
Auth modes supported
Today: Shopify-embedded only (CORS-enforced, X-Merchant-ID header). API
key support coming soon.
Headers
| Header | Required | Value |
|---|---|---|
X-Merchant-ID | yes | Your merchant ID (integer) |
Content-Type | yes | application/json |
Request body
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
query_str | string | one of query_str / color | — | The text query. |
color | string or object | one of query_str / color | — | Named color ("navy"), hex ("#0a1f44"), or { "rgb": [r, g, b] }. |
limit | integer | no | 10 | Maximum results. |
min_price | number | no | — | Explicit minimum price. |
max_price | number | no | — | Explicit maximum price. |
user_profile_id | integer | no | — | Attach for analytics and personalization. |
user_name | string | no | — | Used in the recommendation text. |
conversational_style | string | no | — | Style hint for the recommendation LLM. |
session_id, client_ip, location | string | no | — | Analytics fields. |
400 if both are empty
If both query_str and color are missing or empty, the endpoint returns
400: At least one of 'text' or 'color' must be provided.
Response — 200 OK
{
"results": {
"data": [
{
"product_id": "SKU-2003",
"product_name": "Navy Linen Blazer",
"price": "189.00",
"price_numeric": 189.0,
"image1": "https://cdn.example.com/images/navy-blazer.jpg",
"color": "navy blue",
"distance": 0.187
}
]
},
"recommendation": "These navy pieces should layer beautifully for a smart-casual look.",
"success": true
}Example
curl -X POST \
https://developer-search-devID.region.run.app/api/v0/product/search/textandcolor \
-H "X-Merchant-ID: 12345" \
-H "Content-Type: application/json" \
-d '{
"query_str": "blazer for a wedding",
"color": "navy",
"limit": 8
}'const res = await fetch(
'https://developer-search-devID.region.run.app/api/v0/product/search/textandcolor',
{
method: 'POST',
headers: {
'X-Merchant-ID': '12345',
'Content-Type': 'application/json',
},
body: JSON.stringify({
query_str: 'blazer for a wedding',
color: 'navy',
limit: 8,
}),
},
);import httpx
r = httpx.post(
"https://developer-search-devID.region.run.app/api/v0/product/search/textandcolor",
headers={"X-Merchant-ID": "12345", "Content-Type": "application/json"},
json={"query_str": "blazer for a wedding", "color": "navy", "limit": 8},
)Errors
| Status | Response |
|---|---|
| 400 | { "detail": "Missing or invalid X-Merchant-ID header" } |
| 400 | { "detail": "At least one of 'text' or 'color' must be provided." } |
| 422 | Validation error. |
| 500 | Internal search error. Safe to retry with backoff. |
Image search
POST /api/v1/product/search/imageUpload an image and get the closest visually matching products from the catalog. Internally, the image is embedded, its dominant colors extracted, and a hybrid vector + color similarity search is run.
Unlike the text endpoints, this is a multipart/form-data upload.
Auth modes supported
Today: Shopify-embedded only (CORS-enforced, X-Merchant-ID header). API
key support coming soon.
No text + image fusion
Image search runs on the image alone. There is no single-call mode that blends a reference image with a text query or color — combine them with separate requests. See Demo 2.
Headers
| Header | Required | Value |
|---|---|---|
X-Merchant-ID | yes | Your merchant ID (integer) |
Content-Type | yes | multipart/form-data (set by your client) |
Form fields
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
file | file | yes | — | The reference image (JPEG/PNG/WebP, up to 10 MB). |
limit | integer | no | 10 | Maximum number of results. |
crop_values | string | no | — | JSON string of {x, y, width, height} pixels to crop before embedding. |
user_profile_id | integer | no | — | Attach results to a user profile. |
user_name | string | no | — | Shopper first name, used in recommendation text. |
conversational_style | string | no | — | Style hint for the recommendation LLM. |
Response — 200 OK
{
"results": [
{
"product_id": "SKU-4471",
"product_name": "Olive Utility Jacket",
"price": "149.00",
"price_numeric": 149.0,
"image1": "https://cdn.example.com/images/olive-utility.jpg",
"distance": 0.142
}
],
"recommendation": "Here are some olive-toned outerwear options with a similar silhouette.",
"success": true
}Note the shape difference: image search returns results as a flat
array, not wrapped in results.data like the text endpoints. This is a
legacy difference that will be reconciled in a future /api/v2/ namespace.
Example
curl -X POST \
https://developer-search-devID.region.run.app/api/v1/product/search/image \
-H "X-Merchant-ID: 12345" \
-F "file=@reference.jpg" \
-F "limit=8"const form = new FormData();
form.append('file', fileInput.files[0]);
form.append('limit', '8');
const res = await fetch(
'https://developer-search-devID.region.run.app/api/v1/product/search/image',
{
method: 'POST',
headers: { 'X-Merchant-ID': '12345' },
body: form,
},
);
const data = await res.json();
console.log(`${data.results.length} visually similar products`);import httpx
with open("reference.jpg", "rb") as f:
r = httpx.post(
"https://developer-search-devID.region.run.app/api/v1/product/search/image",
headers={"X-Merchant-ID": "12345"},
files={"file": ("reference.jpg", f, "image/jpeg")},
data={"limit": 8},
)
print(len(r.json()["results"]))Errors
| Status | Response |
|---|---|
| 400 | { "detail": "Missing or invalid X-Merchant-ID header" } |
| 422 | Validation error — usually a missing file. |
| 500 | Internal error during image embedding. Safe to retry. |
Text search cache check
POST /api/v0/product/search/text/cache-checkA lightweight companion to /api/v0/product/search/text
that only checks whether a cached result exists for the given query — it
does not run the search. Use this when you want to show a "loading…"
state only if a real search is about to run.
Takes the same body as the text search endpoint so the cache key matches exactly.
Auth modes supported
Today: Shopify-embedded only. API key support coming soon.
Headers
| Header | Required | Value |
|---|---|---|
X-Merchant-ID | yes | Your merchant ID (integer) |
Content-Type | yes | application/json |
Request body
Identical to /api/v0/product/search/text.
The body is used only to compute the cache key — preferences,
favorite_colors, price filters, and the query itself all contribute.
Response — 200 OK
Cache hit
{
"cached": true,
"result_count": 10,
"cache_key": "comfortable linen shirt",
"hits": 47
}Cache miss
{
"cached": false,
"result_count": 0,
"cache_key": "comfortable linen shirt"
}| Field | Type | Description |
|---|---|---|
cached | boolean | Whether a cached result exists. |
result_count | integer | Number of products in the cached result set (0 on a miss). |
cache_key | string | The cache key the server computed from your request body. |
hits | integer | Number of times the cached entry has been served (omitted on a miss). |
Example
curl -X POST \
https://developer-search-devID.region.run.app/api/v0/product/search/text/cache-check \
-H "X-Merchant-ID: 12345" \
-H "Content-Type: application/json" \
-d '{"query_str": "comfortable linen shirt"}'const res = await fetch(
'https://developer-search-devID.region.run.app/api/v0/product/search/text/cache-check',
{
method: 'POST',
headers: {
'X-Merchant-ID': '12345',
'Content-Type': 'application/json',
},
body: JSON.stringify({ query_str: 'comfortable linen shirt' }),
},
);
const { cached, result_count } = await res.json();
if (!cached) showLoadingState();import httpx
r = httpx.post(
"https://developer-search-devID.region.run.app/api/v0/product/search/text/cache-check",
headers={"X-Merchant-ID": "12345", "Content-Type": "application/json"},
json={"query_str": "comfortable linen shirt"},
)
print(r.json())Errors
| Status | Response |
|---|---|
| 400 | { "detail": "Missing or invalid X-Merchant-ID header" } |
| 422 | Validation error — usually a missing query_str. |
Text and color search cache check
POST /api/v0/product/search/textandcolor/cache-checkThe cache-check companion to
/api/v0/product/search/textandcolor.
Returns whether a cached result exists for a given (query_str, color)
combination without running the search.
Auth modes supported
Today: Shopify-embedded only. API key support coming soon.
Headers
| Header | Required | Value |
|---|---|---|
X-Merchant-ID | yes | Your merchant ID (integer) |
Content-Type | yes | application/json |
Request body
Identical to /api/v0/product/search/textandcolor.
At least one of query_str and color must be provided.
Response — 200 OK
Cache hit
{
"cached": true,
"result_count": 8,
"cache_key": "blazer for a wedding|[0,0,128]",
"operation_type": "textandcolor",
"hits": 12
}Cache miss
{
"cached": false,
"result_count": 0,
"cache_key": "blazer for a wedding|[0,0,128]",
"operation_type": "textandcolor"
}| Field | Type | Description |
|---|---|---|
cached | boolean | Whether a cached result exists. |
result_count | integer | Number of products in the cached result set. |
cache_key | string | The cache key the server computed (`normalized_query |
operation_type | string | text, color, or textandcolor. |
hits | integer | Cached-entry hit count (omitted on a miss). |
Example
curl -X POST \
https://developer-search-devID.region.run.app/api/v0/product/search/textandcolor/cache-check \
-H "X-Merchant-ID: 12345" \
-H "Content-Type: application/json" \
-d '{"query_str": "blazer for a wedding", "color": "navy"}'const res = await fetch(
'https://developer-search-devID.region.run.app/api/v0/product/search/textandcolor/cache-check',
{
method: 'POST',
headers: {
'X-Merchant-ID': '12345',
'Content-Type': 'application/json',
},
body: JSON.stringify({ query_str: 'blazer for a wedding', color: 'navy' }),
},
);
const data = await res.json();import httpx
r = httpx.post(
"https://developer-search-devID.region.run.app/api/v0/product/search/textandcolor/cache-check",
headers={"X-Merchant-ID": "12345", "Content-Type": "application/json"},
json={"query_str": "blazer for a wedding", "color": "navy"},
)
print(r.json())Errors
| Status | Response |
|---|---|
| 400 | { "detail": "Missing or invalid X-Merchant-ID header" } |
| 400 | { "detail": "At least one of 'text' or 'color' must be provided." } |
| 422 | Validation error. |
Max product price
GET /api/v0/product/max-priceReturns the highest price_numeric value across the merchant's catalog.
Used to size the upper bound of price range filters in the storefront UI.
Cached for 1 hour server-side.
Auth modes supported
Today: Shopify-embedded only. API key support coming soon.
Headers
| Header | Required | Value |
|---|---|---|
X-Merchant-ID | yes | Your merchant ID (integer) |
Response — 200 OK
{
"max_price": 799.0,
"product_count": 4821
}| Field | Type | Description |
|---|---|---|
max_price | number | Highest price_numeric across status = 'active' rows. 0 if empty. |
product_count | integer | Total number of active products in the catalog. |
Example
curl https://developer-search-devID.region.run.app/api/v0/product/max-price \
-H "X-Merchant-ID: 12345"const res = await fetch(
'https://developer-search-devID.region.run.app/api/v0/product/max-price',
{ headers: { 'X-Merchant-ID': '12345' } },
);
const { max_price } = await res.json();import httpx
r = httpx.get(
"https://developer-search-devID.region.run.app/api/v0/product/max-price",
headers={"X-Merchant-ID": "12345"},
)
print(r.json()["max_price"])Errors
| Status | Response |
|---|---|
| 400 | { "detail": "Missing or invalid X-Merchant-ID header" } |
| 500 | Internal database error. Safe to retry with backoff. |
User API
Create user profile
POST /api/v1/user/profileCreates a new user profile for this
merchant, or returns the existing row if one already exists with the same
email. Profiles are unique on (merchant_id, email_id).
Auth modes supported
Today: Shopify-embedded only. API key support coming soon.
Headers
| Header | Required | Value |
|---|---|---|
X-Merchant-ID | yes | Your merchant ID (integer) |
Content-Type | yes | application/json |
Request body
| Field | Type | Required | Description |
|---|---|---|---|
user_name | string | yes | Display name. |
first_name | string | yes | First name. |
last_name | string | yes | Last name. |
email_id | string | yes | Email address (unique per merchant). |
profile_name | string | no | Optional profile label. |
Response — 200 OK
{
"id": 812,
"user_name": "alex",
"first_name": "Alex",
"last_name": "Martinez",
"email_id": "alex@example.com",
"profile_name": null
}Example
curl -X POST \
https://developer-search-devID.region.run.app/api/v1/user/profile \
-H "X-Merchant-ID: 12345" \
-H "Content-Type: application/json" \
-d '{
"user_name": "alex",
"first_name": "Alex",
"last_name": "Martinez",
"email_id": "alex@example.com"
}'const res = await fetch(
'https://developer-search-devID.region.run.app/api/v1/user/profile',
{
method: 'POST',
headers: {
'X-Merchant-ID': '12345',
'Content-Type': 'application/json',
},
body: JSON.stringify({
user_name: 'alex',
first_name: 'Alex',
last_name: 'Martinez',
email_id: 'alex@example.com',
}),
},
);
const profile = await res.json();import httpx
r = httpx.post(
"https://developer-search-devID.region.run.app/api/v1/user/profile",
headers={"X-Merchant-ID": "12345", "Content-Type": "application/json"},
json={
"user_name": "alex",
"first_name": "Alex",
"last_name": "Martinez",
"email_id": "alex@example.com",
},
)
print(r.json())Errors
| Status | Response |
|---|---|
| 400 | { "detail": "Missing or invalid X-Merchant-ID header" } |
| 422 | Validation error — missing required fields. |
| 500 | Database error. Safe to retry. |
Get profile by email
POST /api/v1/user/profile/emailReturns the user profile matching the supplied email within the authenticated
merchant. This is a POST (not a GET) so the email can be passed in a JSON
body rather than a query string — keeps shopper PII out of URLs and logs.
Returns 404 if no profile with that email exists.
Auth modes supported
Today: Shopify-embedded only. API key support coming soon.
Headers
| Header | Required | Value |
|---|---|---|
X-Merchant-ID | yes | Your merchant ID (integer) |
Content-Type | yes | application/json |
Request body
| Field | Type | Required | Description |
|---|---|---|---|
email_id | string | yes | Email address (valid format). |
Response — 200 OK
{
"id": 812,
"user_name": "alex",
"first_name": "Alex",
"last_name": "Martinez",
"email_id": "alex@example.com",
"profile_name": null
}Example
curl -X POST \
https://developer-search-devID.region.run.app/api/v1/user/profile/email \
-H "X-Merchant-ID: 12345" \
-H "Content-Type: application/json" \
-d '{"email_id": "alex@example.com"}'const res = await fetch(
'https://developer-search-devID.region.run.app/api/v1/user/profile/email',
{
method: 'POST',
headers: {
'X-Merchant-ID': '12345',
'Content-Type': 'application/json',
},
body: JSON.stringify({ email_id: 'alex@example.com' }),
},
);
if (res.status === 404) {
// Profile doesn't exist yet — create one.
}import httpx
r = httpx.post(
"https://developer-search-devID.region.run.app/api/v1/user/profile/email",
headers={"X-Merchant-ID": "12345", "Content-Type": "application/json"},
json={"email_id": "alex@example.com"},
)
if r.status_code == 404:
... # Profile not foundErrors
| Status | Response |
|---|---|
| 400 | { "detail": "Missing or invalid X-Merchant-ID header" } |
| 404 | { "detail": "User profile not found" } |
| 422 | Validation error — invalid email format. |
Create preferences
POST /api/v1/user/preferencesCreates a user_preferences row for a
user profile. There can be only one preferences row per user_profile_id.
If a row already exists, use
POST /api/v1/user/preferences/update
instead.
Auth modes supported
Today: Shopify-embedded only. API key support coming soon.
Headers
| Header | Required | Value |
|---|---|---|
X-Merchant-ID | yes | Your merchant ID (integer) |
Content-Type | yes | application/json |
Request body
| Field | Type | Required | Description |
|---|---|---|---|
user_profile_id | integer | yes | Foreign key to user_profile.id. |
conversational_style | string | no | Free text (friendly, concise, technical, …). |
personal_fashion_style | string | no | Free text (minimalist, streetwear, classic, …). |
favorite_colors | string[] | no | Preferred colors. |
fashion_material_prefer | string[] | no | Preferred materials. |
body_type | string | no | |
fashion_goals | string | no | |
age_group | string | no |
Response — 200 OK
Returns the stored preferences row including server-managed fields
(id, is_modified, product_ids).
{
"id": 43,
"user_profile_id": 812,
"conversational_style": "friendly",
"personal_fashion_style": "classic",
"favorite_colors": ["navy", "olive"],
"fashion_material_prefer": ["linen", "cotton"],
"body_type": null,
"fashion_goals": "beach vacation outfits",
"age_group": null,
"is_modified": false,
"product_ids": null
}Example
curl -X POST \
https://developer-search-devID.region.run.app/api/v1/user/preferences \
-H "X-Merchant-ID: 12345" \
-H "Content-Type: application/json" \
-d '{
"user_profile_id": 812,
"conversational_style": "friendly",
"personal_fashion_style": "classic",
"favorite_colors": ["navy", "olive"],
"fashion_material_prefer": ["linen", "cotton"],
"fashion_goals": "beach vacation outfits"
}'await fetch(
'https://developer-search-devID.region.run.app/api/v1/user/preferences',
{
method: 'POST',
headers: {
'X-Merchant-ID': '12345',
'Content-Type': 'application/json',
},
body: JSON.stringify({
user_profile_id: 812,
favorite_colors: ['navy', 'olive'],
fashion_material_prefer: ['linen', 'cotton'],
}),
},
);import httpx
httpx.post(
"https://developer-search-devID.region.run.app/api/v1/user/preferences",
headers={"X-Merchant-ID": "12345", "Content-Type": "application/json"},
json={
"user_profile_id": 812,
"favorite_colors": ["navy", "olive"],
"fashion_material_prefer": ["linen", "cotton"],
},
)Errors
| Status | Response |
|---|---|
| 400 | { "detail": "Missing or invalid X-Merchant-ID header" } |
| 422 | Validation error — missing user_profile_id. |
| 500 | Database error, likely a unique-constraint violation (preferences already exist for this profile — use update instead). |
Update preferences
POST /api/v1/user/preferences/updateUpdates an existing user_preferences
row. Fields omitted from the request are left unchanged.
Sets is_modified = true to mark that the user has explicitly edited their
preferences — useful for distinguishing user-set from defaulted values.
Auth modes supported
Today: Shopify-embedded only. API key support coming soon.
Headers
| Header | Required | Value |
|---|---|---|
X-Merchant-ID | yes | Your merchant ID (integer) |
Content-Type | yes | application/json |
Request body
Same shape as POST /api/v1/user/preferences.
user_profile_id is required — everything else is optional.
Response — 200 OK
Returns the updated row.
{
"id": 43,
"user_profile_id": 812,
"conversational_style": "concise",
"favorite_colors": ["forest green", "charcoal"],
"is_modified": true,
...
}Example
curl -X POST \
https://developer-search-devID.region.run.app/api/v1/user/preferences/update \
-H "X-Merchant-ID: 12345" \
-H "Content-Type: application/json" \
-d '{
"user_profile_id": 812,
"conversational_style": "concise",
"favorite_colors": ["forest green", "charcoal"]
}'await fetch(
'https://developer-search-devID.region.run.app/api/v1/user/preferences/update',
{
method: 'POST',
headers: {
'X-Merchant-ID': '12345',
'Content-Type': 'application/json',
},
body: JSON.stringify({
user_profile_id: 812,
conversational_style: 'concise',
}),
},
);import httpx
httpx.post(
"https://developer-search-devID.region.run.app/api/v1/user/preferences/update",
headers={"X-Merchant-ID": "12345", "Content-Type": "application/json"},
json={"user_profile_id": 812, "conversational_style": "concise"},
)Errors
| Status | Response |
|---|---|
| 400 | { "detail": "Missing or invalid X-Merchant-ID header" } |
| 404 | Preferences row not found for user_profile_id. |
| 422 | Validation error. |
Get preferences
GET /api/v1/user/preferences/{user_profile_id}Returns the user_preferences row for
the given user_profile_id. Returns 404 if the profile has no preferences
row yet.
Cached for 5 minutes per user_profile_id.
Auth modes supported
Today: Shopify-embedded only. API key support coming soon.
Headers
| Header | Required | Value |
|---|---|---|
X-Merchant-ID | yes | Your merchant ID (integer) |
Path parameters
| Parameter | Type | Description |
|---|---|---|
user_profile_id | integer | The user profile's primary key. |
Response — 200 OK
{
"id": 43,
"user_profile_id": 812,
"conversational_style": "friendly",
"personal_fashion_style": "classic",
"favorite_colors": ["navy", "olive"],
"fashion_material_prefer": ["linen", "cotton"],
"body_type": null,
"fashion_goals": "beach vacation outfits",
"age_group": null,
"is_modified": true,
"product_ids": null
}Example
curl https://developer-search-devID.region.run.app/api/v1/user/preferences/812 \
-H "X-Merchant-ID: 12345"const res = await fetch(
`https://developer-search-devID.region.run.app/api/v1/user/preferences/${userProfileId}`,
{ headers: { 'X-Merchant-ID': '12345' } },
);
if (res.status === 404) {
// No preferences set yet — show the onboarding form.
}import httpx
r = httpx.get(
f"https://developer-search-devID.region.run.app/api/v1/user/preferences/{user_profile_id}",
headers={"X-Merchant-ID": "12345"},
)
if r.status_code == 404:
... # no preferences yetErrors
| Status | Response |
|---|---|
| 400 | { "detail": "Missing or invalid X-Merchant-ID header" } |
| 404 | { "detail": "User preferences not found" } |
Random data by preferences
POST /api/v1/user/preferences/randomdataReturns a batch of products seeded from a user's stored preferences, plus a time-of-day appropriate greeting. Used to populate the cold-start state of the storefront chat widget (before the shopper has typed a query).
Auth modes supported
Today: Shopify-embedded only. API key support coming soon.
Headers
| Header | Required | Value |
|---|---|---|
X-Merchant-ID | yes | Your merchant ID (integer) |
Content-Type | yes | application/json |
Request body
| Field | Type | Required | Description |
|---|---|---|---|
user_profile_id | integer | yes | The user profile whose preferences should seed the results. |
userPrefsNotPresent | boolean | yes | Hint the server that the user has no stored preferences. |
current_hour | integer | yes | Local hour (0–23), used to pick a time-of-day greeting. |
user_name | string | yes | Shopper first name. |
limit | integer | yes | Maximum number of products to return. |
conversational_style | string | no | Style hint. |
personal_fashion_style | string | no | |
favorite_colors | string[] | no | |
fashion_material_prefer | string[] | no | |
body_type | string | no | |
fashion_goals | string | no | |
age_group | string | no |
Response — 200 OK
{
"results": {
"greeting": "Good afternoon, Alex",
"data": [
{
"product_id": "SKU-2301",
"product_name": "Oversized Boyfriend Tee",
"price_numeric": 42.0,
"image1": "https://cdn.example.com/images/boyfriend-tee.jpg"
}
]
}
}| Field | Type | Description |
|---|---|---|
results.greeting | string | A time-of-day greeting. null if no greeting template matched. |
results.data | object[] | Randomized preference-aware products. |
Example
curl -X POST \
https://developer-search-devID.region.run.app/api/v1/user/preferences/randomdata \
-H "X-Merchant-ID: 12345" \
-H "Content-Type: application/json" \
-d '{
"user_profile_id": 812,
"userPrefsNotPresent": false,
"current_hour": 14,
"user_name": "Alex",
"limit": 12
}'const res = await fetch(
'https://developer-search-devID.region.run.app/api/v1/user/preferences/randomdata',
{
method: 'POST',
headers: {
'X-Merchant-ID': '12345',
'Content-Type': 'application/json',
},
body: JSON.stringify({
user_profile_id: 812,
userPrefsNotPresent: false,
current_hour: new Date().getHours(),
user_name: 'Alex',
limit: 12,
}),
},
);import httpx
httpx.post(
"https://developer-search-devID.region.run.app/api/v1/user/preferences/randomdata",
headers={"X-Merchant-ID": "12345", "Content-Type": "application/json"},
json={
"user_profile_id": 812,
"userPrefsNotPresent": False,
"current_hour": 14,
"user_name": "Alex",
"limit": 12,
},
)Errors
| Status | Response |
|---|---|
| 400 | { "detail": "Missing or invalid X-Merchant-ID header" } |
| 422 | Validation error — missing required fields. |
Pipeline API
Create pipeline status
POST /api/v1/pipeline/statusCreates a new pipeline_status row for
this merchant. If a row already exists and current_step is the first step
(shopify_etl_dev), the existing row is reset — completed_steps and
failed_steps are cleared and status returns to running. This is how
reinstalls are handled.
The merchant_id in the request body must match the authenticated
X-Merchant-ID header.
Auth modes supported
Today: Shopify-embedded only. API key support coming soon.
Headers
| Header | Required | Value |
|---|---|---|
X-Merchant-ID | yes | Your merchant ID (integer) |
Content-Type | yes | application/json |
Request body
| Field | Type | Required | Description |
|---|---|---|---|
merchant_id | integer | yes | Must match X-Merchant-ID header. |
current_step | string | no | Initial step name (default initialized). Use shopify_etl_dev for a fresh install. |
shop_domain | string | no | Optional source-platform domain (e.g. store.myshopify.com). |
config | object | no | Arbitrary configuration carried through the pipeline. |
Response — 200 OK
Returns the full PipelineStatusResponse.
{
"merchant_id": 12345,
"shop_domain": "store.myshopify.com",
"current_step": "shopify_etl_dev",
"completed_steps": [],
"failed_steps": [],
"status": "running",
"started_at": "2026-04-10T16:14:22.109Z",
"completed_at": null,
"last_updated": "2026-04-10T16:14:22.109Z",
"error_message": null,
"progress_percentage": 0.0,
"estimated_completion": null
}Example
curl -X POST \
https://developer-search-devID.region.run.app/api/v1/pipeline/status \
-H "X-Merchant-ID: 12345" \
-H "Content-Type: application/json" \
-d '{
"merchant_id": 12345,
"current_step": "shopify_etl_dev",
"shop_domain": "store.myshopify.com"
}'await fetch(
'https://developer-search-devID.region.run.app/api/v1/pipeline/status',
{
method: 'POST',
headers: {
'X-Merchant-ID': '12345',
'Content-Type': 'application/json',
},
body: JSON.stringify({
merchant_id: 12345,
current_step: 'shopify_etl_dev',
}),
},
);import httpx
httpx.post(
"https://developer-search-devID.region.run.app/api/v1/pipeline/status",
headers={"X-Merchant-ID": "12345", "Content-Type": "application/json"},
json={"merchant_id": 12345, "current_step": "shopify_etl_dev"},
)Errors
| Status | Response |
|---|---|
| 400 | { "detail": "Missing or invalid X-Merchant-ID header" } |
| 400 | { "detail": "Merchant ID in header must match merchant_id in request body" } |
| 500 | Database error. Safe to retry. |
Get pipeline status
GET /api/v1/pipeline/status/{merchant_id}Returns the current pipeline_status for
this merchant — current step, completed steps, failure state, and a
calculated progress_percentage.
The merchant_id in the path must match the authenticated X-Merchant-ID
header. Cross-merchant access returns 403.
Auth modes supported
Today: Shopify-embedded only. API key support coming soon.
Headers
| Header | Required | Value |
|---|---|---|
X-Merchant-ID | yes | Your merchant ID (integer) |
Path parameters
| Parameter | Type | Description |
|---|---|---|
merchant_id | integer | The merchant whose status to read. Must match header. |
Response — 200 OK
{
"merchant_id": 12345,
"shop_domain": "store.myshopify.com",
"current_step": "create_text_embeddings",
"completed_steps": [
"shopify_etl_dev",
"enrichment_dev",
"create_color_embeddings",
"add_color_names"
],
"failed_steps": [],
"status": "running",
"started_at": "2026-04-10T16:14:22.109Z",
"completed_at": null,
"last_updated": "2026-04-10T16:21:03.447Z",
"error_message": null,
"progress_percentage": 57.14,
"estimated_completion": "2026-04-10T16:28:40.000Z"
}Example
curl https://developer-search-devID.region.run.app/api/v1/pipeline/status/12345 \
-H "X-Merchant-ID: 12345"const res = await fetch(
`https://developer-search-devID.region.run.app/api/v1/pipeline/status/${merchantId}`,
{ headers: { 'X-Merchant-ID': String(merchantId) } },
);
const status = await res.json();
console.log(`${status.progress_percentage.toFixed(0)}% complete`);import httpx
r = httpx.get(
f"https://developer-search-devID.region.run.app/api/v1/pipeline/status/{merchant_id}",
headers={"X-Merchant-ID": str(merchant_id)},
)
status = r.json()Errors
| Status | Response |
|---|---|
| 400 | { "detail": "Missing or invalid X-Merchant-ID header" } |
| 403 | { "detail": "Access denied to this merchant's pipeline status" } |
| 404 | { "detail": "Pipeline status not found for this merchant" } |
| 500 | Database error. Safe to retry with backoff. |
Update pipeline status
PUT /api/v1/pipeline/status/{merchant_id}Updates a merchant's pipeline_status
row. Any non-null field in the request body is applied; omitted fields are
left unchanged.
The merchant_id in the path must match the authenticated X-Merchant-ID.
Auth modes supported
Today: Shopify-embedded only. API key support coming soon.
Typical caller
This endpoint is primarily called by internal pipeline workers as they
complete steps. Admin UIs read status via
GET /api/v1/pipeline/status/{merchant_id}
but rarely write here directly.
Headers
| Header | Required | Value |
|---|---|---|
X-Merchant-ID | yes | Your merchant ID (integer) |
Content-Type | yes | application/json |
Path parameters
| Parameter | Type | Description |
|---|---|---|
merchant_id | integer | The merchant whose status to update. Must match header. |
Request body
| Field | Type | Required | Description |
|---|---|---|---|
current_step | string | no | New current step. Also appended to completed_steps if not already there. |
completed_step | string | no | Explicitly append a step to completed_steps (legacy path from theme extension). |
status | string | no | running, completed, or failed. Setting completed stamps completed_at. |
error_message | string | no | Error detail. Setting this automatically switches status to failed. |
config | object | no | Partial config patch — merged onto existing config. |
Response — 200 OK
Returns the updated PipelineStatusResponse.
Example
curl -X PUT \
https://developer-search-devID.region.run.app/api/v1/pipeline/status/12345 \
-H "X-Merchant-ID: 12345" \
-H "Content-Type: application/json" \
-d '{"current_step": "create_img_embeddings"}'await fetch(
`https://developer-search-devID.region.run.app/api/v1/pipeline/status/${merchantId}`,
{
method: 'PUT',
headers: {
'X-Merchant-ID': String(merchantId),
'Content-Type': 'application/json',
},
body: JSON.stringify({ current_step: 'create_img_embeddings' }),
},
);import httpx
httpx.put(
f"https://developer-search-devID.region.run.app/api/v1/pipeline/status/{merchant_id}",
headers={"X-Merchant-ID": str(merchant_id), "Content-Type": "application/json"},
json={"current_step": "create_img_embeddings"},
)Errors
| Status | Response |
|---|---|
| 400 | { "detail": "Missing or invalid X-Merchant-ID header" } |
| 403 | { "detail": "Access denied to this merchant's pipeline status" } |
| 404 | { "detail": "Pipeline status not found for this merchant" } |
| 500 | Database error. Safe to retry. |
Delete pipeline status
DELETE /api/v1/pipeline/status/{merchant_id}Deletes a merchant's pipeline_status
row. Typically used during uninstall or when forcing a clean reset before
reinstalling.
The merchant_id in the path must match the authenticated X-Merchant-ID.
Auth modes supported
Today: Shopify-embedded only. API key support coming soon.
Does not delete catalog data
This endpoint only removes the pipeline tracking row. It does not delete
products, user profiles, or preferences. For that, see
DELETE /api/v1/merchant/{merchant_id}/data.
Headers
| Header | Required | Value |
|---|---|---|
X-Merchant-ID | yes | Your merchant ID (integer) |
Path parameters
| Parameter | Type | Description |
|---|---|---|
merchant_id | integer | The merchant whose status to delete. Must match header. |
Response — 200 OK
When a row existed
{
"status": "deleted",
"message": "Pipeline status deleted for merchant 12345"
}When no row existed
{
"status": "not_found",
"message": "No pipeline status found for merchant 12345"
}Example
curl -X DELETE \
https://developer-search-devID.region.run.app/api/v1/pipeline/status/12345 \
-H "X-Merchant-ID: 12345"await fetch(
`https://developer-search-devID.region.run.app/api/v1/pipeline/status/${merchantId}`,
{
method: 'DELETE',
headers: { 'X-Merchant-ID': String(merchantId) },
},
);import httpx
httpx.delete(
f"https://developer-search-devID.region.run.app/api/v1/pipeline/status/{merchant_id}",
headers={"X-Merchant-ID": str(merchant_id)},
)Errors
| Status | Response |
|---|---|
| 400 | { "detail": "Missing or invalid X-Merchant-ID header" } |
| 403 | { "detail": "Access denied to this merchant's pipeline status" } |
| 500 | Database error. Safe to retry. |
Merchant API
Delete merchant data
DELETE /api/v1/merchant/{merchant_id}/dataDeletes all merchant-specific rows from the product database — products, user profiles, user preferences, user greetings, product synonyms, and pipeline status. This is used during an uninstall when the merchant has explicitly requested data deletion.
Auth modes supported
Today: Shopify-embedded only. API key support coming soon.
Disabled by default
This endpoint is gated by the server-side DELETE_MERCHANT_DATA_ON_UNINSTALL
environment flag. If that flag is not true, every call returns 403
without deleting anything. Contact enterprise@vairetail.com to enable
deletion for your merchant account.
What is NOT deleted
- Merchant metadata (
merchant_info,credentials) is preserved so reinstalling is zero-friction. search_resultsrows are preserved for analytics and historical reporting. They are query logs, not personal data.
Headers
| Header | Required | Value |
|---|---|---|
X-Merchant-ID | yes | Your merchant ID (integer) |
Path parameters
| Parameter | Type | Description |
|---|---|---|
merchant_id | integer | The merchant whose data to delete. Must match header. |
Response — 200 OK
{
"status": "deleted",
"message": "All merchant data deleted for merchant 12345",
"deleted_counts": {
"user_preferences": 42,
"user_greetings": 6,
"user_profile": 41,
"product_synonyms": 188,
"products": 4821,
"pipeline_status": 1
}
}Example
curl -X DELETE \
https://developer-search-devID.region.run.app/api/v1/merchant/12345/data \
-H "X-Merchant-ID: 12345"const res = await fetch(
`https://developer-search-devID.region.run.app/api/v1/merchant/${merchantId}/data`,
{
method: 'DELETE',
headers: { 'X-Merchant-ID': String(merchantId) },
},
);import httpx
httpx.delete(
f"https://developer-search-devID.region.run.app/api/v1/merchant/{merchant_id}/data",
headers={"X-Merchant-ID": str(merchant_id)},
)Errors
| Status | Response |
|---|---|
| 400 | { "detail": "Missing or invalid X-Merchant-ID header" } |
| 400 | { "detail": "Invalid merchant_id: <id>" } — non-positive ID |
| 403 | { "detail": "Merchant data deletion is disabled. Set DELETE_MERCHANT_DATA_ON_UNINSTALL=true…" } |
| 403 | { "detail": "Access denied to this merchant's data" } |
| 500 | { "detail": "RLS context setup failed: …" } — safety check tripped, nothing was deleted |
Utilities
Image proxy
GET /api/v1/proxy-imageFetches an image URL server-side and returns its bytes with the original
Content-Type. Used by the storefront extension to load product images into
canvas elements without running into cross-origin restrictions on the CDN.
Auth modes supported
No auth required. This endpoint is a stateless utility — no
X-Merchant-ID header, no API key.
Query parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
url | string | yes | The image URL to fetch. Must be URL-encoded. |
Response — 200 OK
The raw image bytes with the upstream Content-Type (e.g. image/jpeg,
image/png, image/webp). No JSON wrapping.
Example
curl "https://developer-search-devID.region.run.app/api/v1/proxy-image?url=https%3A%2F%2Fcdn.example.com%2Fimages%2Flinen-shirt.jpg" \
--output linen-shirt.jpgconst imageUrl = 'https://cdn.example.com/images/linen-shirt.jpg';
const proxied = `https://developer-search-devID.region.run.app/api/v1/proxy-image?url=${encodeURIComponent(imageUrl)}`;
const res = await fetch(proxied);
const blob = await res.blob();
const objectUrl = URL.createObjectURL(blob);
document.getElementById('preview').src = objectUrl;import httpx
r = httpx.get(
"https://developer-search-devID.region.run.app/api/v1/proxy-image",
params={"url": "https://cdn.example.com/images/linen-shirt.jpg"},
)
with open("linen-shirt.jpg", "wb") as f:
f.write(r.content)Errors
| Status | Response |
|---|---|
| 502 | { "detail": "Failed to fetch image from source." } |
Health check
GET /healthReturns { "status": "ok" } as soon as the API is ready to serve requests.
Models are loaded at process startup (not lazily), so a successful /health
response implies the text and image embedding services are warm.
Auth modes supported
No auth required. Intended for uptime monitors and load balancer
health probes — no X-Merchant-ID header, no API key.
Response — 200 OK
{ "status": "ok" }Example
curl https://developer-search-devID.region.run.app/healthconst res = await fetch(
'https://developer-search-devID.region.run.app/health',
);
if (!res.ok) alert('discoverGPT API is down');import httpx
r = httpx.get("https://developer-search-devID.region.run.app/health")
assert r.status_code == 200Errors
Any non-200 response (5xx, network error, timeout) means the API is not ready or is experiencing an outage. Exponentially back off retries.
Demo queries
GET /api/v1/demo-queriesReturns up to 12 active demo queries for the merchant, selected at random on each request. These are the curated query suggestions the frontend can display to demonstrate discoverGPT's capabilities (distinct from the user-facing example searches, which are shorter and appear in the search bar placeholder UI).
Response — 200 OK
{
"demo_queries": [
{
"id": 7,
"query_text": "floral midi dress for a summer wedding",
"category": "dresses",
"subcategory": "midi",
"gender": "womens",
"season": "summer",
"style": "floral",
"materials": null,
"price_range": null,
"use_case": "wedding",
"created_at": "2026-04-10T09:00:00",
"updated_at": "2026-04-10T09:00:00"
}
],
"count": 12,
"merchant_id": 12345
}An empty demo_queries array with count: 0 is returned when no active rows
exist for the merchant (no error is raised).
Example
curl https://developer-search-devID.region.run.app/api/v1/demo-queries \
-H "X-Merchant-ID: 12345"const res = await fetch(
'https://developer-search-devID.region.run.app/api/v1/demo-queries',
{ headers: { 'X-Merchant-ID': '12345' } },
);
const { demo_queries, count } = await res.json();
console.log(`${count} demo queries available`);import httpx
r = httpx.get(
"https://developer-search-devID.region.run.app/api/v1/demo-queries",
headers={"X-Merchant-ID": "12345"},
)
print(r.json()["count"])Trending search lookups
GET /api/v1/searchlookup/trendingReturns trending SearchLookup rows for a specific user profile. Used by the
frontend to surface personalised trending searches for a logged-in shopper.
Query parameters
| Param | Type | Required | Description |
|---|---|---|---|
user_profile_id | integer | yes | The shopper's user profile ID. |
Response — 200 OK
A JSON array of SearchLookupRead objects (shape mirrors the search_lookup
table). Each object includes at minimum the lookup query text, operation type,
and timestamps; additional fields may be present.
[
{
"id": 412,
"query_string": "olive linen trousers",
"operation_type": "text",
"user_profile_id": 99,
"merchant_id": 12345,
"created_at": "2026-06-20T11:42:00"
}
]Example
curl -G https://developer-search-devID.region.run.app/api/v1/searchlookup/trending \
-H "X-Merchant-ID: 12345" \
--data-urlencode "user_profile_id=99"const res = await fetch(
'https://developer-search-devID.region.run.app/api/v1/searchlookup/trending?user_profile_id=99',
{ headers: { 'X-Merchant-ID': '12345' } },
);
const trending = await res.json();
console.log(`${trending.length} trending searches`);import httpx
r = httpx.get(
"https://developer-search-devID.region.run.app/api/v1/searchlookup/trending",
headers={"X-Merchant-ID": "12345"},
params={"user_profile_id": 99},
)
print(r.json())Synonyms API
Per-merchant synonym rules let the search engine treat terms as equivalent, boost exact-match phrases, or penalise antonym pairs. The rules are stored in the merchant's catalog database and applied automatically at query time.
Status: Private beta
The Synonyms API is in private beta and access-gated — authentication is
being finalized and these endpoints are not yet generally available. Email
enterprise@vairetail.com to request access. The discover,
rules, stats,
preview, and quality
endpoints are read-only; generate and
validate-a-pair write to your catalog, so try them
with dry_run first.
Discover synonyms
GET /api/v1/synonyms/discover/{merchant_id}Analyses the merchant's product catalog and returns a read-only preview of synonym, antonym, and exact-match candidates. No rules are written to the database — it surfaces candidate terms without changing your catalog. Results are filtered to high-confidence candidates before being returned (antonym confidence ≥ 0.5, synonym confidence ≥ 0.3 with ≥ 3 shared categories).
Query parameters
| Param | Type | Default | Range | Description |
|---|---|---|---|---|
min_frequency | integer | 5 | 1–100 | Minimum term frequency to include in analysis. |
max_antonym_terms | integer | 200 | 50–1000 | Maximum number of terms to check for antonyms. |
max_synonym_terms | integer | 300 | 50–1000 | Maximum number of terms to check for synonyms. |
Response — 200 OK
{
"total_products": 4200,
"frequent_terms_count": 312,
"antonyms_count": 18,
"exact_matches_count": 7,
"synonyms_count": 42,
"frequent_terms": {
"dress": 980,
"linen": 430,
"midi": 220
},
"sample_antonyms": [
{ "term": "formal", "related_term": "casual" }
],
"sample_exact_matches": [
{ "term": "button up", "related_term": "button up" }
],
"sample_synonyms": [
{ "term": "trousers", "related_term": "pants" }
]
}Example
curl -G https://developer-search-devID.region.run.app/api/v1/synonyms/discover/12345 \
--data-urlencode "min_frequency=3" \
--data-urlencode "max_synonym_terms=500"const params = new URLSearchParams({ min_frequency: '3', max_synonym_terms: '500' });
const res = await fetch(
`https://developer-search-devID.region.run.app/api/v1/synonyms/discover/12345?${params}`,
);
const preview = await res.json();
console.log(`${preview.synonyms_count} synonym candidates found`);import httpx
r = httpx.get(
"https://developer-search-devID.region.run.app/api/v1/synonyms/discover/12345",
params={"min_frequency": 3, "max_synonym_terms": 500},
)
print(r.json()["synonyms_count"])List synonym rules
GET /api/v1/synonyms/rules/{merchant_id}Returns existing synonym rules for the merchant, optionally filtered by type.
Query parameters
| Param | Type | Default | Description |
|---|---|---|---|
synonym_type | string | — | Optional filter: synonym, antonym, or exact_match. |
limit | integer | 100 | Maximum rules to return (1–1000). |
Response — 200 OK
[
{
"merchant_id": 12345,
"term": "trousers",
"synonym_type": "synonym",
"related_term": "pants",
"boost_factor": 1.0,
"penalty_factor": 1.0
},
{
"merchant_id": 12345,
"term": "formal",
"synonym_type": "antonym",
"related_term": "casual",
"boost_factor": 1.0,
"penalty_factor": 0.5
}
]Example
curl -G https://developer-search-devID.region.run.app/api/v1/synonyms/rules/12345 \
--data-urlencode "synonym_type=synonym" \
--data-urlencode "limit=50"const params = new URLSearchParams({ synonym_type: 'synonym', limit: '50' });
const res = await fetch(
`https://developer-search-devID.region.run.app/api/v1/synonyms/rules/12345?${params}`,
);
const rules = await res.json();
console.log(`${rules.length} synonym rules`);import httpx
r = httpx.get(
"https://developer-search-devID.region.run.app/api/v1/synonyms/rules/12345",
params={"synonym_type": "synonym", "limit": 50},
)
print(len(r.json()))Synonym rule statistics
GET /api/v1/synonyms/stats/{merchant_id}Returns a count summary of synonym rules for the merchant, broken down by type.
Response — 200 OK
{
"merchant_id": 12345,
"total_rules": 87,
"by_type": {
"synonym": 42,
"antonym": 18,
"exact_match": 27
}
}Example
curl https://developer-search-devID.region.run.app/api/v1/synonyms/stats/12345const res = await fetch(
'https://developer-search-devID.region.run.app/api/v1/synonyms/stats/12345',
);
const stats = await res.json();
console.log(`${stats.total_rules} total rules`);import httpx
r = httpx.get(
"https://developer-search-devID.region.run.app/api/v1/synonyms/stats/12345",
)
print(r.json()["by_type"])Generate synonym rules
POST /api/v1/synonyms/generate/{merchant_id}Analyses the catalog and writes the high-confidence synonym, antonym, and
exact-match rules it finds into the merchant's rule set. Pass dry_run=true to
get a preview of what would be inserted without changing anything — always do a
dry run first.
Query parameters
| Param | Type | Default | Range | Description |
|---|---|---|---|---|
min_frequency | integer | 5 | 1–100 | Minimum term frequency to include. |
max_antonym_terms | integer | 200 | 50–1000 | Maximum terms to check for antonyms. |
max_synonym_terms | integer | 300 | 50–1000 | Maximum terms to check for synonyms. |
dry_run | boolean | false | — | Preview only; do not write rules. |
Response — 200 OK
Dry run — rules_preview carries up to the first 20 rule objects that would be
written:
{
"status": "dry_run",
"rules_generated": 12,
"rules_preview": [
{ "term": "trousers", "synonym_type": "synonym", "related_term": "pants", "confidence": 0.62 }
],
"message": "No changes were made. Remove dry_run=true to apply."
}Applied (dry_run=false):
{
"status": "success",
"rules_generated": 12,
"rules_inserted": 9,
"rules_skipped": 3,
"errors": []
}Example
# Preview first — writes nothing
curl -X POST -G https://developer-search-devID.region.run.app/api/v1/synonyms/generate/12345 \
--data-urlencode "dry_run=true"
# Apply
curl -X POST https://developer-search-devID.region.run.app/api/v1/synonyms/generate/12345Preview generated rules
GET /api/v1/synonyms/validate/{merchant_id}A read-only discovery preview that goes deeper than discover:
it reports how many candidates were found at each confidence level, how many were
filtered out, and estimated_rules_to_insert — i.e. exactly what
generate would write. Writes nothing.
Query parameters
| Param | Type | Default | Range | Description |
|---|---|---|---|---|
min_frequency | integer | 5 | 1–100 | Minimum term frequency to include. |
max_antonym_terms | integer | 200 | 50–1000 | Maximum terms to check for antonyms. |
max_synonym_terms | integer | 300 | 50–1000 | Maximum terms to check for synonyms. |
Response — 200 OK
{
"total_products": 4200,
"frequent_terms_count": 312,
"antonyms_discovered": 24,
"antonyms_high_confidence": 18,
"antonyms_filtered_out": 6,
"exact_matches_count": 7,
"synonyms_discovered": 61,
"synonyms_high_confidence": 42,
"synonyms_filtered_out": 19,
"sample_high_confidence_antonyms": [
{ "term": "formal", "related_term": "casual", "confidence": 0.71 }
],
"sample_high_confidence_synonyms": [
{ "term": "trousers", "related_term": "pants", "confidence": 0.62 }
],
"estimated_rules_to_insert": 67
}The sample_* arrays carry up to 20 pairs each.
Example
curl -G https://developer-search-devID.region.run.app/api/v1/synonyms/validate/12345 \
--data-urlencode "min_frequency=3"Validate a discovered pair
POST /api/v1/synonyms/validate/{merchant_id}Record a human approve/reject decision for one discovered pair. This feedback trains the precision metric reported by the quality report.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
term1 | string | yes | First term of the pair. |
term2 | string | yes | Second term of the pair. |
synonym_type | string | yes | synonym, antonym, or exact_match. |
validation_status | string | yes | approved or rejected. |
notes | string | no | Optional reviewer note. |
Response — 200 OK
{ "status": "success", "message": "Pair validated: trousers ↔ pants = approved" }404 { "detail": "Pair not found for validation" } if the pair was never discovered.
Example
curl -X POST https://developer-search-devID.region.run.app/api/v1/synonyms/validate/12345 \
-H "Content-Type: application/json" \
-d '{ "term1": "trousers", "term2": "pants", "synonym_type": "synonym", "validation_status": "approved" }'Synonym quality report
GET /api/v1/synonyms/quality/{merchant_id}Historical quality metrics for the merchant's synonym discovery runs: per-run
counts and confidence, the distribution of validation decisions, and overall
precision once enough pairs have been validated. Returns
404 { "detail": "No metrics found" } until at least one discovery run has been
recorded.
Query parameters
| Param | Type | Default | Range | Description |
|---|---|---|---|---|
discovery_run_id | string | — | — | Scope to one discovery run. |
days | integer | — | 1–365 | Look-back window. |
Response — 200 OK
{
"discovery_runs": [
{
"discovery_run_id": "run_2026-06-20T14:00:00",
"run_timestamp": "2026-06-20T14:00:00",
"total_discovered": 85,
"antonyms_discovered": 24,
"synonyms_discovered": 61,
"exact_matches_discovered": 7,
"avg_confidence": 0.58,
"discovery_method_distribution": { "embedding": 60, "cooccurrence": 25 },
"category_distribution": { "tops": 30, "bottoms": 22 },
"total_products_analyzed": 4200,
"frequent_terms_count": 312
}
],
"validation_stats": {
"approved": { "count": 40, "avg_confidence": 0.66 },
"rejected": { "count": 8, "avg_confidence": 0.41 }
},
"precision": 0.83,
"total_validated": 48,
"pending_count": 37
}Example
curl -G https://developer-search-devID.region.run.app/api/v1/synonyms/quality/12345 \
--data-urlencode "days=30"Analytics API
All analytics endpoints require the X-Merchant-ID header unless otherwise
noted.
| Header | Required | Value |
|---|---|---|
X-Merchant-ID | yes (most endpoints) | Your merchant ID (integer) |
Content-Type | on POST | application/json |
Analytics summary
GET /api/v1/analytics/summaryReturns a comprehensive analytics summary for the merchant over a configurable time window. The response is large — it covers search volume, engagement funnel, intent quality, image-similarity quality, color demand vs. catalog coverage, session depth, commerce metrics (orders, revenue, AOV, returning-customer rate), and a comparison against a pre-install baseline or prior period. Fields may be added in future releases without a version bump; treat unknown keys as informational.
Query parameters
| Param | Type | Default | Description |
|---|---|---|---|
range | string | 30d | Date window: 24h, 1d, mtd, prev_month, ytd, 7d, 14d, 30d, 90d. |
Response — 200 OK (representative structure, trimmed)
{
"date_range": { "start": "2026-05-23", "end": "2026-06-22" },
"search_volume": {
"totals": { "total": 8420, "nlq": 6100, "color": 1200, "image": 800, "combo": 310, "other": 10 },
"timeseries": [
{ "date": "2026-06-21", "total": 312, "nlq": 210, "color": 60, "image": 32, "combo": 10, "other": 0 }
]
},
"engagement": {
"ctr": { "value": 0.42, "clicks": 3530, "impressions": 8420 },
"zero_result_rate":{ "value": 0.03, "zero_results": 253, "total_queries": 8420 },
"add_to_cart_rate":{ "value": 0.18, "sessions_with_add_to_cart": 720, "total_sessions": 4000 }
},
"intent_insights": {
"intent_rich_rate": 0.34,
"intent_rich_count": 2862,
"keyword_only_count": 5558,
"total_queries": 8420,
"top_attributes": [
{ "category": "occasion", "attribute": "work", "count": 610 }
]
},
"image_similarity": {
"total_queries": 800,
"no_close_match_rate": 0.12,
"no_close_match_count": 96,
"avg_best_score": 0.31,
"hero_products": [
{ "product_id": "SKU-1001", "product_name": "Relaxed Linen Shirt", "count": 42 }
]
},
"color_demand": {
"total_color_searches": 1510,
"total_products_with_color": 3800,
"top_gaps": [
{ "color_family": "sage", "demand_count": 280, "product_count": 12, "demand_per_product": 23.3 }
]
},
"session_depth": {
"views_per_query": 8.2,
"views_per_session": 24.6,
"sessions_with_search": 3800,
"total_product_views": 93480
},
"messages": [],
"visits": 4000,
"time_on_site_avg": 182.4,
"time_on_site": { "p50_seconds": 145.0, "p90_seconds": 420.0, "avg_seconds": 182.4, "sessions": 3800 },
"searches_per_visit": 2.1,
"product_page_visits": 8420,
"added_to_cart": 1440,
"checkouts": 310,
"revenue": 18600.0,
"revenue_per_checkout": 60.0,
"churn_rate": 0.21,
"popular_colors": [
{ "color": "Navy", "count": 620, "hex": "#000080" }
],
"image_searches_by_photo": 520,
"image_searches_by_file": 280,
"user_preferences_set": 94,
"top_product_matches": [
{ "product_id": "SKU-1001", "product_name": "Relaxed Linen Shirt", "search_count": 182 }
],
"top_categories": [
{ "category": "tops", "search_count": 2400 }
],
"top_subcategories": [
{ "subcategory": "blouses", "search_count": 910 }
],
"bounce_rate": { "value": 0.18, "bounce_sessions": 720, "total_sessions": 4000 },
"results_per_query": {
"avg_results": 9.4, "total_queries": 8420,
"zero_results_queries": 253, "low_results_queries": 510, "low_results_rate": 0.06
},
"time_to_first_click": { "p50_seconds": 4.2, "p90_seconds": 18.1, "avg_seconds": 6.8, "sessions": 3530 },
"top_zero_result_queries": [
{ "query": "cashmere joggers", "count": 18 }
],
"commerce": {
"available": true,
"compare_available": true,
"compare_label": "Pre-install 30 days",
"compare_range": { "start": "2026-03-01", "end": "2026-03-31" },
"current": {
"orders_count": 310, "revenue_gross": 18600.0, "revenue_net": 17900.0,
"refunds_total": 700.0, "aov": 60.0, "items_per_order": 2.1,
"returning_customer_rate": 0.34, "repeat_purchase_rate": 0.22
},
"compare": { "orders_count": 210, "revenue_gross": 11000.0, "..." : "..." },
"delta": { "orders_count": 100, "revenue_gross": 7600.0, "..." : "..." },
"delta_pct": { "orders_count": 0.476, "revenue_gross": 0.691, "..." : "..." }
}
}Example
curl -G https://developer-search-devID.region.run.app/api/v1/analytics/summary \
-H "X-Merchant-ID: 12345" \
--data-urlencode "range=7d"const res = await fetch(
'https://developer-search-devID.region.run.app/api/v1/analytics/summary?range=7d',
{ headers: { 'X-Merchant-ID': '12345' } },
);
const summary = await res.json();
console.log(`${summary.visits} visits, ${summary.revenue} revenue`);import httpx
r = httpx.get(
"https://developer-search-devID.region.run.app/api/v1/analytics/summary",
headers={"X-Merchant-ID": "12345"},
params={"range": "7d"},
)
data = r.json()
print(data["search_volume"]["totals"]["total"])Record analytics event
POST /api/v1/analytics/eventRecords a single analytics event from the storefront or Shopify Web Pixel. For
calls from the Web Pixel sandbox (where navigator.sendBeacon cannot attach
custom headers), the merchant can be identified via the ?merchant_id= query
parameter instead of the X-Merchant-ID header.
Headers / query fallback
| Source | Value |
|---|---|
X-Merchant-ID header | Preferred. Integer merchant ID. |
?merchant_id= query | Fallback for navigator.sendBeacon / Pixel sandbox. |
Request body
| Field | Type | Required | Description |
|---|---|---|---|
event_type | string | yes | See valid types below. |
event_data | object | no | Arbitrary payload for the event. |
session_id | string | no | Browser session identifier. |
user_id | integer | no | Shopper profile ID. |
timestamp | string | no | ISO-8601 timestamp; defaults to server time. |
Valid event_type values:
| Event type | When to fire |
|---|---|
page_viewed | Any page visit. |
product_viewed | Product detail page view. |
collection_viewed | Collection / category page view. |
search_submitted | Shopper submitted a search query. |
search_icon_clicked | Shopper opened the discoverGPT search panel (counts as a visit). |
product_card_clicked | Shopper clicked a product card in results. |
product_eye_icon_clicked | Shopper used visual search on a product. |
eye_icon_clicked | Shopper clicked the eye icon in the search bar. |
product_added_to_cart | Add-to-cart action. |
checkout_started | Checkout flow initiated. |
checkout_completed | Purchase complete. Include revenue in event_data. |
session_end | Session ended. Include time_on_site_seconds in event_data. |
Response — 200 OK
{ "success": true, "message": "Event recorded" }Example
curl -X POST \
https://developer-search-devID.region.run.app/api/v1/analytics/event \
-H "X-Merchant-ID: 12345" \
-H "Content-Type: application/json" \
-d '{
"event_type": "product_added_to_cart",
"event_data": { "product_id": "SKU-1001", "price": 69.00 },
"session_id": "sess_abc123"
}'await fetch(
'https://developer-search-devID.region.run.app/api/v1/analytics/event',
{
method: 'POST',
headers: {
'X-Merchant-ID': '12345',
'Content-Type': 'application/json',
},
body: JSON.stringify({
event_type: 'product_added_to_cart',
event_data: { product_id: 'SKU-1001', price: 69.00 },
session_id: 'sess_abc123',
}),
},
);import httpx
httpx.post(
"https://developer-search-devID.region.run.app/api/v1/analytics/event",
headers={"X-Merchant-ID": "12345"},
json={
"event_type": "product_added_to_cart",
"event_data": {"product_id": "SKU-1001", "price": 69.00},
"session_id": "sess_abc123",
},
)Record analytics events (batch)
POST /api/v1/analytics/eventsRecords multiple analytics events in a single request. Use this instead of
repeated single-event POSTs when flushing a buffered event queue. Unlike the
single-event endpoint, the batch endpoint requires the X-Merchant-ID header —
it does not accept the ?merchant_id= query fallback.
Request body
{
"events": [
{
"event_type": "page_viewed",
"session_id": "sess_abc123",
"timestamp": "2026-06-22T14:00:00Z"
},
{
"event_type": "product_added_to_cart",
"event_data": { "product_id": "SKU-1001", "price": 69.00 },
"session_id": "sess_abc123"
}
]
}Each object in events follows the same shape as the single-event body above.
Response — 200 OK
{ "success": true, "message": "Recorded 2 events" }Example
curl -X POST \
https://developer-search-devID.region.run.app/api/v1/analytics/events \
-H "X-Merchant-ID: 12345" \
-H "Content-Type: application/json" \
-d '{"events":[{"event_type":"page_viewed","session_id":"sess_abc123"}]}'await fetch(
'https://developer-search-devID.region.run.app/api/v1/analytics/events',
{
method: 'POST',
headers: {
'X-Merchant-ID': '12345',
'Content-Type': 'application/json',
},
body: JSON.stringify({
events: [
{ event_type: 'page_viewed', session_id: 'sess_abc123' },
{ event_type: 'product_added_to_cart', event_data: { product_id: 'SKU-1001' }, session_id: 'sess_abc123' },
],
}),
},
);import httpx
httpx.post(
"https://developer-search-devID.region.run.app/api/v1/analytics/events",
headers={"X-Merchant-ID": "12345"},
json={
"events": [
{"event_type": "page_viewed", "session_id": "sess_abc123"},
{"event_type": "product_added_to_cart", "event_data": {"product_id": "SKU-1001"}, "session_id": "sess_abc123"},
]
},
)A/B comparison
GET /api/v1/analytics/ab-comparisonReturns a side-by-side funnel comparison between the control (native Shopify
search) and spt (discoverGPT search) variants for sessions in the given date range.
Metrics are computed from analytics_events using a "control wins" attribution
rule: if any event in a session is tagged control, the whole session is
attributed to the control cohort.
Query parameters
| Param | Type | Default | Description |
|---|---|---|---|
range | string | 7d | Date window: 24h, 7d, 14d, 30d, 90d. |
Response — 200 OK
{
"range": "7d",
"start": "2026-06-15",
"end": "2026-06-22",
"control": {
"sessions": 1800,
"ctr": 12.5,
"atc_rate": 8.2,
"checkout_rate": 2.1,
"rev_per_session": 1.24
},
"spt": {
"sessions": 2200,
"ctr": 41.8,
"atc_rate": 18.6,
"checkout_rate": 5.4,
"rev_per_session": 3.87
},
"test_running": true,
"ab_config": {
"split_pct": 50,
"seed": "a3f9c12b",
"enabled": true
}
}ctr, atc_rate, and checkout_rate are percentages (0–100). ab_config is
null if the merchant has no config row yet.
Example
curl -G https://developer-search-devID.region.run.app/api/v1/analytics/ab-comparison \
-H "X-Merchant-ID: 12345" \
--data-urlencode "range=30d"const res = await fetch(
'https://developer-search-devID.region.run.app/api/v1/analytics/ab-comparison?range=30d',
{ headers: { 'X-Merchant-ID': '12345' } },
);
const { control, spt } = await res.json();
console.log(`CTR lift: ${(spt.ctr - control.ctr).toFixed(1)}pp`);import httpx
r = httpx.get(
"https://developer-search-devID.region.run.app/api/v1/analytics/ab-comparison",
headers={"X-Merchant-ID": "12345"},
params={"range": "30d"},
)
data = r.json()
print(f"SPT checkout rate: {data['spt']['checkout_rate']}%")Resolve merchant by shop domain
GET /api/v1/analytics/resolve-merchantResolves a Shopify myshopifyDomain to the platform's internal numeric
merchant_id. This is the canonical "look up a merchant by shop domain"
endpoint.
No authentication required
This endpoint is intentionally public. It is used by the Shopify Web Pixel sandbox — a strict iframe environment that cannot carry custom headers. The response only exposes the numeric ID for an already-installed store; unregistered domains receive a 404.
Query parameters
| Param | Type | Required | Description |
|---|---|---|---|
shop | string | yes | The store's myshopifyDomain, e.g. acme.myshopify.com. |
Response — 200 OK
{ "merchant_id": 12345, "shop": "acme.myshopify.com" }Errors
| Status | Response |
|---|---|
| 404 | Domain is not registered on the platform. |
Example
curl "https://developer-search-devID.region.run.app/api/v1/analytics/resolve-merchant?shop=acme.myshopify.com"const res = await fetch(
'https://developer-search-devID.region.run.app/api/v1/analytics/resolve-merchant?shop=acme.myshopify.com',
);
const { merchant_id } = await res.json();import httpx
r = httpx.get(
"https://developer-search-devID.region.run.app/api/v1/analytics/resolve-merchant",
params={"shop": "acme.myshopify.com"},
)
print(r.json()["merchant_id"])A/B Testing
Controls what percentage of a merchant's storefront traffic sees discoverGPT
versus the store's native search (the control variant). The split is
deterministic per-visitor, keyed on the configured seed.
Get A/B config
GET /api/v1/ab/{merchant_id}/configReturns the merchant's current A/B split configuration. Called by the storefront theme extension on every page load.
Origin / Referer authentication
This endpoint does not use X-Merchant-ID. The request's Origin (or
Referer) header must match the merchant's registered Shopify domain or an
allowed custom domain. Mismatched origins receive a 403. On first read for a
new merchant, a default config of {split_pct: 0, seed: "<random hex>", enabled: true} is auto-created — 0% means all visitors see native search until
you explicitly dial up split_pct.
Response — 200 OK
{ "split_pct": 50, "seed": "a3f9c12b", "enabled": true }Responses are cached at the edge for 30 seconds (Cache-Control: private, max-age=30). Combined with a 60-second server-side cache, worst-case rollback
propagation is ~90 seconds.
Errors
| Status | Response |
|---|---|
| 403 | { "detail": "Origin not allowed for this merchant" } |
| 404 | { "detail": "Merchant not found" } |
Example
curl https://developer-search-devID.region.run.app/api/v1/ab/12345/config \
-H "Origin: https://acme.myshopify.com"// Called automatically by the theme extension; shown here for reference.
const res = await fetch(
'https://developer-search-devID.region.run.app/api/v1/ab/12345/config',
{ headers: { Origin: 'https://acme.myshopify.com' } },
);
const { split_pct, seed, enabled } = await res.json();import httpx
r = httpx.get(
"https://developer-search-devID.region.run.app/api/v1/ab/12345/config",
headers={"Origin": "https://acme.myshopify.com"},
)
print(r.json())Update A/B config
POST /api/v1/ab/{merchant_id}/configUpdates the merchant's A/B split configuration from the merchant admin dashboard. All body fields are optional (PATCH semantics) — omitted fields retain their current values.
X-Merchant-ID must match path
The X-Merchant-ID header is required and must equal the merchant_id path
parameter. Mismatches return 403.
Request body
| Field | Type | Required | Constraints | Description |
|---|---|---|---|---|
split_pct | integer | no | 0–100 | Percentage of visitors who see discoverGPT. |
seed | string | no | 1–64 chars, alnum/-/_ | Deterministic bucketing seed. Changing it re-randomises cohorts. |
enabled | boolean | no | — | false immediately pauses the test (all → control). |
Response — 200 OK
Returns the full updated config:
{ "split_pct": 75, "seed": "a3f9c12b", "enabled": true }Errors
| Status | Response |
|---|---|
| 401 | { "detail": "X-Merchant-ID header required" } |
| 403 | { "detail": "X-Merchant-ID header does not match path merchant_id" } |
Example
curl -X POST \
https://developer-search-devID.region.run.app/api/v1/ab/12345/config \
-H "X-Merchant-ID: 12345" \
-H "Content-Type: application/json" \
-d '{"split_pct": 75, "enabled": true}'const res = await fetch(
'https://developer-search-devID.region.run.app/api/v1/ab/12345/config',
{
method: 'POST',
headers: {
'X-Merchant-ID': '12345',
'Content-Type': 'application/json',
},
body: JSON.stringify({ split_pct: 75, enabled: true }),
},
);
const config = await res.json();
console.log(`Split: ${config.split_pct}%`);import httpx
r = httpx.post(
"https://developer-search-devID.region.run.app/api/v1/ab/12345/config",
headers={"X-Merchant-ID": "12345"},
json={"split_pct": 75, "enabled": True},
)
print(r.json())Example Searches
Up to 10 merchant-specific suggested queries shown in the discoverGPT search UI when the panel opens. If a merchant has not configured custom examples, the platform falls back to a global default set.
All endpoints require X-Merchant-ID.
Get example searches
GET /api/v1/example-searchesReturns the merchant's active example searches. If the merchant has no
custom examples, returns global defaults and sets is_default: true.
Response — 200 OK
{
"merchant_id": 12345,
"example_searches": [
{ "id": 1, "search_query": "linen dress for summer", "display_order": 1, "is_active": true },
{ "id": 2, "search_query": "navy blazer under $200", "display_order": 2, "is_active": true }
],
"is_default": false
}is_default: true means the merchant has not set custom examples and these are
the platform's global defaults.
Example
curl https://developer-search-devID.region.run.app/api/v1/example-searches \
-H "X-Merchant-ID: 12345"const res = await fetch(
'https://developer-search-devID.region.run.app/api/v1/example-searches',
{ headers: { 'X-Merchant-ID': '12345' } },
);
const { example_searches, is_default } = await res.json();import httpx
r = httpx.get(
"https://developer-search-devID.region.run.app/api/v1/example-searches",
headers={"X-Merchant-ID": "12345"},
)
data = r.json()
print(data["is_default"], [e["search_query"] for e in data["example_searches"]])Set example searches
PUT /api/v1/example-searchesAtomically replaces all of the merchant's custom example searches with the provided list (1–10 non-empty strings). Any previously stored examples for this merchant are deleted before the new ones are inserted.
Request body
| Field | Type | Required | Constraints | Description |
|---|---|---|---|---|
example_searches | string[] | yes | 1–10 items | Ordered list of query strings. |
Response — 200 OK
{ "status": "success", "message": "Updated 4 example searches", "merchant_id": 12345 }Errors
| Status | Response |
|---|---|
| 400 | { "detail": "Maximum 10 example searches allowed" } |
| 400 | { "detail": "At least one example search is required" } |
Example
curl -X PUT \
https://developer-search-devID.region.run.app/api/v1/example-searches \
-H "X-Merchant-ID: 12345" \
-H "Content-Type: application/json" \
-d '{
"example_searches": [
"linen dress for summer",
"navy blazer under $200",
"wide leg trousers",
"casual white sneakers"
]
}'const res = await fetch(
'https://developer-search-devID.region.run.app/api/v1/example-searches',
{
method: 'PUT',
headers: {
'X-Merchant-ID': '12345',
'Content-Type': 'application/json',
},
body: JSON.stringify({
example_searches: [
'linen dress for summer',
'navy blazer under $200',
'wide leg trousers',
'casual white sneakers',
],
}),
},
);
const result = await res.json();
console.log(result.message);import httpx
r = httpx.put(
"https://developer-search-devID.region.run.app/api/v1/example-searches",
headers={"X-Merchant-ID": "12345"},
json={
"example_searches": [
"linen dress for summer",
"navy blazer under $200",
"wide leg trousers",
"casual white sneakers",
]
},
)
print(r.json()["message"])Reset example searches
DELETE /api/v1/example-searchesDeletes all merchant-specific example searches, reverting the search UI to the
global platform defaults. Subsequent GET calls will return is_default: true.
Response — 200 OK
{ "status": "success", "message": "Reset to default examples (deleted 4 custom examples)", "merchant_id": 12345 }Example
curl -X DELETE \
https://developer-search-devID.region.run.app/api/v1/example-searches \
-H "X-Merchant-ID: 12345"await fetch(
'https://developer-search-devID.region.run.app/api/v1/example-searches',
{ method: 'DELETE', headers: { 'X-Merchant-ID': '12345' } },
);import httpx
httpx.delete(
"https://developer-search-devID.region.run.app/api/v1/example-searches",
headers={"X-Merchant-ID": "12345"},
)Quality API
discoverGPT cleans and enriches messy product data, then gives you a review workflow to approve those changes and publish them back to your store. The enrichment pipeline — the same one that runs during ingest — rewrites titles and descriptions, normalizes tags, and scores each change. The endpoints below are how you review those results: list them, approve or reject, edit, push approved changes to Shopify, and roll them back if needed.
Every cleaned record carries a status that moves through the workflow:
pending → approved → pushed
↑ │ │
└─ undo ─────┘ └─ rollback ─→ pending(or rejected from pending). Scores range 0.0–1.0; quality bands are
excellent (≥ 0.8), good (≥ 0.6), fair (≥ 0.4), else low.
Quality API base URL
All Quality API requests go to:
https://developer-clean-devID.region.run.appThe Quality API uses the same X-Merchant-ID trust model as the Search API — see
Authentication. Every endpoint requires it:
| Header | Required | Value |
|---|---|---|
X-Merchant-ID | yes | Your merchant ID (integer) |
Content-Type | on POST | application/json |
You don't call an endpoint to generate clean data
Enrichment runs automatically as part of the ingest/cleaning pipeline. These endpoints operate on the results — review, approve, publish, and roll back.
Overview
GET /api/v1/cleaner/overviewDashboard stats for the merchant's catalog: product counts by status, average quality scores, and a quality-band distribution (counted per product).
Response — 200 OK
{
"total": 1240,
"pending": 312,
"approved": 95,
"pushed": 820,
"rejected": 13,
"avg_score": 0.642,
"avg_title_score": 0.701,
"avg_desc_score": 0.585,
"quality_distribution": { "excellent": 410, "good": 520, "fair": 230, "low": 80 }
}Example
curl https://developer-clean-devID.region.run.app/api/v1/cleaner/overview \
-H "X-Merchant-ID: 12345"const res = await fetch(
'https://developer-clean-devID.region.run.app/api/v1/cleaner/overview',
{ headers: { 'X-Merchant-ID': '12345' } },
);
const stats = await res.json();
console.log(`${stats.pending} pending, ${stats.pushed} pushed`);import httpx
r = httpx.get(
"https://developer-clean-devID.region.run.app/api/v1/cleaner/overview",
headers={"X-Merchant-ID": "12345"},
)
print(r.json()["quality_distribution"])List products
GET /api/v1/cleaner/productsPaginated before/after list. By default, variants are grouped under their
parent product and nested in a variants array; the parent's
overall_improvement_score is the average across its variants and its status
is the least-advanced status among them.
Query parameters
| Param | Type | Default | Description |
|---|---|---|---|
status | string | — | Filter by status: pending, approved, pushed, rejected. |
search | string | — | Case-insensitive match on original or clean title. |
page | integer | 1 | Page number (≥ 1). |
per_page | integer | 20 | Results per page (1–100). |
sort_by | string | overall_improvement_score | One of overall_improvement_score, title_quality_score, generated_at, product_id, status. |
sort_order | string | desc | asc or desc. |
group_variants | boolean | true | Group variants under their parent product. |
Response — 200 OK
{
"products": [
{
"product_id": "8472910564",
"original_title": "BLUE DRESS WOMENS SUMMER NEW",
"clean_title": "Women's Blue Summer Midi Dress",
"original_description": "<p>nice dress</p>",
"clean_description": "A breezy blue midi dress cut for warm-weather wear...",
"title_quality_score": 0.82,
"description_quality_score": 0.71,
"overall_improvement_score": 0.77,
"status": "pending",
"generated_at": "2026-05-02 18:04:11+00:00",
"reviewed_at": null,
"image_url": "https://cdn.example.com/blue-dress.jpg",
"price": "58.00",
"clean_tags": "dress, summer, blue, midi",
"original_tags": "dress",
"variant_count": 3,
"variants": [
{ "variant_id": "4471", "original_title": "BLUE DRESS WOMENS SUMMER NEW", "clean_title": "Women's Blue Summer Midi Dress", "price": "58.00", "status": "pending", "overall_improvement_score": 0.77 }
]
}
],
"total": 312,
"page": 1,
"per_page": 20,
"total_pages": 16
}Example
curl -G https://developer-clean-devID.region.run.app/api/v1/cleaner/products \
-H "X-Merchant-ID: 12345" \
--data-urlencode "status=pending" \
--data-urlencode "per_page=50"const params = new URLSearchParams({ status: 'pending', per_page: '50' });
const res = await fetch(
`https://developer-clean-devID.region.run.app/api/v1/cleaner/products?${params}`,
{ headers: { 'X-Merchant-ID': '12345' } },
);
const { products, total } = await res.json();import httpx
r = httpx.get(
"https://developer-clean-devID.region.run.app/api/v1/cleaner/products",
headers={"X-Merchant-ID": "12345"},
params={"status": "pending", "per_page": 50},
)
print(r.json()["total"])Get product detail
GET /api/v1/cleaner/product/{product_id}Full before/after comparison for a single product, including the enrichment model used and any Shopify push error.
Response — 200 OK
{
"product_id": "8472910564",
"variant_id": null,
"original": { "title": "BLUE DRESS WOMENS SUMMER NEW", "description": "<p>nice dress</p>" },
"clean": {
"title": "Women's Blue Summer Midi Dress",
"description": "A breezy blue midi dress cut for warm-weather wear...",
"tags": "dress, summer, blue, midi"
},
"scores": { "title": 0.82, "description": 0.71, "overall": 0.77 },
"status": "pending",
"model_used": "gemini-2.5-flash",
"generated_at": "2026-05-02 18:04:11+00:00",
"reviewed_at": null,
"pushed_at": null,
"push_error": null,
"product": {
"image_url": "https://cdn.example.com/blue-dress.jpg",
"price": "58.00",
"brand": "Cove & Coast",
"category": "dresses",
"tags": "dress",
"image_description": "A blue sleeveless midi dress on a hanger."
}
}Example
curl https://developer-clean-devID.region.run.app/api/v1/cleaner/product/8472910564 \
-H "X-Merchant-ID: 12345"const res = await fetch(
'https://developer-clean-devID.region.run.app/api/v1/cleaner/product/8472910564',
{ headers: { 'X-Merchant-ID': '12345' } },
);
const detail = await res.json();import httpx
r = httpx.get(
"https://developer-clean-devID.region.run.app/api/v1/cleaner/product/8472910564",
headers={"X-Merchant-ID": "12345"},
)
print(r.json()["clean"]["title"])Errors
| Status | Response |
|---|---|
| 404 | { "detail": "Product override not found" } |
Approve products
POST /api/v1/cleaner/approveMove products from pending to approved. Approve a specific set, or every
pending product.
Request body
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
product_ids | string[] | no | — | Products to approve. Omit (or send null) to approve all pending. |
approve_all | boolean | no | false | If true, approve every pending product (ignores product_ids). |
Response — 200 OK
{ "approved": 24 }Example
curl -X POST https://developer-clean-devID.region.run.app/api/v1/cleaner/approve \
-H "X-Merchant-ID: 12345" \
-H "Content-Type: application/json" \
-d '{ "product_ids": ["8472910564", "8472910565"] }'await fetch(
'https://developer-clean-devID.region.run.app/api/v1/cleaner/approve',
{
method: 'POST',
headers: { 'X-Merchant-ID': '12345', 'Content-Type': 'application/json' },
body: JSON.stringify({ approve_all: true }),
},
);import httpx
httpx.post(
"https://developer-clean-devID.region.run.app/api/v1/cleaner/approve",
headers={"X-Merchant-ID": "12345"},
json={"product_ids": ["8472910564"]},
)Reject products
POST /api/v1/cleaner/rejectMark products as rejected so they are excluded from pushes.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
product_ids | string[] | yes | Products to reject. |
Response — 200 OK
{ "rejected": 2 }Example
curl -X POST https://developer-clean-devID.region.run.app/api/v1/cleaner/reject \
-H "X-Merchant-ID: 12345" -H "Content-Type: application/json" \
-d '{ "product_ids": ["8472910599"] }'await fetch('https://developer-clean-devID.region.run.app/api/v1/cleaner/reject', {
method: 'POST',
headers: { 'X-Merchant-ID': '12345', 'Content-Type': 'application/json' },
body: JSON.stringify({ product_ids: ['8472910599'] }),
});import httpx
httpx.post(
"https://developer-clean-devID.region.run.app/api/v1/cleaner/reject",
headers={"X-Merchant-ID": "12345"},
json={"product_ids": ["8472910599"]},
)Undo a review decision
POST /api/v1/cleaner/undoSend approved or rejected products back to pending. Does not affect
products that have already been pushed (use rollback
for those).
Request body
| Field | Type | Required | Description |
|---|---|---|---|
product_ids | string[] | yes | Products to reset. |
Response — 200 OK
{ "undone": 3 }Example
curl -X POST https://developer-clean-devID.region.run.app/api/v1/cleaner/undo \
-H "X-Merchant-ID: 12345" -H "Content-Type: application/json" \
-d '{ "product_ids": ["8472910564"] }'await fetch('https://developer-clean-devID.region.run.app/api/v1/cleaner/undo', {
method: 'POST',
headers: { 'X-Merchant-ID': '12345', 'Content-Type': 'application/json' },
body: JSON.stringify({ product_ids: ['8472910564'] }),
});import httpx
httpx.post(
"https://developer-clean-devID.region.run.app/api/v1/cleaner/undo",
headers={"X-Merchant-ID": "12345"},
json={"product_ids": ["8472910564"]},
)Edit clean fields
POST /api/v1/cleaner/editManually override the AI's clean_title and/or clean_description before
approving. Only allowed while the product is pending or approved.
Request body
| Field | Type | Required | Description |
|---|---|---|---|
product_id | string | yes | Product to edit. |
clean_title | string | no | New cleaned title. |
clean_description | string | no | New cleaned description. |
At least one of clean_title / clean_description must be provided.
Response — 200 OK
{ "updated": "8472910564" }Errors
| Status | Response |
|---|---|
| 400 | { "detail": "No fields to update" } or Cannot edit product with status '...' |
| 404 | { "detail": "Product not found" } |
Example
curl -X POST https://developer-clean-devID.region.run.app/api/v1/cleaner/edit \
-H "X-Merchant-ID: 12345" -H "Content-Type: application/json" \
-d '{ "product_id": "8472910564", "clean_title": "Women'\''s Blue Linen Midi Dress" }'await fetch('https://developer-clean-devID.region.run.app/api/v1/cleaner/edit', {
method: 'POST',
headers: { 'X-Merchant-ID': '12345', 'Content-Type': 'application/json' },
body: JSON.stringify({ product_id: '8472910564', clean_title: "Women's Blue Linen Midi Dress" }),
});import httpx
httpx.post(
"https://developer-clean-devID.region.run.app/api/v1/cleaner/edit",
headers={"X-Merchant-ID": "12345"},
json={"product_id": "8472910564", "clean_title": "Women's Blue Linen Midi Dress"},
)Push to Shopify
POST /api/v1/cleaner/pushWrite approved clean data back to the merchant's Shopify store. Small batches (≤ 10 products) push synchronously and return per-product results; larger batches run in the background — poll push status for progress. Before overwriting, the original Shopify values are backed up so the change can be rolled back.
Request body
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
product_ids | string[] | no | — | Approved products to push. Omit to push all approved. |
Response — 200 OK
Synchronous (≤ 10 products):
{
"status": "complete",
"pushed": 4,
"failed": 0,
"results": [
{ "product_id": "8472910564", "status": "pushed" }
]
}Background (> 10 products):
{ "status": "queued", "products_to_push": 240, "message": "Push started in background" }Nothing to push:
{ "status": "complete", "pushed": 0, "failed": 0, "message": "No approved products to push" }Example
curl -X POST https://developer-clean-devID.region.run.app/api/v1/cleaner/push \
-H "X-Merchant-ID: 12345" -H "Content-Type: application/json" \
-d '{}'const res = await fetch('https://developer-clean-devID.region.run.app/api/v1/cleaner/push', {
method: 'POST',
headers: { 'X-Merchant-ID': '12345', 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
console.log((await res.json()).status); // "complete" or "queued"import httpx
r = httpx.post(
"https://developer-clean-devID.region.run.app/api/v1/cleaner/push",
headers={"X-Merchant-ID": "12345"},
json={},
)
print(r.json()["status"])Push status
GET /api/v1/cleaner/push-statusProgress of the merchant's current (or most recent) push job. Returns
{ "status": "idle" } when no push has run.
Response — 200 OK
{
"status": "running",
"total": 240,
"pushed": 96,
"failed": 1,
"started_at": "2026-05-02T18:20:04+00:00",
"completed_at": null
}status becomes complete and completed_at is set when the job finishes.
Example
curl https://developer-clean-devID.region.run.app/api/v1/cleaner/push-status \
-H "X-Merchant-ID: 12345"const res = await fetch(
'https://developer-clean-devID.region.run.app/api/v1/cleaner/push-status',
{ headers: { 'X-Merchant-ID': '12345' } },
);import httpx
r = httpx.get(
"https://developer-clean-devID.region.run.app/api/v1/cleaner/push-status",
headers={"X-Merchant-ID": "12345"},
)Push history
GET /api/v1/cleaner/historyPaginated write-back log: every field pushed to Shopify (and every rollback), newest first.
Query parameters
| Param | Type | Default | Description |
|---|---|---|---|
page | integer | 1 | Page number (≥ 1). |
per_page | integer | 20 | Results per page (1–100). |
Response — 200 OK
{
"history": [
{
"product_id": "8472910564",
"field_name": "title",
"old_value": "BLUE DRESS WOMENS SUMMER NEW",
"new_value": "Women's Blue Summer Midi Dress",
"shopify_response_code": 200,
"status": "pushed",
"pushed_at": "2026-05-02 18:21:07+00:00"
}
],
"total": 1640,
"page": 1,
"per_page": 20
}Example
curl -G https://developer-clean-devID.region.run.app/api/v1/cleaner/history \
-H "X-Merchant-ID: 12345" --data-urlencode "per_page=50"const res = await fetch(
'https://developer-clean-devID.region.run.app/api/v1/cleaner/history?per_page=50',
{ headers: { 'X-Merchant-ID': '12345' } },
);import httpx
r = httpx.get(
"https://developer-clean-devID.region.run.app/api/v1/cleaner/history",
headers={"X-Merchant-ID": "12345"},
params={"per_page": 50},
)Roll back a product
POST /api/v1/cleaner/rollback/{product_id}Restore a pushed product to its original Shopify values using the backup
captured at push time, and reset its status to pending. Only valid for
products currently in pushed status.
Response — 200 OK
{ "status": "rolled_back", "product_id": "8472910564" }Errors
| Status | Response |
|---|---|
| 400 | { "detail": "Can only rollback pushed products" } or No backup data available for rollback |
| 404 | { "detail": "Product override not found" } |
| 502 | { "detail": "Shopify rollback failed: ..." } |
Example
curl -X POST https://developer-clean-devID.region.run.app/api/v1/cleaner/rollback/8472910564 \
-H "X-Merchant-ID: 12345"await fetch(
'https://developer-clean-devID.region.run.app/api/v1/cleaner/rollback/8472910564',
{ method: 'POST', headers: { 'X-Merchant-ID': '12345' } },
);import httpx
httpx.post(
"https://developer-clean-devID.region.run.app/api/v1/cleaner/rollback/8472910564",
headers={"X-Merchant-ID": "12345"},
)Get cleaner config
GET /api/v1/cleaner/configCurrent automation settings for the merchant. If none are saved, the defaults shown below are returned.
Response — 200 OK
{
"auto_approve": false,
"auto_push": false,
"clean_titles": true,
"clean_descriptions": true,
"clean_tags": true,
"tag_push_mode": "skip",
"min_improvement_score": 0.3,
"max_title_length": 80
}Example
curl https://developer-clean-devID.region.run.app/api/v1/cleaner/config \
-H "X-Merchant-ID: 12345"const res = await fetch(
'https://developer-clean-devID.region.run.app/api/v1/cleaner/config',
{ headers: { 'X-Merchant-ID': '12345' } },
);import httpx
r = httpx.get(
"https://developer-clean-devID.region.run.app/api/v1/cleaner/config",
headers={"X-Merchant-ID": "12345"},
)Update cleaner config
POST /api/v1/cleaner/configUpdate one or more settings. Only the fields you send are changed; omitted fields are left as-is. Creates the config row with sensible defaults if it doesn't exist yet.
Request body
| Field | Type | Description |
|---|---|---|
auto_approve | boolean | Auto-approve cleaned products above min_improvement_score. |
auto_push | boolean | Auto-push approved products to Shopify. |
clean_titles | boolean | Enrich product titles. |
clean_descriptions | boolean | Enrich product descriptions. |
clean_tags | boolean | Enrich product tags. |
tag_push_mode | string | How tags are written back: skip, replace, or append. |
min_improvement_score | number | Minimum score (0.0–1.0) required for auto-approve. |
max_title_length | integer | Maximum cleaned-title length. |
Response — 200 OK
{ "status": "ok" }Example
curl -X POST https://developer-clean-devID.region.run.app/api/v1/cleaner/config \
-H "X-Merchant-ID: 12345" -H "Content-Type: application/json" \
-d '{ "auto_approve": true, "min_improvement_score": 0.5, "tag_push_mode": "append" }'await fetch('https://developer-clean-devID.region.run.app/api/v1/cleaner/config', {
method: 'POST',
headers: { 'X-Merchant-ID': '12345', 'Content-Type': 'application/json' },
body: JSON.stringify({ auto_approve: true, min_improvement_score: 0.5 }),
});import httpx
httpx.post(
"https://developer-clean-devID.region.run.app/api/v1/cleaner/config",
headers={"X-Merchant-ID": "12345"},
json={"auto_approve": True, "min_improvement_score": 0.5},
)Lifecycle endpoints
Two additional endpoints support the Shopify app install lifecycle and are
called by the app, not typically by integrators:
POST /api/v1/cleaner/revert-all/{merchant_id} bulk-reverts every pushed
product (used pre-uninstall, while the access token is still valid), and
POST /api/v1/cleaner/reset-reverted/{merchant_id} resets reverted products
back to pending on reinstall.
Feeds and Visibility API
discoverGPT turns an enriched catalog into AI-discovery formats — the GEO (Generative Engine Optimization) layer. From one enriched catalog the pipeline produces five agentic-commerce formats; you download the four feed files, query the catalog over UCP, track how your catalog surfaces across AI assistants, check feed health, and (for non-Shopify merchants) ingest products and pull feeds over an API-key-authenticated endpoint.
Five formats are produced:
| Format | Media type | Shape |
|---|---|---|
ucp | application/json | Universal Commerce Protocol Product records, queried (not downloaded) via the UCP Catalog query API — discovery profile + search / lookup / product. |
acp | application/jsonl | One ACP product object per line (Agentic Commerce Protocol). |
jsonld | application/ld+json | A single Schema.org document with all products under @graph. |
perplexity | application/json | A single JSON envelope wrapping every product (Perplexity Shopping shape). |
llmstxt | text/plain | An llms.txt Markdown summary of the catalog for AI assistants. |
The four acp / jsonld / perplexity / llmstxt formats are
downloadable feed files (the GET .../feeds/{format} routes below).
ucp is different — it is queried, not downloaded; see
UCP (Universal Commerce Protocol) Catalog.
The Feeds and Visibility API has two API surfaces:
- Feed downloads — for Shopify merchants, authenticated with
X-Merchant-ID(the same model as the Search API). Prefix/api/v1/feeds. - Public API — for non-Shopify merchants, authenticated with an
X-API-Keyyou mint via generate-key. Prefix/api/v1/catalog.
Feeds and Visibility API base URL
All Feeds and Visibility API requests go to:
https://developer-catalog-devID.region.run.appFeed downloads (Shopify)
These endpoints serve the compiled feed for the merchant identified by the
X-Merchant-ID header.
| Header | Required | Value |
|---|---|---|
X-Merchant-ID | yes | Your merchant ID (integer) |
Download ACP feed
GET /api/v1/feeds/acpReturns the full ACP feed as JSONL (one JSON object per line), as a file
download. 404 if no ACP feed has been compiled yet.
curl https://developer-catalog-devID.region.run.app/api/v1/feeds/acp \
-H "X-Merchant-ID: 12345" -o acp_feed.jsonlconst res = await fetch(
'https://developer-catalog-devID.region.run.app/api/v1/feeds/acp',
{ headers: { 'X-Merchant-ID': '12345' } },
);
const jsonl = await res.text();import httpx
r = httpx.get(
"https://developer-catalog-devID.region.run.app/api/v1/feeds/acp",
headers={"X-Merchant-ID": "12345"},
)
open("acp_feed.jsonl", "w").write(r.text)Download JSON-LD feed
GET /api/v1/feeds/jsonldReturns a single Schema.org JSON-LD document with every product under @graph.
404 if none compiled.
{
"@context": "https://schema.org",
"@graph": [
{
"@type": "Product",
"name": "Women's Blue Summer Midi Dress",
"brand": { "@type": "Brand", "name": "Cove & Coast" },
"offers": { "@type": "Offer", "price": "58.00", "priceCurrency": "USD", "availability": "https://schema.org/InStock" }
}
]
}curl https://developer-catalog-devID.region.run.app/api/v1/feeds/jsonld \
-H "X-Merchant-ID: 12345" -o jsonld_feed.jsonconst res = await fetch(
'https://developer-catalog-devID.region.run.app/api/v1/feeds/jsonld',
{ headers: { 'X-Merchant-ID': '12345' } },
);
const feed = await res.json();import httpx
r = httpx.get(
"https://developer-catalog-devID.region.run.app/api/v1/feeds/jsonld",
headers={"X-Merchant-ID": "12345"},
)
graph = r.json()["@graph"]Download Perplexity feed
GET /api/v1/feeds/perplexityReturns the Perplexity Shopping feed as a single JSON envelope — every product
wrapped under products with a top-level merchant_id and product_count.
Served as a file download (Content-Disposition: attachment; filename=perplexity_feed.json).
404 if no feed has been compiled yet.
{
"version": "1.0",
"merchant_id": 12345,
"product_count": 164,
"products": [
{
"id": "10750448533814-54137246810422",
"title": "Gift Card",
"brand": "Cove & Coast",
"price": "10.00 USD",
"colour": "light mustard",
"condition": "new",
"availability": "in_stock",
"link": "https://your-store.myshopify.com/products/gift-card?variant=54137246810422",
"image_link": "https://cdn.shopify.com/s/files/.../gift_card.png",
"seller_name": "Your Store",
"seller_url": "https://your-store.myshopify.com",
"item_group_id": "10750448533814",
"product_category": "Gift Cards",
"description": "Give the gift of shred-ready style...",
"q_and_a": [
{ "q": "What can I buy with this gift card?", "a": "This gift card can be used to purchase any product..." }
],
"enable_search": true,
"enable_checkout": true
}
]
}curl https://developer-catalog-devID.region.run.app/api/v1/feeds/perplexity \
-H "X-Merchant-ID: 12345" -o perplexity_feed.jsonimport httpx
r = httpx.get(
"https://developer-catalog-devID.region.run.app/api/v1/feeds/perplexity",
headers={"X-Merchant-ID": "12345"},
)
products = r.json()["products"]Download llms.txt feed
GET /api/v1/feeds/llmstxtReturns the merchant's llms.txt — a plain-text Markdown
summary of the catalog that AI assistants can read to ground answers about your
products. The body is text/plain, grouped by category, with one bullet per
product (title, link, and a short description). 404 if none compiled.
# Your Store
> Product catalog for Your Store. 53 products available. This file helps AI
> assistants discover and reference the catalog.
## accessories > book stand
- [Kitchen Counter Cookbook Bookshelf](https://your-store.myshopify.com/products/kitchen-cooking-bookshelf): This charming wooden cooking bookshelf...curl https://developer-catalog-devID.region.run.app/api/v1/feeds/llmstxt \
-H "X-Merchant-ID: 12345" -o llms.txtllms.txt is also served at the storefront root
For Shopify storefronts the same document is published, unauthenticated, at the
App-Proxy path GET /llms.txt?shop={shop_domain} (e.g. for crawlers). The
/api/v1/feeds/llmstxt route above is the merchant-authenticated equivalent.
llmstxt is the one format not available through the API-key Public API
download route — it is served Shopify-side only.
Feed status
GET /api/v1/feeds/statusSummary of the merchant's feed: product count, compile/push timestamps, which formats are enabled, and how many unresolved health issues exist.
{
"total_products": 1240,
"last_compiled": "2026-06-01 04:12:55+00:00",
"last_modified": "2026-06-01 03:59:10+00:00",
"last_pushed": "2026-06-01 04:15:02+00:00",
"acp_enabled": true,
"jsonld_enabled": true,
"unresolved_issues": 7
}curl https://developer-catalog-devID.region.run.app/api/v1/feeds/status \
-H "X-Merchant-ID: 12345"Feed health
GET /api/v1/feeds/healthLists feed health issues (missing fields, invalid prices, etc.), newest first, capped at 200.
Query parameters
| Param | Type | Description |
|---|---|---|
resolved | boolean | Filter by resolution state. |
severity | string | Filter by severity (e.g. error, warning). |
{
"issues": [
{
"id": 91,
"product_id": "8472910564",
"issue_type": "missing_narrative",
"severity": "warning",
"details": "The product has no descriptive narrative for AI assistants.",
"resolved": false,
"detected_at": "2026-06-01 04:12:55+00:00",
"resolved_at": null
}
],
"count": 1
}curl -G https://developer-catalog-devID.region.run.app/api/v1/feeds/health \
-H "X-Merchant-ID: 12345" --data-urlencode "resolved=false" --data-urlencode "severity=error"UCP (Universal Commerce Protocol) Catalog
UCP is the Universal Commerce Protocol — the open agentic-commerce standard backed by Google and Shopify. Where the feed downloads above hand an agent a file to fetch and parse, UCP lets an agent query your catalog directly: it reads a discovery profile, learns which capabilities you support and where they live, then calls those endpoints.
UCP is queried, not downloaded
Unlike the downloadable feeds (acp, jsonld,
perplexity, llmstxt), UCP is a JSON query API with its own discovery
profile and spec — an agent calls it rather than fetching a file. UCP is
not a value for GET /v1/feed/{feed_format}; there is no ucp feed file
to download.
Through the unified gateway (JWT + MCP)
The UCP catalog is also exposed on the unified gateway — same api.vairetail.com
base and OAuth credential as the rest of /v1 — so you can query it with your
agency token (merchant resolved from the JWT, no path id) or call it from an
agent as MCP tools:
Gateway REST (api.vairetail.com) | MCP tool | UCP capability |
|---|---|---|
GET /v1/ucp/profile | get_ucp_profile | Discovery profile |
POST /v1/ucp/catalog/search | ucp_catalog_search | Catalog Search |
POST /v1/ucp/catalog/lookup | ucp_catalog_lookup | Catalog Lookup (by ids) |
POST /v1/ucp/catalog/product | ucp_get_product | Get Product |
These forward to the per-merchant catalogGPT routes documented below; catalogGPT
enforces the per-merchant ucp_enabled gate. Request bodies follow the UCP
Catalog spec (the same shapes shown below, minus the {merchant_id} path
segment). Example:
curl -s -X POST https://api.vairetail.com/v1/ucp/catalog/search \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"query":"linen dress","filters":{"categories":["Dresses"],"price":{"max":100}},"pagination":{"limit":10}}'The raw per-merchant routes below are the public, unauthenticated surface a shopping agent follows from a merchant's discovery profile. Two things set them apart from the rest of the catalogGPT API:
- Merchant is in the path, not a header. Each merchant's profile advertises
its own merchant-scoped URLs, so agents follow them directly — no
X-Merchant-IDheader, no out-of-band merchant identification. - No API key. These endpoints are public by design; a shopping agent has to be able to reach them unauthenticated.
Discovery profile
GET /.well-known/ucp/{merchant_id}The entry point. Returns the merchant's advertised UCP version and a
capabilities map pointing at the live endpoint for each catalog capability.
Agents should read this first and follow the endpoint URLs rather than
hardcoding paths.
{
"ucp": { "versions": ["2026-04-08"] },
"merchant": { "id": "12345", "domain": null, "name": null },
"capabilities": {
"dev.ucp.shopping.catalog.search": [
{
"version": "2026-04-08",
"endpoint": "https://developer-catalog-devID.region.run.app/api/v1/ucp/12345/catalog/search",
"transport": "rest"
}
],
"dev.ucp.shopping.catalog.lookup": [{ "version": "2026-04-08", "endpoint": "...", "transport": "rest" }],
"dev.ucp.shopping.catalog.product": [{ "version": "2026-04-08", "endpoint": "...", "transport": "rest" }]
},
"signing_keys": []
}signing_keys is currently empty — response signing is a later follow-up.
curl https://developer-catalog-devID.region.run.app/.well-known/ucp/12345Catalog search
POST /api/v1/ucp/{merchant_id}/catalog/searchFree-text search across the merchant's UCP catalog, with optional filters and cursor pagination.
| Field | Type | Description |
|---|---|---|
query | string | Free-text query. Omit to list the catalog. |
filters | object | categories (array of strings) and/or price ({ "max": number }). |
pagination | object | limit (default 50, max 200) and cursor (the last id from a prior page). |
context / signals / attribution | object | Optional UCP passthrough blocks; accepted and ignored today. |
Every response carries a ucp envelope advertising the capability and version,
alongside the results.
{
"ucp": {
"version": "2026-04-08",
"capabilities": { "dev.ucp.shopping.catalog.search": [{ "version": "2026-04-08" }] }
},
"products": [{ "id": "SKU-1001", "title": "Relaxed Linen Button-Up", "...": "..." }],
"pagination": { "cursor": "SKU-1001" }
}Page forward by passing the returned pagination.cursor back as
pagination.cursor; an empty string means there is nothing more to read.
curl -X POST https://developer-catalog-devID.region.run.app/api/v1/ucp/12345/catalog/search \
-H "Content-Type: application/json" \
-d '{"query":"linen shirt","filters":{"price":{"max":100.0}},"pagination":{"limit":20}}'Catalog lookup
POST /api/v1/ucp/{merchant_id}/catalog/lookupFetch UCP Product records for a known set of IDs — the batch counterpart to search.
| Field | Type | Description |
|---|---|---|
ids | array of string | Required. 1–200 product IDs. |
filters | object | Same shape as search; applied to the results. |
curl -X POST https://developer-catalog-devID.region.run.app/api/v1/ucp/12345/catalog/lookup \
-H "Content-Type: application/json" \
-d '{"ids":["SKU-1001","SKU-1002"]}'Get product
POST /api/v1/ucp/{merchant_id}/catalog/productFetch a single UCP Product record by id. Returns the record under product
rather than products.
curl -X POST https://developer-catalog-devID.region.run.app/api/v1/ucp/12345/catalog/product \
-H "Content-Type: application/json" \
-d '{"id":"SKU-1001"}'The UCP Product record
Search, lookup, and get-product all return the same spec-conformant Product shape:
| Field | Description |
|---|---|
id | Product identifier. |
title | Product title. |
description | Description object. |
categories | Category path for the product. |
price_range | Min/max price across variants. |
media | Product imagery. |
options | Option definitions (e.g. Size, Color) shared across variants. |
variants | Each with id, title, description, price, availability, and options. |
metadata | Non-spec extras (e.g. brand, faq) kept out of the first-class UCP fields. |
Prices are integer minor units
Every UCP price.amount is an integer in ISO 4217 minor units — 5800 is
$58.00, not $5,800. This is a deliberate difference from the ACP and JSON-LD
feeds, which express price as a decimal. Read price.currency alongside it.
AI Visibility and GEO
discoverGPT continuously checks whether your products surface inside AI
assistants — ChatGPT, Perplexity, Google AI Overviews, and other engines — and
scores your catalog's Generative Engine Optimization (GEO). These endpoints
use the same X-Merchant-ID header as the feed downloads.
| Header | Required | Value |
|---|---|---|
X-Merchant-ID | yes | Your merchant ID (integer) |
Visibility score
GET /api/v1/visibility/scoreOverall AI-visibility score for the catalog with a per-platform breakdown.
score values run 0–100; avg_rank is the mean position among visible results
(null when nothing is visible); last_checked is null until the first check
runs.
{
"overall_score": 72.5,
"products_checked": 120,
"products_visible": 87,
"total_checks": 480,
"visible_checks": 348,
"platforms": {
"chatgpt": { "total_checked": 120, "visible": 90, "score": 75.0, "avg_rank": 2.3 },
"perplexity": { "total_checked": 120, "visible": 80, "score": 66.7, "avg_rank": 1.8 }
},
"last_checked": "2026-06-20 14:32:00+00:00"
}curl https://developer-catalog-devID.region.run.app/api/v1/visibility/score \
-H "X-Merchant-ID: 12345"import httpx
r = httpx.get(
"https://developer-catalog-devID.region.run.app/api/v1/visibility/score",
headers={"X-Merchant-ID": "12345"},
)
print(r.json()["overall_score"])Visibility products
GET /api/v1/visibility/productsPer-product visibility rows across platforms — which products were visible, their rank, and the query that surfaced them.
Query parameters
| Param | Type | Default | Description |
|---|---|---|---|
platform | string | — | Filter to one platform (e.g. chatgpt, perplexity). |
visible_only | boolean | false | Return only rows where the product was visible. |
limit | integer | 50 | Page size. |
offset | integer | 0 | Page offset. |
{
"products": [
{
"product_id": "8412345678",
"product_name": "Classic Linen Blazer",
"brand": "Soleil Studio",
"handle": "classic-linen-blazer",
"platform": "chatgpt",
"visible": true,
"rank_position": 2,
"query_used": "linen blazer women summer",
"checked_at": "2026-06-20 14:32:00+00:00"
}
],
"count": 1,
"total": 240,
"limit": 50,
"offset": 0
}curl -G https://developer-catalog-devID.region.run.app/api/v1/visibility/products \
-H "X-Merchant-ID: 12345" \
--data-urlencode "platform=chatgpt" --data-urlencode "visible_only=true"import httpx
r = httpx.get(
"https://developer-catalog-devID.region.run.app/api/v1/visibility/products",
headers={"X-Merchant-ID": "12345"},
params={"platform": "chatgpt", "visible_only": True, "limit": 50},
)Visibility trends
GET /api/v1/visibility/trendsDaily visibility time-series for the catalog, optionally scoped to one platform.
Query parameters
| Param | Type | Default | Description |
|---|---|---|---|
days | integer | 30 | Look-back window in days (1–365). |
platform | string | — | Restrict the series to one platform. |
{
"days": 30,
"platform": null,
"trends": [
{ "date": "2026-06-01", "total_checks": 480, "visible_count": 348, "score": 72.5, "avg_rank": 2.1 }
]
}curl -G https://developer-catalog-devID.region.run.app/api/v1/visibility/trends \
-H "X-Merchant-ID: 12345" --data-urlencode "days=7"import httpx
r = httpx.get(
"https://developer-catalog-devID.region.run.app/api/v1/visibility/trends",
headers={"X-Merchant-ID": "12345"},
params={"days": 7},
)Visibility issues
GET /api/v1/visibility/issuesThe products that are holding your AI-visibility score back, with the root
cause and which capability resolves it. severity runs 1–5 (higher is worse);
issues are returned worst-last (ascending severity). route tells you where to
fix it: cleaner (improve the title/description via the data-quality pipeline) or catalog
(re-compile the feed). Takes no parameters.
{
"total": 53,
"by_cause": { "weak_title": 18, "weak_description": 35 },
"by_route": { "catalog": 0, "cleaner": 53 },
"issues": [
{ "product_id": "10750467440950", "root_cause": "weak_title", "route": "cleaner", "severity": 4, "status": "open" },
{ "product_id": "10750448533814", "root_cause": "weak_description", "route": "cleaner", "severity": 5, "status": "open" }
]
}curl https://developer-catalog-devID.region.run.app/api/v1/visibility/issues \
-H "X-Merchant-ID: 12345"Visibility readiness
GET /api/v1/visibility/readinessA fast, key-free AI-readiness check — does the catalog have the artifacts AI
assistants need (JSON-LD, an llms.txt, and fresh feeds)? Unlike the
visibility score, this does not call any external AI
platform; it reads the latest readiness snapshot. available is false (and the
score 0) until the first snapshot has been taken. Takes no parameters.
{
"available": true,
"readiness_score": 100.0,
"jsonld_ok": true,
"llms_txt_ok": true,
"feeds_fresh": true,
"products_total": 164,
"products_with_feed": 164,
"checked_at": "2026-06-20 11:26:32.795422"
}curl https://developer-catalog-devID.region.run.app/api/v1/visibility/readiness \
-H "X-Merchant-ID: 12345"BYO visibility keys
The visibility score checks whether your products surface
inside AI assistants. To run those checks against your own ChatGPT, Gemini, or
Perplexity quota, supply your own provider API keys. Keys are stored
encrypted and are never returned — responses only ever expose the last
four characters, a validity flag, and a set / not_set status. All three
platforms are always present in the response.
| Header | Required | Value |
|---|---|---|
X-Merchant-ID | yes | Your merchant ID (integer) |
Valid platform values: chatgpt, gemini, perplexity. Note the trailing
slash on the collection routes.
List keys (masked)
GET /api/v1/visibility-keys/{
"platforms": [
{ "platform": "chatgpt", "status": "set", "key_last4": "sk-9", "is_valid": true, "validated_at": "2026-06-20 21:45:45.459101+00:00" },
{ "platform": "gemini", "status": "not_set", "key_last4": null, "is_valid": null, "validated_at": null },
{ "platform": "perplexity", "status": "not_set", "key_last4": null, "is_valid": null, "validated_at": null }
]
}curl https://developer-catalog-devID.region.run.app/api/v1/visibility-keys/ \
-H "X-Merchant-ID: 12345"Set or rotate a key
POST /api/v1/visibility-keys/Stores (or replaces) the key for one platform — upserted on
(merchant_id, platform), so posting again rotates it. On save, discoverGPT makes
a best-effort validation call to the provider and records the result in
is_valid (true / false, or null if the check couldn't run).
Request body
| Field | Type | Required | Description |
|---|---|---|---|
platform | string | yes | chatgpt, gemini, or perplexity. |
api_key | string | yes | Your provider API key (stored encrypted). |
The response is the same masked list as List keys, reflecting the new state.
curl -X POST https://developer-catalog-devID.region.run.app/api/v1/visibility-keys/ \
-H "X-Merchant-ID: 12345" -H "Content-Type: application/json" \
-d '{ "platform": "chatgpt", "api_key": "sk-..." }'import httpx
httpx.post(
"https://developer-catalog-devID.region.run.app/api/v1/visibility-keys/",
headers={"X-Merchant-ID": "12345"},
json={"platform": "chatgpt", "api_key": "sk-..."},
)Delete a key
DELETE /api/v1/visibility-keys/{platform}/Removes the stored key for one platform (discoverGPT falls back to its shared
quota for that platform). Returns the same masked list, with the deleted platform
back to not_set. There is no separate update route — re-POST to change a key.
curl -X DELETE https://developer-catalog-devID.region.run.app/api/v1/visibility-keys/chatgpt/ \
-H "X-Merchant-ID: 12345"Feed health detail
Beyond the issue list at /api/v1/feeds/health, these endpoints
score how complete and enrichment-ready your catalog is. All use the
X-Merchant-ID header. (A paginated version of the issue list is also available
at GET /api/v1/health/issues, which adds limit/offset/total.)
Health summary
GET /api/v1/health/summaryCatalog-wide readiness: how many products are feed-complete (ACP and JSON-LD present) and the count of unresolved issues by severity.
{
"total_products": 1240,
"complete_products": 1108,
"health_score": 89.4,
"unresolved_errors": 2,
"unresolved_warnings": 7,
"unresolved_info": 0
}curl https://developer-catalog-devID.region.run.app/api/v1/health/summary \
-H "X-Merchant-ID: 12345"Product health
GET /api/v1/health/product/{product_id}Per-product readiness and enrichment-quality breakdown: which feed formats and
enrichment artifacts exist, plus a weighted factor-by-factor score.
readiness_score is the share of the five checks that pass.
{
"product_id": "8412345678",
"readiness_score": 100.0,
"enrichment_quality_score": 75.0,
"checks": {
"has_acp": true, "has_jsonld": true,
"has_narrative": true, "has_qa": true, "no_errors": true
},
"enrichment_factors": {
"description_quality": { "score": 20, "max": 20, "detail": "142 chars" },
"brand": { "score": 10, "max": 10, "detail": "Soleil Studio" },
"qa_coverage": { "score": 8, "max": 15, "detail": "1 Q&A pair (needs more)" },
"feed_completeness": { "score": 10, "max": 10, "detail": "ACP + JSON-LD" }
},
"compiled_at": "2026-06-20 10:00:00+00:00",
"narrative_count": 1,
"qa_count": 1,
"unresolved_issues": []
}curl https://developer-catalog-devID.region.run.app/api/v1/health/product/8412345678 \
-H "X-Merchant-ID: 12345"Enrichment quality
GET /api/v1/health/enrichment-qualityRanked enrichment-quality scores across the catalog, with a band distribution
(excellent ≥ 80, good ≥ 60, fair ≥ 40, poor < 40).
Query parameters
| Param | Type | Default | Description |
|---|---|---|---|
limit | integer | 50 | Page size. |
offset | integer | 0 | Page offset. |
min_score | number | — | Only products at or above this score. |
max_score | number | — | Only products at or below this score. |
{
"average_score": 68.4,
"total_products": 1240,
"score_distribution": { "excellent": 410, "good": 520, "fair": 230, "poor": 80 },
"products": [
{
"product_id": "8412345678",
"product_name": "Classic Linen Blazer",
"enrichment_quality_score": 89.5,
"factors": { "...": "same shape as Product health → enrichment_factors" }
}
],
"count": 50,
"limit": 50,
"offset": 0
}curl -G https://developer-catalog-devID.region.run.app/api/v1/health/enrichment-quality \
-H "X-Merchant-ID: 12345" --data-urlencode "min_score=80"import httpx
r = httpx.get(
"https://developer-catalog-devID.region.run.app/api/v1/health/enrichment-quality",
headers={"X-Merchant-ID": "12345"},
params={"min_score": 80, "limit": 50},
)Health issues
GET /api/v1/health/issuesThe paginated companion to /api/v1/feeds/health: the same feed
health issues (missing fields, invalid prices, etc.), newest first, but with
limit / offset / total so you can page through a large backlog. A catalog
with no open issues returns an empty issues array.
Query parameters
| Param | Type | Default | Description |
|---|---|---|---|
resolved | boolean | — | Filter by resolution state. |
severity | string | — | Filter by severity (e.g. error, warning). |
limit | integer | 100 | Page size. |
offset | integer | 0 | Page offset. |
{
"issues": [
{
"id": 91,
"product_id": "8472910564",
"issue_type": "missing_narrative",
"severity": "warning",
"details": "The product has no descriptive narrative for AI assistants.",
"resolved": false,
"detected_at": "2026-06-01 04:12:55+00:00",
"resolved_at": null
}
],
"count": 1,
"total": 1,
"limit": 100,
"offset": 0
}curl -G https://developer-catalog-devID.region.run.app/api/v1/health/issues \
-H "X-Merchant-ID: 12345" --data-urlencode "resolved=false" --data-urlencode "limit=50"Public API (non-Shopify)
For merchants not on Shopify, the Public API accepts product data from any
source and serves feeds — authenticated with an API key instead of
X-Merchant-ID.
| Header | Required | Value |
|---|---|---|
X-API-Key | yes | The key returned by generate-key. |
Generate an API key
POST /api/v1/catalog/generate-keyMint (or rotate) the merchant's Public API key. This is the one Public-API
endpoint authenticated with X-Merchant-ID — every other endpoint below uses
the returned X-API-Key. Calling it again replaces the existing key.
| Header | Required | Value |
|---|---|---|
X-Merchant-ID | yes | Your merchant ID (integer) |
Response — 200 OK
{ "api_key": "cgpt_xxxxx", "merchant_id": 12345 }curl -X POST https://developer-catalog-devID.region.run.app/api/v1/catalog/generate-key \
-H "X-Merchant-ID: 12345"import httpx
r = httpx.post(
"https://developer-catalog-devID.region.run.app/api/v1/catalog/generate-key",
headers={"X-Merchant-ID": "12345"},
)
api_key = r.json()["api_key"]Ingest products
POST /api/v1/catalog/ingestUpsert products into the catalog from any platform. Products are matched on
(merchant_id, product_id, variant_id); existing rows are updated. A product
with no variants is stored as a single default variant.
| Header | Required | Value |
|---|---|---|
X-API-Key | yes | Your Public API key. |
Content-Type | yes | application/json |
Request body
| Field | Type | Required | Description |
|---|---|---|---|
products | ProductIngest[] | yes | Products to upsert. |
Each ProductIngest:
| Field | Type | Required | Description |
|---|---|---|---|
product_id | string | yes | Your stable product identifier. |
title | string | yes | Product title. |
description | string | no | Product description / details. |
handle | string | no | URL handle (defaults to product_id). |
vendor | string | no | Brand / vendor. |
product_type | string | no | Category. |
tags | string[] | no | Tags (stored comma-joined). |
image_url | string | no | Primary image URL. |
variants | ProductVariant[] | no | Variants; if omitted, one default is created. |
Each ProductVariant: variant_id, sku, price, compare_at_price,
option1, option2, option3 (all strings, optional), and available
(boolean, default true).
Response — 200 OK
{
"status": "ok",
"products_processed": 2,
"rows_upserted": 5,
"errors": []
}curl -X POST https://developer-catalog-devID.region.run.app/api/v1/catalog/ingest \
-H "X-API-Key: cgpt_xxxxx" -H "Content-Type: application/json" \
-d '{
"products": [
{
"product_id": "SKU-1001",
"title": "Relaxed-Fit Linen Button-Up",
"vendor": "Cove & Coast",
"product_type": "tops",
"tags": ["linen", "summer"],
"image_url": "https://cdn.example.com/linen.jpg",
"variants": [{ "variant_id": "SKU-1001-M", "price": "69.00", "option1": "M" }]
}
]
}'await fetch('https://developer-catalog-devID.region.run.app/api/v1/catalog/ingest', {
method: 'POST',
headers: { 'X-API-Key': 'cgpt_xxxxx', 'Content-Type': 'application/json' },
body: JSON.stringify({
products: [{ product_id: 'SKU-1001', title: 'Relaxed-Fit Linen Button-Up', variants: [{ price: '69.00' }] }],
}),
});import httpx
httpx.post(
"https://developer-catalog-devID.region.run.app/api/v1/catalog/ingest",
headers={"X-API-Key": "cgpt_xxxxx"},
json={"products": [{"product_id": "SKU-1001", "title": "Relaxed-Fit Linen Button-Up"}]},
)Download a feed
GET /api/v1/catalog/feed/{format}Download the compiled feed in the given format — acp, jsonld, or
perplexity (same payloads as the Shopify feed downloads),
but authenticated by X-API-Key. The key may be passed as the X-API-Key header
or an ?api_key= query parameter. (llmstxt is not served on this route — use
the Shopify llms.txt route.)
| Path param | Values |
|---|---|
format | acp, jsonld, perplexity |
Errors
| Status | Response |
|---|---|
| 400 | { "detail": "Format must be 'acp', 'jsonld', or 'perplexity'" } |
| 401 | { "detail": "Invalid API key" } |
| 404 | { "detail": "No ACP feed data" } (per format) |
curl https://developer-catalog-devID.region.run.app/api/v1/catalog/feed/jsonld \
-H "X-API-Key: cgpt_xxxxx" -o jsonld_feed.jsonimport httpx
r = httpx.get(
"https://developer-catalog-devID.region.run.app/api/v1/catalog/feed/jsonld",
headers={"X-API-Key": "cgpt_xxxxx"},
)Get enriched product
GET /api/v1/catalog/product/{product_id}/enrichedFull enriched record for one product: base fields, the AI narrative, Q&A pairs, and the compiled ACP / JSON-LD feed records.
Response — 200 OK
{
"product_id": "SKU-1001",
"product": {
"name": "Relaxed-Fit Linen Button-Up",
"description": "A breezy linen button-up...",
"brand": "Cove & Coast",
"category": "tops",
"color": "sand",
"image_url": "https://cdn.example.com/linen.jpg",
"handle": "relaxed-fit-linen-button-up",
"price": "69.00",
"tags": "linen,summer",
"status": "active"
},
"narrative": {
"description": "Cut from breathable linen, this relaxed button-up...",
"model_used": "gemini-2.5-flash",
"generated_at": "2026-06-01 04:10:22+00:00"
},
"qa": [
{ "question": "Is it machine washable?", "answer": "Yes, cold wash...", "source": "ai", "display_order": 1 }
],
"feeds": {
"acp": { "...": "ACP object" },
"jsonld": { "...": "Schema.org object" },
"compiled_at": "2026-06-01 04:12:55+00:00"
}
}Errors
| Status | Response |
|---|---|
| 401 | { "detail": "Invalid API key" } |
| 404 | { "detail": "Product not found" } |
curl https://developer-catalog-devID.region.run.app/api/v1/catalog/product/SKU-1001/enriched \
-H "X-API-Key: cgpt_xxxxx"import httpx
r = httpx.get(
"https://developer-catalog-devID.region.run.app/api/v1/catalog/product/SKU-1001/enriched",
headers={"X-API-Key": "cgpt_xxxxx"},
)
print(r.json()["narrative"]["description"])Gateway API (/v1)
The discoverGPT gateway exposes a unified /v1/* surface authenticated with Authorization: Bearer <jwt> (see Authentication). All endpoints below require a valid token. The gateway is served at:
https://api.vairetail.comUnified surface overview
The complete /v1 surface covers all four capabilities:
| Endpoint | Method | Capability | Notes |
|---|---|---|---|
/v1/search | POST | Search | Trimodal search — text, image, color |
/v1/uploads/sign | POST | Search | Presigned upload URL for an image-search reference image |
/v1/ingest | POST | Platform | Push products into the canonical catalog |
/v1/enrich | POST | Data cleaning | Trigger enrichment for product IDs |
/v1/quality/overview | GET | Data cleaning | Catalog-quality overview |
/v1/quality/product/{product_id} | GET | Data cleaning | Per-product quality detail |
/v1/feed/status | GET | Feeds & GEO | Feed pipeline status |
/v1/feed/{feed_format} | GET | Feeds & GEO | Download jsonld / llmstxt / manifest |
/v1/feed/product/{product_id}/acp | GET | Feeds & GEO | Per-product ACP record |
/v1/visibility/score | GET | Feeds & GEO | AI-visibility score + per-platform breakdown |
/v1/visibility/products | GET | Feeds & GEO | Per-product AI-visibility status |
/v1/visibility/trends | GET | Feeds & GEO | AI-visibility trend data |
/v1/visibility/geo-recommendations | GET | Feeds & GEO | GEO gap recommendations |
/v1/vto/compose | POST | Virtual try-on | Virtual try-on image composition — usage-metered |
/v1/vto/metrics | GET | Virtual try-on | Try-on engagement & conversion metrics |
/v1/billing/checkout-session | POST | Platform | Start a Stripe checkout |
/v1/billing/portal-session | POST | Platform | Open the Stripe billing portal |
/v1/checkout | POST | Platform | Agentic checkout — Preview |
/v1/checkout/{checkout_id} | GET | Platform | Checkout status — Preview |
/mcp | SSE | Platform | MCP transport |
The search, ingest, enrich, and visibility endpoints map to the same underlying services described in the Search and Feeds & GEO sections — the gateway adds the OAuth layer and a unified host. The sections below document the billing, feed, quality, virtual try-on, checkout, and MCP surfaces in detail.
Billing
Billing endpoints let you initiate Stripe subscription flows on behalf of a user. All require Authorization: Bearer <jwt>.
Start a checkout session
POST /v1/billing/checkout-sessionCreates a Stripe Checkout session for the specified tier and billing interval. Returns a redirect URL that you open in the user's browser.
Request body (CheckoutSessionBody):
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
tier | string | yes | — | Tier name — case-sensitive: Growth or Pro. (Starter is free — no checkout needed; Platinum is sales-led — contact us.) |
interval | string | no | month | Billing interval: month or year |
Response — 200 OK:
{ "url": "https://checkout.stripe.com/pay/cs_live_..." }Example:
curl -X POST https://api.vairetail.com/v1/billing/checkout-session \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{ "tier": "Growth", "interval": "month" }'Redirect the user to the returned url to complete payment. On success, Stripe redirects back to your configured return URL.
Open the billing portal
POST /v1/billing/portal-sessionCreates a Stripe Customer Portal session where the customer can view invoices, update payment methods, and manage their subscription.
Response — 200 OK:
{ "url": "https://billing.stripe.com/session/bps_live_..." }Example:
curl -X POST https://api.vairetail.com/v1/billing/portal-session \
-H "Authorization: Bearer $TOKEN"Stripe webhook — not for partner calls
POST /stripe/webhook handles Stripe-to-platform event sync (subscription updates, payment failures, etc.). It is signature-verified and intended only for Stripe's servers, not for your integration code.
Feed
Feed endpoints expose discoverGPT's feed output via the gateway. All require Authorization: Bearer <jwt>. For the Shopify-authenticated feed downloads (/api/v1/feeds/*) see Feed downloads (Shopify).
Feed status
GET /v1/feed/statusReturns feed pipeline status — last compiled/pushed timestamps, issue count, and enabled formats.
Example:
curl https://api.vairetail.com/v1/feed/status \
-H "Authorization: Bearer $TOKEN"Plausible response shape (full schema is in the OpenAPI spec):
{
"total_products": 1240,
"last_compiled": "2026-06-20 04:12:55+00:00",
"acp_enabled": true,
"jsonld_enabled": true,
"unresolved_issues": 7
}Download a feed
GET /v1/feed/{feed_format}Downloads the compiled feed in the given format. Used by the MCP get_product_details and get_merchant_brand_context tools internally.
| Path param | Values |
|---|---|
feed_format | jsonld, llmstxt, or manifest |
Example:
curl https://api.vairetail.com/v1/feed/jsonld \
-H "Authorization: Bearer $TOKEN" \
-o jsonld_feed.jsonPer-product ACP record
GET /v1/feed/product/{product_id}/acpReturns the Agentic Commerce Protocol (ACP) record for a single product from the merchant catalog feed.
Example:
curl https://api.vairetail.com/v1/feed/product/SKU-1001/acp \
-H "Authorization: Bearer $TOKEN"Quality
Quality endpoints expose discoverGPT's data-quality assessments via the gateway. All require Authorization: Bearer <jwt>.
Catalog quality overview
GET /v1/quality/overviewReturns catalog-wide quality metrics: status counts, average scores, and quality-band distribution across all products.
Example:
curl https://api.vairetail.com/v1/quality/overview \
-H "Authorization: Bearer $TOKEN"Plausible response shape:
{
"total_products": 1240,
"avg_score": 0.74,
"band_distribution": { "excellent": 410, "good": 520, "fair": 230, "poor": 80 },
"status_counts": { "clean": 960, "pending": 180, "rejected": 100 }
}Per-product quality detail
GET /v1/quality/product/{product_id}Returns before/after content, scores, and synthesized quality issues for a single product.
Example:
curl https://api.vairetail.com/v1/quality/product/SKU-1001 \
-H "Authorization: Bearer $TOKEN"Plausible response shape:
{
"product_id": "SKU-1001",
"original": { "title": "BLUE DRESS", "description": "nice dress" },
"clean": { "title": "Women's Blue Summer Midi Dress", "description": "A breezy blue midi dress..." },
"scores": { "title": 0.82, "description": 0.71, "overall": 0.77 },
"status": "pending"
}Virtual Try-On
Virtual Try-On composes a shopper (or model) image wearing your garments — the
one usage-metered discoverGPT capability. Both endpoints require
Authorization: Bearer <jwt> and are scoped to the merchant in your token. The
gateway derives merchant_id from the JWT and bridges it to the Virtual Try-On service
internally; you never send a merchant ID or any Virtual Try-On credential yourself.
Metering — pay only for a live compose
A live (cache-miss) compose is metered at ~$0.18 and counts against your
plan's VTO allotment / prepaid credits. Cache hits and subject-only lookups are
free — repeating the same subject + garment combination returns the cached
image at no cost. A content-filtered result (blocked: true) is not billed.
Compose a try-on image
POST /v1/vto/composeGenerates an image of the subject wearing the supplied garment(s) and returns a
public URL to the composed image. Identify the subject and garments by their
catalog IDs; you can override the image for any slot with a *_url, or reference
a product *_handle. dress is mutually exclusive with top / bot.
Request body (ComposeRequest)
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
subject | string | yes | — | Model/avatar base-image ID (or URL) to dress. |
top | string | no | — | Top garment ID. |
bot | string | no | — | Bottom garment ID. |
dress | string | no | — | Dress garment ID (excludes top/bot). |
shoe | string | no | — | Shoe garment ID. |
top_url, bot_url, dress_url, shoe_url | string | no | — | Image-URL override for that slot. |
top_handle, bot_handle, dress_handle, shoe_handle | string | no | — | Product handle for that slot. |
source | string | no | — | Entry-point tag for try-on analytics: header, pdp, card, search, or standalone. |
refresh | boolean | no | false | Bypass the cache and force a fresh (metered) compose. |
The body is strict — unknown fields (including a merchant_id) are rejected with
422.
Response — 200 OK
{
"path": "https://storage.googleapis.com/.../vto/abc123.png",
"cached": false,
"latency_ms": 1234,
"calls_made": 1,
"source": "live",
"model": "gemini-3.1-flash-image-preview",
"credits_remaining": 9,
"credits_window": "2026-06-30"
}path— public URL of the composed image (always present).cached—truefor a free cache hit (thenlatency_msandcalls_madeare0);falsefor a metered live compose.source—"live"(metered), a cache source ("gemini-cache","matrix","outfit-cache"), or"subject"for a subject-only lookup.credits_remaining/credits_window— appended only on a metered live success.
A content-filtered garment returns 200 with { "blocked": true, "reason": "content_filtered", "message": "Try-on preview isn't available for this item." } (not billed). If Virtual Try-On is not enabled for the merchant, the gateway returns 403 { "detail": { "error": "entitlement_denied", "message": "..." } }.
Example
curl -X POST https://api.vairetail.com/v1/vto/compose \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{ "subject": "model-aria", "dress": "SKU-1001", "source": "pdp" }'import httpx
r = httpx.post(
"https://api.vairetail.com/v1/vto/compose",
headers={"Authorization": f"Bearer {token}"},
json={"subject": "model-aria", "dress": "SKU-1001", "source": "pdp"},
)
print(r.json()["path"], "cached:", r.json()["cached"])Try-on metrics
GET /v1/vto/metricsReturns try-on engagement for the merchant over a rolling window: try-on counts (live vs cached, distinct shoppers), a breakdown by entry point, the most tried-on products, and the try-on → add-to-cart conversion rate. Read-only and not metered.
Query parameters
| Param | Type | Default | Range | Description |
|---|---|---|---|---|
window_hours | integer | 720 | 1–8784 | Look-back window. ≤ 48 yields hourly buckets, otherwise daily. |
Response — 200 OK
{
"entitled": true,
"merchant_id": 12345,
"metrics": {
"windowHours": 720,
"granularity": "day",
"generated_at": "2026-06-30T12:00:00Z",
"tryOns": {
"total": 1840,
"distinctShoppers": 1120,
"live": 610,
"cached": 1230,
"byEntryPoint": { "header": 120, "pdp": 980, "card": 410, "search": 230, "standalone": 100, "unknown": 0 },
"byDay": [ { "date": "2026-06-01", "count": 62 } ]
},
"topProducts": [ { "product_id": "gid://shopify/Product/8412345678", "count": 240 } ],
"cart": {
"totalAdds": 410,
"distinctShoppers": 360,
"byEntryPoint": { "pdp": 300, "card": 110 },
"byDay": [ { "date": "2026-06-01", "count": 14 } ]
},
"conversion": { "shoppersTriedOn": 1120, "shoppersAddedToCart": 360, "tryOnToCartRate": 0.321 },
"checkout": { "status": "pending", "note": "Checkout is a redirect to Shopify-hosted checkout; order attribution needs a Shopify order webhook, tracked separately." }
}
}A merchant without Virtual Try-On enabled returns 200 { "entitled": false, "merchant_id": 12345 }. The checkout block is pending — try-on → order attribution is tracked separately via a Shopify order webhook.
Example
curl -G https://api.vairetail.com/v1/vto/metrics \
-H "Authorization: Bearer $TOKEN" \
--data-urlencode "window_hours=168"Checkout (Preview — not yet live)
Preview — not yet live
The checkout endpoints implement the Agentic Commerce Protocol (ACP) checkoutable-product flow. They are included in the API surface for forward-compatibility planning but are not yet live. Do not build production dependencies on these endpoints.
Create an agentic checkout
POST /v1/checkoutCreate an agentic checkout session for a product (ACP-compliant flow).
Request body (CreateCheckoutBody):
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
product_id | string | yes | — | The product to check out |
variant_id | string | no | — | Specific variant (optional) |
quantity | integer | no | 1 | Quantity (minimum 1) |
Example:
curl -X POST https://api.vairetail.com/v1/checkout \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{ "product_id": "SKU-1001", "quantity": 1 }'Get checkout status
GET /v1/checkout/{checkout_id}Returns the status of a checkout session created by POST /v1/checkout.
Example:
curl https://api.vairetail.com/v1/checkout/chk_abc123 \
-H "Authorization: Bearer $TOKEN"MCP transport
discoverGPT exposes an MCP (Model Context Protocol) transport over Server-Sent Events (SSE) at:
https://api.vairetail.com/mcp/sseMCP-aware hosts (Claude, ChatGPT with MCP support, Cursor, custom agents) connect to this endpoint and authorize once through a browser consent screen — there is no static token to paste or rotate (see Authorization below). The host then discovers the full tool set automatically. The server currently exposes 24 tools:
| Tool | What it does |
|---|---|
search_products_trimodal | Trimodal product search — text + image + color — over the merchant catalog |
browse_catalog | Facet-driven catalog browse (category, brand, price range, …) |
get_product_details | Composed discovery + transaction + JSON-LD detail for one product |
recommend_complementary_products | Complementary / cross-sell products for a given product |
get_merchant_brand_context | Brand voice + catalog manifest to ground downstream tool calls |
get_visibility_score | AI-visibility score + per-platform breakdown for the catalog |
get_visibility_products | Per-product AI-visibility status across platforms |
get_visibility_trends | AI-visibility trend data over a configurable lookback window |
get_geo_recommendations | Geo-gap recommendations from not-visible products across platforms |
get_feed_status | Feed pipeline status — last compiled/pushed, issue count, enabled formats |
get_product_acp | Per-product ACP (agentic-commerce) records from the catalog feed |
get_catalog_quality_overview | Catalog-quality overview — status counts, average scores, quality bands |
get_product_quality | Per-product quality — before/after content, scores, and issues |
create_checkout | Create an agentic checkout for a product (preview — not yet live) |
get_checkout_status | Status of a checkout session (preview — not yet live) |
virtual_try_on | Virtual try-on — dress a subject image in catalog garments (50 free per agency, then $0.18 pay-per-comp) |
get_catalog_feed | Export the full catalog feed in a format — jsonld / acp / gmc / perplexity / llmstxt |
get_vto_credits | The agency's virtual-try-on allowance — free used/remaining, prepaid credits, pay-per-comp |
get_vto_metrics | Virtual-try-on analytics over a configurable look-back window |
get_ucp_profile | UCP (Universal Commerce Protocol) discovery profile for the merchant |
ucp_catalog_search | UCP Catalog Search — spec-conformant product search over the catalog |
ucp_catalog_lookup | UCP Catalog Lookup — UCP Product records for a list of ids |
ucp_get_product | UCP Catalog Get Product — a single UCP Product record by id |
health | Liveness probe for the MCP transport |
query_image_url dominates
On search_products_trimodal, a non-null query_image_url selects
image-similarity search and ignores query_text / query_color in the same
call. Run separate calls to combine modes. See
Demo 2.
Authorization — connect once
The MCP endpoint uses the OAuth 2.1 "authorize once" flow from the MCP Authorization spec — not a static Bearer token. You never paste a client_secret for MCP: the host registers itself, opens a browser consent screen, and the connection refreshes itself from then on.
What happens on first connect:
- The host hits
/mcp/sse, gets a401pointing at the gateway's OAuth discovery metadata, and reads.well-known/oauth-authorization-server. - It registers itself automatically — OAuth Dynamic Client Registration (DCR); no pre-provisioned MCP client id.
- Your browser opens the consent screen at
developer.vairetail.com/authorize. You sign in, choose which merchant to grant access to, and approve. - The host exchanges the grant (PKCE) for an access token and a refresh token, then connects.
- When the short-lived access token expires, the host refreshes silently — you authorize once and stay connected.
One-command setup (Claude Code):
claude mcp add --transport sse discovergpt https://api.vairetail.com/mcp/sseThen run /mcp in Claude Code → discovergpt → Authenticate, and complete the browser consent (pick the demo merchant 98906636598 or one of your own). The tools appear and stay authorized. (If the server was just added, restart Claude Code so it loads before you authenticate.)
Generic MCP host config (Claude Desktop, Cursor, and other mcpServers hosts):
{
"mcpServers": {
"discovergpt": {
"transport": "sse",
"url": "https://api.vairetail.com/mcp/sse"
}
}
}No Authorization header or API key here — the host runs the browser OAuth flow above on first connect.
MCP vs. REST auth
MCP uses the authorize-once OAuth flow above (browser consent + silent refresh). Direct REST calls to /v1/* use the client-credentials grant with your client_id / client_secret — see Authentication. Both bill through the same account, tiers, and quotas, and both can use the demo merchant 98906636598.
Demos
Three copy-paste POCs you can run today against the shared demo catalog —
no catalog upload, no production access required. Every new account is
auto-authorized for demo merchant 98906636598 (read-only, real products),
so these calls return live results on your first request.
Try it live: the interactive search demo runs real
search_products_trimodalqueries against the shared demo catalog — type words, pick a precise color, or paste an image URL and get live product results (no token needed). A self-contained Artifact version shows the same widget as a shareable, offline preview.
What you need
A dev client_id / client_secret (see Authentication)
and a 15-minute access token scoped to the demo merchant:
curl -s -X POST https://api.vairetail.com/oauth/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d grant_type=client_credentials \
-d "client_id=$CLIENT_ID" \
-d "client_secret=$CLIENT_SECRET" \
-d "scope=merchant:98906636598" | jq -r .access_token| Demo | What it shows | Transport |
|---|---|---|
| 1 — NL shopping agent | An agent that answers shopping questions over your catalog | MCP (agent-callable) |
| 2 — Image similarity | "Find products like this photo" | MCP + presigned upload, or direct REST |
| 3 — Search widget | A drop-in trimodal search box for a storefront | Starter-kit React component |
Demo 1 — Natural-language shopping agent (MCP)
Point any MCP-aware host at discoverGPT and it discovers ready-to-use catalog tools — no glue code. This demo uses Claude Desktop; the same tools work in any MCP host.
Setup — add discoverGPT to claude_desktop_config.json
(~/Library/Application Support/Claude/ on macOS):
{
"mcpServers": {
"discovergpt": {
"url": "https://api.vairetail.com/mcp/sse",
"transport": "sse"
}
}
}Restart Claude Desktop. On first use it opens the browser consent screen —
sign in, pick the demo merchant 98906636598, and approve (the
authorize-once flow; no token to paste).
tools/list then surfaces search_products_trimodal, browse_catalog,
get_product_details, recommend_complementary_products, and
get_merchant_brand_context.
Example prompts to build the agent's behavior
- "Use
search_products_trimodalto find linen dresses under $100. Show the first three as a table with name, price, and product_id." - "For that first product, call
get_product_detailsand tell me the material and whether it's machine washable." - "Call
recommend_complementary_productson that product_id — what pairs with it?" - "Ground yourself first: call
get_merchant_brand_context, then recommend an outfit in the brand's voice."
The model invokes the tools and renders the discovery field group — the same
UCP + Schema.org product records the Search API returns. (A
parallel transaction field group for agentic checkout is a
preview capability, not live yet.)
Auth modes supported
The MCP connection authorizes once via browser consent — no token to paste (see MCP transport). The direct REST calls in Demo 2 and the widget in Demo 3 use a short-lived Bearer JWT from the client-credentials grant.
Demo 2 — Image-similarity search
"Find products like this photo." Two ways to run it.
2a — Agent / MCP path. First turn a local file into a public URL with the
presigned-upload endpoint, then pass that URL as query_image_url.
TOKEN=<15-min token>
# 1. get a presigned PUT URL
SIGN=$(curl -s -X POST https://api.vairetail.com/v1/uploads/sign \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{"content_type":"image/jpeg","filename":"ref.jpg"}')
UPLOAD_URL=$(echo "$SIGN" | jq -r .upload_url)
PUBLIC_URL=$(echo "$SIGN" | jq -r .public_url)
# 2. upload the bytes straight to GCS (never touches our server)
curl -s -X PUT "$UPLOAD_URL" -H "Content-Type: image/jpeg" --data-binary @ref.jpg
# 3. search by image
curl -s -X POST https://api.vairetail.com/v1/search \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d "{\"query_image_url\":\"$PUBLIC_URL\",\"limit\":8}" | jq '.discovery'In an agent, the same query_image_url goes straight into
search_products_trimodal.
Image search does not combine with text or color
When query_image_url is present it drives the search on its own — any
query_text or query_color in the same call is ignored (image dominates).
discoverGPT does not fuse image + text in a single request. To combine intents,
run the modes as separate calls and merge the results client-side.
2b — Direct REST POC (server-side, not agent-callable). If you already have the image bytes server-side, the legacy multipart endpoint skips the upload step entirely:
curl -s -X POST https://developer-search-devID.region.run.app/api/v1/product/search/image \
-H "X-Merchant-ID: 98906636598" \
-F "file=@ref.jpg" -F "limit=8" | jq '.results'This is a different mechanism — a direct product-service call using the
X-Merchant-ID header, not the gateway Bearer token, and not exposed as an MCP
tool. Use it for quick server-side POCs; use 2a for anything agent-facing.
Demo 3 — Drop-in search widget
The starter kit ships a React <TrimodalSearch /> component that renders a
text + color + image search box and calls the MCP tools for you.
Setup (clone → 3 env vars → running in ~15 min):
git clone <your fork of vairetail-app-starter-kit>
cd vairetail-app-starter-kit
cp .env.example .env
# set:
# VITE_MERCHANT_ID=98906636598
# VITE_AGENCY_CLIENT_ID=<your client_id>
# VITE_MCP_ENDPOINT_URL=https://api.vairetail.com/mcp/sse
# VITE_PRESIGNED_UPLOAD_URL=https://api.vairetail.com/v1/uploads/sign
# VITE_DEV_BEARER_TOKEN=<15-min token, dev only>
npm install
npm run devOpen http://localhost:5173. Type a query, optionally pick a color or upload a
reference image — the image upload uses the same /v1/uploads/sign flow as
Demo 2, so the same "image dominates" rule
applies.
Where the component lives
<TrimodalSearch /> is exported from @vairetail/apps-sdk-iframe; the starter
kit wires it into an Apps-SDK-compatible iframe. Customize the stubs in
src/components/ freely.
Terms of Service
Pre-GA draft
These terms are published ahead of discoverGPT general availability and may be updated before launch. The version in effect at the time you access the API governs your use. Material changes will be announced in the Changelog.
Effective date: 2026-06-26
These Terms of Service ("Terms") govern access to and use of the discoverGPT API, MCP transport, dashboards, SDKs, sample code, and related services (collectively, the "Services") provided by visualAI retail solutions, inc, a California corporation ("visualAI", "we", "us"). By requesting credentials, calling the Services, or otherwise accessing them, you and the organization you represent ("you", "Partner") agree to these Terms. If you do not agree, do not use the Services.
1. Accounts and credentials
Credentials (client_id / client_secret, Bearer tokens, and any legacy X-Merchant-ID access) are issued per agency partner during onboarding. You are responsible for keeping credentials confidential, for all activity under your account, and for the acts and omissions of every developer, store, and end user you grant access to. Notify enterprise@vairetail.com immediately if you suspect credentials have been compromised. One billing account is intended to represent one agency or organization; provisioning multiple accounts to split usage and evade tier limits is prohibited (see Section 4).
2. License grant
Subject to your compliance with these Terms and payment of applicable fees, visualAI grants you a limited, non-exclusive, non-transferable, non-sublicensable, revocable license to access the Services and to integrate their outputs into your own applications and into the storefronts of merchants you are authorized to act for. All rights not expressly granted are reserved by visualAI.
3. Ownership and data rights
- Your catalog data. As between the parties, you and your merchants retain all rights to the product catalog data you submit ("Partner Data"). You grant visualAI a worldwide license to host, process, and transform Partner Data solely to provide, secure, and support the Services.
- The engine. visualAI retains all right, title, and interest in the Services, including the underlying models, embeddings, rankings, indexes, software, and documentation, and in all improvements to them. The Services are licensed, not sold.
- Outputs. You may use search results, recommendations, feeds, enriched fields, and composed images returned by the Services ("Outputs") for the authorized merchant storefronts and applications described in Section 2. visualAI retains all intellectual property rights in the Services that produce those Outputs.
- Usage data. visualAI may collect and use aggregated, de-identified usage and performance data (for example, query volumes, latencies, and relevance signals) to operate, secure, and improve the Services. visualAI will not publish Partner Data or merchant-identifying information.
4. Fees, tiers, and metering
Access is gated by aggregate SKUs at the billing-account level and by the plan tiers described in Plans & Pricing. Virtual Try-On compose calls are usage-metered. You agree not to circumvent, disable, or artificially inflate or split SKU counts, rate limits, quotas, or fair-use caps. We may meter usage, throttle, or require an upgrade when limits are exceeded. Fees are non-refundable except where required by law.
5. Acceptable use and restrictions
Your use of the Services is subject to the Acceptable Use & Restrictions section below, which is incorporated into these Terms by reference. Violation of that section is a material breach.
6. Suspension and termination
We may suspend or terminate access, with or without notice, if you breach these Terms, if your use poses a security, legal, or operational risk, or for non-payment. You may stop using the Services at any time. On termination, the license in Section 2 ends and you must cease using the Services and Outputs, except that Outputs already delivered to and displayed on an authorized merchant storefront may remain in place. Sections 3, 5, 7, 8, and 9 survive termination.
7. Confidentiality
Non-public information disclosed in connection with the Services, including credentials, pricing, and unreleased features, is confidential and may be used only to exercise rights under these Terms.
8. Disclaimers and limitation of liability
The Services are provided "as is" and "as available", without warranties of any kind, express or implied, including merchantability, fitness for a particular purpose, and non-infringement. visualAI does not warrant that the Services will be uninterrupted, error-free, or that Outputs will be accurate or complete. To the maximum extent permitted by law, visualAI's aggregate liability arising out of or related to the Services is limited to the fees you paid in the three months preceding the claim, and visualAI is not liable for indirect, incidental, special, consequential, or exemplary damages.
9. Governing law and changes
These Terms are governed by the laws of the State of California, without regard to conflict-of-laws rules. We may update these Terms; the version in effect when you access the Services governs, and material changes will be posted to the Changelog. Questions: enterprise@vairetail.com.
Acceptable Use & Restrictions
This Acceptable Use & Restrictions section is part of the Terms of Service. It defines what you may and may not do with the discoverGPT API, the MCP transport, and any Outputs.
Permitted use
The Services are built for agencies and developers to embed visualAI's capabilities into their own applications and into the storefronts of merchants they are authorized to serve. You may:
- Integrate search, discovery, recommendations, enrichment, feeds, GEO, and virtual try-on into merchant storefronts and your own merchant-facing tooling.
- Build AI commerce and merchandising agents on top of the REST API and MCP tools for those merchants.
- Operate across unlimited stores and seats within your tier, subject to the aggregate SKU gate.
Prohibited use
You may not, directly or indirectly, and may not permit any third party to:
- Build or operate a competing service. Use the Services, the MCP tools, or any Outputs to develop, train, market, or operate a product or service that competes with discoverGPT or any visualAI capability, including visual or image search, product discovery, catalog data enrichment, AI-discovery or agent feeds, AI-visibility / GEO, or virtual try-on.
- Resell or white-label the engine. Resell, sublicense, rent, lease, time-share, or otherwise make the Services or their Outputs available to third parties as a standalone search, discovery, enrichment, feed, or try-on service, or expose the Services under your own or a third party's brand as a competing API or widget. Embedding Outputs into an authorized merchant's storefront is permitted; offering the engine itself to others as a service is not.
- Train competing models. Use Outputs, embeddings, vectors, rankings, or responses to train, fine-tune, distill, or benchmark any machine-learning model intended to replicate or compete with the Services.
- Extract or reconstruct the system. Scrape, bulk-export, harvest, or reconstruct the catalog index, embeddings, ranking logic, or model behavior; or reverse engineer, decompile, or disassemble any part of the Services, except to the limited extent that restriction is prohibited by applicable law.
- Circumvent limits. Evade or attempt to evade SKU tiers, rate limits, quotas, fair-use caps, or authentication, including by registering multiple accounts to split or disguise usage.
- Misuse or abuse the Services. Submit unlawful, infringing, or rights-violating content; probe or breach security; introduce malware; or use the Services in violation of applicable law or third-party rights.
High-volume, reseller, and platform use
Reseller, white-label, OEM, platform-embedding, and other high-volume arrangements are available only under a separate written agreement (the sales-led Platinum tier). visualAI reviews such use cases individually and may approve, condition, or decline provisioning at its discretion. Self-serve and standard-tier credentials do not grant reseller or white-label rights, and continued self-serve use does not create them by default. To discuss a reseller or platform arrangement, contact enterprise@vairetail.com.
Agency use vs. reselling the engine
The line is simple: using discoverGPT to power the storefronts and applications of merchants you serve is the intended use. Repackaging discoverGPT's search, discovery, enrichment, feeds, or try-on as your own competing service for third parties is not, and requires a separate written agreement.
Enforcement
We may investigate suspected violations and may suspend or terminate access under Section 6 of the Terms of Service. We may also require you to certify compliance with this section as a condition of continued or expanded access.
Changelog
2026-07-17 — UCP Catalog; GMC feed retired
- Added the UCP (Universal Commerce Protocol) Catalog
query API — a per-merchant discovery profile (
GET /.well-known/ucp/{merchant_id}) plussearch/lookup/productendpoints. Unlike the downloadable feeds, agents query UCP directly rather than fetching a file. - Retired the GMC (Google Merchant Center) feed. Shopify's native Google &
YouTube sales channel syncs products to Merchant Center directly, so the feed
was redundant.
GET /api/v1/feeds/gmcandformat=gmcare removed, and feed completeness now scores ACP + JSON-LD. catalogGPT's focus is the AI/agentic formats (ACP, JSON-LD, UCP, Perplexity, llms.txt).
2026-06-30 — Virtual Try-On, more catalogGPT feeds & synonyms
- Documented studioGPT Virtual Try-On on the
gateway —
POST /v1/vto/compose(usage-metered image composition; cache hits and subject-only lookups are free) andGET /v1/vto/metrics(try-on engagement & conversion). Replaces the earlier "coming soon" placeholders. - Expanded catalogGPT feeds to all five formats — added the
perplexityandllmstxtdownloads alongside ACP, JSON-LD, and GMC. - Added catalogGPT visibility issues, visibility readiness, BYO visibility keys (ChatGPT / Gemini / Perplexity), and the paginated health issues list.
- Added shopperGPT synonyms generate, preview, validate-a-pair, and quality report.
2026-06-26 — Terms of Service & Acceptable Use
- Published the Terms of Service and Acceptable Use & Restrictions, covering license scope, data and engine ownership, tier/metering anti-circumvention, the no-competing-service and no-white-label-resale restrictions, and the sales-led path for reseller / platform use.
2026-06-24 — discoverGPT reframe + Gateway API (/v1)
- Reframed docs as discoverGPT platform docs (four capabilities: shopperGPT, cleanerGPT, catalogGPT, studioGPT).
- Added Introduction with full value proposition, what's included at every tier, and pricing table (Starter/Growth/Pro/Platinum).
- Rewrote Authentication — OAuth 2.0 client-credentials is now the primary method; legacy
X-Merchant-IDpreserved as "Direct product API (legacy)". - Rewrote Quickstart around the OAuth token →
POST /v1/searchflow; legacy quickstart retained as a sub-section. - Added Gateway API (/v1) section: Billing (checkout-session, portal-session), Feed (status, download, per-product ACP), Quality overview + per-product, Checkout Preview, and MCP transport at
/mcp.
2026-06-22 — Visibility, Analytics, Synonyms & more
- Documented catalogGPT AI Visibility and GEO — catalog visibility score, per-product visibility, and trends across AI assistants — plus feed-health detail (summary, per-product health, and enrichment quality).
- Added shopperGPT Synonyms API, Analytics API, A/B Testing, and Example Searches.
- Documented two more Utilities endpoints — demo queries and trending search lookups.
2026-06-03 — cleanerGPT & catalogGPT APIs
- Added a Platform overview and dedicated cleanerGPT API and catalogGPT API references.
- Documented the cleanerGPT review workflow — overview, products, approve, reject, edit, undo, push, push status/history, rollback, and config.
- Documented catalogGPT feed downloads (ACP, JSON-LD, GMC) and the API-key-authenticated Public API (generate-key, ingest, feed, enriched).
2026-04-10 — Initial public docs
- Launched
docs.vairetail.com. - Documented 20 in-service endpoints across Search, User, Pipeline, Merchant, and Utilities.
- Published the canonical Data Model and accepted CSV / NDJSON formats.
- Shipped the client-side Column Mapper.
Coming soon
- Per-request outcome selection for catalog feed compile and AI visibility
(GEO) on
POST /v1/ingest— natural-language search, color search, image search, clean data, generate narratives, and generate Q&A are all selectable today. - API key authentication alongside
X-Merchant-ID. - Non-Shopify merchant onboarding self-service.
Support
enterprise@vairetail.com — integration questions, onboarding, API keys, bug reports.
When reporting an issue, include:
- The
X-Request-IDheader from the failing response (every response includes one). - The full request body you sent (with PII redacted).
- The response status code and body.
- Your merchant ID.
Marketing site
vairetail.com — product overview, case studies, and scheduling a demo.