Generate product feeds for advertising platforms like Google, Meta, and Amazon.
Product feeds export your catalog in formats required by advertising platforms:
Start with a Vendure Query extractor to get product data:
.extract('get-products', {
adapterCode: 'vendureQuery',
entity: 'PRODUCT',
relations: ['variants', 'featuredAsset', 'collections', 'translations'],
languageCode: 'en',
batchSize: 1000,
})
Map fields to feed format:
.transform('prepare-feed', {
operators: [
{ op: 'template', args: {
template: 'https://mystore.com/products/${slug}',
target: 'link',
}},
{ op: 'template', args: {
template: 'https://mystore.com${featuredAsset.preview}',
target: 'image_link',
}},
{ op: 'set', args: { path: 'condition', value: 'new' }},
{ op: 'copy', args: { source: 'variants.0.stockLevel', target: 'stockStatus' }},
{ op: 'lookup', args: {
source: 'stockStatus',
map: {
'IN_STOCK': 'in_stock',
'OUT_OF_STOCK': 'out_of_stock',
'LOW_STOCK': 'in_stock',
},
target: 'availability',
default: 'out_of_stock',
}},
],
})
Use the feed step to output the final file:
.feed('generate-google-feed', {
adapterCode: 'googleMerchant',
format: 'XML',
outputPath: 'feeds/google-shopping.xml',
targetCountry: 'US',
contentLanguage: 'en',
currency: 'USD',
storeUrl: 'https://mystore.com',
})
| Field | Source | Notes |
|---|---|---|
| id | product.id or variant.sku | Unique identifier |
| title | name | Product title |
| description | description | Product description |
| link | Generated URL | Product page URL |
| image_link | featuredAsset.preview | Main product image |
| price | variant.price | Format: “29.99 USD” |
| availability | stock status | in_stock, out_of_stock |
| condition | Usually “new” | new, used, refurbished |
| Field | Source |
|---|---|
| brand | customFields.brand |
| gtin | customFields.gtin |
| mpn | variant.sku |
| google_product_category | Google taxonomy ID |
| product_type | collection path |
| item_group_id | product.id (for variants) |
| color, size, material | option values |
| shipping | shipping configuration |
| custom_label_0-4 | Custom segmentation |
Vendure query price fields are minor-unit integers, so built-in feeds default priceUnit to MINOR and format them with the configured Vendure MoneyStrategy precision. Set priceUnit: 'MAJOR' only for external records that already contain values such as 19.99.
.feed('google-feed', {
adapterCode: 'googleMerchant',
format: 'XML',
outputPath: 'feeds/google.xml',
// Required settings
targetCountry: 'US',
contentLanguage: 'en',
currency: 'USD',
storeUrl: 'https://mystore.com',
// Field mappings
titleField: 'name',
descriptionField: 'description',
priceField: 'variants.0.price',
imageField: 'featuredAsset.preview',
brandField: 'customFields.brand',
gtinField: 'customFields.gtin',
// Options
includeOutOfStock: false,
})
| Field | Source |
|---|---|
| id | unique identifier |
| title | product name |
| description | product description |
| availability | in stock, out of stock |
| condition | new, refurbished, used |
| price | formatted price |
| link | product URL |
| image_link | product image URL |
.feed('meta-catalog', {
adapterCode: 'metaCatalog',
format: 'CSV', // or 'xml'
outputPath: 'feeds/meta-catalog.csv',
currency: 'USD',
// Field mappings
titleField: 'name',
descriptionField: 'description',
priceField: 'variants.0.price',
imageField: 'featuredAsset.preview',
brandField: 'customFields.brand',
// Options
includeVariants: true,
})
Meta accepts CSV with these headers:
id,title,description,availability,condition,price,link,image_link,brand
.feed('amazon-feed', {
adapterCode: 'amazonFeed',
outputPath: 'feeds/amazon.txt',
currency: 'USD',
// Field mappings
titleField: 'name',
descriptionField: 'description',
priceField: 'variants.0.price',
imageField: 'featuredAsset.preview',
brandField: 'customFields.brand',
gtinField: 'customFields.gtin',
})
Create feeds in any format for any platform.
.feed('custom-csv', {
adapterCode: 'customFeed',
format: 'CSV',
outputPath: 'feeds/products.csv',
fieldMapping: {
'Product ID': 'id',
'Product Name': 'name',
'Price': 'variants.0.price',
'Category': 'collections.0.name',
},
})
.feed('custom-json', {
adapterCode: 'customFeed',
format: 'JSON',
fieldMapping: {
sku: 'sku',
name: 'name',
price: 'variants.0.price',
},
outputPath: 'feeds/products.json',
})
.feed('custom-xml', {
adapterCode: 'customFeed',
format: 'XML',
fieldMapping: {
sku: 'sku',
name: 'name',
price: 'variants.0.price',
},
outputPath: 'feeds/products.xml',
})
Custom XML uses <feed> as its root and <item> for each mapped record.
Every custom feed requires a non-empty fieldMapping; omitting it fails before
any source fields are written.
Save to the file system:
outputPath: 'feeds/google.xml'
Files are written under DATA_HUB_EXPORT_ROOT, which defaults to <cwd>/exports; the example resolves to <cwd>/exports/feeds/google.xml. Feed files are not Vendure assets. Keep outputPath relative and use S3 or HTTP destination settings for remote delivery.
Upload directly to S3:
bucket: 'my-feeds-bucket',
prefix: 'google/',
connectionCode: 'aws-s3',
POST the feed to an API:
outputUrl: 'https://api.platform.com/feeds',
bearerTokenSecretCode: 'platform-api-key',
Generate feeds on a schedule:
const feedPipeline = createPipeline()
.trigger('schedule', {
type: 'SCHEDULE',
cron: '0 4 * * *', // Daily at 4 AM
})
.extract('get-products', { ... })
.transform('prepare', { ... })
.feed('generate', { ... })
.edge('schedule', 'get-products')
.edge('get-products', 'prepare')
.edge('prepare', 'generate')
.build();
The Admin API also exposes persisted feed resources. Unlike a pipeline FEED
step, a managed feed queries the active Vendure channel’s product variants,
stores its current artifact through the Data Hub storage backend, and returns a
permissioned download URL.
mutation CreateCatalogFeed {
createDataHubFeed(input: {
code: "google-catalog"
name: "Google catalog"
format: GOOGLE_SHOPPING
options: {
currency: "EUR"
baseUrl: "https://shop.example.com"
}
schedule: {
enabled: true
cron: "0 4 * * *"
timezone: "Europe/Berlin"
}
}) {
id
code
schedule { enabled cron timezone }
}
}
Feed codes are unique within a channel. The request’s active channel is
authoritative; channelToken is accepted only when it matches that channel.
Every built-in format requires options.baseUrl so product, image, and feed
links use the real storefront origin. Data Hub rejects a missing or invalid URL
instead of publishing placeholder links. Custom generators may omit it when
their own output has no storefront URLs.
Schedules use standard five-field cron expressions and optional IANA timezone
names. Distributed locking prevents two instances from claiming the same feed
minute when a shared lock backend is configured.
Creation is strict: it rejects a code that already exists in the active
channel. Retrieve the feed ID from dataHubFeeds or dataHubFeed(id: ...), then
replace its complete definition explicitly:
mutation UpdateCatalogFeed($id: ID!) {
updateDataHubFeed(id: $id, input: {
code: "google-catalog"
name: "Google catalog"
format: GOOGLE_SHOPPING
options: {
currency: "EUR"
baseUrl: "https://shop.example.com"
}
schedule: {
enabled: true
cron: "0 3 * * *"
timezone: "Europe/Berlin"
}
}) {
id
updatedAt
downloadUrl
}
}
Any definition change invalidates and removes the previous generated artifact. An identical full update is idempotent and preserves the current artifact. Deleting the feed also removes its current artifact:
mutation DeleteCatalogFeed($id: ID!) {
deleteDataHubFeed(id: $id) {
result
message
}
}
Generate and store the current artifact with:
mutation GenerateCatalogFeed {
generateDataHubFeed(feedCode: "google-catalog") {
success
itemCount
generatedAt
downloadUrl
errors
warnings
}
}
The generated URL has the form /data-hub/files/:id/download. Generating a feed
requires ManageDataHubFeeds; downloading the artifact requires
ReadDataHubFiles. Regenerating replaces the current artifact, and changing the
feed definition invalidates the previous artifact.
Dashboard previews request 20 items. The Admin API accepts an integer limit from 1 to 1,000 and defaults to 10. Preview queries apply the item limit before feed rendering. The response always contains a complete feed document; content larger than 1 MiB is rejected with a request to lower the item limit rather than being returned as invalid truncated JSON, XML, or CSV.
includeOutOfStock: false
Add a filter step:
.transform('filter-electronics', {
operators: [
{ op: 'when', args: {
conditions: [{ field: 'collections.0.name', cmp: 'contains', value: 'Electronics' }],
action: 'keep',
}},
],
})
.transform('custom-filter', {
operators: [
{ op: 'when', args: {
conditions: [
{ field: 'variants.0.price', cmp: 'gte', value: 10 },
{ field: 'variants.0.stockLevel', cmp: 'ne', value: 'OUT_OF_STOCK' },
],
action: 'keep',
}},
],
})