GPT Ads
ChatGPT Ads

OpenAI product feed spec: Shopify field mapping

Which Shopify product field goes into which OpenAI product feed column — plus the price, brand, barcode and ID mismatches that get rows rejected.

By 8 min read

Two columns of tiles joined by crossing lines, one tile on the left left unconnected

OpenAI publishes a detailed product feed specification. Shopify has a detailed product model. The two mostly line up — and the places they don't are exactly where feeds get rejected, show the wrong price, or quietly drop products.

This is the field-by-field map, followed by the mismatches worth knowing about before you build anything.

Everything here comes from OpenAI's product feed specification and Shopify's Admin GraphQL reference. Where either source is silent, I say so.


Two sets of column names#

OpenAI accepts two schemas and picks one automatically when you upload:

  • The OpenAI schemaitem_id, url, image_url, group_id
  • The Google-compatible schemaid, link, image_link, item_group_id

The table below gives both. Use one set consistently throughout a file; OpenAI chooses a single parser per upload and does not switch between rows.


The mapping#

Shopify field names are from the Admin GraphQL API. "Variant" means ProductVariant; everything else is on Product.

OpenAI fieldGoogle-compatibleShopify sourceNotes
item_ididVariant legacyResourceIdOne row per variant. Must stay stable.
group_iditem_group_idProduct legacyResourceIdGroups variants of one product.
titletitletitle, or variant displayNameUp to 150 characters.
descriptiondescriptiondescriptionPlain text, up to 5,000 characters.
urllinkBuilt from handle + variant IDSee below.
image_urlimage_linkVariant media, else featuredMedia
additional_image_urlsadditional_image_linkOther product mediaComma-separated.
brandbrandvendorCheck it really is the brand.
pricepricecompareAtPrice or priceSee below — this is the big one.
sale_pricesale_priceVariant priceOnly when discounted.
availabilityavailabilityVariant availableForSale
gtingtinVariant barcodeOnly if it is a real GTIN.
product_categoryproduct_type / google_product_categorycategory.fullNameShopify Standard Product Taxonomy path.
color, sizecolor, sizeVariant selectedOptionsMatch on option name.
weightVariant inventoryItem.measurement.weightNot on the variant itself.
target_countriesThe market you are building forISO country code; first entry used.
is_ads_eligibleSet true for Ads feeds.

Columns Shopify has no native equivalent for — condition, star_rating, review_count — are covered further down.


Where Shopify and OpenAI disagree#

Price and compare-at price run in opposite directions#

This is the mapping most feeds get wrong. In Shopify, price is what the customer pays and compareAtPrice is the higher "was" price shown crossed out. In OpenAI's schema, price is the regular price and sale_price is the discounted one — and sale_price must be lower than price, or the row is rejected.

So when a variant is discounted, the fields swap:

Shopify variantFeed priceFeed sale_price
price 99.00, compareAtPrice 129.00129.00 USD99.00 USD
price 99.00, no compareAtPrice99.00 USD(leave empty)
price 99.00, compareAtPrice 99.0099.00 USD(leave empty)

Copy Shopify's price straight into the feed's price column on a discounted product and your ad shows the sale price as the full price — or, if you also copy compareAtPrice into sale_price, the row fails validation because the "sale" is higher than the price.

Feed prices use major units followed by a currency code: 79.99 USD, not 7999. (The Conversions API uses minor units; the feed does not. Mixing them up puts your prices out by a factor of a hundred.)

Vendor is not always the brand#

brand is required and holds up to 70 characters. The obvious source is Shopify's vendor — but plenty of stores use vendor for the supplier, the dropshipper, or their own store name. Look at what your vendor values actually contain before mapping them. If they are not brands, you need another source, such as a metafield.

A barcode is not always a GTIN#

Shopify's barcode field accepts anything. Many stores fill it with internal codes. OpenAI strips spaces and dashes and keeps the first valid GTIN, so an internal code is simply discarded.

The rule from the spec: provide a valid gtin or mpn unless you set identifier_exists to no — and only set it to no when the product genuinely has no identifier. Handmade and own-brand goods often qualify. Branded goods you resell usually do not.

The description is HTML#

descriptionHtml contains markup; the feed needs plain text. Shopify's description field already has the HTML removed and accepts a truncateAt argument, so request description(truncateAt: 5000) and you have a spec-compliant value in one step.

The URL has to be built#

Shopify's variant object has no URL field. Build it from the product handle and the variant's numeric ID, in the format Shopify documents:

https://yourstore.com/products/trail-shell?variant=39897499729985

Without the ?variant= parameter, every variant of a product links to the same page with the default option selected, so an ad for the blue medium lands on the red small.

Two products to leave out entirely: anything whose status is not ACTIVE, and anything where onlineStoreUrl is null — Shopify's documentation says that means the product "isn't published to the online store sales channel", so the link would be dead.

Never put credentials in a URL. A url or seller_url containing a username or password rejects the row outright.

