# The OpenAI product feed specification, explained field by field (with rejection examples)

> The OpenAI product feed has 9 required fields, strict money and boolean formats and legacy aliases. Every rule with a valid and a rejected example.

Canonical: https://convrail.com/blog/openai-product-feed-spec-explained/

The OpenAI product feed specification defines 9 required fields (item_id, title, description, url, brand, seller_name, image_url, availability, price), a set of recommended fields for variants, identifiers and sale pricing, and optional fields for attributes, media, fulfilment, ads and checkout. Values follow strict formats: money as `79.99 USD`, booleans as `true`/`false`, identifiers as strings. Rows that break a rule are not usable, and the documentation describes no per-row error report.

## Why the exact wording of the rules matters

The [file upload overview](https://developers.openai.com/commerce/specs/file-upload/overview) lists the three most common failure causes as "missing required fields", "outdated or non-spec field names" and "malformed field values". All three are formatting problems: the product is fine, the row is not. Because the documentation describes no per-row error report, a merchant learns about a malformed row only by noticing that a product never appears in ChatGPT. That is why every rule below comes with a rejected example. Quoted text is verbatim from the [product feed specification](https://developers.openai.com/commerce/product-feeds/spec) and its two companion pages listed in the sources.

## The 9 required fields

A row that omits any of these, or supplies an empty or unrecognized value, is not usable. The specification is explicit for availability: omitted, empty or unrecognized values reject the row.

| Field | Type | Constraint (from the specification) | Valid example | Rejected example |
| --- | --- | --- | --- | --- |
| `item_id` | string | "Stable ID, unique per item or variant within your feed. Never reuse it for a different item." | `TRAIL-BLK-10` | A database row number that changes on re-import |
| `title` | string | "Product name, including the selected variant when relevant." Max 150 characters, plain text | `Trail running shoes, black, size 10` | A 300-character title stuffed with keywords |
| `description` | string | "Factual product description for this item." Max 5,000 characters, plain text | `Waterproof trail shoes with a rubber outsole and mesh lining.` | `<p>Waterproof <strong>trail</strong> shoes</p>` |
| `url` | URL | "Product detail page for the item, with the variant selected when possible. Keep it stable." Absolute HTTP or HTTPS, publicly accessible | `https://example.com/products/trail?color=black&size=10` | `/products/trail` (relative path) |
| `brand` | string | "Product brand as shown on the product page." A real brand, not a placeholder | `Northline` | `n/a` |
| `seller_name` | string | "Name of the seller supplying this offer." A real name, not a placeholder | `Northline Outdoor` | `unknown` |
| `image_url` | URL | "Main product image, showing this variant. Use a direct image URL, such as a JPEG or PNG." | `https://example.com/images/trail-black.jpg` | `https://example.com/products/trail` (a page, not an image) |
| `availability` | enum | "in_stock, out_of_stock, pre_order, backorder, or unknown." | `in_stock` | `available`, `preorder`, `In Stock` |
| `price` | money | "Regular item price in major currency units." Format `amount CURRENCY` | `79.99 USD` | `79,99`, `$79.99`, `79.99`, `1,079.99 USD` |

Three details in this table cause most of the trouble.

**`item_id` must survive re-imports.** An identifier generated from a row position or a timestamp makes every snapshot look like a new catalog. Use the SKU or the platform's variant identifier, as a string.

**`availability` is a closed list.** Google Shopping uses `preorder`; OpenAI uses `pre_order`. A feed copied from a Google export carries the wrong spelling on every pre-order product.

**`price` is one string, not two columns.** The specification asks for "a decimal amount in major units, a space, and an uppercase three-letter ISO 4217 currency code", with "a decimal point, no thousands separators or exponent notation, and no more fractional digits than the currency permits". A price exported from a French locale as `25,99` fails on the decimal separator alone.

## Recommended fields

These are not required, but a malformed value in a recommended field is still a malformed value. If you cannot populate one correctly, omit it.

### Variants: `group_id`, `listing_has_variations`, `variant_dict`

The three fields work together. `group_id` is a "Stable parent-listing ID shared by all variants." The specification adds: "Omitted or empty: uses item_id, which does not establish a variant group." So a `group_id` copied from `item_id` produces no group at all, silently.

`listing_has_variations` must be set to `true` on every variant row: "Omitted, empty, or false: no variant options are used."

`variant_dict` is a map of option names to selected values, as strings. It "Requires listing_has_variations=true and group_id different from item_id." Keys and values must be nonempty, and the same option names must be used across the whole group with unique option combinations. The specification also asks you to keep top-level attributes such as `color` and `size` consistent with the same options in `variant_dict`, because "Neither representation reconciles conflicting values for you."

| Situation | Result |
| --- | --- |
| `group_id` = `TRAIL`, `item_id` = `TRAIL-BLK-10`, `listing_has_variations` = `true`, `variant_dict` = `{"color":"Black","size":"10"}` | Variant group established |
| `group_id` = `TRAIL-BLK-10` (same as `item_id`) | No variant group |
| `group_id` set, `listing_has_variations` omitted | No variant options used |
| `variant_dict` = `{}` | Treated as no options |
| `color` = `Blue` at top level, `variant_dict.color` = `Black` | Conflicting values, not reconciled |

### Identifiers: `gtin`, `mpn`, `offer_id`

`gtin` is "One assigned GTIN: exactly 8, 12, 13, or 14 digits, including a valid check digit. Preserve leading zeros; no spaces or dashes." Two consequences: a GTIN with a typo fails the check digit, and a GTIN exported from a spreadsheet that dropped the leading zero has the wrong length. Validate before uploading.

`mpn` is the "Manufacturer-assigned part number, preserving its punctuation and casing." The specification is blunt about a common shortcut: "do not invent a value to replace a missing GTIN."

`offer_id` is a "Stable offer ID, unique within the feed. Use it to distinguish offers that share a product URL." It is a string, so leading zeros are preserved.

### Condition and sale price

`condition` accepts `new`, `refurbished` or `used`. Omitted or empty "may be treated as new", so always specify `used` or `refurbished` when it applies.

`sale_price` is the "Current sale price: greater than zero, strictly less than price, and in the same currency." A sale price that is "Nonpositive, equal, higher, or different-currency" is not used. The specification also tells you when to update it: "Submit the current price; update the feed when a sale starts or ends." Do not pre-schedule a sale by sending the future price.

| `price` | `sale_price` | Result |
| --- | --- | --- |
| `79.99 USD` | `59.99 USD` | Used |
| `79.99 USD` | `79.99 USD` | Not used (equal) |
| `79.99 USD` | `59.99 EUR` | Not used (different currency) |
| `79.99 USD` | `0.00 USD` | Not used (nonpositive) |

### `is_eligible_search`

"true enables search eligibility; false disables it and checkout eligibility. Omitted or empty: true." This is the switch that removes a product quickly: the file upload overview recommends setting `is_eligible_search=false` to make a product ineligible in the next processing cycle, rather than simply dropping the row, because OpenAI "retains its most recently processed record for up to 14 days".

## Optional fields, grouped

### Item attributes

| Field | Rule |
| --- | --- |
| `product_category` | "Your category path, from broad to specific, separated by >", for example `Apparel & Accessories > Shoes` |
| `material` | Principal materials in the item |
| `color` | Selected color, consistent with the product image |
| `size` | Selected size label; use `variant_dict` when size distinguishes variants |
| `gender` | `male`, `female` or `unisex`; anything else means no gender supplied |
| `age_group` | `newborn`, `infant`, `toddler`, `kids` or `adult`; "a product attribute, not a purchase-age restriction" |
| `dimensions` | Object with positive decimal strings for at least two of length, width, height, plus a unit (`in`, `cm`, `ft`, `m`, `mm`); an empty object is invalid |
| `weight` and `item_weight_unit` | Positive net weight without packaging; unit `g`, `kg`, `oz` or `lb`; the unit is required with the weight |

### Media

`additional_image_urls` is an array in JSON or Parquet, and a comma-separated string in CSV or TSV. Invalid URLs are omitted. If an image URL itself contains a comma, the specification asks you to "Percent-encode commas as %2C in URL", otherwise the delimiter splits the URL in two.

### Fulfilment and returns

| Field | Rule |
| --- | --- |
| `shipping_price` | Money format, same currency as price, nonnegative; "zero = no charge. Omitted/empty = unknown, not free" |
| `shipping` | Tuple `country:region:service_class:price`, keeping the empty region position, for example `US::Standard:5.00 USD` |
| `accepts_returns` | `true` or `false`; omitted means unspecified |
| `return_deadline_in_days` | Positive whole number, "Supply only with accepts_returns=true" |
| `return_policy` | Public HTTP or HTTPS URL to the returns or final-sale terms |

### Reviews and ratings

`review_count` is a nonnegative whole number of product reviews (not seller or store reviews); zero means no reviews, omitted means unknown. `star_rating` is a decimal string on a 0 to 5 scale with two decimal places, for example `4.50`, and must be paired with a matching positive `review_count`.

### Merchant information

`seller_url` points to the seller's storefront or profile page (for marketplace offers, the specific seller's page). `marketplace_seller` names the marketplace where checkout occurs and requires setup with OpenAI.

