Search by

magenxcommerce / module-product-feed

magenxcommerce

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

Statistics

Installs: 0

Dependents: 2

Suggesters: 0

Stars: 1

Open Issues: 0

v1.1.2 2026-10-05 19:04 UTC

This package is auto-updated.

Last update: 2026-10-24 19:34:44 UTC


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 bool as lowercase true / false and a list as one comma-separated cell with commas inside an item encoded as %2C; json stays 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

  1. Enable the Merchant API on a Google Cloud project.
  2. Create a service account, download its JSON key, and upload it to var/magenx_feed/gmc/ — never pub/media, which is web-served; that key grants programmatic access to the Merchant Center account.
  3. Add the service account's client_email as a user on the Merchant Center account (Settings → Users), Standard access or higher.
  4. 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.
  5. 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 and is_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_id is 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.
  • url is 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.
  • brand is manufacturer (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; a gtin rule (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.xml default or comment. Magento emits COMMENT "%s" and DEFAULT "%s" unescaped, so one unbalances the whole CREATE TABLE and setup:upgrade fails 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\Parser walks 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\Builder with the CatalogWidget condition classes — only those implement getMappedSqlField(). 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 -l clean on all PHP files; xmllint clean 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.xml files: 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.xml field using the email-template source model has a matching <template id> of section_group_field. (A mismatch renders the whole config section blank, with only a line in var/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.