A price in a JSON-LD block is a claim made directly to a machine, and it is the one claim on a product page an agent is most likely to act on without reading anything else. That makes it the most valuable field on the page and the most dangerous one to get wrong. This post covers what a correct Product and Offer block looks like, exactly what checks B2 and C5 test for, why a schema price that disagrees with the visible price is worse than no schema, and how to compare the two yourself, because the scanner does not yet do that part for you.
It assumes the engine has typed your site as e-commerce. If it has not, C5 does not run and its four points are shared across C1 to C4; which schema types your site type actually needs explains how the type is detected and how to make a storefront's homepage say what it is.
What a correct block looks like
{
"@context": "https://schema.org",
"@type": "Product",
"name": "Field notebook, A5, dot grid",
"sku": "NB-A5-DOT",
"description": "192 pages of 100gsm dot-grid paper, sewn binding, lies flat.",
"image": "https://example.com/images/nb-a5-dot.jpg",
"brand": { "@type": "Brand", "name": "Example Paper Co" },
"offers": {
"@type": "Offer",
"url": "https://example.com/products/field-notebook-a5-dot",
"price": "14.00",
"priceCurrency": "USD",
"availability": "https://schema.org/InStock",
"itemCondition": "https://schema.org/NewCondition",
"seller": { "@type": "Organization", "name": "Example Paper Co" }
}
}
Three fields carry the weight. price is a plain number, as a number or a string, with no currency symbol and no thousands separator: 14.00, not $14.00 and not 1,400. priceCurrency is a three-letter ISO 4217 code, USD or SGD, never a symbol. availability is one of the schema.org enumeration URLs written in full: https://schema.org/InStock, OutOfStock, PreOrder, BackOrder, SoldOut, Discontinued or LimitedAvailability. The bare word InStock is a string that happens to look right; the URL is the value a consumer matching against the vocabulary will recognise.
The property definitions are at schema.org/Offer, and Google's product structured data documentation sets out which properties its own features require and how it expects prices and availability to be formatted. The two agree on everything above, so the block that helps an agent is the same block that helps a search engine.
Emit the block server-side, in the raw HTML, one per product page. Both checks read the raw fetch, not a rendered DOM, so a block injected by a tag manager or a storefront script after load is invisible to them and to any agent doing a plain fetch. That is the fifth mistake in JSON-LD that parses, and it is disproportionately common on product pages, because that is where pricing widgets live.
How B2 and C5 test for it
B2 collects every @type across all crawled pages and, for a site typed e-commerce, awards its three type-specific points if the set contains Product or Offer. Either one. It reads nested objects, so an Offer inside a Product's offers is found. It does not look inside the Offer for a price.
C5 is where the rubric text says "price and availability in schema matching the page". As of rubric v1.0.1 the engine's test for that point is narrower than the sentence: it takes the first crawled page whose URL contains /product/, /products/, /shop/, /collections/ or /item/, and awards the point if the raw HTML of that page contains the literal text "@type": "Offer", with any whitespace around the colon. If no such page was crawled it looks at the homepage instead. The result is the offerSchema field in the C5 evidence, alongside productPageFound, cartReachable and expressCheckout.
Two consequences are worth knowing before you scan. An AggregateOffer on its own does not pass, because the test is a literal match on "Offer" immediately after the colon and "AggregateOffer" is a different string. If your product has a price range, include a plain Offer for the default variant as well, which is also what an agent wants, because a range is not a price it can act on. And a product page whose URL has none of the five segments is not recognised as a product page: stores using /p/123 or a bare slug at the root will see productPageFound false, and C5 falls back to the homepage, which usually has no Offer. That limitation is stated in the evidence rather than hidden; the cheapest fix is to make sure at least one product page under a conventional path is linked from the homepage or listed in the sitemap.
So the scanner can tell you that an Offer is present on a product page. It cannot, as of this writing, tell you that the price inside it is the one on the page. That part is yours, and it is the part that matters most.
Why a mismatch is worse than no schema
Consider an agent asked to find a specific notebook under fifteen dollars. It fetches your product page. If there is no schema, it reads the visible text, finds the price in the prose, and proceeds. Slower, less certain, but correct.
If there is schema, it reads the Offer first. Structured data exists precisely so that a machine need not parse prose, and a well-built consumer treats it as the authoritative statement of price and stock. If the Offer says 14.00 and InStock while the page says 16.00 and back order, the agent has two answers, and the one it is designed to prefer is wrong. It reports the budget as met, adds the item to a comparison, or attempts a checkout that fails at the last step. Nobody on your side is told. From the person's point of view your site lied, and in any system that scores sources by how often they turn out to be right, that outcome is part of the record.
No schema costs you a machine-readable price. Wrong schema costs you a transaction and some standing. Google's structured data guidelines make the same point from the search side: marked-up content is expected, as of this writing, to reflect what is visible on the page.
Where mismatches come from
They are rarely deliberate. The usual sources are architectural.
- Two renderers. The visible price comes from a client-side component reading the live cart API; the schema is rendered by the server template from the catalogue price at build or cache time. The page is right, the schema is stale, and nobody looks at the schema.
- Variants. The schema describes the default variant; the page shows whichever variant the URL or selector chose. A
?variant=URL with a different price and the same Offer is the commonest single-page mismatch. - Sale pricing. The template writes the list price into
priceand the discount is applied by a promotions layer the template does not know about. - Currency switching. The storefront localises the visible price by geography while the schema is fixed to one currency, so an agent fetching from another region sees two currencies on one page.
- Stock on different schedules. Availability is copied into the schema from a nightly feed; the page reads the warehouse in real time. The Offer says
InStockfor hours after the last unit sold.
In every case the cure is the same: one source of truth for price and availability, read at request time, feeding both the visible page and the schema. A headless storefront that assembles the page from one API and the schema from a separate service has two sources by construction and needs a test that compares them.
Compare the two yourself
Fetch a product page without a browser, pull the Offer out of the raw HTML, and check that its price appears in the visible text. This is the comparison the rubric describes and the scanner does not yet perform.
curl -s https://example.com/products/field-notebook-a5-dot | node -e '
const html = require("fs").readFileSync(0, "utf8");
const re = /<script[^>]*type="application\/ld\+json"[^>]*>([\s\S]*?)<\/script>/gi;
const blocks = [...html.matchAll(re)].map((m) => JSON.parse(m[1]));
const offers = [];
const walk = (n) => {
if (Array.isArray(n)) return n.forEach(walk);
if (!n || typeof n !== "object") return;
if (n["@type"] === "Offer") offers.push(n);
Object.values(n).forEach(walk);
};
walk(blocks);
const text = html
.replace(/<script[\s\S]*?<\/script>|<style[\s\S]*?<\/style>/gi, " ")
.replace(/<[^>]+>/g, " ");
if (offers.length === 0) console.log("no Offer in the raw HTML");
for (const o of offers) {
const price = String(o.price ?? "");
const onPage = price !== "" && text.includes(price);
console.log(
`${o.priceCurrency ?? "?"} ${price || "(no price)"} ${o.availability ?? "(no availability)"} visible on page: ${onPage ? "yes" : "NO"}`,
);
}
'
Run it against three or four product pages, including one on sale, one with variants and one out of stock. A NO is not always a mismatch: a schema price of 14.00 against a visible $14 is a formatting difference you can judge by eye. What you are looking for is a different number, a different currency, or an InStock on a page that says otherwise. If you find one, the two renderers are reading different sources and the fix is upstream of the template. If that is work you would rather hand over, aligning schema output with the page is squarely within the implementation service.
Check your own site
The free scan tells you whether the engine typed you as a shop, whether it found a product page, and whether that page carries an Offer in its raw HTML, and the e-commerce leaderboard shows how other shops scored on the same checks. The definitions of B2 and C5 are on the methodology page under understanding and actionability.