Ecommerce Products
The Topol editor lets your users place products from your store directly into their emails - either specific products they select manually while designing the template, or a dynamic list that fills in at send time, such as an abandoned cart, a "last viewed" rail, or personalized recommendations.
This is handled by the Product block: a built-in block that renders a product card (image, title, description, price, optional discounted price, and a button) and can be repeated as a grid or a list.
How It Works
The Product block can work in two ways:
- Static – the user picks specific products from a feed, and those exact products are saved into the template.
- Dynamic – the block is connected to a Merge Tag (e.g. a shopping cart) and the ESP fills in the actual products at send time, looping over the array you pass in the payload.
To make products available in the editor, you'll need to expose two API endpoints, plus an optional third one for categories:
/{API.FEEDS}– returns the list of feeds./{API.PRODUCTS}– returns products for a specific feed./{API.PRODUCT_CATEGORIES}– optional. Lets users filter products by category inside a feed.
All endpoints should return structured JSON and support pagination and filtering.
WARNING
Before implementing the endpoints, check how to work with API endpoints.
List Feeds
Fetches all available product feeds for the user to select from.
- URL:
/{API.FEEDS} - Method:
GET - Content-Type:
application-json
Params:
| key | value |
|---|---|
| id | Feed ID |
| search | Text search across feed names |
| per_page | Number of feeds per page (default: 10) |
| current_page | Which page of paginated data to return |
Response:
{
"success": true,
"data": [
{
"id": "id of the feed",
"name": "name of the feed"
}
],
// Pagination helpers
"from": "first id of the resource",
"to": "last id of the resource",
"total_records": "total records of the resource",
"per_page": "resource per page",
"current_page": "current page of the resource",
"last_page": "last page of the resource"
}List Products
Returns a paginated list of products for a specific feed. Triggered when a feed is selected in the editor UI.
- URL:
/{API.PRODUCTS} - Method:
GET - Content-Type:
application-json
Params:
| key | value |
|---|---|
| id | Product ID |
| search | Search string to filter products |
| per_page | Number of products per page (default: 10) |
| current_page | Current page of paginated products |
| feed | The ID of the selected feed |
| category_id | Optional category to filter products by |
Response:
{
"success": true,
"data": [
{
"id": "product id",
"name": "name of the product",
"description": "description of the product",
"url": "link to the product",
"img_url": "link to an image of the product",
"price_with_vat": "price of the product including VAT", // Without currency
"currency": "currency of the price",
"price_before": "original price before discount",
"product_feed_id": "id of the feed product belongs to"
}
],
// Pagination helpers
"from": "first id of the resource",
"to": "last id of the resource",
"total_records": "total records of the resource",
"per_page": "resource per page",
"current_page": "current page of the resource",
"last_page": "last page of the resource"
}List Product Categories
Optional. Returns the categories for a specific feed, letting users narrow products by category inside the selection modal. If this endpoint is not configured, the category dropdown is hidden and users filter by feed only.
- URL:
/{API.PRODUCT_CATEGORIES} - Method:
GET - Content-Type:
application-json
Params:
| key | value |
|---|---|
| feed_id | The ID of the selected feed (categories are scoped to a feed) |
| search | Text search across category names |
| per_page | Number of categories per page (default: 10) |
| current_page | Which page of paginated data to return |
Response:
{
"success": true,
"data": [
{
"id": "category id",
"name": "category name",
"feed_id": "id of the feed this category belongs to"
}
],
// Pagination helpers
"from": "first id of the resource",
"to": "last id of the resource",
"total_records": "total records of the resource",
"per_page": "resource per page",
"current_page": "current page of the resource",
"last_page": "last page of the resource"
}Layout Options
The Product block supports a few layout variants, so the same data can be presented as a single column, a side-by-side image and text composition, or a grid.

- Layout –
column,image-text, ortext-image. Decides whether the image sits above the text, to its left, or to its right. - Items per row –
1to4. How many products sit next to each other in a row. - Stack columns on mobile – Decides whether products in a row stack vertically on mobile or stay side-by-side.
INFO
In dynamic mode, items per row acts as a maximum rather than a fixed number. The renderer can shrink the row when fewer products come through in the payload, but it needs a small bit of help from you to do that - see Dynamic Mode below.
Static Mode
Static mode is the default. The user opens the Product block, picks a feed (and a category, if you've enabled them), then chooses the products they want from the modal. Those products are saved into the block's items array, so the template stands on its own without any Merge Tags or ESP-side rendering.

TIP
Use static mode when the products are hand-picked per template, for example a weekly highlight, a featured collection, or a one-off promo where the lineup shouldn't change between sends.
Dynamic Mode
In dynamic mode, the user doesn't pick individual products. Instead, they connect the block to a Merge Tag, such as a "Shopping cart" or "Last viewed" list, and the ESP fills in the real products at send time, looping over the array you pass in the payload.

To enable dynamic mode, three options need to be set in TOPOL_OPTIONS:
emailServiceProvider: "sparkpost",
productMergetagTags: [
{ label: "Shopping cart", value: "MT_FOR_ARRAY" },
],
smartMergeTags: {
enabled: true,
syntax: {
start: "{{",
end: "}}",
},
},emailServiceProvider: "sparkpost"– dynamic mode relies on SparkPost-compatible loop syntax.productMergetagTags– the Merge Tags users can pick from in the block's dynamic dropdown. Each entry becomes one option, wherelabelis what the user sees andvalueis what the renderer uses internally.smartMergeTags– must be enabled with double curly bracket syntax. No other syntax is supported in dynamic mode.
One important detail: alongside the Merge Tag for the array (e.g. MT_FOR_ARRAY), your SparkPost payload must also include its length as a separate variable named exactly MT_FOR_ARRAY_count. The renderer uses this value to pick the right column width for the actual number of products in the array.
If _count is missing, the block still renders correctly, but every row will use the maximum items-per-row width and it won't expand to fill the row when fewer products are passed.
