HomeBlog › Subscription pricing and AI agents

Shopify subscription pricing and AI shopping agents: why they show the wrong price

2026-06-02  •  ~14 min read  •  Tags: subscriptions, PriceSpecification, JSON-LD, Recharge, Bold

Your subscribe-and-save price is typically 15–20% cheaper than the one-time price. AI shopping agents almost never know this, because the four dominant subscription apps on Shopify all inject pricing via JavaScript — and AI crawlers read your initial HTML, not the DOM after JS executes. This post explains the problem mechanically, shows what each major agent displays instead, and walks through the complete structured-data fix.

The invisible discount problem

When a shopper asks ChatGPT Shopping "find me the best price on collagen peptides powder, 20oz," and your store sells the product for $39.99 one-time but $33.99 on subscription, the AI agent quotes $39.99. A competitor who has done the structured data correctly, and whose subscribe-and-save price of $36.99 is embedded in their JSON-LD, shows up at $36.99. You're more expensive — even though a committed buyer would pay less at your store.

This is the invisible discount problem. It doesn't show up in your Shopify analytics because it happens before the visit. The shopper compares prices across platforms, sees your one-time rate, and goes elsewhere. You never see the session that didn't happen.

In our 100-store scan, 94% of stores with subscription pricing active had no PriceSpecification JSON-LD differentiating subscription from one-time pricing. Of those, zero displayed subscription savings in ChatGPT Shopping or Perplexity Shopping results during our verification checks.

This isn't a fringe edge case. Subscription products are a significant slice of DTC e-commerce — supplements, pet food, coffee, personal care, cleaning products. The entire business model assumes recurring revenue. But the catalog signals that AI shopping agents rely on were designed for one-time purchases, and the apps that power Shopify subscriptions have not updated their approach to account for crawler behavior.

What AI shopping agents actually read

AI shopping agents fall into two categories when reading your product pages: those that execute JavaScript and those that don't.

Google's traditional crawlers do eventually render JavaScript, but the Shopping Graph that feeds Google AI Mode pulls pricing primarily from your Google Merchant Center feed and from JSON-LD in the initial HTML response. Rendering JavaScript for every product page in real time is computationally expensive, so the structured data path is faster and more reliable.

GPTBot (ChatGPT Shopping) reads your initial HTML. It does not execute JavaScript. What it reads is what's in the page when the server sends the response — the Shopify server-rendered output, including any Liquid-generated JSON-LD in your theme, and nothing else. No JS-injected prices. No dynamically updated variant selectors. No subscription widget.

PerplexityBot behaves similarly: HTML-first, JSON-LD when present, no client-side execution. It does follow the schema fields more strictly than GPTBot, which means correct PriceSpecification markup has a higher immediate payoff for Perplexity Shopping results.

The verification test: curl -s -A "GPTBot" https://yourstore.myshopify.com/products/your-product | grep -i "priceCurrency\|price\|PriceSpecification" — this shows exactly what ChatGPT Shopping reads from your product page. Compare the price value in that output to what a subscriber would actually pay.

Why subscription app JS injection is the core failure

All four dominant Shopify subscription apps — Recharge, Bold Subscriptions, Stay.ai, and Skio — use the same architectural pattern: they add a widget to your product page via a JavaScript include, and that widget dynamically replaces or annotates the one-time price UI with subscription pricing options.

From the customer's perspective, this works perfectly. The page loads, they see two options (one-time / subscribe), they choose the subscription, checkout handles the billing correctly. The problem is entirely invisible to the merchant because the customer experience is fine.

From the AI crawler's perspective, the subscription price literally does not exist. The initial HTML contains Shopify's default JSON-LD, which emits the compare-at price and the current one-time price. The subscription widget JavaScript is fetched, parsed, and executed in the browser — after the crawler has already read and indexed the page. The crawler logs a price. It's your one-time price.

Here's what the initial JSON-LD emitted by Dawn (Shopify's default theme) looks like for a subscription product running Recharge:

{
  "@context": "https://schema.org/",
  "@type": "Product",
  "name": "Daily Collagen Peptides",
  "offers": {
    "@type": "Offer",
    "price": "39.99",
    "priceCurrency": "USD",
    "availability": "https://schema.org/InStock"
  }
}