### Ads

`is_ads_eligible`: "Set true for products Ads should process; false explicitly opts out. Omitted/empty: disabled unless feed-level default applies." Set it explicitly rather than relying on a default. `ads_metadata` is a string-to-string object using keys configured for your Ads integration, for example `{"custom_label_0":"summer"}`; "Do not invent keys."

### Checkout

`is_eligible_checkout` "true opts in only when search eligibility also true and checkout enabled. Omitted/empty/false: disabled. Search=false overrides this." When you opt in, publish `seller_privacy_policy` and `seller_tos` as public URLs; supplying them "does not establish checkout readiness" on its own.

### Geo-tagging

`target_countries` is an array of uppercase ISO 3166-1 alpha-2 codes "configured for feed. Omitted/empty does not mean worldwide. Requires market setup." So `["US"]` is valid, `["United States"]` or `["us"]` is not. `store_country` is the seller's store country as an ISO code, not a regional price or stock override.

## Data rules that apply to every field

These conventions come from the "general conventions" section of the specification and apply regardless of file format.

- **Omit unknown values.** "Omit an unknown value. Unless a row says otherwise, an omitted field, JSON null, or an empty delimited cell supplies no value."
- **No placeholders.** "Do not use placeholder strings such as null, unknown, or n/a; unknown is valid only where explicitly listed." The only place `unknown` is a legal value is `availability`.
- **Booleans.** "For boolean fields, use JSON true or false, or the lowercase strings true and false in delimited files." `TRUE`, `1`, `yes` and `Y` are not booleans.
- **Decimals.** Decimal point, no thousands separators, no exponent notation.
- **Identifiers as strings.** "Keep identifiers as strings to preserve leading zeros." This matters for `gtin`, `item_id`, `offer_id` and `mpn`, and most of all in Parquet, where a writer that infers an integer column drops the zeros.
- **Text and URLs.** "Use UTF-8 text and absolute HTTP or HTTPS URLs; prefer HTTPS."
- **CSV quoting.** "In CSV, quote a cell containing commas, quotes, or newlines, and double each embedded quote. JSON objects in CSV or TSV cells must be serialized as JSON." A `variant_dict` in a CSV cell looks like `"{""color"":""Black"",""size"":""10""}"`.
- **Stability across updates.** Keep `item_id`, `group_id` and `offer_id` stable when price, stock, title or images change.

