Integrate Shopify via the API

VeryQuery's API is a complete integration target for Shopify storefronts that don't fit the standard app install: headless storefronts (Hydrogen, Remix, Next.js), custom themes, agency builds, and any setup where you want to own the integration end-to-end. This guide walks the integration: connecting your catalog, running search, surfacing similar items, and feeding shopper signals back. Everything below is server-to-server; you own the storefront UI.

On a standard Shopify theme? Use our Shopify app instead. It does everything below as a managed install with no code, including catalog sync, storefront search, similar-items rows, smart categories, and signals capture. Reach for the API only when the app isn't a fit (headless, custom checkout, etc.).

How it fits together

The shape:

shopper's browser
      ↓  search input
your server  ←── ingest webhooks ──  Shopify
      ↓
VeryQuery API  ──→  back to shopper

Two flows live on your server.

Ingest
Shopify product webhooks, your server, POST to our items endpoint.
Search
Shopper query, your server, POST to our search endpoint, results back to the browser.

Why server-side: our API is intentionally CORS-restricted to our own origins. API keys must never reach a shopper's browser. Every call to VeryQuery happens from a server you control.

Set up your VeryQuery account

  1. Sign up at dashboard.veryquery.com.
  2. Create a property for the store you're integrating. The property is the data scope: items, queries, intelligence, and billing all attach to it.
  3. Generate an API key on the property's API keys page. Treat it like a password. Store it as an environment variable on your server (e.g. VERYQUERY_API_KEY).
  4. Note the property's id from the dashboard URL (the propertyId path segment). Most API calls include it: POST /v1/properties/{propertyId}/...

Sync your catalog

Shopify fires product webhooks (products/create, products/update, products/delete) whenever a product changes. Subscribe to those on your server, translate the Shopify product shape to VeryQuery's item shape, and POST to our items endpoint. Our items endpoint is batch-only: send up to 1000 items per call. Content-hash dedup means re-sending an unchanged item is a free no-op; you don't need to track which items have changed.

The item shape

An item upsert:

POST /v1/properties/{propertyId}/items
Authorization: Bearer vq_...
Content-Type: application/json

{
  "items": [
    {
      "itemId": "7422585307191",
      "title": "Lightweight Merino Crew",
      "description": "Soft 18.5 micron merino. Crew neck, regular fit.",
      "price": 14800,
      "regularPrice": 17500,
      "inStock": true,
      "category": "Sweaters",
      "brand": "Maison Véga",
      "tags": ["knitwear", "merino", "crew"],
      "handle": "lightweight-merino-crew",
      "images": [
        { "url": "https://cdn.shopify.com/s/files/1/.../crew.jpg" }
      ],
      "variantDimensions": [
        { "name": "Color", "values": ["Bone", "Navy", "Olive", "Charcoal"] },
        { "name": "Size",  "values": ["XS", "S", "M", "L", "XL", "XXL"] }
      ],
      "metadata": {
        "swatches": [
          { "name": "bone", "hex": "#E4DDC8" },
          { "name": "navy", "hex": "#13283F" }
        ]
      }
    }
  ]
}

itemId is your stable id for the item; we recommend the Shopify product id. title and description are required for new items; partial updates on existing items can omit them.

What's first-class vs. metadata

Most fields you'd need from a Shopify product map directly to first-class fields on the item. These are searchable, filterable, and surface on every search/similar response without you stuffing them anywhere special.

Shopify fieldVeryQuery fieldNotes
id (numeric tail of the GID)itemIdString. Use {{ product.id }} from theme templates.
titletitle
body_html / descriptionHtmldescriptionStrip HTML. Free-form text.
variants[0].pricepriceNumeric.
variants[0].compare_at_priceregularPriceThe reference price for sale display.
any variant in stockinStockBoolean.
product_typecategory
vendorbrand
tagstagsArray of strings.
handlehandleFor building /products/{handle} links.
images[].srcimages[].urlUp to 4 per product.
options[].{name, values}variantDimensions[].{name, values}Variant dimensions (Color, Size, Material, etc.). Up to 3 dimensions, up to 50 values per dimension. Skip Shopify's Title placeholder for non-variant products. Variant values feed into search ranking and become trait dimensions in your intelligence map.

The metadata bag is for anything that doesn't fit a first-class field but your storefront still needs at render time. It's opaque to us: we don't read it for ranking, don't index it, don't interpret its shape. We hand it back unchanged on every search and similar response.

Use it for things like swatches, badges, custom labels, or structured product attributes that your card design renders. Keep it small: it sits in the wire response on every search call.

Backfill

