ADSX
JUNE 20, 2026 // UPDATED JUN 20, 2026

Sync Shopify Catalog to Meta Product Catalog

A developer tutorial to sync your Shopify product catalog to a Meta product catalog for Advantage+ catalog ads, with Admin GraphQL and items_batch code.

AUTHOR
AE
AdsX Engineering
SHOPIFY API & COMMERCE ENGINEERING
READ TIME
7 MIN
SUMMARY

A developer tutorial to sync your Shopify product catalog to a Meta product catalog for Advantage+ catalog ads, with Admin GraphQL and items_batch code.

Sync a Shopify product catalog to a Meta product catalog by exporting products with the Shopify Admin GraphQL API, mapping each variant to Meta's catalog fields (id, availability, price, image_link), and delivering them either through the Graph API items_batch endpoint or a hosted feed. Keep it fresh with Shopify webhooks. Accurate identifiers and availability are what make Advantage+ catalog ads perform.

This is a step-by-step tutorial for the data pipeline behind Advantage+ catalog ads. For the conceptual model of Shopify's catalog APIs, start with the Shopify Product Catalog API guide, and keep the query cookbook open for copy-paste snippets. The same pipeline shape powers the Google Merchant Center sync — only the field names and delivery endpoint change.

1. Pull the catalog from Shopify

Shopify is your source of truth. For a small store you can paginate the products connection synchronously, but for any real catalog use the Bulk Operations API — it runs the query asynchronously and hands you a JSONL file with every product and variant, with no query-cost throttling.

mutation StartCatalogExport {
  bulkOperationRunQuery(
    query: """
    {
      products(query: "status:active") {
        edges {
          node {
            id
            title
            descriptionHtml
            vendor
            productType
            onlineStoreUrl
            featuredImage { url }
            images(first: 10) { edges { node { url } } }
            variants {
              edges {
                node {
                  id
                  sku
                  price
                  availableForSale
                  inventoryQuantity
                }
              }
            }
          }
        }
      }
    }
    """
  ) {
    bulkOperation { id status }
    userErrors { field message }
  }
}

Poll currentBulkOperation until status is COMPLETED, then download the url. Each line of the JSONL is one object; child rows (variants, images) carry a __parentId pointing back to the product, so you reassemble the tree client-side as you stream the file. Pin an explicit API version in your endpoint (for example /admin/api/2025-07/graphql.json).

2. Map Shopify fields to Meta catalog fields

A Meta catalog item is a flat record. The work is translating one Shopify variant into one Meta item and getting the required fields exactly right. id and availability matter most — they drive matching, attribution, and whether Meta will even serve the product.

Meta fieldSource (Shopify)Notes
idvariant id (numeric portion)Must match the content_id your Pixel/CAPI sends. Stable forever.
titleproduct titleUp to 200 chars; keep it human, not keyword-stuffed.
descriptiondescriptionHtml (stripped)Plain text; 5,000 char max.
availabilityavailableForSale / inventoryQuantityin stock or out of stock. Most performance-critical field.
conditionconstant or metafieldUsually new.
pricevariant price + currencyFormat "19.99 USD" — amount and ISO currency.
linkonlineStoreUrl (+ ?variant=)Deep link to the buyable variant.
image_linkfeaturedImage.urlHTTPS, publicly reachable, no auth.
brandvendorRequired for many verticals.
google_product_categorymapping from productTypeImproves targeting and policy classification.
additional_image_linkimages (up to 10)Comma-separated extra image URLs.
function toMetaItem(product, variant) {
  return {
    id: variant.id.split("/").pop(),
    title: product.title,
    description: stripHtml(product.descriptionHtml).slice(0, 5000),
    availability:
      variant.availableForSale && variant.inventoryQuantity > 0
        ? "in stock"
        : "out of stock",
    condition: "new",
    price: `${variant.price} ${product.currencyCode || "USD"}`,
    link: `${product.onlineStoreUrl}?variant=${variant.id.split("/").pop()}`,
    image_link: product.featuredImage?.url,
    additional_image_link: product.images
      .map((i) => i.url)
      .filter((u) => u !== product.featuredImage?.url)
      .slice(0, 10)
      .join(","),
    brand: product.vendor,
    google_product_category: mapCategory(product.productType),
  };
}

3. Two ways to deliver the catalog to Meta

You have two delivery mechanisms, and most mature setups use both.