## Legacy aliases

Older names are still accepted, but the specification asks you to "Send only one name per value" and states which name wins when both are present: "item_id wins over id and sku; group_id wins over item_group_id; the enable_ flags win over their is_eligible_ names." Use the current names and never emit the alias in the same file.

| Current name | Legacy alias |
| --- | --- |
| `item_id` | `id`, `sku` |
| `group_id` | `item_group_id` |
| `is_eligible_search` | `enable_search` |
| `is_eligible_checkout` | `enable_checkout` |
| `is_ads_eligible` | `is_eligible_ads` |
| `return_deadline_in_days` | `return_window` |

If your feed is derived from a Google Shopping export, the Google names (`id`, `link`, `image_link`, `item_group_id`) are the ones to translate. The comparison in [Google Shopping feed vs OpenAI feed](/blog/google-shopping-feed-vs-openai-feed/) walks through that mapping.

## A valid JSONL row

One line per item. This row uses the required fields, the variant fields and a few recommended and optional ones. Note the title uses a comma to separate the variant details, and every boolean is a JSON boolean, not a string.

```json
{"item_id":"TRAIL-BLK-10","group_id":"TRAIL","listing_has_variations":true,"variant_dict":{"color":"Black","size":"10"},"offer_id":"northline-TRAIL-BLK-10","title":"Trail running shoes, black, size 10","description":"Waterproof trail shoes with a rubber outsole and mesh lining. Lace closure, 320 g per shoe.","url":"https://example.com/products/trail?color=black&size=10&utm_medium=feed","brand":"Northline","seller_name":"Northline Outdoor","image_url":"https://example.com/images/trail-black.jpg","additional_image_urls":["https://example.com/images/trail-black-side.jpg"],"availability":"in_stock","price":"79.99 USD","sale_price":"59.99 USD","gtin":"00012345678905","condition":"new","color":"Black","size":"10","product_category":"Apparel & Accessories > Shoes","is_eligible_search":true,"is_ads_eligible":true,"target_countries":["US"]}
```

