Organization Schema Framework
Use conditional-logic schema generation to keep Organization markup aligned with real page state and avoid stale or invalid static templates.
When To Use It
Google Search CentralOrganization documentationUse on the home page or a single page that describes the organization, such as the about page; it isn't needed on every page. It informs the logo shown in Search results, knowledge panels and merchant knowledge panels.
Key Implementation Documentation Highlights
- Google lists no required properties: add the ones that apply to your organization.
- Recommended properties: name, alternateName, legalName, description, url, logo, sameAs, telephone, email, address, contactPoint, foundingDate, numberOfEmployees, identifiers (iso6523Code, duns, leiCode, globalLocationNumber, naics, taxID, vatID) and the merchant policies hasMerchantReturnPolicy, hasShippingService, hasMemberProgram.
- Use the same name and alternateName as your WebSite site name markup (alternateName in order of preference); put the registered company name in legalName.
- logo: at least 112x112px, crawlable, indexable, and it should look right on a white background. A logo ImageObject needs a valid url or contentUrl.
- Identifiers: Google recommends iso6523Code (ICD, a colon, then the identifier: 0199 for LEI, 0060 for DUNS, 0088 for GLN) over duns and leiCode. taxID must match the address country. telephone includes the country and area code.
- Use the most specific subtype: OnlineStore for ecommerce rather than OnlineBusiness, and the most specific LocalBusiness subtype for local businesses.
- hasMerchantReturnPolicy: give applicableCountry and returnPolicyCategory, or merchantReturnLink alone. MerchantReturnFiniteReturnWindow requires merchantReturnDays, returnShippingFeesAmount belongs only with returnFees ReturnShippingFees, and applicableCountry takes at most 50 ISO 3166-1 codes.
- hasShippingService: a ShippingService requires shippingConditions. shippingRate is a currency plus value (0 for free) or maxValue, never both, or a ShippingRateSettings percentage between 0 and 1; handlingTime and transitTime durations are whole days with unitCode DAY; DefinedRegion.addressRegion works for US, AU and JP only.
- hasMemberProgram: a MemberProgram requires name, description and hasTiers; each tier requires name and hasTierBenefit (TierBenefitLoyaltyPoints or TierBenefitLoyaltyPrice), plus membershipPointsEarned for points. Give tiers an @id and use one program url. Loyalty program markup is available in Australia, Brazil, Canada, France, Germany, Mexico, the UK and the US.
- Policy precedence: Content API for Shopping, then Merchant Center or Search Console settings, then product-level markup, then organization-level markup.
- Google expects content parity: every marked-up value must match what users can see on the page.
- Validate with the Rich Results Test and URL Inspection, then monitor the related Search Console report for errors and warnings after deployment.
Template Approach
const pageData = {
siteName: "Trailhead Outfitters",
homeUrl: "https://www.example.com/",
logo: "https://www.example.com/images/logo-512.png",
description: "Online retailer of hiking packs, tents and trail apparel.",
linkedIn: "https://www.linkedin.com/company/example"
};
const staticTemplate = {
"@context": "https://schema.org",
"@type": "OnlineStore",
"name": pageData.siteName,
"url": pageData.homeUrl,
"logo": pageData.logo,
"description": pageData.description,
"sameAs": [pageData.linkedIn]
};
{
"@context": "https://schema.org",
"@type": "OnlineStore",
"name": "Trailhead Outfitters",
"url": "https://www.example.com/",
"logo": "https://www.example.com/images/logo-512.png",
"description": "Online retailer of hiking packs, tents and trail apparel.",
"sameAs": [
"https://www.linkedin.com/company/example"
]
}
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;
}
const S = "https://schema.org/";
const ORGANIZATION_TYPES = {
online_store: "OnlineStore",
online_business: "OnlineBusiness",
corporation: "Corporation",
ngo: "NGO",
school: "EducationalOrganization"
}; // physical local businesses: use the LocalBusiness framework instead
const RETURN_CATEGORY = {
finite: S + "MerchantReturnFiniteReturnWindow",
not_permitted: S + "MerchantReturnNotPermitted",
unlimited: S + "MerchantReturnUnlimitedWindow"
};
const RETURN_FEES = { free: S + "FreeReturn", customer_pays: S + "ReturnFeesCustomerResponsibility", shipping_fee: S + "ReturnShippingFees" };
const RETURN_METHOD = { kiosk: S + "ReturnAtKiosk", mail: S + "ReturnByMail", in_store: S + "ReturnInStore" };
const RETURN_LABEL = { customer: S + "ReturnLabelCustomerResponsibility", download: S + "ReturnLabelDownloadAndPrint", in_box: S + "ReturnLabelInBox" };
const REFUND_TYPE = { exchange: S + "ExchangeRefund", full: S + "FullRefund", store_credit: S + "StoreCreditRefund" };
const ITEM_CONDITION = { new: S + "NewCondition", refurbished: S + "RefurbishedCondition", used: S + "UsedCondition", damaged: S + "DamagedCondition" };
const TIER_BENEFIT = { points: S + "TierBenefitLoyaltyPoints", member_price: S + "TierBenefitLoyaltyPrice" };
const ICD_PREFIX = { lei: "0199", duns: "0060", gln: "0088" }; // ISO 6523 International Code Designators
const WEEKDAYS = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"].map((d) => S + d);
function chooseOrganizationType(source) {
return ORGANIZATION_TYPES[source.kind] || "Organization";
}
function toCountryCode(value) {
const code = String(value || "").toUpperCase();
return /^[A-Z]{2}$/.test(code) ? code : null;
}
function toWholeNumber(value) {
return Number.isInteger(value) && value >= 0 ? value : null;
}
function mapEnum(keys, table) {
return (keys || []).map((k) => table[k]).filter(Boolean);
}
function buildMoney(amount) {
const currency = String((amount && amount.currency) || "").toUpperCase();
if (!amount || typeof amount.value !== "number" || amount.value < 0 || !/^[A-Z]{3}$/.test(currency)) return null;
return { "@type": "MonetaryAmount", value: amount.value, currency };
}
function buildServicePeriod([min, max] = [], cutoffTime = null) {
// Durations are non-negative whole days, minValue <= maxValue, unitCode DAY.
if (toWholeNumber(min) === null || toWholeNumber(max) === null || min > max) return null;
return {
"@type": "ServicePeriod",
duration: { "@type": "QuantitativeValue", minValue: min, maxValue: max, unitCode: "DAY" },
businessDays: WEEKDAYS,
cutoffTime
};
}
function buildReturnPolicy(policy) {
// Either applicableCountry + returnPolicyCategory, or merchantReturnLink alone.
const countries = (policy.countries || []).map(toCountryCode).filter(Boolean);
const category = RETURN_CATEGORY[policy.category];
const link = isAbsoluteUrl(policy.link) ? policy.link : null;
if (countries.length > 50) return null; // at most 50 countries
if (!(countries.length && category) && !link) return null;
const days = toWholeNumber(policy.days);
if (category === RETURN_CATEGORY.finite && days === null) return null; // finite window needs days
if (category === RETURN_CATEGORY.not_permitted) {
return { "@type": "MerchantReturnPolicy", applicableCountry: countries, returnPolicyCategory: category, merchantReturnLink: link };
}
// returnShippingFeesAmount only (and always) together with ReturnShippingFees.
const feeAmount = buildMoney(policy.shippingFee);
let fees = RETURN_FEES[policy.fees] || null;
if (fees === RETURN_FEES.shipping_fee && !feeAmount) fees = null;
return {
"@type": "MerchantReturnPolicy",
applicableCountry: countries,
returnPolicyCountry: (policy.returnToCountries || []).map(toCountryCode).filter(Boolean),
returnPolicyCategory: category,
merchantReturnDays: category === RETURN_CATEGORY.finite ? days : null,
merchantReturnLink: link,
returnMethod: mapEnum(policy.methods, RETURN_METHOD),
returnFees: fees,
returnShippingFeesAmount: fees === RETURN_FEES.shipping_fee ? feeAmount : null,
returnLabelSource: RETURN_LABEL[policy.label] || null,
refundType: mapEnum(policy.refundTypes, REFUND_TYPE),
itemCondition: mapEnum(policy.conditions, ITEM_CONDITION),
restockingFee: typeof policy.restockingFeePercent === "number" ? policy.restockingFeePercent : null
};
}
function buildShippingConditions(rule) {
const destination = (rule.destinations || []).map((d) => toCountryCode(d.country) && {
"@type": "DefinedRegion",
addressCountry: toCountryCode(d.country),
// addressRegion is supported for US, AU and JP only
addressRegion: ["US", "AU", "JP"].includes(toCountryCode(d.country)) ? d.regions : null
}).filter(Boolean);
if (!destination.length) return null;
const base = { "@type": "ShippingConditions", shippingDestination: destination };
if (rule.doesNotShip) return { ...base, doesNotShip: true }; // no rate or transit time when not shipping
const currency = String(rule.currency || "").toUpperCase();
if (!/^[A-Z]{3}$/.test(currency)) return null;
// shippingRate: currency plus value (0 = free) or maxValue, never both.
const rate = typeof rule.rate === "number" && rule.rate >= 0
? { "@type": "MonetaryAmount", value: rule.rate, currency }
: typeof rule.maxRate === "number" && rule.maxRate >= 0
? { "@type": "MonetaryAmount", maxValue: rule.maxRate, currency }
: null;
return {
...base,
shippingOrigin: toCountryCode(rule.originCountry) && { "@type": "DefinedRegion", addressCountry: toCountryCode(rule.originCountry) },
orderValue: typeof rule.minOrderValue === "number" ? { "@type": "MonetaryAmount", minValue: rule.minOrderValue, currency } : null,
shippingRate: rate,
transitTime: buildServicePeriod(rule.transitDays)
};
}
function buildShippingService(service) {
const shippingConditions = (service.conditions || []).map(buildShippingConditions).filter(Boolean);
if (!shippingConditions.length) return null; // shippingConditions is required
return {
"@type": "ShippingService",
name: service.name,
description: service.description,
fulfillmentType: S + (service.pickup ? "FulfillmentTypeCollectionPoint" : "FulfillmentTypeDelivery"),
handlingTime: buildServicePeriod(service.handlingDays, service.cutoffTime),
shippingConditions
};
}
function buildTierRequirement(req) {
if (!req) return null; // no requirement: anyone can join for free
if (req.type === "card") return req.name ? { "@type": "CreditCard", name: req.name } : null;
if (req.type === "spend") return buildMoney(req);
return typeof req.text === "string" ? req.text : null;
}
function buildMemberProgram(program) {
if (!program || !program.name || !program.description) return null; // name, description, hasTiers required
const hasTiers = (program.tiers || []).map((tier) => {
const benefits = mapEnum(tier.benefits, TIER_BENEFIT);
if (!tier.name || !benefits.length) return null;
const points = toWholeNumber(tier.pointsPerUnit);
// TierBenefitLoyaltyPoints requires membershipPointsEarned.
if (benefits.includes(TIER_BENEFIT.points) && points === null) return null;
return {
"@type": "MemberProgramTier",
"@id": isAbsoluteUrl(tier.id) ? tier.id : null, // referenced by Offer validForMemberTier
name: tier.name,
hasTierBenefit: benefits,
hasTierRequirement: buildTierRequirement(tier.requirement),
membershipPointsEarned: benefits.includes(TIER_BENEFIT.points) ? { "@type": "QuantitativeValue", value: points } : null,
url: isAbsoluteUrl(tier.url) ? tier.url : null
};
}).filter(Boolean);
if (!hasTiers.length) return null;
return {
"@type": "MemberProgram",
name: program.name,
description: program.description,
url: isAbsoluteUrl(program.url) ? program.url : null, // a single URL only
hasTiers
};
}
function buildOrganizationSchema(source) {
// (a) Page gates: indexable, self-canonical, and the home or about page that describes the organization.
if (!passesPageGates(source)) return null;
if (!["home", "about"].includes(source.pageRole)) return null;
// (b) Google lists no required properties. House rule: a name and an absolute home page URL.
const name = source.siteName || source.name; // same name as the WebSite site name markup
if (!name || !isAbsoluteUrl(source.homeUrl)) return null;
const address = source.address || {};
const country = toCountryCode(address.country);
const ids = source.identifiers || {};
const icd = ["lei", "duns", "gln"].find((k) => ids[k]);
const logo = source.logo || {};
const employees = source.employees || {};
// (c) Recommended properties, only when the source has them; (d) value rules inline.
const schema = {
"@context": "https://schema.org",
"@type": chooseOrganizationType(source),
"@id": `${source.homeUrl}#organization`,
name,
alternateName: source.alternateNames, // same order as the WebSite alternateName
legalName: source.legalName,
description: source.description,
url: source.homeUrl,
logo: isAbsoluteUrl(logo.url) && !(logo.width < 112 || logo.height < 112) // at least 112x112px
? { "@type": "ImageObject", url: logo.url, width: logo.width, height: logo.height }
: null,
sameAs: (source.profiles || []).filter(isAbsoluteUrl),
telephone: /^\+\d/.test(source.telephone || "") ? source.telephone : null,
email: source.email,
address: address.street && {
"@type": "PostalAddress",
streetAddress: address.street,
addressLocality: address.locality,
addressRegion: address.region,
postalCode: address.postalCode,
addressCountry: country
},
contactPoint: (source.contactPoints || []).map((cp) => ({
"@type": "ContactPoint",
contactType: cp.type,
telephone: /^\+\d/.test(cp.telephone || "") ? cp.telephone : null,
email: cp.email
})),
foundingDate: toIsoDate(source.foundingDate),
numberOfEmployees: toWholeNumber(employees.value) !== null
? { "@type": "QuantitativeValue", value: employees.value }
: toWholeNumber(employees.min) !== null && toWholeNumber(employees.max) !== null && employees.min <= employees.max
? { "@type": "QuantitativeValue", minValue: employees.min, maxValue: employees.max }
: null,
iso6523Code: icd ? `${ICD_PREFIX[icd]}:${ids[icd]}` : null, // preferred over duns and leiCode
globalLocationNumber: ids.gln,
naics: ids.naics,
vatID: ids.vatId,
taxID: ids.taxId && ids.taxIdCountry === country ? ids.taxId : null, // must match the address country
hasMerchantReturnPolicy: (source.returnPolicies || []).map(buildReturnPolicy),
hasShippingService: (source.shippingServices || []).map(buildShippingService),
hasMemberProgram: buildMemberProgram(source.loyaltyProgram)
};
// (e) Strip empty values.
return compact(schema);
}
// Usage:
// const source = {
// "url": "https://www.example.com/about",
// "canonicalUrl": "https://www.example.com/about",
// "indexable": true,
// "contentVisible": true,
// "pageRole": "about",
// "kind": "online_store",
// "siteName": "Trailhead Outfitters",
// "alternateNames": [
// "Trailhead"
// ],
// "legalName": "Trailhead Outfitters LLC",
// "description": "Online retailer of hiking packs, tents and trail apparel, shipping from Massachusetts since 2014.",
// "homeUrl": "https://www.example.com/",
// "logo": {
// "url": "https://www.example.com/images/logo-512.png",
// "width": 512,
// "height": 512
// },
// "profiles": [
// "https://www.linkedin.com/company/example",
// "https://www.instagram.com/example"
// ],
// "telephone": "+1-781-555-0190",
// "email": "hello@example.com",
// "address": {
// "street": "50 Main Street",
// "locality": "Waltham",
// "region": "MA",
// "postalCode": "02453",
// "country": "US"
// },
// "contactPoints": [
// {
// "type": "customer support",
// "telephone": "+1-781-555-0191",
// "email": "support@example.com"
// }
// ],
// "foundingDate": "2014-04-01",
// "employees": {
// "min": 40,
// "max": 60
// },
// "identifiers": {
// "lei": "5493001KJTIIGC8Y1R12",
// "naics": "459110",
// "taxId": "04-3456789",
// "taxIdCountry": "US"
// },
// "returnPolicies": [
// {
// "countries": [
// "US",
// "CA"
// ],
// "returnToCountries": [
// "US"
// ],
// "category": "finite",
// "days": 30,
// "methods": [
// "mail",
// "in_store"
// ],
// "fees": "free",
// "label": "download",
// "refundTypes": [
// "full"
// ],
// "conditions": [
// "new"
// ],
// "restockingFeePercent": 0
// }
// ],
// "shippingServices": [
// {
// "name": "Standard shipping",
// "description": "Free on orders of $75 or more within the US.",
// "handlingDays": [
// 0,
// 1
// ],
// "cutoffTime": "14:00:00-05:00",
// "conditions": [
// {
// "destinations": [
// {
// "country": "US"
// }
// ],
// "originCountry": "US",
// "currency": "USD",
// "minOrderValue": 75,
// "rate": 0,
// "transitDays": [
// 2,
// 5
// ]
// }
// ]
// }
// ],
// "loyaltyProgram": {
// "name": "Trail Rewards",
// "description": "Earn points on every order and unlock member prices.",
// "url": "https://www.example.com/rewards",
// "tiers": [
// {
// "id": "https://www.example.com/rewards#base",
// "name": "Base",
// "benefits": [
// "points"
// ],
// "pointsPerUnit": 1
// },
// {
// "id": "https://www.example.com/rewards#summit",
// "name": "Summit",
// "benefits": [
// "points",
// "member_price"
// ],
// "pointsPerUnit": 2,
// "requirement": {
// "type": "spend",
// "value": 500,
// "currency": "USD"
// }
// }
// ]
// }
// };
// const schema = buildOrganizationSchema(source); // null when a gate or required property fails
import re
from datetime import date, datetime, timezone
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
S = "https://schema.org/"
ORGANIZATION_TYPES = {
"online_store": "OnlineStore",
"online_business": "OnlineBusiness",
"corporation": "Corporation",
"ngo": "NGO",
"school": "EducationalOrganization",
} # physical local businesses: use the LocalBusiness framework instead
RETURN_CATEGORY = {
"finite": S + "MerchantReturnFiniteReturnWindow",
"not_permitted": S + "MerchantReturnNotPermitted",
"unlimited": S + "MerchantReturnUnlimitedWindow",
}
RETURN_FEES = {"free": S + "FreeReturn", "customer_pays": S + "ReturnFeesCustomerResponsibility", "shipping_fee": S + "ReturnShippingFees"}
RETURN_METHOD = {"kiosk": S + "ReturnAtKiosk", "mail": S + "ReturnByMail", "in_store": S + "ReturnInStore"}
RETURN_LABEL = {"customer": S + "ReturnLabelCustomerResponsibility", "download": S + "ReturnLabelDownloadAndPrint", "in_box": S + "ReturnLabelInBox"}
REFUND_TYPE = {"exchange": S + "ExchangeRefund", "full": S + "FullRefund", "store_credit": S + "StoreCreditRefund"}
ITEM_CONDITION = {"new": S + "NewCondition", "refurbished": S + "RefurbishedCondition", "used": S + "UsedCondition", "damaged": S + "DamagedCondition"}
TIER_BENEFIT = {"points": S + "TierBenefitLoyaltyPoints", "member_price": S + "TierBenefitLoyaltyPrice"}
ICD_PREFIX = {"lei": "0199", "duns": "0060", "gln": "0088"} # ISO 6523 International Code Designators
WEEKDAYS = [S + d for d in ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"]]
def choose_organization_type(source):
return ORGANIZATION_TYPES.get(source.get("kind"), "Organization")
def to_country_code(value):
code = str(value or "").upper()
return code if re.fullmatch(r"[A-Z]{2}", code) else None
def to_whole_number(value):
return value if isinstance(value, int) and not isinstance(value, bool) and value >= 0 else None
def is_number(value):
return isinstance(value, (int, float)) and not isinstance(value, bool)
def map_enum(keys, table):
return [table[k] for k in keys or [] if k in table]
def phone_or_none(value):
return value if re.match(r"^\+\d", value or "") else None
def build_money(amount):
currency = str((amount or {}).get("currency") or "").upper()
if not amount or not is_number(amount.get("value")) or amount["value"] < 0 or not re.fullmatch(r"[A-Z]{3}", currency):
return None
return {"@type": "MonetaryAmount", "value": amount["value"], "currency": currency}
def build_service_period(days, cutoff_time=None):
# Durations are non-negative whole days, minValue <= maxValue, unitCode DAY.
low, high = (list(days or []) + [None, None])[:2]
if to_whole_number(low) is None or to_whole_number(high) is None or low > high:
return None
return {
"@type": "ServicePeriod",
"duration": {"@type": "QuantitativeValue", "minValue": low, "maxValue": high, "unitCode": "DAY"},
"businessDays": WEEKDAYS,
"cutoffTime": cutoff_time,
}
def build_return_policy(policy):
# Either applicableCountry + returnPolicyCategory, or merchantReturnLink alone.
countries = [c for c in (to_country_code(v) for v in policy.get("countries") or []) if c]
category = RETURN_CATEGORY.get(policy.get("category"))
link = policy.get("link") if is_absolute_url(policy.get("link")) else None
if len(countries) > 50:
return None # at most 50 countries
if not (countries and category) and not link:
return None
days = to_whole_number(policy.get("days"))
if category == RETURN_CATEGORY["finite"] and days is None:
return None # finite window needs days
if category == RETURN_CATEGORY["not_permitted"]:
return {"@type": "MerchantReturnPolicy", "applicableCountry": countries, "returnPolicyCategory": category, "merchantReturnLink": link}
# returnShippingFeesAmount only (and always) together with ReturnShippingFees.
fee_amount = build_money(policy.get("shippingFee"))
fees = RETURN_FEES.get(policy.get("fees"))
if fees == RETURN_FEES["shipping_fee"] and not fee_amount:
fees = None
restocking = policy.get("restockingFeePercent")
return {
"@type": "MerchantReturnPolicy",
"applicableCountry": countries,
"returnPolicyCountry": [c for c in (to_country_code(v) for v in policy.get("returnToCountries") or []) if c],
"returnPolicyCategory": category,
"merchantReturnDays": days if category == RETURN_CATEGORY["finite"] else None,
"merchantReturnLink": link,
"returnMethod": map_enum(policy.get("methods"), RETURN_METHOD),
"returnFees": fees,
"returnShippingFeesAmount": fee_amount if fees == RETURN_FEES["shipping_fee"] else None,
"returnLabelSource": RETURN_LABEL.get(policy.get("label")),
"refundType": map_enum(policy.get("refundTypes"), REFUND_TYPE),
"itemCondition": map_enum(policy.get("conditions"), ITEM_CONDITION),
"restockingFee": restocking if is_number(restocking) else None,
}
def build_shipping_conditions(rule):
destination = []
for d in rule.get("destinations") or []:
code = to_country_code(d.get("country"))
if code:
destination.append({
"@type": "DefinedRegion",
"addressCountry": code,
# addressRegion is supported for US, AU and JP only
"addressRegion": d.get("regions") if code in ("US", "AU", "JP") else None,
})
if not destination:
return None
base = {"@type": "ShippingConditions", "shippingDestination": destination}
if rule.get("doesNotShip"):
return {**base, "doesNotShip": True} # no rate or transit time when not shipping
currency = str(rule.get("currency") or "").upper()
if not re.fullmatch(r"[A-Z]{3}", currency):
return None
# shippingRate: currency plus value (0 = free) or maxValue, never both.
rate, max_rate = rule.get("rate"), rule.get("maxRate")
if is_number(rate) and rate >= 0:
shipping_rate = {"@type": "MonetaryAmount", "value": rate, "currency": currency}
elif is_number(max_rate) and max_rate >= 0:
shipping_rate = {"@type": "MonetaryAmount", "maxValue": max_rate, "currency": currency}
else:
shipping_rate = None
origin = to_country_code(rule.get("originCountry"))
min_order = rule.get("minOrderValue")
return {
**base,
"shippingOrigin": origin and {"@type": "DefinedRegion", "addressCountry": origin},
"orderValue": {"@type": "MonetaryAmount", "minValue": min_order, "currency": currency} if is_number(min_order) else None,
"shippingRate": shipping_rate,
"transitTime": build_service_period(rule.get("transitDays")),
}
def build_shipping_service(service):
conditions = [c for c in (build_shipping_conditions(r) for r in service.get("conditions") or []) if c]
if not conditions:
return None # shippingConditions is required
return {
"@type": "ShippingService",
"name": service.get("name"),
"description": service.get("description"),
"fulfillmentType": S + ("FulfillmentTypeCollectionPoint" if service.get("pickup") else "FulfillmentTypeDelivery"),
"handlingTime": build_service_period(service.get("handlingDays"), service.get("cutoffTime")),
"shippingConditions": conditions,
}
def build_tier_requirement(req):
if not req:
return None # no requirement: anyone can join for free
if req.get("type") == "card":
return {"@type": "CreditCard", "name": req["name"]} if req.get("name") else None
if req.get("type") == "spend":
return build_money(req)
return req.get("text") if isinstance(req.get("text"), str) else None
def build_member_program(program):
if not program or not program.get("name") or not program.get("description"):
return None # name, description, hasTiers required
tiers = []
for tier in program.get("tiers") or []:
benefits = map_enum(tier.get("benefits"), TIER_BENEFIT)
if not tier.get("name") or not benefits:
continue
points = to_whole_number(tier.get("pointsPerUnit"))
earns_points = TIER_BENEFIT["points"] in benefits
if earns_points and points is None:
continue # TierBenefitLoyaltyPoints requires membershipPointsEarned
tiers.append({
"@type": "MemberProgramTier",
"@id": tier.get("id") if is_absolute_url(tier.get("id")) else None, # referenced by Offer validForMemberTier
"name": tier["name"],
"hasTierBenefit": benefits,
"hasTierRequirement": build_tier_requirement(tier.get("requirement")),
"membershipPointsEarned": {"@type": "QuantitativeValue", "value": points} if earns_points else None,
"url": tier.get("url") if is_absolute_url(tier.get("url")) else None,
})
if not tiers:
return None
return {
"@type": "MemberProgram",
"name": program["name"],
"description": program["description"],
"url": program.get("url") if is_absolute_url(program.get("url")) else None, # a single URL only
"hasTiers": tiers,
}
def build_organization_schema(source):
# (a) Page gates: indexable, self-canonical, and the home or about page that describes the organization.
if not passes_page_gates(source):
return None
if source.get("pageRole") not in ("home", "about"):
return None
# (b) Google lists no required properties. House rule: a name and an absolute home page URL.
name = source.get("siteName") or source.get("name") # same name as the WebSite site name markup
home_url = source.get("homeUrl")
if not name or not is_absolute_url(home_url):
return None
address = source.get("address") or {}
country = to_country_code(address.get("country"))
ids = source.get("identifiers") or {}
icd = next((k for k in ("lei", "duns", "gln") if ids.get(k)), None)
logo = source.get("logo") or {}
employees = source.get("employees") or {}
small_logo = any(is_number(logo.get(k)) and logo[k] < 112 for k in ("width", "height"))
value, low, high = (to_whole_number(employees.get(k)) for k in ("value", "min", "max"))
if value is not None:
number_of_employees = {"@type": "QuantitativeValue", "value": value}
elif low is not None and high is not None and low <= high:
number_of_employees = {"@type": "QuantitativeValue", "minValue": low, "maxValue": high}
else:
number_of_employees = None
# (c) Recommended properties, only when the source has them; (d) value rules inline.
schema = {
"@context": "https://schema.org",
"@type": choose_organization_type(source),
"@id": f"{home_url}#organization",
"name": name,
"alternateName": source.get("alternateNames"), # same order as the WebSite alternateName
"legalName": source.get("legalName"),
"description": source.get("description"),
"url": home_url,
"logo": {"@type": "ImageObject", "url": logo["url"], "width": logo.get("width"), "height": logo.get("height")}
if is_absolute_url(logo.get("url")) and not small_logo else None, # at least 112x112px
"sameAs": [u for u in source.get("profiles") or [] if is_absolute_url(u)],
"telephone": phone_or_none(source.get("telephone")),
"email": source.get("email"),
"address": address.get("street") and {
"@type": "PostalAddress",
"streetAddress": address.get("street"),
"addressLocality": address.get("locality"),
"addressRegion": address.get("region"),
"postalCode": address.get("postalCode"),
"addressCountry": country,
},
"contactPoint": [
{
"@type": "ContactPoint",
"contactType": cp.get("type"),
"telephone": phone_or_none(cp.get("telephone")),
"email": cp.get("email"),
}
for cp in source.get("contactPoints") or []
],
"foundingDate": to_iso_date(source.get("foundingDate")),
"numberOfEmployees": number_of_employees,
"iso6523Code": f"{ICD_PREFIX[icd]}:{ids[icd]}" if icd else None, # preferred over duns and leiCode
"globalLocationNumber": ids.get("gln"),
"naics": ids.get("naics"),
"vatID": ids.get("vatId"),
"taxID": ids.get("taxId") if ids.get("taxId") and ids.get("taxIdCountry") == country else None, # must match the address country
"hasMerchantReturnPolicy": [build_return_policy(p) for p in source.get("returnPolicies") or []],
"hasShippingService": [build_shipping_service(s) for s in source.get("shippingServices") or []],
"hasMemberProgram": build_member_program(source.get("loyaltyProgram")),
}
# (e) Strip empty values.
return compact(schema)
{
"@context": "https://schema.org",
"@type": "OnlineStore",
"@id": "https://www.example.com/#organization",
"name": "Trailhead Outfitters",
"alternateName": [
"Trailhead"
],
"legalName": "Trailhead Outfitters LLC",
"description": "Online retailer of hiking packs, tents and trail apparel, shipping from Massachusetts since 2014.",
"url": "https://www.example.com/",
"logo": {
"@type": "ImageObject",
"url": "https://www.example.com/images/logo-512.png",
"width": 512,
"height": 512
},
"sameAs": [
"https://www.linkedin.com/company/example",
"https://www.instagram.com/example"
],
"telephone": "+1-781-555-0190",
"email": "hello@example.com",
"address": {
"@type": "PostalAddress",
"streetAddress": "50 Main Street",
"addressLocality": "Waltham",
"addressRegion": "MA",
"postalCode": "02453",
"addressCountry": "US"
},
"contactPoint": [
{
"@type": "ContactPoint",
"contactType": "customer support",
"telephone": "+1-781-555-0191",
"email": "support@example.com"
}
],
"foundingDate": "2014-04-01",
"numberOfEmployees": {
"@type": "QuantitativeValue",
"minValue": 40,
"maxValue": 60
},
"iso6523Code": "0199:5493001KJTIIGC8Y1R12",
"naics": "459110",
"taxID": "04-3456789",
"hasMerchantReturnPolicy": [
{
"@type": "MerchantReturnPolicy",
"applicableCountry": [
"US",
"CA"
],
"returnPolicyCountry": [
"US"
],
"returnPolicyCategory": "https://schema.org/MerchantReturnFiniteReturnWindow",
"merchantReturnDays": 30,
"returnMethod": [
"https://schema.org/ReturnByMail",
"https://schema.org/ReturnInStore"
],
"returnFees": "https://schema.org/FreeReturn",
"returnLabelSource": "https://schema.org/ReturnLabelDownloadAndPrint",
"refundType": [
"https://schema.org/FullRefund"
],
"itemCondition": [
"https://schema.org/NewCondition"
],
"restockingFee": 0
}
],
"hasShippingService": [
{
"@type": "ShippingService",
"name": "Standard shipping",
"description": "Free on orders of $75 or more within the US.",
"fulfillmentType": "https://schema.org/FulfillmentTypeDelivery",
"handlingTime": {
"@type": "ServicePeriod",
"duration": {
"@type": "QuantitativeValue",
"minValue": 0,
"maxValue": 1,
"unitCode": "DAY"
},
"businessDays": [
"https://schema.org/Monday",
"https://schema.org/Tuesday",
"https://schema.org/Wednesday",
"https://schema.org/Thursday",
"https://schema.org/Friday"
],
"cutoffTime": "14:00:00-05:00"
},
"shippingConditions": [
{
"@type": "ShippingConditions",
"shippingDestination": [
{
"@type": "DefinedRegion",
"addressCountry": "US"
}
],
"shippingOrigin": {
"@type": "DefinedRegion",
"addressCountry": "US"
},
"orderValue": {
"@type": "MonetaryAmount",
"minValue": 75,
"currency": "USD"
},
"shippingRate": {
"@type": "MonetaryAmount",
"value": 0,
"currency": "USD"
},
"transitTime": {
"@type": "ServicePeriod",
"duration": {
"@type": "QuantitativeValue",
"minValue": 2,
"maxValue": 5,
"unitCode": "DAY"
},
"businessDays": [
"https://schema.org/Monday",
"https://schema.org/Tuesday",
"https://schema.org/Wednesday",
"https://schema.org/Thursday",
"https://schema.org/Friday"
]
}
}
]
}
],
"hasMemberProgram": {
"@type": "MemberProgram",
"name": "Trail Rewards",
"description": "Earn points on every order and unlock member prices.",
"url": "https://www.example.com/rewards",
"hasTiers": [
{
"@type": "MemberProgramTier",
"@id": "https://www.example.com/rewards#base",
"name": "Base",
"hasTierBenefit": [
"https://schema.org/TierBenefitLoyaltyPoints"
],
"membershipPointsEarned": {
"@type": "QuantitativeValue",
"value": 1
}
},
{
"@type": "MemberProgramTier",
"@id": "https://www.example.com/rewards#summit",
"name": "Summit",
"hasTierBenefit": [
"https://schema.org/TierBenefitLoyaltyPoints",
"https://schema.org/TierBenefitLoyaltyPrice"
],
"hasTierRequirement": {
"@type": "MonetaryAmount",
"value": 500,
"currency": "USD"
},
"membershipPointsEarned": {
"@type": "QuantitativeValue",
"value": 2
}
}
]
}
}
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 Organization documentation requires and recommends, property by property. These are the same rules the SchemaCDN validator checks. Each name links to its schema.org definition.
| Property | Expects | Notes | |
|---|---|---|---|
| address | Recommended | PostalAddress, can repeat | The address (physical or mailing) of your organization. |
| address.streetAddress | Recommended | Text | The street address. |
| address.addressLocality | Recommended | Text | The city or locality. |
| address.addressRegion | Recommended | Text | The region, state or province. |
| address.postalCode | Recommended | Text | The postal code. |
| address.addressCountry | Recommended | Text or Country | The country, as a two-letter ISO 3166-1 alpha-2 code. |
| alternateName | Recommended | Text, can repeat | Another common name that your organization goes by, if applicable. |
| contactPoint | Recommended | ContactPoint, can repeat | The best way for a user to contact your business. |
| contactPoint.email | Recommended | Text | The email address to contact your business. |
| contactPoint.telephone | Recommended | Text | The phone number to contact your business; include country and area code. |
| description | Recommended | Text | A detailed description of your organization, if applicable. |
| duns | Recommended | Text | The Dun & Bradstreet DUNS number for identifying your Organization. |
| Recommended | Text | The email address to contact your business. | |
| foundingDate | Recommended | Date | The date your Organization was founded, in ISO 8601 date format. |
| globalLocationNumber | Recommended | Text | The GS1 Global Location Number identifying the location of your Organization. |
| hasMemberProgram | Recommended | MemberProgram, can repeat | A loyalty program offered by your business. |
| hasMerchantReturnPolicy | Recommended | MerchantReturnPolicy, can repeat | The return policy used by your business. |
| hasShippingService | Recommended | ShippingService, can repeat | Shipping policy information used by your business. |
| iso6523Code | Recommended | Text | The ISO 6523 identifier of your organization, as ICD:identifier. |
| legalName | Recommended | Text | The registered, legal name of your Organization, if applicable and different from name. |
| leiCode | Recommended | Text | The ISO 17442 identifier for your Organization. |
| logo | Recommended | URL or ImageObject | A logo representative of your organization, at least 112x112px. |
| naics | Recommended | Text | The North American Industry Classification System (NAICS) code for your Organization. |
| name | Recommended | Text | The name of your organization. |
| numberOfEmployees | Recommended | QuantitativeValue | The number of employees in your Organization. |
| numberOfEmployees.value | Recommended | Number | Exact number of employees. |
| numberOfEmployees.minValue | Recommended | Number | Minimum of an employee range. |
| numberOfEmployees.maxValue | Recommended | Number | Maximum of an employee range. |
| sameAs | Recommended | URL, can repeat | The URL of a page on another website with additional information about your organization. |
| taxID | Recommended | Text | The tax ID associated with your Organization, matching the country in address. |
| telephone | Recommended | Text | A business phone number meant to be the primary contact method for customers. |
| url | Recommended | URL | The URL of the website of your organization; helps Google uniquely identify it. |
| vatID | Recommended | Text | The VAT code associated with your Organization, if applicable. |