Recharge's widget then injects the subscribe-and-save option at $33.99 via JavaScript. The crawler never sees $33.99. It indexes the product at $39.99, full stop.

What makes this particularly hard to catch is that your Shopify theme preview, your browser, and any manual inspection you do will show the correct subscription widget — because your browser executes JavaScript. Only a tool that reads the raw HTML (like curl or our scan) reveals the discrepancy.

How each major AI agent handles subscription pricing

The behavior differs meaningfully across platforms once you understand the mechanics:

AI agent Price source Subscription aware? What they display
ChatGPT Shopping JSON-LD in initial HTML Yes, with PriceSpecification Shows lowest available price when markup is correct; shows one-time price when it's missing
Perplexity Shopping JSON-LD + Open Graph pricing meta tags Yes, with PriceSpecification + unitText "Subscribe & save" label in results when dual-offer markup is present; flat price otherwise
Google AI Mode Merchant Center feed + JSON-LD Yes, via Merchant Center "subscription" attribute + JSON-LD "Subscribe & save" callout with discount percentage; requires both Merchant Center and JSON-LD to be correct
Bing Shopping AI Bing Merchant Center + JSON-LD Partially (Bing MC subscription attribute) Shows subscription price when Merchant Center feed includes subscription_price attribute

The common thread: every platform that supports subscription-aware display requires an explicit structured signal beyond the default one-time price. None of them infer subscription discounts from JavaScript-injected UI. They read what's in the markup.

Google AI Mode has the most developed subscription display because Google Shopping has had subscription product attributes in Merchant Center since 2023. But Merchant Center and JSON-LD interact: if your JSON-LD says $39.99 and your Merchant Center feed says $33.99 subscription, Google may flag the data inconsistency as a quality issue rather than surfacing the subscription price. You need both to agree.

What the scan data shows

94%
subscription stores with no dual-offer PriceSpecification
0
stores where the subscription discount appeared in ChatGPT Shopping results (no markup)
12–22 pts
CatalogScan score delta between stores with correct vs. missing subscription markup
15–20%
typical subscribe-and-save discount that agents never quote

When we scanned our 100-store DTC cohort, 31 stores had subscription pricing active (identifiable via Recharge, Bold, Stay.ai, or Skio widget JavaScript in the page source). Of those 31, only 2 had any PriceSpecification markup at all. Of those 2, only 1 had structured data that correctly represented both purchase options with the right priceType values.

The implication: if you sell subscription products on Shopify, you almost certainly have this problem. The base rate of correct markup is effectively zero across major DTC brands.

The fix: PriceSpecification JSON-LD

The correct approach is to emit two separate Offer blocks inside your Product JSON-LD: one for the one-time purchase and one for the subscription. Each Offer uses PriceSpecification to describe the billing terms. Here is the minimal correct structure:

{
  "@context": "https://schema.org/",
  "@type": "Product",
  "name": "Daily Collagen Peptides",
  "offers": [
    {
      "@type": "Offer",
      "name": "One-time purchase",
      "price": "39.99",
      "priceCurrency": "USD",
      "availability": "https://schema.org/InStock",
      "priceSpecification": {
        "@type": "PriceSpecification",
        "price": "39.99",
        "priceCurrency": "USD",
        "priceType": "https://schema.org/ListPrice"
      }
    },
    {
      "@type": "Offer",
      "name": "Subscribe & save 15%",
      "price": "33.99",
      "priceCurrency": "USD",
      "availability": "https://schema.org/InStock",
      "priceSpecification": {
        "@type": "PriceSpecification",
        "price": "33.99",
        "priceCurrency": "USD",
        "priceType": "https://schema.org/SalePrice",
        "unitText": "per delivery",
        "eligibleQuantity": {
          "@type": "QuantitativeValue",
          "minValue": 1,
          "unitCode": "C62"
        },
        "billingIncrement": 1,
        "billingDuration": {
          "@type": "QuantitativeValue",
          "value": 1,
          "unitCode": "MON"
        }
      }
    }
  ]
}

Several things in this structure are load-bearing:

UN/CEFACT billing period codes