The `utm_medium=feed` parameter on `url` follows the [best practices page](https://developers.openai.com/commerce/guides/best-practices), which suggests adding "feed attribution parameters to url (for example utm_medium=feed)" so that clicks from the feed can be told apart in your analytics. The same page asks for "concise, factual copy" in titles and descriptions, and to keep "title, url, description, media, availability, and price variant-specific when those values differ".

## Common mistakes

- Exporting `price` and `currency` as two columns, or with a locale decimal comma.
- Copying `group_id` from `item_id`, which produces no variant group.
- Writing `TRUE`/`FALSE` or `1`/`0` in boolean columns.
- Filling `brand` or `seller_name` with `n/a` to pass a "required" check in an internal tool; the specification treats the placeholder as invalid.
- Leaving HTML from the store editor inside `description`.
- Sending both `id` and `item_id` in the same file.
- Letting a spreadsheet turn `gtin` into a number and drop the leading zero.
- Setting `is_eligible_checkout=true` on a product whose `is_eligible_search` is `false`.

## How Convrail validates each rule before delivery

Convrail applies these rules before any file reaches OpenAI, so a broken row is reported to you instead of vanishing:

- The 9 required fields are checked for presence and format on every row: money as `amount CURRENCY`, `availability` against the closed list, `url` and `image_url` as absolute HTTP or HTTPS URLs pointing at a direct JPEG or PNG. Convrail also caps `item_id` at 100 characters and `brand`, `seller_name` and `mpn` at 70, and rejects titles written entirely in capital letters.
- Placeholder strings (`null`, `unknown`, `n/a`) in `brand` and `seller_name` are rejected.
- Conditional fields are validated when present: `sale_price` strictly below `price` in the same currency, `group_id` different from `item_id`, `is_eligible_checkout` requiring search eligibility plus the two policy URLs, `target_countries` as uppercase alpha-2 codes, `star_rating` between 0 and 5.
- The GTIN check digit is verified. An invalid GTIN is omitted rather than sent, so the row stays valid; the omission is journaled.
- HTML is stripped from descriptions, and booleans are emitted as JSON booleans in JSONL and Parquet and as lowercase strings in CSV and TSV.
- Every rejection is recorded with the `item_id`, the field and the rule, in a run journal that also counts items read, accepted and rejected.

The same catalog can also be projected into a Google-compatible feed. If your products are already missing from ChatGPT results, [Why your products do not show up in ChatGPT shopping](/blog/products-not-showing-chatgpt-shopping/) maps each symptom to a rule above, and [Parquet vs JSONL vs CSV](/blog/parquet-vs-jsonl-vs-csv-openai-feed/) covers the file-level choices.

## What to do next

Connect your store and let Convrail validate your catalog against every rule on this page before the first delivery: see the [product feed page](/product-feed/).