If you publish a JSON version of a product’s facts, a “twin” of the page, it has one job: to say exactly what the page says, in a form a program can read without guessing. Generate it from the same source as the visible page. Before every release, test that the two agree on each value, unit, condition, scope, source, unknown and date. Put a version on the file so anyone reading it can tell which page it matches.
Be clear about what it isn’t. No platform we checked requires a twin, and none says it helps you rank. Google says you don’t need new machine-readable files for its AI features. On Shopify, the Catalog is the authoritative feed to agentic channels. A twin is a convenience for readers that want structured facts. The format and the checks below are our proposal.
What the platforms say about extra files
Before building anything, read what each route already takes.
| Route | What the documentation says | What it means for a twin |
|---|---|---|
| Google AI Overviews and AI Mode | No new machine-readable files, AI text files or markup needed; no special schema.org structured data. Make sure structured data matches the visible text | A twin isn’t required. If you publish one, the same matching rule is the sensible bar |
| Shopify agentic storefronts | Stores serve /agents.md, /llms.txt and /llms-full.txt automatically. Catalog is the authoritative product feed; those files don’t replace it |
Fix product data in Shopify first. A twin sits beside Catalog, not in place of it |
| ChatGPT | You don’t need a feed if ChatGPT crawls your site, but feeds give you more control. Shopify and Etsy catalogs are already integrated | Control comes from the feed or Catalog route, not a file on your site |
| OpenAI feed format | Omit unknown optional values; no “unknown” placeholders; empty isn’t zero or false; conflicting values aren’t reconciled for you | Good rules for any structured copy, including a twin |
Whether a product needs a public data endpoint at all is a separate decision. Our access-route guide works through it. This page assumes you’ve decided to publish one and want it to stay honest.
A versioned product-evidence twin
Here is a complete twin for one hypothetical product. Square brackets mark placeholders. The structure is what matters: every fact carries the exact text the page shows, so a test can compare them.
{
"twin_format": "product-evidence",
"twin_version": "1.2",
"record_version": 7,
"page_url": "https://example.com/products/humidifier-4l",
"built_from": "[content hash of the source record]",
"built_at": "2026-10-27T14:05:00-04:00",
"product": {
"group_id": "humidifier-4l",
"title": "4 L top-fill ultrasonic humidifier",
"variants": [
{ "variant_id": "humidifier-4l-white", "options": { "Colour": "White" } }
]
},
"facts": [
{
"fact_id": "runtime-max",
"applies_to": "humidifier-4l-white",
"value": 32,
"unit": "h",
"qualifier": "up_to",
"condition": "low mist setting, full 4 L tank",
"scope": "whole unit",
"state": "stated",
"visible_text": "Up to 32 hours on the low setting with a full 4 L tank",
"source": { "type": "manufacturer_test", "document": "[test report]", "issued": "[date]" },
"last_verified_at": "[date]"
},
{
"fact_id": "noise",
"applies_to": "humidifier-4l-white",
"value": null,
"unit": "dB(A)",
"state": "unknown",
"unknown_reason": "not measured",
"visible_text": null
},
{
"fact_id": "room-limit",
"applies_to": "humidifier-4l-white",
"polarity": "excludes",
"value": 400,
"unit": "sq_ft",
"qualifier": "not_above",
"state": "stated",
"visible_text": "Not for rooms over 400 sq ft",
"source": { "type": "manufacturer_spec", "document": "[manual]", "issued": "[date]" },
"last_verified_at": "[date]"
}
],
"offer": {
"variant_id": "humidifier-4l-white",
"price": "69.99 USD",
"availability": "in_stock",
"observed_at": "2026-10-27T14:05:00-04:00"
}
}
What each part is for:
| Part | What it holds | Why it’s there |
|---|---|---|
twin_format, twin_version |
The shape of the file | A reader can tell when the structure changed |
record_version, built_from |
Which edit of the product record this is, and a hash of its source | Ties the twin to one specific page build, so a cached copy can be spotted as old |
built_at |
When the file was made, with a time zone | Says when it was built, nothing more. Verification dates live on each fact |
facts[].visible_text |
The exact string the page shows, or null |
The anchor every parity test compares against |
facts[].state, unknown_reason |
stated, unknown, conflicting or not_applicable |
Keeps “not measured” apart from “zero” and from “nobody looked” |
facts[].polarity |
affirms or excludes |
“Not for rooms over 400 sq ft” is a fact too |
facts[].source, last_verified_at |
The document and when someone checked it | Lets a reader trace the claim |
offer |
Price, stock, and when they were read | Offer facts change on their own schedule, so they carry their own timestamp |
The internal claim record behind each fact, with every copy and whether it still matches, is covered in our claim-record guide. The twin is the public, page-shaped slice of it.
Notice the unknown. The noise fact is in the twin with "state": "unknown" and "visible_text": null, and the page says nothing about noise. That’s parity too. What breaks parity is a twin that says 0, or a page that says “whisper quiet” while the twin says unknown.
The parity checks
Run these on every change, before release. Each compares the twin with the rendered page, not with your memory of it. The last column shows the same kind of break in real records, all from copies inside Shopify catalog records in our snapshot of October 26, 2026.
| Check | Passes when | Fails when | Seen in a real record |
|---|---|---|---|
| Value | The twin’s value and unit appear in visible_text, and visible_text appears on the page |
The numbers differ | Description “up to 32 hours”; generated spec “Up to 36 hours” |
| Unit | The unit in the twin is the unit on the page | A unit or attribute is swapped | Description “Depth: 2.4lbs”; generated spec “Depth 2.4 in” |
| Scope | The twin’s scope matches the page’s wording | A part-level claim becomes a whole-product claim | Description: the hood’s insulation rated to -30°C. Generated spec: “Insulation” rated to -30°C |
| Unknown | Unknown in the twin means silence or “not rated” on the page, and never 0 | A blank becomes a zero | Our own first script turned 440 absent ratings into zeros, and 28 of 162 searches showed a median of 0 reviews |
| Presence | Every fact in the twin is visible on the page, and every factual claim on the page is in the twin | One copy says more than the other | Google’s rule: structured data should match the visible text |
| Variant | Each fact is bound to the same variant in both | A value from one size shows up on another | See the variants guide: a returned variant whose size wasn’t in the option list |
| Source and date | The twin’s source and last_verified_at match what the page cites |
The twin says “verified” where the page cites nothing | A build date presented as a check date. See the freshness guide |
| Offer | Price and stock in the twin equal the page at the same moment | They differ | See the price-mismatch guide |
Two rules make these checks reliable.
Compare against the rendered page. A test that compares the twin with the database it was built from only proves the build ran. Parse the HTML a shopper gets, find each visible_text string, and fail if it’s missing.
Fail closed on conflicts. OpenAI’s feed reference says neither its top-level fields nor its variant map will reconcile conflicting values. Don’t expect any other reader to either. If your two copies disagree, don’t publish either until the source is settled.
Worked example: how this site does it
We run the same idea on this site, for articles rather than products. Every article has a data twin at the same address plus .json. For this page, that’s /guides/machine-readable-product-data.json. The page links to it with a <link rel="alternate" type="application/json"> tag.
| What we do | How | What it doesn’t cover |
|---|---|---|
| One source | The page, the twin and the Article JSON-LD are built from the same frontmatter | The article body isn’t in the twin, so body tables are checked by editors, not tests |
| Field tests | For every article, an automated test checks that the twin’s id, title, description, answer, claims, sources, limitations, dates and kind equal the page’s source fields, and that the JSON-LD headline, description and dates equal the twin’s | Agreement at build time, not after someone edits a deployed file by hand |
| Rendered checks | A browser script loads each page and compares its title, meta description, H1, answer, deck, checked date and limitations with the twin | It counts claim blocks on the page against the twin’s claims; it doesn’t compare their text |
| Dates per claim | Each claim carries its own source date; the article carries published, updated and checked | Whether the sources still say the same thing today. That needs a human re-read |
Building from one source makes drift much less likely. It doesn’t make it impossible. A hand edit to a deployed file, a template bug or a body table that disagrees with a claim can all get past it, which is why the rendered checks exist and why editors still read the body against the claims.
One more lesson from our own files. Our homepage’s data version carries a generated date. It records when the file was built, in UTC, so a build on the evening of October 27 in Toronto reads October 28. In the product twin above, that kind of date is built_at, with its time zone, and it never stands in for last_verified_at.
Generate it, don’t maintain it by hand
A hand-maintained twin will drift. The safe shape is one source record that feeds every copy:
| Copy | Built from | Checked by |
|---|---|---|
| Product page text | The source record, through your theme or template | Rendered-page checks |
| Twin JSON | The same source record | Value, unit, scope, unknown and presence checks |
| Product JSON-LD | The same source record | Google’s rule: match the visible text |
| Feed or Catalog fields | Your product data in Shopify, or your feed | The received record, read back after each change |
On Shopify, the last row is the one agents on the Catalog route actually receive. Shopify marks some Catalog fields, including the description and generated spec lists, as generated or enriched by Shopify. A perfect twin doesn’t fix a wrong Catalog field. Fix the product data first, then make the twin agree with it.
What a twin can’t do
- It can’t make you rank. Nothing we read says any assistant prefers sites with JSON twins, and we haven’t measured it.
- It can’t replace the route that matters. On Shopify, ChatGPT and Copilot get products through Catalog. Google AI Mode and Gemini get them through the Google & YouTube channel, which Shopify says is still rolling out to eligible stores.
- It can’t make a fact true. Parity proves two copies agree. If both say 36 hours and the manual says 32, both are wrong.
- It can’t carry hidden instructions. A twin holds facts the page shows, nothing aimed at the agent instead of the shopper.
How we checked
On October 27, 2026 we re-read Google’s AI features page, Shopify’s agentic storefronts help page, OpenAI’s merchant page and its products feed reference (Stable), and quoted or paraphrased only what they say now.
The real failure examples are copies inside Shopify agent-catalog records saved on October 26, 2026 (162 searches, top 10 each, no buyer context), already published in our receipts and missing review data audit. They compare a record’s description with Shopify’s generated lists, not a merchant’s page with a twin.
For this site’s twins, we read the twin builder, the article tests and the browser verification script in our repository on October 27, 2026, and opened a locally built twin and its page to confirm the alternate link and fields. We describe what those checks compare; we didn’t change them for this article.
Your next step
Pick one product and write down five facts its page states, each with the exact sentence the page uses. If you already publish structured data or a JSON file for that product, check each fact against it: same value, same unit, same condition, same variant. Any fact that appears in only one of the two, and any blank that became a zero, is your first fix.