For the initial backfill, page through every product via Shopify's GraphQL Admin API. The products query takes first + after for cursor pagination and returns a pageInfo envelope:

POST https://{shop}.myshopify.com/admin/api/2026-04/graphql.json
X-Shopify-Access-Token: shpat_...
Content-Type: application/json

{
  "query": "query Products($first: Int!, $after: String) { products(first: $first, after: $after) { edges { cursor node { id title descriptionHtml handle vendor productType tags totalInventory featuredImage { url altText } images(first: 4) { edges { node { url } } } variants(first: 1) { edges { node { price compareAtPrice availableForSale } } } } } pageInfo { hasNextPage endCursor } } }",
  "variables": { "first": 250, "after": null }
}

Loop until pageInfo.hasNextPage is false, passing the previous page's endCursor as the next after value. Map each node per the table above and POST in batches of up to 1000 to our items endpoint. After backfill, the products/create, products/update, and products/delete webhooks keep things current.

Shopify's GraphQL product id is a GID (gid://shopify/Product/7422585307191). Store the numeric tail as your itemId so it matches what storefront templates expose via {{ product.id }}.

Deletes

Single delete on a products/delete webhook:

DELETE /v1/properties/{propertyId}/items/{itemId}
Authorization: Bearer vq_...

Or batch:

DELETE /v1/properties/{propertyId}/items
Authorization: Bearer vq_...
Content-Type: application/json

{ "itemIds": ["7422585307191", "7422585341959"] }

See docs.veryquery.com for the complete item shape, validation rules, and per-item accounting envelope returned by the batch endpoint.

Wire up search

From your storefront, send the shopper's query to your server, your server calls VeryQuery, you return the result list to the browser:

POST /v1/properties/{propertyId}/items/search
Authorization: Bearer vq_...
Content-Type: application/json

{
  "text": "merino sweater for cold mornings",
  "limit": 24,
  "sessionId": "anon-9f3..."
}

Response:

{
  "results": [
    {
      "itemId": "7422585307191",
      "title": "Lightweight Merino Crew",
      "price": 14800,
      "regularPrice": 17500,
      "inStock": true,
      "category": "Sweaters",
      "brand": "Maison Véga",
      "tags": ["knitwear", "merino", "crew"],
      "handle": "lightweight-merino-crew",
      "variantDimensions": [
        { "name": "Color", "values": ["Bone", "Charcoal", "Navy", "Olive"] },
        { "name": "Size",  "values": ["L", "M", "S", "XL", "XS", "XXL"] }
      ],
      "metadata": {
        "swatches": [...]
      }
    }
  ],
  "pagination": { "limit": 24, "offset": 0, "hasMore": true }
}

Items come back in ranking order. The per-result shape is intentionally lean: first-class filterable fields plus your metadata pass-through bag, byte-for-byte. description, images, and lifecycle status are intentionally omitted from search results. The expectation is that your storefront has its own product store keyed on itemId and hydrates display imagery and long-form copy from there. Stash anything else you need to render a card (image URLs, alt text, swatches, badges) in metadata at ingest time and it'll come back here on every search.

For the link to the product page, use handle: /products/{handle}.

How you render the results is your call: build cards in React, render Liquid server-side, return Hydrogen components, whatever fits your stack.

The endpoint also accepts filters (price range, in-stock, metadata equality) and offset for pagination. Pass sessionId on every call (anonymous, per-tab) so we can correlate searches with cart-adds and purchases for your intelligence map. See docs.veryquery.com for the full request body.

Show similar items on product pages

Per-item similarity through a dedicated endpoint. Useful for "you might also like," out-of-stock alternatives, or cart upsells. Pass a source item's itemId and we return a ranked list of catalog items closest to it.

POST /v1/properties/{propertyId}/items/{itemId}/similar
Authorization: Bearer vq_...
Content-Type: application/json

{
  "limit": 8,
  "filters": { "inStock": true }
}

Response shape mirrors search: sourceItemId echoes the input, results is a ranked list with the same per-item shape (itemId, title, price, regularPrice, inStock, category, brand, tags, handle, metadata). No pagination envelope: similar is one shot.

If you ingested items with itemId set to the Shopify product id (recommended), you can pass {{ product.id }} straight through. The filters object uses the same shape as search, so any filter-building logic reuses. limit defaults to 12, capped at 50.

Similar items is included with every plan and doesn't have its own meter; it draws on the same flat tier price as search.

Capture queries for the demand layer

VeryQuery's intelligence surface (catalog map, gap analysis, demand heatmaps) is driven by the stream of real shopper queries against your catalog. Two endpoints depending on your situation:

Live capture (you ran the search through another engine)

If your storefront serves search results from another provider but you want VeryQuery to receive the query as a demand signal, post one query at a time:

POST /v1/properties/{propertyId}/items/search/capture-only
Authorization: Bearer vq_...
Content-Type: application/json

{
  "query": "merino sweater for cold mornings",
  "sessionId": "anon-9f3...",
  "ts": "2026-04-30T19:42:11Z"
}

The field is query (single string), not text or an array. Captured as one search-query event, same as /items/search; counted toward fair-use, not billed per call. Use the same anonymous per-tab session id you use everywhere else; never send a customer identifier.

If you're already calling /items/search for results, you don't need this: every call to the search endpoint records the query automatically.

Bulk historical import

If you have past shopper queries from analytics exports, synthetic queries you want in the corpus, or any other batch source, send them as a one-shot import:

POST /v1/properties/{propertyId}/search-queries/bulk
Authorization: Bearer vq_...
Content-Type: application/json

{
  "queries": [
    { "text": "merino sweater for cold mornings", "occurredAt": "2026-04-15T14:32:05Z", "sessionId": "anon-9f3..." },
    { "text": "linen blazer 42R" },
    "navy boat shoes"
  ]
}

Each entry is either a plain string or an object with text (required), plus optional occurredAt, sessionId, userId, metadata. Imports are not metered; send what you have.

Send purchase and cart-add events

Order and cart-add signals sharpen relevance and feed the per-item "what shoppers do with this product" surface. Subscribe to Shopify's orders/paid webhook on your server and forward to:

POST /v1/properties/{propertyId}/events
Authorization: Bearer vq_...
Content-Type: application/json

{
  "events": [
    {
      "kind": "purchase",
      "orderId": "5678901234",
      "occurredAt": "2026-04-30T19:42:11Z",
      "items": [
        { "itemId": "7422585307191" },
        { "itemId": "7422585341959" }
      ],
      "sessionId": "anon-9f3..."
    },
    {
      "kind": "cart_add",
      "occurredAt": "2026-04-30T19:38:02Z",
      "items": [{ "itemId": "7422585307191" }],
      "sessionId": "anon-9f3..."
    }
  ]
}

Two kinds: purchase (one row in produces one row per item in items[], dedup'd by orderId) and cart_add (lower-confidence signal; server dedupes within a recent window when sessionId is provided). Pass the same anonymous session id you used at search and cart-add time so we can join queries to outcomes. Don't send shopper identity, prices, quantities, or anything financial. Events are free of charge.

Headless storefronts

The most natural fit. Your framework already has server-side route handlers; your search route calls VeryQuery server-side and returns rendered components or JSON to the browser. No CORS dance. Sample shape (Remix-style):

// app/routes/search.tsx
export async function loader({ request }) {
  const url = new URL(request.url);
  const text = url.searchParams.get("q");
  const res = await fetch(
    `${VERYQUERY}/v1/properties/${PROPERTY_ID}/items/search`,
    {
      method: "POST",
      headers: {
        "Authorization": `Bearer ${process.env.VERYQUERY_API_KEY}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ text, limit: 24, sessionId: getSessionId(request) }),
    },
  );
  return json(await res.json());
}

Catalog ingest webhook handlers live in the same app, behind whichever path you registered with Shopify (e.g. /webhooks/products).

Standard Shopify storefronts: use our app instead

If your storefront is a Shopify-hosted theme (Dawn, Sense, custom Online Store 2.0 theme, etc.), the simplest path is to install the official VeryQuery Shopify app. It does everything in this guide as a managed install: catalog sync via webhooks, storefront search as a theme app embed, similar items as a section block, smart categories as a section block, and signals capture as a separate embed. No server to host, no proxy to wire.

If you've already gone the API route and want to keep that architecture, you'll need to host a private Shopify app that declares an App Proxy route, since the storefront browser can't reach an arbitrary external server without CORS. Storefront calls go to {shop}.myshopify.com/apps/{your-handle}/search; Shopify proxies them to your server; your server calls VeryQuery; you return JSON or rendered HTML.

Authentication and rate limits

Every call carries the property API key in the Authorization: Bearer vq_... header (or the equivalent X-Api-Key). API keys are scoped to a single property; the URL's {propertyId} must match the key's property or you get a 403 PROPERTY_ID_MISMATCH.

Each response includes RateLimit-Limit, RateLimit-Remaining, and RateLimit-Reset headers. If you exceed your tier's per-property RPS limit you'll get 429 with a Retry-After hint. Tier limits are listed at veryquery.com/pricing.

Where to look next