Syncing a Shopify catalog to Google Merchant Center is a four-stage pipeline: pull products and variants from the Shopify Admin GraphQL API, map each field to a Google product attribute, transform the result into a Content API product resource, and push it to Merchant Center. Shopify webhooks then keep prices and inventory fresh, and the productstatuses endpoint surfaces disapprovals to fix.
This is a build tutorial. For the conceptual model behind these APIs, start with the Shopify Product Catalog API guide, and keep the query cookbook open for copy-paste GraphQL. If you are still deciding between a managed feed and the API, read Product Feed API vs CSV first.
1. Pull the catalog from Shopify
Read products and their variants from the Admin GraphQL API. For small catalogs, paginate the products connection with a cursor. Pull every attribute Google will need in one pass.
query CatalogExport($cursor: String) {
products(first: 100, after: $cursor, sortKey: UPDATED_AT) {
pageInfo { hasNextPage endCursor }
nodes {
id
title
descriptionHtml
handle
status
vendor
onlineStoreUrl
featuredImage { url }
variants(first: 100) {
nodes {
id
sku
barcode # GTIN source
price
availableForSale
inventoryQuantity
}
}
}
}
}
Loop until hasNextPage is false, passing the previous endCursor as after. For catalogs larger than a few thousand products, switch to the Bulk Operations API so you do not pay the per-query cost of synchronous pagination:
mutation {
bulkOperationRunQuery(
query: """
{
products {
edges { node {
id title descriptionHtml handle status vendor onlineStoreUrl
featuredImage { url }
variants { edges { node { id sku barcode price availableForSale } } }
} }
}
}
"""
) {
bulkOperation { id status }
userErrors { field message }
}
}
Poll currentBulkOperation until status is COMPLETED, then download the JSONL result file from the returned url. Each line is one node — products and nested variants arrive as separate records linked by __parentId, which you stitch back together client-side.
2. Map Shopify fields to Google Merchant Center attributes
Google matches and ranks your items on a fixed set of product attributes. Here is the core mapping. Submit one item per purchasable variant, not one per product.
| Google attribute | Shopify source | Notes |
|---|---|---|
id | variant.id (or SKU) | Unique and stable per variant |
title | product.title | Append variant option (e.g. size) if it differs |
description | product.descriptionHtml | Strip HTML to plain text |
link | product.onlineStoreUrl | Must be a live, crawlable URL |
image_link | product.featuredImage.url | Use a variant image when one exists |
price | variant.price | Include currency, e.g. 49.99 USD |
availability | variant.availableForSale | in_stock or out_of_stock |
gtin | variant.barcode | The product identifier — see below |
brand | product.vendor | Required unless a valid GTIN is present |
condition | constant new | Override for refurbished/used catalogs |
itemGroupId | product.id | Groups variants of the same product |
mpn | variant.sku | Fallback identifier when no GTIN |
GTIN is the highest-leverage field for Shopping performance. It is a global, unique identifier Google uses to match your item to its catalog, unlock price benchmarks, and improve placement quality — which lifts impressions and click-through. Store it in Shopify as the variant barcode and map it to gtin. If a variant truly has no barcode, supply brand + mpn, or set identifierExists to false for genuinely custom or handmade goods. Do not invent fake barcodes; invalid GTINs cause disapprovals.
3. Transform into a Content API product resource
Build the JSON the Content API for Shopping expects. Each variant becomes one product resource:
function toGoogleProduct(product, variant) {
return {
offerId: variant.id, // becomes the item id
title: product.title,
description: stripHtml(product.descriptionHtml),
link: product.onlineStoreUrl,
imageLink: product.featuredImage?.url,
contentLanguage: "en",
targetCountry: "US",
feedLabel: "US",
channel: "online",
availability: variant.availableForSale ? "in stock" : "out of stock",
condition: "new",
brand: product.vendor,
itemGroupId: product.id,
price: { value: variant.price, currency: "USD" },
// Identifier: prefer GTIN, fall back to brand+mpn
...(variant.barcode
? { gtin: variant.barcode }
: { mpn: variant.sku, identifierExists: !!product.vendor }),
};
}
stripHtml should turn descriptionHtml into clean plain text — Google rejects markup in description. Keep contentLanguage, targetCountry, and channel consistent with how the product is configured in Merchant Center, or inserts will be rejected.
4. Push via the Content API
For one-off updates, call products.insert (insert is also the update path — re-inserting the same offerId overwrites it):
await content.products.insert({
merchantId: MERCHANT_ID,
requestBody: toGoogleProduct(product, variant),
});
For a full sync, batch up to 1,000 entries per call with products.custombatch to stay inside quotas and cut round-trips:
const entries = variants.map((v, i) => ({
batchId: i,
merchantId: MERCHANT_ID,
method: "insert",
product: toGoogleProduct(v.product, v.variant),
}));
const { data } = await content.products.custombatch({ requestBody: { entries } });
for (const r of data.entries) {
if (r.errors) console.error(`offer ${r.batchId} failed`, r.errors.errors);
}
Authenticate with a Google service account that has Standard access to the Merchant Center account. Chunk your entries array into groups of 1,000 and send them sequentially or with light concurrency.
5. Keep it fresh with Shopify webhooks
A nightly full sync is a backstop, not the primary mechanism. Subscribe to Shopify webhooks and push only what changed, so prices and stock in Merchant Center stay within minutes of your store.
// Express handler — verify HMAC first (omitted for brevity)
app.post("/webhooks/products-update", async (req, res) => {
res.sendStatus(200); // ack fast, then process async
const product = req.body;
for (const variant of product.variants) {
await content.products.insert({
merchantId: MERCHANT_ID,
requestBody: toGoogleProduct(product, variant),
});
}
});
Subscribe at minimum to:
products/update— title, description, image, price, status changes.inventory_levels/update— flipavailabilitybetween in/out of stock. Look up the affected variant from the inventory item ID, then re-insert just that item.products/delete— callproducts.deleteso removed SKUs stop serving ads.
Always return 200 immediately and do the Google call on a queue; webhook endpoints that are slow or error out get throttled and eventually removed by Shopify.
6. Validate and handle errors
Insertion success does not mean the item is serving. Poll the productstatuses resource to read item-level approval and disapproval reasons:
const { data } = await content.productstatuses.get({
merchantId: MERCHANT_ID,
productId: `online:en:US:${variant.id}`,
});
data.itemLevelIssues?.forEach((issue) =>
console.warn(issue.attributeName, issue.description, issue.resolution)
);
Common fixes:
- Missing required attributes — most disapprovals are an absent
gtin/identifier,brand,image_link, or a non-crawlablelink. Backfill at the mapping step. - Price/availability mismatch — Google crawls your landing page; the price and stock there must match the feed. Keep webhooks current.
- Invalid identifier — a malformed barcode. Validate GTIN length/checksum before mapping, or drop to
brand+mpn.
Retry transient 5xx and quota errors with exponential backoff; log permanent 4xx validation errors for a human. Track a per-SKU sync state so a failed item gets retried on the next run instead of silently disappearing from your ads.
Next steps
You now have an end-to-end pipeline: GraphQL export, attribute mapping, Content API push, webhook-driven freshness, and disapproval handling. Harden it with a job queue, GTIN validation, and a daily reconciliation full-sync as a safety net. Then connect it to demand — see how an automated catalog feeds profitable campaigns in the Shopify AI advertising guide, and revisit the query cookbook when you extend the export to price lists or multiple markets.