magenxcommerce / module-product-feed
Product feed generation and delivery for Magento 2 — define feeds in the admin, filter the catalog with the standard condition widget, render XML/CSV/TSV/JSONL from a safe template language, publish on a schedule, and deliver by public URL, FTP/SFTP, the Google Merchant API, or a push catalog API su
Package info
github.com/magenxcommerce/module-product-feed
Type:magento2-module
pkg:composer/magenxcommerce/module-product-feed
Requires
- php: ~8.3.0||~8.4.0||~8.5.0
- ext-json: *
- ext-openssl: *
- magento/framework: *
- magento/module-backend: *
- magento/module-catalog: *
- magento/module-catalog-inventory: *
- magento/module-catalog-rule: *
- magento/module-catalog-widget: *
- magento/module-config: *
- magento/module-cron: *
- magento/module-directory: *
- magento/module-eav: *
- magento/module-email: *
- magento/module-review: *
- magento/module-rule: *
- magento/module-store: *
- magento/module-ui: *
Requires (Dev)
None
Suggests
- ext-ftp: Required only for plain FTP delivery. Nothing else needs it: SFTP goes through Magento's own Filesystem\Io\Sftp adapter, and the Google and Meta destinations are plain HTTPS.
- ext-zlib: Required only for feeds set to gzip compression, which the OpenAI (agentic commerce) file upload expects.
Provides
None
Conflicts
None
Replaces
None
README
Built with AI assistance. This module is built with significant use of Claude Code, under active human review and guidance. It is open source and in active development. AI assistance does not lower the engineering bar: every change must meet the same correctness, security, documentation and maintainability requirements as any other contribution.
No warranty. We are not responsible for any incompatibility. By installing this software, you accept responsibility for its security, upgrades, patches and monitoring.
Product feed generation and delivery for Magento 2.4.8 — define feeds in the admin, filter the catalog with the standard condition widget, render XML / CSV / TSV / JSONL from a safe template language, publish on a schedule, and deliver by public URL, FTP/SFTP, the Google Merchant API, a direct catalog-API push, or the OpenAI Agentic Commerce Protocol (ACP) product feed — by SFTP file upload or the ACP Products API. See Agentic commerce (OpenAI / ACP).
Why this exists
The stack had no feed surface at all: nothing wrote an export file, talked to a marketplace API, or scheduled a catalog dump. A merchant could not list on Google Shopping, Meta, TikTok, Pinterest, Bing or any European comparison engine without buying a third-party extension.
Timing forced the API choice. Google's Content API for Shopping v2.1 sunsets on
18 August 2026, after which its endpoints return 410 Gone, and the Merchant
API's own v1beta was shut down on 28 February 2026. This module targets
Merchant API v1, which is the only supported path.
Backend-only — there is no GraphQL sibling
Unlike most Magenx_* modules here, this one ships no *GraphQl companion and
touches no storefront file. Feeds are an operator feature with no storefront
consumer — the storefront never reads a feed, marketplaces do. packages/engine
and the /api/graphql persisted-query allowlist are both unchanged.
If a storefront need ever appears it belongs in a sibling Magenx_ProductFeedGraphQl,
per the house split. etc/di.xml carries a note to that effect so a later session
does not "fix" the missing module.
Admin
| Feeds | Catalog → Product Feeds → Feeds |
| Settings | Stores → Configuration → Magenx → Product Feeds |
| ACL | Magenx_ProductFeed::feeds (+ ::feed, ::generate, ::deliver, ::config) |
| Log | var/log/magenx_product_feed.log |
Configuration
| Path | Default | Notes |
|---|---|---|
magenx_product_feed/general/enabled |
0 |
Off by default. While off, both cron jobs return immediately. |
magenx_product_feed/generation/batch_size |
200 |
Products per batch. Query count per batch is fixed, so this trades memory for round trips. |
magenx_product_feed/generation/max_execution_seconds |
50 |
Time budget per cron tick; the run resumes on the next one. |
magenx_product_feed/generation/validate_after_generation |
1 |
Record validation findings against the feed's own rules. |
magenx_product_feed/generation/history_retention_days |
30 |
Rolling prune of magenx_feed_history. |
magenx_product_feed/google/merchant_id |
— | Numeric Merchant Center account ID. Per website / store view. |
magenx_product_feed/google/service_account_key |
— | File name inside var/magenx_feed/gmc/, not a path. |
magenx_product_feed/agentic/* |
— | Seller facts for the OpenAI feed, per store view: seller name, fallback brand, headless storefront URL, privacy / terms / return policy URLs, returns accepted + window, checkout opt-in. Exposed to templates as context.store.*. |
magenx_product_feed/notifications/* |
off | Failure email, sent through Magento's own Mail Sending Settings. |
magenx_product_feed/cron/dispatch_schedule |
*/5 * * * * |
Dispatcher tick, not a feed's schedule. |
magenx_product_feed/cron/deliver_schedule |
*/15 * * * * |
Delivery retry. |
Template language
A feed template is data, never code. Filters resolve through a hard-coded
match(), the render context holds nested arrays rather than Magento models, and
there is no eval, no call_user_func on a template-supplied name, and no
facility for executing an admin-authored PHP file. Feed extensions commonly
offer that last feature; it is remote code execution reachable by anyone with
feed permissions, and its absence here is deliberate. Do not add it, and do not
add a generic "call this PHP function" filter, which is the same hole with a
smaller door.
{{ product.sku }}
{{ product.name | stripHtml | truncate: 150 }}
{{ product.final_price | price }}
{{ product.gallery[0] }}
{{ product.parent.url }} falls back to the product's own
{{ product.inventory:warehouse_b }}
{% if product.qty > 0 %}in stock{% else %}out of stock{% endif %}
{% for tp in product.tier_prices %}{{ tp.quantity }}:{{ tp.price }}{% endfor %}
{{ product.final_price | price }} {{ context.currency }} "59.99 USD"
{{ product.gallery | slice: 1 | json }} every image but the first, as a JSON list
{{ product.variant_dict | json }} {"Color":"Black","Size":"10"}
Beyond attributes, a product record carries url, prices (regular_price,
final_price, min_price, max_price), stock (qty, is_in_stock,
stock_status), category / categories, gallery / images / image,
reviews_count / rating_summary, parent, and for a configurable's child
variant_group_id (the parent sku), variant_dict and configurable_attributes
([{code, label, value}], the parent's super attributes in its order, store-view
labels). context holds date, time, currency and store (the
magenx_product_feed/agentic/* settings).
Filters: lowercase uppercase capitalize replace remove append prepend escape html_entity_decode nl2br strip_newlines stripHtml stripStyleTag clean trim ltrim rtrim truncate truncatewords ifEmpty dateFormat json ceil floor round numberFormat price convert plus minus times divided_by modulo first last slice count join secure unsecure. An unknown filter passes the value through and logs once,
rather than failing the export.
The product loop is lifted out
Write the whole document; wrap the per-product part in
{% for product in context.products %} … {% endfor %}. The header is written
once at the start, the loop body once per product across as many cron ticks as
needed, and the footer when the run completes. That is what makes a catalog
larger than one cron window exportable at all.
Formats and what they can do
| Format | Driven by | Can push to a catalog API |
|---|---|---|
xml |
template | no — arbitrary nesting has no record equivalent |
csv / tsv / jsonl |
field map | yes |
A field map row is {"column": "...", "value": "<template>", "type": "..."}.
type is optional: string (default), bool, int, number, json or list.
- JSONL writes real JSON types:
true/false, numbers, objects (json) and arrays (list, from a comma-separated value or a JSON list). A value that does not convert stays a string, so validation can report it rather than it being silently zeroed. With Omit Empty Values on, a column that rendered empty is left out of the record instead of being written as"". - CSV / TSV write a
boolas lowercasetrue/falseand alistas one comma-separated cell with commas inside an item encoded as%2C;jsonstays serialized JSON in the cell.
Compression: gzip publishes <file name>.gz. The run still appends plain
text to its work file across ticks; the finished file is stream-compressed into a
staging file beside the final one and renamed into place, so publication stays
atomic and memory stays flat.
Purchasable Products Only limits a feed to enabled simple, virtual and downloadable products — the rows a cart accepts by sku alone. Configurable, bundle and grouped parents drop out; their children stay.
Delivery
| Type | What it does |
|---|---|
file |
Publishes at the feed's public URL for a consumer to fetch. |
sftp |
Uploads through Magento's Filesystem\Io\Sftp. No extra dependency. |
ftp |
Uploads through Filesystem\Io\Ftp. Needs ext-ftp. Unencrypted — prefer SFTP. |
google_datasource |
Registers the feed as a FETCH data source in Merchant Center and triggers a fetch. |
meta_batch |
Pushes records straight into a Meta catalog via items_batch, live during the export. |
acp_feed_api |
Pushes the catalog to OpenAI's ACP Products API (PATCH /product_feeds/{id}/products), grouped into products with variants. |
sftp authenticates with a password or an SSH private key (private_key,
optionally private_key_password). Magento's Io\Sftp passes its credential
straight to phpseclib 3's login(), which accepts a loaded key, so no adapter of
its own is needed.
Destinations are configured with bin/magento magenx:feed:delivery (see
CLI). Settings whose name ends in password, token, secret or key
are encrypted at rest.
A new destination is one class implementing Api\DelivererInterface plus one
<item> in di.xml. Adding Pinterest, Microsoft Advertising, TikTok
or Google's direct productInputs.insert needs no pipeline change.
Test-connection writes nothing, anywhere. A probe file left in a destination folder gets ingested as a bogus feed by whatever consumer watches it, and one copied from the module's own directory publishes source code to a third party.
Google setup
- Enable the Merchant API on a Google Cloud project.
- Create a service account, download its JSON key, and upload it to
var/magenx_feed/gmc/— neverpub/media, which is web-served; that key grants programmatic access to the Merchant Center account. - Add the service account's
client_emailas a user on the Merchant Center account (Settings → Users), Standard access or higher. - Register the Cloud project with the Merchant Center account (Settings → Developer registration). The Merchant API rejects every call until this is done, and it takes a few minutes to take effect. A project can be registered with only one Merchant Center account at a time.
- Make the feed URL publicly fetchable — see
deploy/nginx/feed.conf.example.
Meta setup
Needs a system user token (a user token expires) with catalog_management,
and a role on the catalog. Requires a field-mapped feed; the deliverer refuses a
template-driven one rather than sending nothing and reporting success.
Agentic commerce (OpenAI / ACP)
The OpenAI product feed — the discovery side of the Agentic Commerce Protocol the
MagenX storefront already implements for checkout under /api/acp.
Spec: https://developers.openai.com/commerce/specs/file-upload/products.
1. Seller settings — Stores → Configuration → Magenx → Product Feeds →
Agentic Commerce, per store view: seller name, the headless storefront URL
including its locale (e.g. https://shop.example.com/en), privacy / terms / return
policy URLs, returns accepted and window, and Offer Checkout in ChatGPT (only
once OpenAI has enabled checkout and the storefront's ACP_ENABLE=1 is live).
2. The feed — create a feed with Target Channel AI / agentic shopping and leave Template and Field Mapping empty. Saving fills in:
- a field map for the OpenAI columns: the nine required ones (
item_id,title,description,url,brand,seller_name,image_url,availability,price),sale_price, variants (group_id,listing_has_variations,variant_dict),additional_image_urls,product_category, weight, review aggregates, returns, seller links andis_eligible_checkout— typed, so JSONL carries real booleans, integers and objects; - validation rules mirroring what the spec rejects or degrades;
- JSONL, gzip, omit-empty and purchasable-only.
Decisions baked into that mapping:
item_idis the sku of the purchasable row. The storefront's ACP checkout adds exactly that id to a Magento cart by sku, so a configurable parent's sku (not addable without options) is excluded by purchasable-only.urlis the headless PDP,<storefront URL>/product/<url_key>— the parent's page for a variant. Without a storefront URL it falls back to Magento's product URL, which a headless install does not serve.brandismanufacturer(what the storefront's brand pages read), then the parent's, then the configured fallback brand.- Only attributes every install has are referenced — the export aborts on an
unknown attribute code. Add
gtin,mpn,condition,color,material... against your own attribute codes; agtinrule (check digit) is already in place.
3. Delivery — OpenAI's file upload is SFTP push, full snapshot, stable file name, at least daily:
bin/magento magenx:feed:delivery --code=openai --type=sftp \
--set host=<host from OpenAI> --set username=<user> --set path=/ \
--set remote_filename=products.jsonl.gz \
--key-file=/secure/openai_sftp_key --enable --test
Or the ACP Products API, which OpenAI recommends for updates during the day on top of the daily file (or alone for a small catalog). The API root is issued at onboarding and is not public, so it is a setting:
ACP_API_KEY=... bin/magento magenx:feed:delivery --code=openai --type=acp_feed_api \
--set base_url=<https API root> --set feed_id=<product feed id> --set target_country=US \
--secret-env=api_key=ACP_API_KEY --enable --test
The API deliverer maps the same rows: grouped by group_id into a Product with
Variants, money converted to integer minor units, sale_price → price with the
regular price as list_price, variant_dict → variant_options, images →
media, policy URLs → seller.links. Because a PATCH upserts a product whole,
records are spooled to var/magenx_feed/acp_api/ across ticks and sent when the
run completes, so a product is never sent with only some of its variants. The
last complete spool is kept, which is what magenx:feed:deliver re-sends.
Not covered: Parquet output (OpenAI's preferred format; gzip JSONL is
accepted), multi-file sharding (one file up to ~500k items / ~500 MB is within
OpenAI's guidance), the Promotions API, and the file feed's shipping tuple and
market columns, which need feed-specific setup with OpenAI first.
Serving the feed file
pub/media/magenx_feed/<store_code>/<url_secret>/<filename>
The random segment is not decoration: a feed is a complete machine-readable dump of the catalog and may carry cost price or supplier SKU if the merchant maps those columns.
Up to 1.1.x the root was pub/media/magenx-feed/. setup:upgrade moves it to
magenx_feed/, so the public URL of every feed changes: update the nginx
location, and re-register the URL with any consumer that fetches it on its own
schedule. The Google Merchant API destination updates its fetch URL on the next
run by itself.
In this stack /media/* is owned by imgproxy and will not serve an XML or CSV
file. Without an nginx location of its own the request falls through to the
Next.js storefront, which answers a 307 to /en/media/… — the consumer reports
a broken feed and Magento's log shows nothing. See
deploy/nginx/feed.conf.example; wiring it is a documented ops step, because
this repo ships no complete storefront nginx config to merge it into.
CLI
bin/magento magenx:feed:list
bin/magento magenx:feed:generate --code=google_shopping # runs to completion
bin/magento magenx:feed:generate --all [--slice] # --slice = one cron-sized tick
bin/magento magenx:feed:deliver --code=google_shopping # re-send without regenerating
bin/magento magenx:feed:delivery --code=openai --type=sftp --set host=... [--key-file=...] [--secret-env=key=ENV] [--enable|--disable] [--test]
magenx:feed:delivery creates or updates one destination of one feed. Secrets
belong in --key-file or --secret-env, not --set, which lands in shell history.
Validation rules
Declared per feed as JSON: {"field", "type", "severity", "message", ...params}.
| Type | Params | Passes when |
|---|---|---|
required |
the value is not empty | |
max_length / min_length |
length |
|
start_with / end_with / is_one_of |
values |
|
alphanumeric ascii unicode numeric without_html |
||
regex |
pattern (no delimiters) |
the pattern matches |
gtin |
8, 12, 13 or 14 digits with a valid GS1 check digit | |
url |
absolute http(s), no whitespace, no embedded credentials | |
money |
allow_zero |
79.99 USD: decimal amount, space, ISO 4217 code; above zero unless allowed |
boolean |
true/false/1/0/yes/no — what a bool column converts |
|
integer / decimal |
min, max, decimals |
no exponent, no thousands separator |
json_object |
keys |
a JSON object of non-empty string values (variant_dict, dimensions) |
required_if |
other, equals |
not empty whenever other is non-empty (or equals the value) |
only_if |
other, equals |
present only when other is non-empty (or equals the value) |
less_than_field |
other |
strictly below other, same currency for money |
not_equal_field |
other |
differs from other |
unique |
no earlier row had this value | |
variant_group |
group_by |
rows of one group share option names and differ in combination |
Every rule but required / required_if skips an empty value. unique and
variant_group compare rows within the part of the run the report covers — one
cron tick, like the rest of the findings.
Install
bin/magento module:enable Magenx_ProductFeed
bin/magento setup:upgrade
bin/magento setup:di:compile # production mode only
bin/magento cache:flush
Tables
| Table | Holds |
|---|---|
magenx_feed |
Feed definitions, filter tree, schedule, run state |
magenx_feed_delivery |
Destinations and their credentials (encrypted) and last state |
magenx_feed_history |
Generation / delivery / validation runs, findings in details |
Design notes worth keeping
- Never put a double quote in a
db_schema.xmldefault or comment. Magento emitsCOMMENT "%s"andDEFAULT "%s"unescaped, so one unbalances the wholeCREATE TABLEandsetup:upgradefails with "Multiple queries can't be executed" — pointing at neither the table nor the column. That is why the CSV delimiter and enclosure are stored as codes (comma,double) and resolved to characters in PHP. - The template decides what gets loaded.
Template\Parserwalks the compiled tree once per run and reports which attributes and joins are used, so a feed of sku/name/price costs the collection query plus one price query instead of nine. - The filter is pushed into SQL via
Rule\Model\Condition\Sql\Builderwith the CatalogWidget condition classes — only those implementgetMappedSqlField(). Known cost: an attribute the builder cannot map is skipped, which over-selects harmlessly inside an AND group and silently under-selects inside an OR group. - Paging seeks on
entity_id, not OFFSET — constant cost per page, and stable when a product is deleted mid-run. - Publication is an atomic same-directory rename. A consumer polling the URL sees the previous complete file or the new one, never a half-written one.
- Locking is Magento's DB
LockManager, not a lock file — a file lock fails on NFS/EFS and a crash leaves it wedged. - Store emulation wraps the run, or product and image URLs resolve against the admin store and come out wrong on cron while looking fine from the admin.
- Delivery only follows a completed generation. A partial run has no published file, and pushing half a catalog reads to a marketplace as every absent product being delisted.
Not implemented (and why)
- A "create from template" picker. Starter templates for the major channels are worth shipping but the picker UI is not built; write the template into the form directly for now.
- Category mapping to a marketplace taxonomy. Real work, and useless without the taxonomy files.
- Click tracking and revenue reporting. Belongs with analytics, not here.
- Amazon. Not a feed destination but a marketplace integration: SP-API needs a Selling Partner account, an approved LWA developer app, role authorisation and per-marketplace product-type schemas, and it owns listings, orders and inventory rather than an advertising catalog. A separate module.
Verification status
Everything below was run in a sandbox with PHP 8.4 but no Magento install:
php -lclean on all PHP files;xmllintclean on all XML; both JSON files parse.- Every core class and method this module references was checked to exist at tag 2.4.8.
- ACL re-declarations were cross-checked against core's own
acl.xmlfiles: all five shared ids sit at core's exact path. Menu parents and resources resolve. (A mismatch in either 500s every admin page, and XSD validation cannot see it.) - Every
system.xmlfield using the email-template source model has a matching<template id>ofsection_group_field. (A mismatch renders the whole config section blank, with only a line invar/log.) - The template engine, scheduler, validator and all four writers were exercised against the real classes in a standalone harness: 72 cases, all passing — filters, parent fallback, gallery indexing, numeric vs string comparison, loops, syntax-error reporting, requirements analysis, store-timezone and DST scheduling, catch-up after cron downtime, validation severities, trailing empty CSV columns, quote escaping, TSV tab forcing, CDATA terminator splitting and illegal control-character stripping.
- The agentic-commerce additions were exercised the same way, 94 cases, all
passing: value typing and omit-empty in JSONL, typed CSV / TSV cells, every
new validation rule (GS1 check digits, money, cross-field, unique, variant
groups), the ACP preset rendered through the real template engine against
simple, variant and parentless records and passing its own rules, the
requirements it triggers, minor-unit price conversion, product / variant
grouping of the API payload across two cron ticks with a stale spool discarded,
retry with a reused idempotency key, re-send from the kept spool, and gzip
output verified with
gzip -t. - Magento's coding standard (
phpcs.xml.dist) reports nothing.
Nothing has been exercised against a live Magento. Required before trusting
it: setup:upgrade completing; the admin loading at all; the feed grid and the
edit form rendering with a working "+" button on the condition tree (the
likeliest failure); a feed generating end-to-end with the row count matching the
filter; the published file being reachable at its public URL through nginx;
store emulation producing store-scoped product and image URLs on a cron run
rather than only from the admin; an SFTP test leaving the remote folder
untouched; and a Google data source being created and fetched against a real
Merchant Center account with a registered Cloud project. For the ACP additions:
the variant loader's SQL against a real configurable catalog (including Adobe
Commerce's row_id), SFTP key login against OpenAI's endpoint, and the Products
API against the base URL OpenAI issues — its host, rate limits and batch ceiling
are not public, so the defaults (100 products per PATCH, three attempts) are
guesses to confirm at onboarding.