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.
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 schema —
item_id,url,image_url,group_id - The Google-compatible schema —
id,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 field | Google-compatible | Shopify source | Notes |
|---|---|---|---|
item_id | id | Variant legacyResourceId | One row per variant. Must stay stable. |
group_id | item_group_id | Product legacyResourceId | Groups variants of one product. |
title | title | title, or variant displayName | Up to 150 characters. |
description | description | description | Plain text, up to 5,000 characters. |
url | link | Built from handle + variant ID | See below. |
image_url | image_link | Variant media, else featuredMedia | |
additional_image_urls | additional_image_link | Other product media | Comma-separated. |
brand | brand | vendor | Check it really is the brand. |
price | price | compareAtPrice or price | See below — this is the big one. |
sale_price | sale_price | Variant price | Only when discounted. |
availability | availability | Variant availableForSale | |
gtin | gtin | Variant barcode | Only if it is a real GTIN. |
product_category | product_type / google_product_category | category.fullName | Shopify Standard Product Taxonomy path. |
color, size | color, size | Variant selectedOptions | Match on option name. |
weight | — | Variant inventoryItem.measurement.weight | Not on the variant itself. |
target_countries | — | The market you are building for | ISO country code; first entry used. |
is_ads_eligible | — | — | Set 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 variant | Feed price | Feed sale_price |
|---|---|---|
price 99.00, compareAtPrice 129.00 | 129.00 USD | 99.00 USD |
price 99.00, no compareAtPrice | 99.00 USD | (leave empty) |
price 99.00, compareAtPrice 99.00 | 99.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— settrueon every product you want in ads. Use exactly this name; OpenAI's docs warn that feed ingestion "does not readis_ads_enabled".is_eligible_checkout— leavefalse. Setting ittruemakesseller_privacy_policyandseller_tosmandatory, 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#
- Filter to
ACTIVEproducts with a non-nullonlineStoreUrl. - One row per variant,
item_idfrom the variant ID,group_idfrom the product ID. - Swap price and compare-at price for discounted variants.
- Check
vendorvalues really are brands, and barcodes really are GTINs. - Use
description(truncateAt: 5000)for plain text. - Build variant URLs with
?variant=. - One feed per market, using contextual pricing.
- 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.