Availability, and the backorder trap#

availableForSale maps cleanly: true becomes in_stock, false becomes out_of_stock.

The temptation is to map variants set to keep selling when out of stock — inventoryPolicy: CONTINUE with nothing on hand — to backorder. The spec requires availability_date whenever availability is backorder or pre-order. If you have no restock date to give, don't use backorder.

Images#

Use the variant's own media when it has one, and fall back to the product's featuredMedia. The older image and featuredImage fields are deprecated. Remaining product images go into additional_image_urls, comma-separated. A malformed optional image URL is dropped quietly; a malformed main image is not.


IDs, and why the CSV export will not do#

Use the variant's legacyResourceId as item_id. It is the numeric ID that appears in Shopify's variant URLs, it never changes, and it is unique per variant. SKU is the usual alternative, but SKUs can be empty, duplicated, or edited — and changing an item_id means OpenAI sees a new product and loses the old one's history.

Put the product's legacyResourceId in group_id so variants are grouped. Without it, OpenAI treats every variant as a separate single-item listing.

This is also why Shopify's built-in product CSV export cannot be turned into a feed with a bit of column renaming. Its current columns — URL handle, Description, Price, Compare-at price, Product image URL and so on — include no product ID, no variant ID and no product URL. Variant rows leave the title and description blank, and extra images take extra rows. Every one of those needs rebuilding before the file is usable. The Admin API has everything directly.


Fields Shopify does not have#

condition. Shopify has no condition field. The product CSV export does have a Google Shopping / Condition column if you have filled it in. Otherwise, for new goods, set new yourself.

star_rating and review_count. Not part of Shopify's product model — they live in whichever reviews app you use. Worth the effort: product ads display star ratings, so a feed without them competes against ads that have them.

mpn. Shopify's export carries a Google Shopping / Manufacturer part number (MPN) column. If you have been maintaining Google Shopping data, that is your source.

Custom labels. The export also has Google Shopping / Custom label 0 through Custom label 4. OpenAI's Google-compatible path reads custom_label_0 to custom_label_4 as optional filter columns — but confirm with your Ads setup which filter keys your account actually uses before relying on them. The ad group filter list in Ads Manager doesn't currently show them.


The eligibility flags#

OpenAI's own schema requires a few booleans that have nothing to do with Shopify data:

  • is_ads_eligible — set true on every product you want in ads. Use exactly this name; OpenAI's docs warn that feed ingestion "does not read is_ads_enabled".
  • is_eligible_checkout — leave false. Setting it true makes seller_privacy_policy and seller_tos mandatory, and Shopify checkout happens on your own store anyway.
  • is_eligible_search — required in the OpenAI schema. During the current beta, products uploaded through Ads Manager serve in ads only, not organic results, so this does not change what ads can do.

On the Google-compatible path you don't set these; eligibility comes from the feed's configuration in Ads Manager.


Selling in more than one currency#

Shopify's price and compareAtPrice are "in the default shop currency". If you run Shopify Markets, other countries' prices are not those numbers converted — they can come from price lists, rounding rules or fixed prices. Shopify's own guidance is blunt: don't try to compute international prices from base prices; load them explicitly.

Use the variant's contextualPricing(context: { country: DE }) to get the price for a specific country, and build one feed per market, each with its own target_countries value and a matching currency. OpenAI only reads the first entry in target_countries, and where a feed has market-specific processing configured, it rejects products priced in a currency that feed isn't set up for — so a single multi-currency file is working against the spec.


A worked row#

A discounted variant in the Google-compatible schema, as tab-separated values:

id	item_group_id	title	description	link	image_link	price	sale_price	availability	brand	gtin	condition
39897499729985	7458231	Trail Shell Jacket - Blue / M	Waterproof three-layer shell with taped seams.	https://yourstore.com/products/trail-shell?variant=39897499729985	https://cdn.shopify.com/.../trail-shell-blue.jpg	129.00 USD	99.00 USD	in_stock	Northline	00012345678905	new

Check it against the rules above: numeric variant ID, product ID as the group, plain-text description, variant URL, higher price in price, lower in sale_price, a real brand, a real barcode.


Before you upload#

  1. Filter to ACTIVE products with a non-null onlineStoreUrl.
  2. One row per variant, item_id from the variant ID, group_id from the product ID.
  3. Swap price and compare-at price for discounted variants.
  4. Check vendor values really are brands, and barcodes really are GTINs.
  5. Use description(truncateAt: 5000) for plain text.
  6. Build variant URLs with ?variant=.
  7. One feed per market, using contextual pricing.
  8. Upload, then read Upload History in Ads Manager — it names the missing columns and failing values, and rejects bad rows individually rather than the whole file.

For getting the finished file into Ads Manager and keeping it refreshed, see ChatGPT product feed for Shopify. For what happens once it's there, see product feed campaigns.


I build LLM Pixels, a Shopify app for ChatGPT Ads conversion tracking.