Product feeds
KahunaCart builds two product feeds from your catalog: a Google Merchant Center feed, so products can appear in Google Shopping, AI Mode and Gemini with native checkout, and an OpenAI product feed, so ChatGPT can find them. This page is for setting them up, and for anyone who needs to know exactly what goes in them.
The feeds are files. KahunaCart builds them in the background, keeps them on disk and serves them under the storefront base. The plugin itself pushes nothing anywhere.
Before you begin
- Set
agentic.enabledandfeeds.enabledtotrue. The feeds are off on a fresh install and on upgrade, andfeeds.enableddoes nothing while the master switch is off. See Agentic commerce. - Links in a feed must be absolute. A build from the site takes
system.custom_base_urlwhen it is set, and otherwise the entry ofsecurity.site_urlsthe request came in on, or the first entry (see Hardening → Site address for emailed links). A build from cron or the CLI takessystem.custom_base_url, then the first entry ofsecurity.site_urls, then the address the last build from the site recorded, and fails with none of them. Setsecurity.site_urlsorsystem.custom_base_urlif the first build will come from the command line. - Fill in the Seller and Store policies settings first. The OpenAI feed carries the seller name, the privacy and terms links and the return policy, and Google's shipping column comes from
policies.shipping_summary. - Give products a brand, a GTIN or MPN, a condition and a Google product category where you have them. See Product data for search engines and agents. A product with none of them is still listed, and Google marks it
identifier_exists: no. - The job worker running.
bin/plugin kahunacart statussays whether it is.
The feed URLs
Every URL hangs off the storefront base, {base}, which is the route config key (default /shop) with the language prefix on a multilanguage site.
| URL | Content-Type | What it holds |
|---|---|---|
{base}/feeds/google.tsv |
text/tab-separated-values; charset=utf-8 |
Google primary feed, one row per purchasable variant |
{base}/feeds/google-checkout.tsv |
text/tab-separated-values; charset=utf-8 |
Google supplemental feed: id and native_commerce only |
{base}/feeds/openai.jsonl.gz |
application/gzip |
OpenAI feed, JSON Lines compressed with gzip, one record per variant |
Only these three names answer. Both Google files follow feeds.google, the OpenAI file follows feeds.openai, and all three follow feeds.enabled. A file whose switch is off answers 404. So does every feed while the store status is coming soon, because a store that hasn't launched has nothing to advertise. A paused store's feeds keep answering, so a short pause doesn't take its listings down.
Each language is its own file. /de/shop/feeds/google.tsv is built from the German titles and descriptions and links to the German product pages. Merchant Center registers a feed per language and country, so register each language's URL separately.
Currencies. A store that sells in more than one currency has one file per currency, because Google wants one feed per country and currency. The store's own currency keeps the URL it has always had, so a feed you registered before adding a currency does not move. Every other currency is the same URL with ?currency= and the code: {base}/feeds/google.tsv?currency=EUR is the euro file. The store's own code is accepted too and answers the bare file, ?currency= is read in any case, and a currency the store does not sell in answers 404. With feeds.access_key set, add &key= after it. A store that sells in one currency ignores ?currency=, as it always has.
The supplemental feed is for a store whose primary Google feed comes from another tool. Add google-checkout.tsv as a supplemental data source in Merchant Center, and it sets the checkout attribute on the products that tool already sends, matched by id. It writes FALSE on every row while UCP is off, so switching UCP off also tells Google to stop offering checkout.
Set up Google Merchant Center
- Set
agentic.enabled: trueandfeeds.enabled: true. Leavefeeds.googleon. - Optionally set
feeds.access_keyto a long random string. The URL then answers only with?key=equal to it. - In Merchant Center, add a product data source of type "file", choose "scheduled fetch", and give it
https://your-store.example/shop/feeds/google.tsv, with?key=...if you set one. Daily is enough, because the file is rebuilt whenever the catalog changes. - Set the return policy and shipping at account level in Merchant Center too. The Google feed does not carry returns, and its
shippingcolumn is a summary, not your zone rates. - Check the first fetch's diagnostics. If Merchant Center flags the
native_commercecolumn, see The checkout column.
One data source for each currency. When the store sells in more than one currency, add a data source for each file you want Google to show, and set the country each one targets to a country that uses that currency: the bare URL for the store's own currency and its country, ?currency=EUR for the countries that pay in euro, and so on. Google reads the currency from each price (26.00 EUR), and a price in a currency the target country does not use is refused there, so a file registered against the wrong country fills Merchant Center's diagnostics rather than your listings. Every currency file is built from the prices you set in that currency, so there is nothing to convert, and a product with no price in a currency is not in that file: a shopper in that country does not see it in Google either. On a multilanguage store each language and currency pair is its own file, /de/shop/feeds/google.tsv?currency=EUR. Merchant Center's shipping and returns settings are per country as well, so set them for each country you target.
Merchant Center fetches the file on its schedule, and your products appear once Google has processed them.
Set up ChatGPT
OpenAI takes a full snapshot of the feed pushed to it by SFTP, at least daily, under a stable file name. KahunaCart builds the file, and you add a cron line that sends it.
- Register the feed with OpenAI and note the SFTP host, user and key it gives you.
- Add a nightly cron line that builds the feed and pushes it:
# every night at 02:15: build the OpenAI feed, copy it to /var/feeds, push it
15 2 * * * cd /path/to/grav && bin/plugin kahunacart feeds:build --feed=openai --out=/var/feeds && echo 'put /var/feeds/openai.jsonl.gz' | sftp -b - [email protected]
A feed named with --feed is built whatever the switches say, so a store that only pushes to OpenAI can leave feeds.enabled off and serve nothing. On a multilanguage site --out writes one directory per language, /var/feeds/de/openai.jsonl.gz, and each language is a separate push. A store that sells in more than one currency also gets a directory per currency other than its own below that, /var/feeds/EUR/openai.jsonl.gz (/var/feeds/de/EUR/openai.jsonl.gz with languages), which is the file to push for euro prices.
OpenAI also accepts a Google-format TSV, but only once it has confirmed that for your registered feed, so register the JSONL file.
Which products are in a feed
One row per variant a guest could buy today:
- the product is published, and its
published_atis not in the future; - it is listed (an unlisted product is left out, as it is from the sitemap);
- a logged-out visitor may see it: it is limited to no customer group, or to groups that include the store's default group, the same rule the MCP and UCP tools use;
- its type can be bought on its own (an external product is left out, and so is a product whose add-on type is switched off);
- the variant has a price in the file’s currency. A store with extra currencies includes free variants under the free-price rule. A single-currency store still leaves zero prices out;
- the variant is in stock, on backorder, or out of stock with
feeds.include_out_of_stockon (the default).
A grouped product is not a row, unless it is sold as a set (see below). Its children are, each as a product of its own, including unlisted children, since the grouped page is how a shopper reaches them.
A kit is one row, priced at the sum of its parts' regular prices, with a sale_price of what the parts cost after any running sale when that is lower. It is marked is_bundle in the Google feed, out of stock or on backorder when any part is, and left out when a part is missing or unpublished.
A grouped product or a kit that is sold as a set quotes its bundle price as the sale_price, or what the parts cost after their sales when that is lower, because the cart gives the customer the better of the two. A grouped product sold as a set is also a row of its own, priced the way a kit is, beside its children's rows. In a currency the product has no bundle price in, its row quotes the parts.
Identifiers
Every row's id is the same id the storefront MCP tools and UCP checkout use, so a checkout line matches its feed row exactly.
feeds.item_id: sku(the default): the variant's SKU when it is at most 50 characters, uses only letters, digits,.,_,-and/, is not itself spelled likekc-v{n}orkc-p{n}, and no other variant has it (compared without case). Otherwisekc-v{variant id}.feeds.item_id: id: alwayskc-v{variant id}, which never changes.
Variants of one product share kc-p{product id} as their group id (item_group_id for Google, group_id for OpenAI) when the product has two or more rows.
Field mapping
| Field | Source | Google column | OpenAI key |
|---|---|---|---|
| id | see Identifiers | id |
item_id |
| group | kc-p{id} when the product has 2+ rows |
item_group_id |
group_id, listing_has_variations: true |
| options | the variant's option values, translated | color, size, material, pattern when an option is named so (colour counts as color) |
variant_dict (all options), color and size |
| title | product title, plus - and the option values joined with / on a multi-variant row; cut to 150 |
title |
title |
| description | the description as plain text, else the summary, else the title; cut to 5000 | description |
description |
| link | the product page, plus ?variant={id} on a multi-variant row |
link |
url |
| image | the variant's own picture, else its first option value's, else the product's first | image_link |
image_url |
| more images | up to 10 further gallery pictures | additional_image_link |
additional_image_urls |
| availability | the same stock rules as the buy button | in_stock, out_of_stock, backorder |
same |
| availability date | backorder rows only: today plus feeds.backorder_days |
availability_date |
not sent |
| price | the variant's price | price (28.00 USD) |
price |
| sale price | the price a running sale gives it, when lower | sale_price, and sale_price_effective_date when the sale has a start and an end |
sale_price |
| brand | the product's brand, else seller.default_brand |
brand |
brand, falling back to the seller name because OpenAI requires one |
| gtin, mpn | the variant's | gtin, mpn, and identifier_exists: no when both are empty |
gtin, mpn |
| condition | the product's condition, new when unset |
condition |
condition |
| category | the product's Google category, else feeds.google_category; the store's own category trail |
google_product_category, product_type (A > B > C) |
product_category (A > B > C) |
| weight | the variant's weight in grams | shipping_weight (500 g) |
weight, item_weight_unit: g |
| dimensions | length, width and height in mm, when two or more are set | not sent | dimensions, with unit: mm |
| bundle | a kit, or a grouped product sold as a set | is_bundle: yes |
not sent |
| reviews | approved reviews, when reviews are on | not sent | review_count, star_rating |
| returns | policies.returns and the product's final sale flag, only when a refund policy link is set |
not sent (set it in Merchant Center) | accepts_returns, return_deadline_in_days, return_policy |
| seller | the seller name, the site, the policy links | not sent | seller_name, seller_url, seller_privacy_policy, seller_tos |
| shipping | policies.shipping_summary, for products that ship |
shipping (US::Standard:5.00 USD, one entry per country) |
shipping_price from the US row |
| checkout eligibility | see The checkout column, only while UCP is on | native_commerce (TRUE or FALSE) |
not sent |
| search eligibility | always | not sent | is_eligible_search: true |
The price is the catalog price. A sale on the shelf lowers sale_price, a quantity ladder below its first rung does not, and a coupon never does. Prices are written in the store currency with its own decimals, so a yen store writes 1500 JPY.
In the OpenAI file a field with no value is left out rather than sent empty, booleans are JSON booleans, and identifiers are always strings. In the Google file an empty cell means "not submitted", and a tab or line break inside a value becomes a space.
Each foreign-currency feed puts ?currency=CODE on its product landing links and keeps the variant parameter. The base-currency feed also names its currency when a country header can decide browsing prices. Single-currency feed links stay as they were.
The checkout column
While UCP is on, the Google feeds carry native_commerce: TRUE when the variant may be bought through Google's native checkout, FALSE otherwise. It is the same answer the UCP checkout applies to each line, set per product with Agent checkout. See Agent checkout.
Google documents the attribute as native_commerce(checkout_eligibility) and keeps native_commerce as a supported alias. The alias is what a single-value TSV column can carry.
When the files are built
- After a catalog change. Saving, deleting or bulk-editing a product, adjusting stock or finishing an import books a rebuild five minutes out. Every further change inside those five minutes joins the same job, so an import of a thousand products is one build.
- When they are old. The worker rebuilds once the oldest file is older than
feeds.max_age(default 21600 seconds, six hours; anything below 300 counts as 300), or when no file has been built yet. This is also what picks up a sale that starts or ends on a schedule. - On the first request. A file that has never been built is built inside the request when the catalog has at most
feeds.inline_limitvariants (default 2000;0never builds in the request). A larger catalog, or a build that fails, answers503withRetry-After: 120and books the job to run at once. - By hand.
bin/plugin kahunacart feeds:build, or Rebuild feeds on the Agentic commerce screen in the store admin, which books the job to run now.
Only one rebuild waits at a time. The job builds every enabled feed for every language and every currency the store sells in, and deletes the files of a feed, a language or a currency that is no longer on. A currency you add is built by the next job; until then its URL answers 404, or builds the file in the request on a small catalog as a missing file does. Builds read the catalog 200 products at a time and write rows straight to disk, so memory stays flat whatever the catalog size. A product that cannot be read is left out and logged rather than failing the file.
Serving
- Files live in
user/data/kahunacart/feeds/{language}/{name}(_on a single-language site), which Grav's web-server rules do not serve directly. They are written to a temporary name and renamed into place, so a fetch never reads half a file. GETandHEADare answered. Any other method is a405.- A file answer carries
ETag,Last-ModifiedandCache-Control: public, max-age=3600, and a matchingIf-None-MatchorIf-Modified-Sinceanswers304. Error answers carryCache-Control: no-store. - With
feeds.access_keyset, a request without the matching?key=answers403. - Feed requests count against the
agent_readrate limit, and one over it answers429withRetry-After. No Grav session is started and no cookie is set, so a CDN can cache a feed answer.
Build from a shell
bin/plugin kahunacart feeds:build # every enabled feed, every language
bin/plugin kahunacart feeds:build --feed=google # one feed: google, google-checkout or openai
bin/plugin kahunacart feeds:build --lang=de # one language
bin/plugin kahunacart feeds:build --currency=EUR # one currency, on a store that sells in more than one
bin/plugin kahunacart feeds:build --out=/var/feeds # also copy the files there, for sftp or rsync
The command prints each file with its language, row count, the start of its SHA-256 and where it was written. It builds into the same place the job does, so a hand build is served straight away. See CLI → feeds:build.
Policies in more than one currency
The shipping column and the return policy come from policies, and two of its amounts are money: policies.returns.fee and, on each policies.shipping_summary row, rate and free_over. Each is either a plain amount, which is an amount in the store's own currency, or a map from currency code to amount:
policies:
returns:
fee: { USD: '5.00', EUR: '4.50' }
shipping_summary:
- country: US
rate: { USD: '5.99', EUR: '5.49' }
free_over: { USD: '50.00', EUR: '45.00' }
- country: DE
rate: '9.00'
A currency's file reads the amount for its own currency. A plain amount is the store's own currency's and no other's, so the German row above is in the dollar file and not in the euro one: a shipping line with no amount in the file's currency is left out of that file, rather than printed in the wrong currency or converted. That is the same rule the structured data on the product pages and the UCP profile follow. A fixed return fee with no amount in a currency is published as customer_pays in that currency, as Configuration says, and the rest of the policy is unchanged; a free_over with no amount in the currency is no threshold there. A store that sells in one currency keeps writing plain amounts, and nothing changes for it.
Configuration
feeds:
enabled: false
google: true # {base}/feeds/google.tsv and google-checkout.tsv
openai: true # {base}/feeds/openai.jsonl.gz
access_key: '' # when set, feeds answer only with ?key=<this>
item_id: sku # sku | id
include_out_of_stock: true
backorder_days: 14 # availability_date for backorder rows
google_category: '' # default google_product_category for products without one
max_age: 21600 # seconds; the worker rebuilds files older than this
inline_limit: 2000 # build on request when the catalog has at most this many variants
Every key is described in Configuration → Agentic commerce.