Book Actions Schema Framework
Use conditional-logic schema generation to keep Book Actions markup aligned with real page state and avoid stale or invalid static templates.
When To Use It
Google Search CentralBook Actions documentationUse for book catalogs submitted to Google as a Book actions data feed. The framework below builds one feed entity per work, with one edition per format.
Key Implementation Documentation Highlights
- Limited and feed-based: Book actions are open to book providers with a wide selection of available books who register interest, and the data is delivered through a data feed, not page markup. Feed files must be under 1 GB uncompressed with a .json extension; Google fetches the feed about once a day.
- Required on the work (Book): @id (a globally unique, stable URL-format ID), name, author (with name), url (the canonical page for the book) and workExample (one Book per edition).
- Required on each edition: @id, bookFormat (AudiobookFormat, EBook, Hardcover or Paperback), inLanguage (ISO 639-1 two-letter code), isbn (ISBN-13 only, convert ISBN-10) and potentialAction with target.urlTemplate and target.actionPlatform (DesktopWebPlatform, AndroidPlatform, IOSPlatform).
- ReadAction requires expectsAcceptanceOf: an Offer with category (nologinrequired, free, subscription, purchase or rental) and eligibleRegion (ISO 3166-1 alpha-2 country). price and priceCurrency are required for purchase and rental. BorrowAction requires lender, a LibrarySystem referenced by @id.
- Recommended properties: work and author sameAs; edition author, bookEdition, datePublished (YYYY-MM-DD or YYYY), identifier (OCLC_NUMBER, LCCN, JP_E-CODE), name, sameAs, url; offer availabilityStarts and availabilityEnds.
- Every @id, url and urlTemplate must be unique and a production, canonical URL; action links must land on a page that directly supports purchase or borrowing. Remove stale entities whose availabilityEnds has passed.
Template Approach
const pageData = {
title: "The Salt Road",
authorName: "Elena Marsh",
url: "https://www.example.com/books/the-salt-road",
isbn: "9780306406157"
};
const staticTemplate = {
"@context": "https://schema.org",
"@type": "Book",
"@id": `${pageData.url}#work`,
"name": pageData.title,
"author": { "@type": "Person", "name": pageData.authorName },
"url": pageData.url,
"workExample": {
"@type": "Book",
"@id": `${pageData.url}/ebook#edition`,
"bookFormat": "https://schema.org/EBook",
"inLanguage": "en",
"isbn": pageData.isbn,
"potentialAction": {
"@type": "ReadAction",
"target": {
"@type": "EntryPoint",
"urlTemplate": "https://www.example.com/checkout/the-salt-road-ebook",
"actionPlatform": ["https://schema.org/DesktopWebPlatform"]
},
"expectsAcceptanceOf": {
"@type": "Offer",
"category": "purchase",
"price": 14.99,
"priceCurrency": "USD",
"eligibleRegion": { "@type": "Country", "name": "US" }
}
}
}
};
{
"@context": "https://schema.org",
"@type": "Book",
"@id": "https://www.example.com/books/the-salt-road#work",
"name": "The Salt Road",
"author": {
"@type": "Person",
"name": "Elena Marsh"
},
"url": "https://www.example.com/books/the-salt-road",
"workExample": {
"@type": "Book",
"@id": "https://www.example.com/books/the-salt-road/ebook#edition",
"bookFormat": "https://schema.org/EBook",
"inLanguage": "en",
"isbn": "9780306406157",
"potentialAction": {
"@type": "ReadAction",
"target": {
"@type": "EntryPoint",
"urlTemplate": "https://www.example.com/checkout/the-salt-road-ebook",
"actionPlatform": [
"https://schema.org/DesktopWebPlatform"
]
},
"expectsAcceptanceOf": {
"@type": "Offer",
"category": "purchase",
"price": 14.99,
"priceCurrency": "USD",
"eligibleRegion": {
"@type": "Country",
"name": "US"
}
}
}
}
}
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);
const BOOK_FORMATS = ["AudiobookFormat", "EBook", "Hardcover", "Paperback"];
const PLATFORMS = ["DesktopWebPlatform", "AndroidPlatform", "IOSPlatform"];
const OFFER_CATEGORIES = ["nologinrequired", "free", "subscription", "purchase", "rental"];
// Google Search accepts ISBN-13 only: ISBN-10 is converted, checksums are verified.
function toIsbn13(value) {
const raw = String(value ?? "").replace(/[\s-]/g, "").toUpperCase();
let digits = raw;
if (/^\d{9}[\dX]$/.test(raw)) {
const sum10 = [...raw].reduce((s, c, i) => s + (c === "X" ? 10 : Number(c)) * (10 - i), 0);
if (sum10 % 11 !== 0) return null;
digits = `978${raw.slice(0, 9)}`;
} else if (/^97[89]\d{10}$/.test(raw)) {
digits = raw.slice(0, 12);
} else {
return null;
}
const sum = [...digits].reduce((s, c, i) => s + Number(c) * (i % 2 ? 3 : 1), 0);
const isbn = `${digits}${(10 - (sum % 10)) % 10}`;
return raw.length === 13 && isbn !== raw ? null : isbn;
}
// ReadAction needs an Offer (category + eligibleRegion); purchase and rental need a price.
function buildOffer(offer, now) {
const ends = toIsoDateTime(offer.availabilityEnds);
if (ends && new Date(ends) < now) return null; // stale: drop it from the feed
const price = toNumber(offer.price);
const needsPrice = offer.category === "purchase" || offer.category === "rental";
const regions = (offer.regions ?? []).filter((r) => /^[A-Z]{2}$/.test(r));
if (!OFFER_CATEGORIES.includes(offer.category) || regions.length === 0) return null;
if (needsPrice && (price === null || price < 0 || !isCurrencyCode(offer.currency))) return null;
return {
"@type": "Offer",
category: offer.category,
price: needsPrice ? price : null,
priceCurrency: needsPrice ? offer.currency : null,
availabilityStarts: toIsoDateTime(offer.availabilityStarts),
availabilityEnds: ends,
eligibleRegion: regions.map((name) => ({ "@type": "Country", name }))
};
}
function buildAction(action, now) {
const platforms = (action.platforms ?? []).filter((p) => PLATFORMS.includes(p));
if (!isAbsoluteUrl(action.url) || platforms.length === 0) return null;
const target = {
"@type": "EntryPoint",
urlTemplate: action.url,
actionPlatform: platforms.map((p) => `https://schema.org/${p}`)
};
if (action.type === "BorrowAction") {
if (!isAbsoluteUrl(action.libraryId)) return null; // lender @id, described in the library feed
return { "@type": "BorrowAction", target, lender: { "@type": "LibrarySystem", "@id": action.libraryId } };
}
const offers = (action.offers ?? []).map((o) => buildOffer(o, now)).filter(Boolean);
return offers.length ? { "@type": "ReadAction", target, expectsAcceptanceOf: offers } : null;
}
function buildEdition(edition, now) {
const isbn = toIsbn13(edition.isbn);
const actions = (edition.actions ?? []).map((a) => buildAction(a, now)).filter(Boolean);
if (!isAbsoluteUrl(edition.id) || !BOOK_FORMATS.includes(edition.format) || !/^[a-z]{2}$/.test(edition.language ?? "") || !isbn || actions.length === 0) return null;
const published = String(edition.datePublished ?? "");
return {
"@type": "Book",
"@id": edition.id,
url: isAbsoluteUrl(edition.url) ? edition.url : null,
bookFormat: `https://schema.org/${edition.format}`,
inLanguage: edition.language,
isbn,
bookEdition: edition.bookEdition,
datePublished: /^\d{4}(-\d{2}-\d{2})?$/.test(published) && (published.length === 4 || toIsoDate(published)) ? published : null,
identifier: (edition.identifiers ?? [])
.filter((i) => ["OCLC_NUMBER", "LCCN", "JP_E-CODE"].includes(i.type) && i.value)
.map((i) => ({ "@type": "PropertyValue", propertyID: i.type, value: String(i.value).replace(/^\D+/, "") })),
sameAs: isAbsoluteUrl(edition.sameAs) ? edition.sameAs : null,
potentialAction: actions
};
}
// Builds one Book (work) entity for the dataFeedElement array of a Book actions feed.
function buildBookFeedEntity(source, now = new Date()) {
if (!passesPageGates(source)) return null;
// Required: work @id, name, author, canonical url and at least one valid edition.
const authors = (source.authors ?? []).filter((a) => a.name);
const seen = new Set([source.id, source.url]);
const editions = (source.editions ?? []).map((e) => buildEdition(e, now)).filter((e) => {
if (!e) return false;
const keys = [e["@id"], e.url, ...e.potentialAction.map((a) => a.target.urlTemplate)].filter(Boolean);
if (keys.some((k) => seen.has(k))) return false; // @id, url and urlTemplate must be unique
keys.forEach((k) => seen.add(k));
return true;
});
if (!isAbsoluteUrl(source.id) || !source.name || authors.length === 0 || !isAbsoluteUrl(source.url) || editions.length === 0) return null;
return compact({
"@context": "https://schema.org",
"@type": "Book",
"@id": source.id,
name: source.name,
url: source.url,
author: authors.map((a) => ({ "@type": a.type === "Organization" ? "Organization" : "Person", name: a.name, sameAs: isAbsoluteUrl(a.sameAs) ? a.sameAs : null })),
sameAs: isAbsoluteUrl(source.sameAs) ? source.sameAs : null,
workExample: editions
});
}
// Usage:
// const source = {
// url: "https://www.example.com/books/the-salt-road",
// canonicalUrl: "https://www.example.com/books/the-salt-road",
// indexable: true,
// contentVisible: true,
// id: "https://www.example.com/books/the-salt-road#work",
// name: "The Salt Road",
// authors: [{ name: "Elena Marsh", sameAs: "https://en.wikipedia.org/wiki/Elena_Marsh" }],
// sameAs: "https://www.wikidata.org/wiki/Q00000000",
// editions: [{
// id: "https://www.example.com/books/the-salt-road/ebook#edition",
// url: "https://www.example.com/books/the-salt-road/ebook",
// format: "EBook",
// language: "en",
// isbn: "978-0-306-40615-7",
// bookEdition: "2nd Edition",
// datePublished: "2026-02-10",
// identifiers: [{ type: "OCLC_NUMBER", value: "ocm1234567" }],
// sameAs: "https://www.example.com/catalog/9780306406157",
// actions: [{
// type: "ReadAction",
// url: "https://www.example.com/checkout/the-salt-road-ebook",
// platforms: ["DesktopWebPlatform", "AndroidPlatform", "IOSPlatform"],
// offers: [{ category: "purchase", price: 14.99, currency: "USD", regions: ["US", "CA"], availabilityStarts: "2026-02-10T00:00:00-05:00", availabilityEnds: "2027-12-31T23:59:59-05:00" }]
// }]
// }]
// };
// const jsonLd = buildBookFeedEntity(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
from datetime import datetime, timezone
BOOK_FORMATS = ["AudiobookFormat", "EBook", "Hardcover", "Paperback"]
PLATFORMS = ["DesktopWebPlatform", "AndroidPlatform", "IOSPlatform"]
OFFER_CATEGORIES = ["nologinrequired", "free", "subscription", "purchase", "rental"]
# Google Search accepts ISBN-13 only: ISBN-10 is converted, checksums are verified.
def to_isbn13(value):
raw = re.sub(r"[\s-]", "", str(value or "")).upper()
if re.fullmatch(r"\d{9}[\dX]", raw):
sum10 = sum((10 if c == "X" else int(c)) * (10 - i) for i, c in enumerate(raw))
if sum10 % 11 != 0:
return None
digits = "978" + raw[:9]
elif re.fullmatch(r"97[89]\d{10}", raw):
digits = raw[:12]
else:
return None
total = sum(int(c) * (3 if i % 2 else 1) for i, c in enumerate(digits))
isbn = f"{digits}{(10 - total % 10) % 10}"
return None if len(raw) == 13 and isbn != raw else isbn
def parse_instant(value):
return datetime.fromisoformat(value.replace("Z", "+00:00"))
# ReadAction needs an Offer (category + eligibleRegion); purchase and rental need a price.
def build_offer(offer, now):
ends = to_iso_date_time(offer.get("availabilityEnds"))
if ends and parse_instant(ends).astimezone(timezone.utc) < now:
return None # stale: drop it from the feed
price = to_number(offer.get("price"))
needs_price = offer.get("category") in ("purchase", "rental")
regions = [r for r in offer.get("regions") or [] if re.fullmatch(r"[A-Z]{2}", r)]
if offer.get("category") not in OFFER_CATEGORIES or not regions:
return None
if needs_price and (price is None or price < 0 or not is_currency_code(offer.get("currency"))):
return None
return {
"@type": "Offer",
"category": offer["category"],
"price": price if needs_price else None,
"priceCurrency": offer.get("currency") if needs_price else None,
"availabilityStarts": to_iso_date_time(offer.get("availabilityStarts")),
"availabilityEnds": ends,
"eligibleRegion": [{"@type": "Country", "name": name} for name in regions],
}
def build_action(action, now):
platforms = [p for p in action.get("platforms") or [] if p in PLATFORMS]
if not is_absolute_url(action.get("url")) or not platforms:
return None
target = {
"@type": "EntryPoint",
"urlTemplate": action["url"],
"actionPlatform": [f"https://schema.org/{p}" for p in platforms],
}
if action.get("type") == "BorrowAction":
if not is_absolute_url(action.get("libraryId")):
return None # lender @id, described in the library feed
return {"@type": "BorrowAction", "target": target, "lender": {"@type": "LibrarySystem", "@id": action["libraryId"]}}
offers = [o for o in (build_offer(x, now) for x in action.get("offers") or []) if o]
return {"@type": "ReadAction", "target": target, "expectsAcceptanceOf": offers} if offers else None
def build_edition(edition, now):
isbn = to_isbn13(edition.get("isbn"))
actions = [a for a in (build_action(x, now) for x in edition.get("actions") or []) if a]
if (not is_absolute_url(edition.get("id")) or edition.get("format") not in BOOK_FORMATS
or not re.fullmatch(r"[a-z]{2}", edition.get("language") or "") or not isbn or not actions):
return None
published = str(edition.get("datePublished") or "")
valid_published = re.fullmatch(r"\d{4}(-\d{2}-\d{2})?", published) and (len(published) == 4 or to_iso_date(published))
return {
"@type": "Book",
"@id": edition["id"],
"url": edition.get("url") if is_absolute_url(edition.get("url")) else None,
"bookFormat": f"https://schema.org/{edition['format']}",
"inLanguage": edition["language"],
"isbn": isbn,
"bookEdition": edition.get("bookEdition"),
"datePublished": published if valid_published else None,
"identifier": [
{"@type": "PropertyValue", "propertyID": i["type"], "value": re.sub(r"^\D+", "", str(i["value"]))}
for i in edition.get("identifiers") or []
if i.get("type") in ("OCLC_NUMBER", "LCCN", "JP_E-CODE") and i.get("value")
],
"sameAs": edition.get("sameAs") if is_absolute_url(edition.get("sameAs")) else None,
"potentialAction": actions,
}
# Builds one Book (work) entity for the dataFeedElement array of a Book actions feed.
def build_book_feed_entity(source, now=None):
now = now or datetime.now(timezone.utc)
if not passes_page_gates(source):
return None
# Required: work @id, name, author, canonical url and at least one valid edition.
authors = [a for a in source.get("authors") or [] if a.get("name")]
seen = {source.get("id"), source.get("url")}
editions = []
for e in (build_edition(x, now) for x in source.get("editions") or []):
if not e:
continue
keys = [k for k in [e["@id"], e["url"], *(a["target"]["urlTemplate"] for a in e["potentialAction"])] if k]
if any(k in seen for k in keys):
continue # @id, url and urlTemplate must be unique
seen.update(keys)
editions.append(e)
if not is_absolute_url(source.get("id")) or not source.get("name") or not authors or not is_absolute_url(source.get("url")) or not editions:
return None
return compact({
"@context": "https://schema.org",
"@type": "Book",
"@id": source["id"],
"name": source["name"],
"url": source["url"],
"author": [
{"@type": "Organization" if a.get("type") == "Organization" else "Person", "name": a["name"],
"sameAs": a.get("sameAs") if is_absolute_url(a.get("sameAs")) else None}
for a in authors
],
"sameAs": source.get("sameAs") if is_absolute_url(source.get("sameAs")) else None,
"workExample": editions,
})
{
"@context": "https://schema.org",
"@type": "Book",
"@id": "https://www.example.com/books/the-salt-road#work",
"name": "The Salt Road",
"url": "https://www.example.com/books/the-salt-road",
"author": [
{
"@type": "Person",
"name": "Elena Marsh",
"sameAs": "https://en.wikipedia.org/wiki/Elena_Marsh"
}
],
"sameAs": "https://www.wikidata.org/wiki/Q00000000",
"workExample": [
{
"@type": "Book",
"@id": "https://www.example.com/books/the-salt-road/ebook#edition",
"url": "https://www.example.com/books/the-salt-road/ebook",
"bookFormat": "https://schema.org/EBook",
"inLanguage": "en",
"isbn": "9780306406157",
"bookEdition": "2nd Edition",
"datePublished": "2026-02-10",
"identifier": [
{
"@type": "PropertyValue",
"propertyID": "OCLC_NUMBER",
"value": "1234567"
}
],
"sameAs": "https://www.example.com/catalog/9780306406157",
"potentialAction": [
{
"@type": "ReadAction",
"target": {
"@type": "EntryPoint",
"urlTemplate": "https://www.example.com/checkout/the-salt-road-ebook",
"actionPlatform": [
"https://schema.org/DesktopWebPlatform",
"https://schema.org/AndroidPlatform",
"https://schema.org/IOSPlatform"
]
},
"expectsAcceptanceOf": [
{
"@type": "Offer",
"category": "purchase",
"price": 14.99,
"priceCurrency": "USD",
"availabilityStarts": "2026-02-10T00:00:00-05:00",
"availabilityEnds": "2027-12-31T23:59:59-05:00",
"eligibleRegion": [
{
"@type": "Country",
"name": "US"
},
{
"@type": "Country",
"name": "CA"
}
]
}
]
}
]
}
]
}
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 Book actions documentation requires and recommends, property by property. These are the same rules the SchemaCDN validator checks. Each name links to its schema.org definition.
Availability: Limited to book providers with a wide selection of available books (register interest).
| Property | Expects | Notes | |
|---|---|---|---|
@id | Required | URL | A globally unique, stable URL-format ID for the book, unique to your organization. |
| author | Required | Person or Organization, can repeat | The author(s) of the book. |
| author.name | Required | Text | |
| author.sameAs | Recommended | URL | A reference page that identifies the person or organization. |
| name | Required | Text | The title of the book. |
| url | Required | URL | The page on your site where the book is described (canonical). |
| workExample | Required | Book, can repeat | The edition(s) of the work. |
workExample.@id | Required | URL | A globally unique, stable URL-format ID for the edition, unique to your organization. |
| workExample.bookFormat | Required | Enum | One of: AudiobookFormat, EBook, Hardcover, Paperback. |
| workExample.inLanguage | Required | Text | An ISO 639-1 alpha-2 language code. |
| workExample.isbn | Required | Text | The ISBN-13 of the edition. Convert ISBN-10 to ISBN-13. |
| workExample.potentialAction | Required | ReadAction or BorrowAction, can repeat | ReadAction or BorrowAction for users to purchase, download or borrow the book. |
| workExample.author | Recommended | Person or Organization, can repeat | Only when the edition author differs from the work author. |
| workExample.bookEdition | Recommended | Text | For example 2nd Edition. |
| workExample.datePublished | Recommended | Date | In YYYY-MM-DD or YYYY format. |
| workExample.identifier | Recommended | PropertyValue, can repeat | External IDs that unambiguously identify this edition. |
| workExample.name | Recommended | Text | Only when the edition title differs from the work title. |
| workExample.sameAs | Recommended | URL | A reference page that unambiguously indicates the edition. |
| workExample.url | Recommended | URL | The page on your site where the edition is described. |
| sameAs | Recommended | URL | A reference page identifying the work (Wikipedia, Wikidata, VIAF, Library of Congress). |