Conversion
JSON to product feed
Headless storefronts, PIM exports and supplier APIs hand you products as JSON: one object per product, with variants, images and options nested inside. Shopping channels want the opposite, one flat row per sellable variant with their own column names. Google Merchant Center, Meta and Pinterest do not take a JSON file, and neither does Textfinity. So the conversion has two steps: flatten the JSON into a CSV with one row per variant, then map that CSV to each channel. This page gives a short jq command and a short Python script for the first step, and shows how Textfinity handles the second on a schedule.
We connect your store, build the feed and keep it in sync. Updated 2026-10-27.
At a glance
- No major shopping channel takes a nested JSON catalog. Each wants flat rows, one per sellable variant, in its own attribute names.
- Textfinity does not read JSON either. If you paste a JSON URL it tells you to export CSV instead. It reads CSV or TSV by URL (HTTPS or SFTP), Google Sheets, Google Shopping-format XML and Shopify.
- Flattening is a short job. Use the platform's own CSV export if it has one; otherwise a short jq filter or a short Python script writes one row per variant.
- Run the flatten on a schedule and publish the CSV at a fixed URL. Textfinity re-reads it on the shop's sync and rebuilds a CSV or TSV feed per channel.
Why JSON has to be flattened first
A JSON product export is a tree. In the example below, one product object holds a variants array, an images array and option values, so two products carry three sellable items. A product feed is a table: Google's product data specification wants one row per variant, linked by item_group_id, with id, price, availability, color and size on every row. Meta's catalog and Pinterest's catalog work the same way. Nothing in that chain walks a nested tree for you. Merchant Center's file formats are delimited text and RSS or Atom XML, plus Google Sheets and the Merchant API. Meta and Pinterest take CSV, TSV and XML.
Textfinity sits after the flatten. Its sources are Shopify (native, with variants), a CSV or other delimited file reachable by URL or SFTP, Google Shopping-format XML, and Google Sheets. A JSON URL is detected only so the app can ask you for a CSV. That keeps the flattening rule (which object is a row, which array becomes a column) in a script you own and can read, and the channel rules (column names, currency, availability words) in Textfinity, where they apply to every feed.
The source: a typical nested product JSON
This is the shape most headless platforms and product APIs return by default: product-level keys (id, title, vendor, url), an images array of objects, and a variants array with sku, price, inventory and option values. Prices arrive as strings with no currency, and inventory is a count rather than a word. Both recipes below turn exactly this file into the CSV in the download.
products.json (source)
[
{"id": 8821, "title": "Ribbed Crop Tank", "vendor": "Loom",
"url": "https://store.example/p/ribbed-tank",
"images": [{"src": "https://cdn.example/tank-1.jpg"},
{"src": "https://cdn.example/tank-2.jpg"}],
"variants": [
{"sku": "TANK-SAGE-S", "price": "24.00", "inventory": 12,
"option1": "Sage", "option2": "S"},
{"sku": "TANK-SAGE-M", "price": "24.00", "inventory": 0,
"option1": "Sage", "option2": "M"}
]},
{"id": 9912, "title": "Linen Lampshade", "vendor": "Hearth",
"url": "https://store.example/p/linen-lampshade",
"images": [{"src": "https://cdn.example/lamp-1.jpg"}],
"variants": [
{"sku": "LS-LINEN-01", "price": "59.00", "inventory": 4,
"barcode": "00195866188174"}
]}
]Flatten it with jq
jq is a small command-line JSON processor available in most package managers and CI images. The filter prints a header, then, for every variant of every product, one array combining the parent's keys with the variant's keys, written out as CSV. The first image becomes image_link and the remaining images are joined with commas into more_images. jq's @csv quotes every text field; the values are the same as the Python output below.
flatten with jq
jq -r '
["product_id","title","vendor","url","image_link","more_images",
"sku","price","inventory","option1","option2","barcode"],
(.[] | . as $p | .variants[] |
[$p.id, $p.title, $p.vendor, $p.url, $p.images[0].src,
([$p.images[1:][].src] | join(",")),
.sku, .price, .inventory, .option1, .option2, .barcode])
| @csv' products.json > products.csvOr flatten it with Python
The same logic in Python's standard library, for when the flatten runs inside an existing script or needs to call a paginated API first. Missing keys (the lampshade has no options, the tank has no barcode) become empty cells, not errors. For large catalogs, write rows as you read pages instead of loading the whole API response at once; the loop body stays the same.
flatten.py
import csv, json
products = json.load(open("products.json"))
cols = ["product_id", "title", "vendor", "url", "image_link", "more_images",
"sku", "price", "inventory", "option1", "option2", "barcode"]
with open("products.csv", "w", newline="") as f:
out = csv.writer(f, lineterminator="\n")
out.writerow(cols)
for p in products:
imgs = [i["src"] for i in p.get("images", [])]
for v in p["variants"]:
out.writerow([p["id"], p["title"], p["vendor"], p["url"],
imgs[0] if imgs else "", ",".join(imgs[1:]),
v["sku"], v["price"], v.get("inventory", ""),
v.get("option1", ""), v.get("option2", ""),
v.get("barcode", "")])From the flat CSV to channel feeds in Textfinity
Publish products.csv at a stable address: a web server or storage bucket over HTTPS, an SFTP server, or a Google Sheet the script writes to. We add that address as the source in Textfinity. It detects the delimiter, lists the columns, counts the products and runs feed hygiene, which reports fill rate per column, over-long values, mixed languages, inconsistent spellings and malformed image URLs. From there each channel gets its own feed from the same source: the Google Shopping, Meta Ads, Pinterest Ads, TikTok Ads, Microsoft Ads, Snapchat Ads and Amazon Ads templates pre-name the columns, and a custom feed covers channels without a template, such as Idealo or Awin.
Every feed is a CSV or TSV file on a stable URL, rebuilt on the shop's sync schedule and within about five minutes of a mapping edit (median rebuild 10.9 seconds on our platform). Merchant Center fetches that CSV in production on a scheduled fetch, even though Google's help page lists .txt, .tsv and .xml by extension. Commerce Manager and the other channels fetch their feed URL the same way; we register each one with you.
| Flat CSV column | Google Shopping | Meta catalog | Transform in Textfinity | |
|---|---|---|---|---|
| sku | id | id | id | rename |
| product_id | item_group_id | item_group_id | item_group_id | rename |
| title + option1 + option2 | title | title | title | advanced: if [option1] is empty then [title] else [title] + " – " + [option1] + " " + [option2] |
| url | link | link | link | rename |
| image_link | image_link | image_link | image_link | rename |
| more_images | additional_image_link | additional_image_link | additional_image_link | rename (comma-separated list) |
| price | price | price | price | add suffix " USD" |
| inventory | availability | availability | availability | advanced: if [inventory] equals "0" then "out_of_stock" else "in_stock" (Meta and Pinterest: "out of stock" / "in stock") |
| vendor | brand | brand | brand | rename |
| barcode | gtin | gtin | gtin | rename |
| option1 / option2 | color / size | color / size | color / size | rename |
| (absent) | condition | condition | condition | add static value "new" |
Channel attribute names from Google's product data specification, Meta's catalog fields reference and Pinterest's catalog spec, checked 2026-10-27. The availability spelling differs: Google in_stock, Meta and Pinterest 'in stock'.
The Google Shopping feed that comes out
Three rows, one per variant, in Google's attribute names: the two tank sizes share item_group_id 8821, the sold-out size reads out_of_stock, the currency is attached to the price, and the lampshade keeps its plain title because it has no options.
Google Shopping feed (CSV)
id,item_group_id,title,link,image_link,additional_image_link,availability,price,brand,gtin,condition,color,size
TANK-SAGE-S,8821,Ribbed Crop Tank – Sage S,https://store.example/p/ribbed-tank,https://cdn.example/tank-1.jpg,https://cdn.example/tank-2.jpg,in_stock,24.00 USD,Loom,,new,Sage,S
TANK-SAGE-M,8821,Ribbed Crop Tank – Sage M,https://store.example/p/ribbed-tank,https://cdn.example/tank-1.jpg,https://cdn.example/tank-2.jpg,out_of_stock,24.00 USD,Loom,,new,Sage,M
LS-LINEN-01,9912,Linen Lampshade,https://store.example/p/linen-lampshade,https://cdn.example/lamp-1.jpg,,in_stock,59.00 USD,Hearth,00195866188174,new,,Examples
Worked examples, by situation
Three JSON sources, three ways to get a flat CSV. Each shows the fragment that needed work and the route from it to a channel feed.
Nightly jq job in CI, CSV in a bucket
Before
{"id": 8821, "title": "Ribbed Crop Tank", "variants": [{"sku": "TANK-SAGE-S", "option1": "Sage", "option2": "S", "price": "24.00"}]}After
flat CSV: 8821,Ribbed Crop Tank,…,TANK-SAGE-S,24.00,12,Sage,S Google feed: TANK-SAGE-S,8821,Ribbed Crop Tank – Sage S,…,in_stock,24.00 USD
How we set it up
The storefront's product API has no CSV export, so a scheduled CI job runs the jq filter above and uploads products.csv to a public bucket path. We add that URL as the source, build the Google Shopping feed and map id from sku, item_group_id from product_id, title by the advanced rule and price with add suffix " USD". If the storefront runs on Shopify, skip all of this and connect Shopify natively.
The PIM's own CSV export, dropped on SFTP
Before
{"sku": "LAMP-T-02", "images": ["https://cdn.example/lamp-1.jpg", "https://cdn.example/lamp-2.jpg"], "category": {"path": "Home/Lighting/Table lamps"}}After
image_link: https://cdn.example/lamp-1.jpg additional_image_link: https://cdn.example/lamp-2.jpg product_type: Home > Lighting > Table lamps
How we set it up
Most PIMs can export CSV directly, so use that instead of the JSON API: one row per SKU, images joined with commas in one quoted cell, scheduled nightly to an SFTP server Textfinity pulls from. We map image_link with remove from regexp ,.*$, which keeps the part before the first comma; additional_image_link with remove from regexp ^[^,]*,?, which keeps the rest, already comma-separated; product_type with replace from regexp / to ' > '; and google_product_category as a replace from google sheet on the path, conditioned on is in google sheet list.
Python over the pages, availability words in Textfinity
Before
{"page": 3, "items": [{"sku": "HP-4421", "stock": 0, "backorder": true, "price": 149}]}After
flat CSV: HP-4421,149,0,true Meta feed: availability out of stock · price 149.00 EUR
How we set it up
A cron job runs the Python script with a loop over the API's pages, writing each item as a row, and uploads the CSV to SFTP. In Textfinity we map availability with the advanced expression if [stock] equals "0" then "out of stock" else "in stock" and price with [price] + ".00 EUR" for whole-euro prices, and add an exclude filter "stock is less or equal to 0" when sold-out items should leave the catalog.
Copy this plan
Plan: JSON source to channel feeds
An afternoon, most of it on the flatten. After that, we set up the channel work in Textfinity and it repeats on its own.
01
Look for a CSV export before writing code
Most platforms and PIMs that serve JSON can also export CSV, and a Shopify storefront connects natively. A built-in export is maintained by the vendor; a script is maintained by you.
02
Flatten to one row per variant
Run the jq filter or the Python script against the JSON. Keep product-level keys on every variant row and give each variant its own sku. Check the row count against the number of variants.
03
Publish the CSV on a schedule
Schedule the flatten (cron, CI or the PIM's export scheduler) and write the CSV to a fixed HTTPS path, an SFTP server or a Google Sheet.
What we set up: You send us the address; we add the CSV URL (HTTPS or SFTP) or Google Sheet as the source in Textfinity.04
Create a feed per channel
Pick the channel template: Google Shopping, Meta Ads, Pinterest Ads, TikTok Ads, Microsoft Ads, Snapchat Ads, Amazon Ads, eBay, Kelkoo, Prisjakt or PriceRunner. Use a custom feed with the channel's columns for anything else.
What we set up: We build each feed from its channel template, or as a custom feed.05
Map identifiers, numbers and words
id from sku, item_group_id from the product id, title by combine or an advanced rule, currency suffix on price, an advanced rule for availability, a static value for condition.
What we set up: We map id as a rename, price with add suffix and availability with an advanced rule.06
Filter, check hygiene and publish
Exclude rows without an image or a price, read feed hygiene for empty required columns, then give each channel the feed URL to fetch on a schedule.
What we set up: We add the filter rules, read feed hygiene and register each feed URL with you in the channel.
Customization tips
Make it yours
Decide the row level before you flatten
Variant rows give channels exact sizes, prices and stock but multiply the SKU count (and the catalog size you are quoted on). Product rows suit single-variant catalogs. Pick once and keep ids stable.
Keep ids from the source, not from position
An array index shifts when a product is deleted; a SKU does not. Use sku or a stable product id so channels keep performance history across rebuilds.
Flatten in the script, rename in Textfinity
Let the script decide only rows and columns, using the source's own key names. Channel spelling, currency and availability words belong in mapping rules, where one change updates every feed.
Paginated APIs need one finished file
Textfinity reads one URL per shop. Collect every page into the CSV before you publish it, and write to a temporary name then rename, so a fetch never sees a half-written file.
Downloads
Take the files
FAQ
Questions about json to product feed
Can Google Merchant Center read a JSON product feed?
No. Merchant Center takes delimited text and RSS or Atom XML files (Google's help page lists .txt, .tsv and .xml), Google Sheets and Merchant API submissions, not a JSON file. Flatten the JSON to one row per variant first. Merchant Center does fetch Textfinity's comma-delimited CSV in production on a scheduled fetch.
Does Textfinity read JSON?
No. A JSON URL is detected only so the app can ask for a CSV instead. Flatten the JSON with the platform's CSV export, the jq filter or the Python script on this page, publish the CSV by URL, SFTP or Google Sheet, and we map it to each channel in Textfinity, which rebuilds on the shop's schedule.
How do I convert nested JSON to CSV with one row per variant?
Loop over products, then over each product's variants, and write one row per variant that repeats the product-level keys (id, title, brand, url, first image). Join the remaining images into one comma-separated cell. The jq filter and the Python script above do exactly this; missing keys become empty cells.
Can one JSON source produce feeds for several channels?
Yes, once it is a CSV. Each Textfinity feed has its own template, mapping and filters, and all of them rebuild from the same source. We set up each feed for you; the number of feeds is part of the per-catalog quote.
Can I set this up from code?
Textfinity has no public REST API. Clients can connect an AI assistant such as Claude to Textfinity over MCP; it can list shops and feeds, create a feed, add mapping and filter rules, run feed hygiene and read a feed's output. The flatten itself stays in your script.
Set up for you
Flatten the JSON once; we set up the feeds.
Publish the flat CSV by URL, SFTP or Google Sheet and send us the address. We pick the channel templates and map the columns in Textfinity, and the software rebuilds every channel feed on a schedule.
Tell us your store and channels. We reply within 24 hours.