The unitCode field in billingDuration takes UN/CEFACT Recommendation 20 unit codes. For subscription products, the relevant codes are:

Code Meaning Common use case
DAY Day Daily delivery services (meal kits, newspapers)
WEE Week Weekly subscriptions (some meal kits, weekly box subscriptions)
MON Month Monthly subscriptions — the most common case for Shopify DTC
ANN Year (annual) Annual subscriptions, often at a steeper discount
C62 One (dimensionless count) Used in eligibleQuantity.unitCode for "units per delivery"

For a store that offers 30-day and 60-day subscription intervals, use two separate subscription Offer blocks with billingDuration.value: 1, unitCode: "MON" and billingDuration.value: 2, unitCode: "MON" respectively. This lets the agent distinguish between interval options and present them accurately.

Liquid snippets for each subscription app

The cleanest implementation adds a custom Liquid snippet to your theme that runs server-side and emits the dual-offer JSON-LD block. This approach works regardless of which subscription app you use, because it doesn't depend on the app's own JS execution — it reads from your product metafields or Shopify's native selling plan groups.

The Shopify-native approach (Selling Plan Groups)

If your subscription app uses Shopify's native Selling Plans API (all four major apps do as of 2025), you can read selling plan data directly in Liquid without app-specific workarounds. Here is a snippet that works with any Selling Plans-compatible app:

{% comment %} subscription-jsonld.liquid — add to product.liquid before closing </script> {% endcomment %}
{% assign sub_offers = "" %}
{% for selling_plan_group in product.selling_plan_groups %}
  {% for selling_plan in selling_plan_group.selling_plans %}
    {% assign sp_price = selling_plan.price_adjustments[0] %}
    {% if sp_price.value_type == "percentage" %}
      {% assign discount_mult = sp_price.value | divided_by: 100.0 %}
      {% assign sub_price_cents = product.selected_or_first_available_variant.price
         | times: 1 | minus: product.selected_or_first_available_variant.price
         | times: discount_mult %}
      {% assign sub_price = product.selected_or_first_available_variant.price
         | minus: product.selected_or_first_available_variant.price
         | times: discount_mult | money_without_currency %}
    {% elsif sp_price.value_type == "fixed_amount" %}
      {% assign sub_price = product.selected_or_first_available_variant.price
         | minus: sp_price.value | money_without_currency %}
    {% endif %}
    {
      "@type": "Offer",
      "name": {{ selling_plan.name | json }},
      "price": {{ sub_price | json }},
      "priceCurrency": {{ shop.currency | json }},
      "availability": "https://schema.org/InStock",
      "priceSpecification": {
        "@type": "PriceSpecification",
        "price": {{ sub_price | json }},
        "priceCurrency": {{ shop.currency | json }},
        "priceType": "https://schema.org/SalePrice",
        "unitText": "per delivery"
      }
    }{% unless forloop.last %},{% endunless %}
  {% endfor %}
{% endfor %}
Note on Liquid price arithmetic: Shopify stores prices in integer cents. product.selected_or_first_available_variant.price returns 3999 for $39.99. The money_without_currency filter converts to decimal string. When computing subscription discounts server-side, integer cent math avoids floating-point rounding errors that produce prices like $33.9900001.

The metafield approach (simpler, but manual)

If the Selling Plan API approach is too complex to maintain, a simpler fallback is to store the subscription price in a product metafield and reference it directly:

{% comment %} Requires product metafield: custom.subscription_price (single_line_text, e.g. "33.99") {% endcomment %}
{% assign sub_price = product.metafields.custom.subscription_price %}
{% if sub_price != blank %}
,{
  "@type": "Offer",
  "name": "Subscribe & save",
  "price": {{ sub_price | json }},
  "priceCurrency": {{ shop.currency | json }},
  "availability": "https://schema.org/InStock",
  "priceSpecification": {
    "@type": "PriceSpecification",
    "price": {{ sub_price | json }},
    "priceCurrency": {{ shop.currency | json }},
    "priceType": "https://schema.org/SalePrice",
    "unitText": "per delivery",
    "billingDuration": {
      "@type": "QuantitativeValue",
      "value": 1,
      "unitCode": "MON"
    }
  }
}
{% endif %}

