Software App (Beta) Schema Framework
Use conditional-logic schema generation to keep Software App (Beta) markup aligned with real page state and avoid stale or invalid static templates.
When To Use It
Google Search CentralSoftware App (Beta) documentationUse for software product pages that describe platform support and application category clearly.
Key Implementation Documentation Highlights
- Required properties: name, offers.price (use 0 for free apps), and a rating: aggregateRating or review (either-or, at least one is required).
- Recommended properties: applicationCategory, operatingSystem and offers.priceCurrency. priceCurrency becomes required when price is greater than 0.
- applicationCategory must be a supported app type, for example GameApplication, BusinessApplication or TravelApplication. priceCurrency is a three-letter ISO 4217 code.
- Supported types are SoftwareApplication, MobileApplication and WebApplication. VideoGame alone is not eligible: co-type it with another app type.
- Ratings follow the Review snippet rules: aggregateRating needs ratingValue plus ratingCount or reviewCount; each review needs an author name under 100 characters and a reviewRating. The default scale is 1 to 5 unless bestRating and worstRating say otherwise.
- Google expects content parity: every marked-up value must match what users can see on the page.
- Use the Rich Results Test and URL Inspection to validate rendering, eligibility, and crawlability.
Template Approach
const pageData = {
name: "TrailNote",
operatingSystem: "Android 10, iOS 16",
category: "TravelApplication",
price: "0",
currency: "USD",
ratingValue: "4.6",
ratingCount: "1834"
};
const staticTemplate = {
"@context": "https://schema.org",
"@type": "MobileApplication",
"name": pageData.name,
"operatingSystem": pageData.operatingSystem,
"applicationCategory": pageData.category,
"offers": { "@type": "Offer", "price": pageData.price, "priceCurrency": pageData.currency },
"aggregateRating": { "@type": "AggregateRating", "ratingValue": pageData.ratingValue, "ratingCount": pageData.ratingCount }
};
{
"@context": "https://schema.org",
"@type": "MobileApplication",
"name": "TrailNote",
"operatingSystem": "Android 10, iOS 16",
"applicationCategory": "TravelApplication",
"offers": {
"@type": "Offer",
"price": "0",
"priceCurrency": "USD"
},
"aggregateRating": {
"@type": "AggregateRating",
"ratingValue": "4.6",
"ratingCount": "1834"
}
}
Conditional-logic Framework
const ISO_DATE_TIME = /^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2})(?::(\d{2}))?(Z|[+-]\d{2}:\d{2})?)?$/;
function isAbsoluteUrl(value) {
return typeof value === "string" && /^https?:\/\/[^\s/?#]+\.[^\s/?#]+(?:[/?#]\S*)?$/i.test(value);
}
// Returns the ISO 8601 string unchanged (timezone offset kept), or null if unparseable.
function toIsoDateTime(value) {
if (typeof value !== "string") return null;
const text = value.trim();
const m = text.match(ISO_DATE_TIME);
if (!m) return null;
const [, y, mo, d, h = "00", mi = "00", s = "00"] = m;
const date = new Date(Date.UTC(+y, +mo - 1, +d));
if (date.getUTCMonth() !== +mo - 1 || date.getUTCDate() !== +d) return null;
if (+h > 23 || +mi > 59 || +s > 59) return null;
return text;
}
function toIsoDate(value) {
const iso = toIsoDateTime(value);
return iso ? iso.slice(0, 10) : null;
}
// Drops null, undefined, "", [] and {} recursively.
function compact(value) {
if (Array.isArray(value)) {
const items = value.map(compact).filter((v) => v !== undefined);
return items.length ? items : undefined;
}
if (value && typeof value === "object") {
const out = {};
for (const [key, v] of Object.entries(value)) {
const c = compact(v);
if (c !== undefined) out[key] = c;
}
return Object.keys(out).length ? out : undefined;
}
return value === null || value === undefined || value === "" ? undefined : value;
}
function passesPageGates(source) {
if (source.indexable === false) return false;
if (source.canonicalUrl && source.url && source.canonicalUrl !== source.url) return false;
return source.contentVisible !== false;
}
function toNumber(value) {
if (value === null || value === undefined || value === "" || typeof value === "boolean") return null;
const num = Number(value);
return Number.isFinite(num) ? num : null;
}
const isCurrencyCode = (value) => typeof value === "string" && /^[A-Z]{3}$/.test(value);
// Rating or AggregateRating; null when ratingValue falls outside worstRating..bestRating.
function buildRating(input, type) {
if (!input) return null;
const best = toNumber(input.bestRating) ?? 5;
const worst = toNumber(input.worstRating) ?? 1;
const value = toNumber(input.ratingValue);
if (value === null || worst >= best || value < worst || value > best) return null;
const rating = { "@type": type, ratingValue: value, bestRating: best, worstRating: worst };
if (type === "AggregateRating") {
const ratingCount = toNumber(input.ratingCount);
const reviewCount = toNumber(input.reviewCount);
if (!(ratingCount > 0) && !(reviewCount > 0)) return null;
rating.ratingCount = ratingCount > 0 ? ratingCount : null;
rating.reviewCount = reviewCount > 0 ? reviewCount : null;
}
return rating;
}
// Review with a valid author name (under 100 characters) and an in-range rating.
function buildReview(input) {
const authorName = String(input?.authorName ?? "").trim();
const reviewRating = buildRating(input?.rating, "Rating");
if (!authorName || authorName.length >= 100 || !reviewRating) return null;
return {
"@type": "Review",
author: { "@type": input.authorType === "Organization" ? "Organization" : "Person", name: authorName },
reviewRating,
datePublished: toIsoDate(input.datePublished)
};
}
const APP_CATEGORIES = new Set([
"GameApplication", "SocialNetworkingApplication", "TravelApplication", "ShoppingApplication",
"SportsApplication", "LifestyleApplication", "BusinessApplication", "DesignApplication",
"DeveloperApplication", "DriverApplication", "EducationalApplication", "HealthApplication",
"FinanceApplication", "SecurityApplication", "BrowserApplication", "CommunicationApplication",
"DesktopEnhancementApplication", "EntertainmentApplication", "MultimediaApplication",
"HomeApplication", "UtilitiesApplication", "ReferenceApplication"
]);
// VideoGame alone is not eligible, so games are always co-typed with an app type.
function chooseAppType(source) {
const base = { mobile: "MobileApplication", web: "WebApplication" }[source.platform] ?? "SoftwareApplication";
return source.isGame ? ["VideoGame", base] : base;
}
function buildSoftwareAppSchema(source) {
if (!passesPageGates(source)) return null;
// Required: name, offers.price (0 for free apps), and a rating or review.
const name = String(source.name ?? "").trim();
const price = toNumber(source.price);
if (!name || price === null || price < 0) return null;
const currency = isCurrencyCode(source.currency) ? source.currency : null;
if (price > 0 && !currency) return null;
const aggregateRating = buildRating(source.rating, "AggregateRating");
const reviews = (source.reviews ?? []).map(buildReview).filter(Boolean);
if (!aggregateRating && reviews.length === 0) return null;
const category = String(source.applicationCategory ?? "").replace("https://schema.org/", "");
return compact({
"@context": "https://schema.org",
"@type": chooseAppType(source),
name,
url: isAbsoluteUrl(source.url) ? source.url : null,
operatingSystem: source.operatingSystem,
applicationCategory: APP_CATEGORIES.has(category) ? category : null,
offers: { "@type": "Offer", price, priceCurrency: currency },
aggregateRating,
review: reviews
});
}
// Usage:
// const source = {
// url: "https://www.example.com/apps/trailnote",
// canonicalUrl: "https://www.example.com/apps/trailnote",
// indexable: true,
// contentVisible: true,
// name: "TrailNote",
// platform: "mobile",
// isGame: false,
// operatingSystem: "Android 10, iOS 16",
// applicationCategory: "TravelApplication",
// price: 0,
// currency: "USD",
// rating: { ratingValue: 4.6, ratingCount: 1834 },
// reviews: [{ authorName: "Dana Whitfield", rating: { ratingValue: 5 }, datePublished: "2026-08-14" }]
// };
// const jsonLd = buildSoftwareAppSchema(source);
import re
from datetime import date, datetime
ISO_DATE_TIME = re.compile(r"^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2})(?::(\d{2}))?(Z|[+-]\d{2}:\d{2})?)?$")
ABSOLUTE_URL = re.compile(r"^https?://[^\s/?#]+\.[^\s/?#]+(?:[/?#]\S*)?$", re.I)
def is_absolute_url(value):
return isinstance(value, str) and bool(ABSOLUTE_URL.match(value))
# Returns the ISO 8601 string unchanged (timezone offset kept), or None if unparseable.
def to_iso_date_time(value):
if not isinstance(value, str):
return None
text = value.strip()
m = ISO_DATE_TIME.match(text)
if not m:
return None
y, mo, d = int(m[1]), int(m[2]), int(m[3])
try:
date(y, mo, d)
except ValueError:
return None
if int(m[4] or 0) > 23 or int(m[5] or 0) > 59 or int(m[6] or 0) > 59:
return None
return text
def to_iso_date(value):
iso = to_iso_date_time(value)
return iso[:10] if iso else None
# Drops None, "", [] and {} recursively.
def compact(value):
if isinstance(value, list):
items = [c for c in (compact(v) for v in value) if c is not None]
return items or None
if isinstance(value, dict):
out = {k: c for k, c in ((k, compact(v)) for k, v in value.items()) if c is not None}
return out or None
return None if value is None or value == "" else value
def passes_page_gates(source):
if source.get("indexable") is False:
return False
if source.get("canonicalUrl") and source.get("url") and source["canonicalUrl"] != source["url"]:
return False
return source.get("contentVisible") is not False
def to_number(value):
if value is None or value == "" or isinstance(value, bool):
return None
try:
num = float(value)
except (TypeError, ValueError):
return None
if num != num or num in (float("inf"), float("-inf")):
return None
return int(num) if num.is_integer() else num
def is_currency_code(value):
return isinstance(value, str) and re.fullmatch(r"[A-Z]{3}", value) is not None
# Rating or AggregateRating; None when ratingValue falls outside worstRating..bestRating.
def build_rating(data, rating_type):
if not data:
return None
best = to_number(data.get("bestRating"))
best = 5 if best is None else best
worst = to_number(data.get("worstRating"))
worst = 1 if worst is None else worst
value = to_number(data.get("ratingValue"))
if value is None or worst >= best or value < worst or value > best:
return None
rating = {"@type": rating_type, "ratingValue": value, "bestRating": best, "worstRating": worst}
if rating_type == "AggregateRating":
rating_count = to_number(data.get("ratingCount")) or 0
review_count = to_number(data.get("reviewCount")) or 0
if rating_count <= 0 and review_count <= 0:
return None
rating["ratingCount"] = rating_count if rating_count > 0 else None
rating["reviewCount"] = review_count if review_count > 0 else None
return rating
# Review with a valid author name (under 100 characters) and an in-range rating.
def build_review(data):
data = data or {}
author_name = str(data.get("authorName") or "").strip()
review_rating = build_rating(data.get("rating"), "Rating")
if not author_name or len(author_name) >= 100 or not review_rating:
return None
return {
"@type": "Review",
"author": {"@type": "Organization" if data.get("authorType") == "Organization" else "Person", "name": author_name},
"reviewRating": review_rating,
"datePublished": to_iso_date(data.get("datePublished")),
}
APP_CATEGORIES = {
"GameApplication", "SocialNetworkingApplication", "TravelApplication", "ShoppingApplication",
"SportsApplication", "LifestyleApplication", "BusinessApplication", "DesignApplication",
"DeveloperApplication", "DriverApplication", "EducationalApplication", "HealthApplication",
"FinanceApplication", "SecurityApplication", "BrowserApplication", "CommunicationApplication",
"DesktopEnhancementApplication", "EntertainmentApplication", "MultimediaApplication",
"HomeApplication", "UtilitiesApplication", "ReferenceApplication",
}
# VideoGame alone is not eligible, so games are always co-typed with an app type.
def choose_app_type(source):
base = {"mobile": "MobileApplication", "web": "WebApplication"}.get(source.get("platform"), "SoftwareApplication")
return ["VideoGame", base] if source.get("isGame") else base
def build_software_app_schema(source):
if not passes_page_gates(source):
return None
# Required: name, offers.price (0 for free apps), and a rating or review.
name = str(source.get("name") or "").strip()
price = to_number(source.get("price"))
if not name or price is None or price < 0:
return None
currency = source.get("currency") if is_currency_code(source.get("currency")) else None
if price > 0 and not currency:
return None
aggregate_rating = build_rating(source.get("rating"), "AggregateRating")
reviews = [r for r in (build_review(item) for item in source.get("reviews") or []) if r]
if not aggregate_rating and not reviews:
return None
category = str(source.get("applicationCategory") or "").replace("https://schema.org/", "")
return compact({
"@context": "https://schema.org",
"@type": choose_app_type(source),
"name": name,
"url": source.get("url") if is_absolute_url(source.get("url")) else None,
"operatingSystem": source.get("operatingSystem"),
"applicationCategory": category if category in APP_CATEGORIES else None,
"offers": {"@type": "Offer", "price": price, "priceCurrency": currency},
"aggregateRating": aggregate_rating,
"review": reviews,
})
{
"@context": "https://schema.org",
"@type": "MobileApplication",
"name": "TrailNote",
"url": "https://www.example.com/apps/trailnote",
"operatingSystem": "Android 10, iOS 16",
"applicationCategory": "TravelApplication",
"offers": {
"@type": "Offer",
"price": 0,
"priceCurrency": "USD"
},
"aggregateRating": {
"@type": "AggregateRating",
"ratingValue": 4.6,
"bestRating": 5,
"worstRating": 1,
"ratingCount": 1834
},
"review": [
{
"@type": "Review",
"author": {
"@type": "Person",
"name": "Dana Whitfield"
},
"reviewRating": {
"@type": "Rating",
"ratingValue": 5,
"bestRating": 5,
"worstRating": 1
},
"datePublished": "2026-08-14"
}
]
}
The Output As An Entity Graph
The conditional-logic output above, parsed in your browser and drawn as the graph a crawler builds from it. Every nested object is a node and the property that holds it is the edge. Tap a node to inspect its properties, or drag to rearrange.
Why Conditional Logic Is Better Than Static Templates
- Prevents emitting empty, null, or stale fields that frequently trigger rich result warnings.
- Supports multiple page states (for example: no rating yet, no image yet, no offers yet) without duplicate templates.
- Lets engineering teams centralize schema policy checks and validation in reusable code paths.
- Makes large-scale schema maintenance safer when content models evolve over time.
Property Reference
What Google's Software app documentation requires and recommends, property by property. These are the same rules the SchemaCDN validator checks. Each name links to its schema.org definition.
Required, one of: Include a rating or review: aggregateRating or review.
Required, one of: In aggregateRating: at least one of ratingCount or reviewCount is required.
| Property | Expects | Notes | |
|---|---|---|---|
| name | Required | Text | The name of the app. |
| offers | Required | Offer, can repeat | An offer to sell the app. For free apps set offers.price to 0. |
| offers.price | Required | Number or Text | Use 0 for free apps. |
| offers.priceCurrency | Recommended | Text | Required if price is greater than 0. , for example USD. |
| aggregateRating | One of | AggregateRating | The average review score of the app. Follow the Review snippet guidelines. |
| aggregateRating.ratingValue | Required | Number or Text | The average rating for the item. |
| aggregateRating.ratingCount | One of | Number | The total number of ratings for the item on your site. |
| aggregateRating.reviewCount | One of | Number | The number of people who provided a review with or without an accompanying rating. |
| aggregateRating.bestRating | Recommended | Number | The highest value allowed in this rating system. If omitted, 5 is assumed. |
| aggregateRating.worstRating | Recommended | Number | The lowest value allowed in this rating system. If omitted, 1 is assumed. |
| review | One of | Review, can repeat | A single review of the app. Follow the Review snippet guidelines. |
| review.author | Required | Person or Organization | The author of the review. The reviewer's name must be a valid name. Up to 100 characters. |
| review.reviewRating | Required | Rating | The rating given in this review. |
| review.datePublished | Recommended | Date or DateTime | The date the review was published, in ISO 8601 format. |
| applicationCategory | Recommended | Text | The type of app, for example BusinessApplication or GameApplication. Must be a supported app type. |
| operatingSystem | Recommended | Text | The operating system(s) required, for example Windows 7, OSX 10.6, Android 1.6. |