(a) Graph API batch — /{catalog_id}/items_batch. You POST item records directly. Updates land in near real time, you get per-item error reporting, and it composes cleanly with webhook-driven updates. Use this as your steady-state pipeline.

async function uploadBatch(catalogId, items, accessToken) {
  const requests = items.map((item) => ({
    method: "UPDATE", // UPDATE upserts; use DELETE to remove
    retailer_id: item.id, // your stable identifier
    data: item,
  }));

  const res = await fetch(
    `https://graph.facebook.com/v21.0/${catalogId}/items_batch`,
    {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        access_token: accessToken,
        item_type: "PRODUCT_ITEM",
        requests,
      }),
    },
  );
  const json = await res.json();
  return json.handles; // poll these for status
}

Batch up to a few thousand requests per call; chunk larger catalogs and submit sequentially.

(b) Hosted product feed. You publish a CSV, TSV, or XML file at a stable URL and configure Meta Commerce Manager to fetch it on a schedule (hourly to daily). Choose this when you do not run a backend that can receive webhooks, when a non-engineer owns the catalog, or for the initial full load before switching to items_batch for incremental updates. Shopify's own Product Feed API can generate a feed, but a self-hosted file gives you full control over field mapping. The trade-off is freshness: a scheduled feed lags inventory by up to the fetch interval, which is why availability-sensitive stores prefer the batch endpoint.

4. Keep it fresh with Shopify webhooks

A daily feed is not good enough for availability. Subscribe to products/update and inventory_levels/update webhooks, then convert each event into an incremental items_batch call. This is the difference between paying for clicks on sold-out products and not.

// Express handler — verify HMAC first (omitted), then translate to a Meta update
app.post("/webhooks/shopify", async (req, res) => {
  const topic = req.get("X-Shopify-Topic");
  res.sendStatus(200); // ack fast, process async

  if (topic === "inventory_levels/update") {
    const { inventory_item_id, available } = req.body;
    const variant = await lookupVariantByInventoryItem(inventory_item_id);
    await uploadBatch(CATALOG_ID, [
      { id: variant.metaId, availability: available > 0 ? "in stock" : "out of stock" },
    ], TOKEN);
  }

  if (topic === "products/update") {
    const product = req.body;
    const items = product.variants.map((v) => toMetaItem(product, v));
    await uploadBatch(CATALOG_ID, items, TOKEN);
  }
});

Always return 200 immediately and do the Meta call on a queue — Shopify retries and eventually disables endpoints that respond slowly. Run a full reconciliation sync once or twice a day to catch any missed events.

5. Validate and handle errors and rejections

items_batch is asynchronous: a successful POST only means Meta accepted the request, not that every item passed validation. Poll the returned handle for item-level results.

async function checkBatch(catalogId, handle, accessToken) {
  const res = await fetch(
    `https://graph.facebook.com/v21.0/${catalogId}/check_batch_request_status` +
      `?handle=${handle}&access_token=${accessToken}`,
  );
  const { data } = await res.json();
  for (const result of data) {
    if (result.errors?.length) console.error(result.id, result.errors);
    if (result.warnings?.length) console.warn(result.id, result.warnings);
  }
}

The failures you will actually hit: missing required fields (brand, google_product_category), malformed price (amount and currency must both be present), image_link that returns a non-200 or is behind auth, and policy disapprovals that surface in Commerce Manager rather than the API response. Fix the underlying field, then re-submit only the affected ids — never re-push the whole catalog to clear a handful of errors. Log every rejection by id so you can tell a transient image-fetch failure from a persistent data problem.

The single highest-leverage habit: keep id stable and identical to your Pixel/Conversions API content_id, and treat availability and price as real-time fields. Everything Advantage+ does — product-set targeting, dynamic creative, attribution — depends on those three being correct.

Next steps

You now have a full pipeline: bulk export from Shopify, a field map, two delivery options, webhook-driven freshness, and validation. From here:

ABOUT THE AUTHOR
AE
AdsX Engineering
SHOPIFY API & COMMERCE ENGINEERING

The AdsX engineering team builds the data pipelines that turn a Shopify product catalog into high-performing ad feeds across Google, Meta, and AI shopping agents. We work hands-on with the Shopify Admin GraphQL API, the Product Feed and Catalog APIs, metafields, and bulk operations every day, and these guides document the patterns we use in production.

MORE BY ADSX ENGINEERING

Ready to Dominate AI Search?

Get your free AI visibility audit and see how your brand appears across ChatGPT, Claude, and more.

Get Your Free Audit