This approach requires you to populate the custom.subscription_price metafield on each subscribed product. You can do this in bulk via the Shopify admin Metafields UI, the Bulk Editor, or a CSV import. The metafields guide covers the bulk update workflow in detail.

App-specific notes

Recharge: Recharge uses Shopify's native Selling Plan Groups as of version 3.0. The Selling Plan Liquid approach above works directly. Use product.selling_plan_groups to enumerate plans. Recharge's own widget JavaScript is still present on the page — it does not conflict with your Liquid-emitted JSON-LD.

Bold Subscriptions: Bold also migrated to Shopify's native Selling Plans in 2024. Same approach applies. Bold's legacy "V1" installs (pre-migration) store subscription data in proprietary metafields under the bold_subscriptions namespace — if you're on V1, use the metafield approach above after verifying the correct metafield key in your admin.

Stay.ai: Stay.ai uses native Selling Plans and supports the Selling Plan Liquid approach. It also exposes a stay_subscriptions.subscription_price metafield on eligible products, making the metafield shortcut reliable without manual population.

Skio: Skio is native Selling Plans from inception. The Selling Plan Liquid approach works. Skio also exposes discount percentage in the selling_plan_group description field, which you can parse if you need the percentage for your own PriceSpecification logic.

Verifying your fix with curl

After adding the snippet, verify with a direct curl request that mirrors what GPTBot reads:

# Verify the subscription offer appears in the raw HTML
curl -s -A "GPTBot/1.2" "https://yourstore.com/products/your-product-handle" \
  | python3 -c "
import sys, json, re
html = sys.stdin.read()
# Extract all JSON-LD blocks
blocks = re.findall(r'<script type=\"application/ld\+json\">(.*?)</script>', html, re.DOTALL)
for block in blocks:
    try:
        data = json.loads(block)
        if data.get('@type') == 'Product':
            offers = data.get('offers', [])
            if isinstance(offers, dict):
                offers = [offers]
            for o in offers:
                print(f'Offer: {o.get(\"name\",\"unnamed\")} — {o.get(\"priceCurrency\",\"\")} {o.get(\"price\",\"\")}')
    except:
        pass
"

You should see output like:

Offer: One-time purchase — USD 39.99
Offer: Subscribe & save 15% — USD 33.99

If you see only one offer (the one-time price), your subscription JSON-LD is not rendering server-side. Check that the snippet is inside a {% raw %}{% schema %}{% endraw %}-compatible section and that the Selling Plan group data is populated. A common failure is testing on a product that doesn't have any selling plans attached — in that case the product.selling_plan_groups array is empty and the loop produces nothing.

Also check the Google Rich Results Test tool: paste your product URL, and look for multiple Offer blocks under the Product entity. If Rich Results shows one Offer, your JSON-LD fix hasn't landed yet.

Related

The competitive edge for stores that get this right

The base rate of correct subscription markup is effectively zero. This means the first movers in your category get disproportionate benefit. When a shopper asks ChatGPT Shopping for "best protein powder subscription under $40/month," the stores that have correct PriceSpecification markup compete at their actual subscription price. Stores without it compete at their one-time price — often 15–20% higher.

For a supplement brand where the subscribe-and-save price is a core acquisition lever, this is not a marginal SEO concern. It is a pricing visibility gap that directly affects conversion from AI-referred traffic. As AI shopping agents account for a growing share of product discovery — we see roughly 8% of referral sessions from AI agents in our scan cohort's analytics, up from 2% twelve months ago — that gap compounds.

The fix takes a few hours for most stores: understand your subscription app's Selling Plan structure, add the Liquid snippet, populate metafields if needed, verify with curl, push a theme update. There's no API credential to configure, no third-party dependency to wait on. This is purely a catalog hygiene fix.

Run the free scan on your store to see how your subscription products score on the current 13-signal checklist. Subscription PriceSpecification is one of the scored signals — the scan will tell you exactly which products are missing it.

Check your store's subscription markup

The free scan checks subscription PriceSpecification along with 12 other AI-visibility signals. Takes 90 seconds, no login.

Run a free scan See the 100-store report