Product variants
A product with more than one form is not a single record with an options array. Each sellable combination is its own catalog item, and a variant group joins them.
Plan for this when you build a product page: you fetch a group, you get back a set of items, and you pick one based on the axis values the buyer selected.
Item codes
Section titled “Item codes”Every product (variant group) and every variant has a customer-facing item code: the body
of its public code without the kind prefix — UPC-CVG-J57P6LDPMZ is item code J57P6LDPMZ,
UPC-CI-TFAMX5UADI is TFAMX5UADI. Codes live in one namespace across both kinds, never
change and are never reused, so they are safe to print, store and put in URLs. The United Pet
Club store addresses a product as /{locale}/store/{code}/{slug}; the slug is decorative and a
bare /store/{code} forwards to the full URL.
Resolve one, whichever kind it names:
GET /api/catalog/v1/codes/{code}code may be the bare body or the full UPC-… form, in any case. The answer says what it is:
{ "code": "TFAMX5UADI", "kindCode": "ITEM", "targetId": "019fce32-c161-7207-b2d3-7a3383324f81", "publicCode": "UPC-CI-TFAMX5UADI", "isLiveFlag": true, "groupPublicCode": "UPC-CVG-J57P6LDPMZ", "groupItemCode": "J57P6LDPMZ"}kindCode is ITEM (a variant — groupPublicCode / groupItemCode name the product it
belongs to, absent for a standalone item) or GROUP (a product — canonicalItemPublicCode
names its representative variant). A registered code whose target has been unpublished answers
isLiveFlag: false rather than 404; only a code that was never minted is not found.
Reading a group
Section titled “Reading a group”GET /api/catalog/v1/variant-groups/{groupPublicCode}The group tells you which axes it varies across:
{ "id": "…", "publicCode": "UPC-CVG-J57P6LDPMZ", "groupDisplayName": "Pet Passport & Vaccination Record Book", "variantAxesCsv": "COVER_COLOR,LANGUAGE_PAIR,PACK_QUANTITY", "canonicalVariantCatalogItemId": "…", "variantCount": 226, "members": [{ "publicCode": "UPC-CI-…", "…": "…" }]}variantAxesCsv is the contract. Do not hardcode a list of axes: a group may add one, and
a group in a different category will have entirely different axes. members saves you a
round trip per variant.
Many items at once
Section titled “Many items at once”When you hold a list of item codes and want their groups, ask in one call instead of one per item:
GET /api/catalog/v1/item-variant-groups?codes=UPC-CI-AAAA,UPC-CI-BBBB&locale=en-USIt answers an array: each distinct group any of the codes belongs to, once, shaped like
GET /api/catalog/v1/items/{publicCode}/variant-group (members with their own names, axis
values, cover media and live identifiers). A code that is unknown, not visible to you, or not
in a group is simply left out. Send at most 100 codes per call; more is a 400.
Finding the axis values
Section titled “Finding the axis values”Each item carries its axis values as typed attributes:
GET /api/catalog/v1/items/{publicCode}/attributesThe response identifies each attribute by attributeKeyText and
categoryAttributeTemplateId, but it does not tell you which attributes are variant
defining. That lives on the template, so fetch the category’s templates once and join on
categoryAttributeTemplateId:
GET /api/catalog/v1/categories/{categoryId}/attribute-templatesTemplates with usageScopeCode: "VARIANT_DEFINING" are the ones that identify a variant.
Everything else (SPEC, SEARCH_FACET, DISPLAY_ONLY) is descriptive and must not be
used to build a picker. Matching on attributeKeyText alone works today but is not stable
across categories, since two categories can reuse a key with a different scope.
Read the value from the field matching the attribute’s dataTypeCode: enumValueCode for
ENUM, integerValue for INTEGER, multiEnumValueCodesCsv for MULTI_ENUM, and so
on. The stringValue on an enum attribute, where present, is a display label and is not
stable enough to key on.
The canonical variant
Section titled “The canonical variant”canonicalVariantCatalogItemId is the item that owns the shared SEO surface. Link to it
from search results and category listings; deep-link to a specific variant only when the
buyer has actually chosen one.
Packs and composites
Section titled “Packs and composites”An item with isCompositeFlag: true is a bundle or multipack. Fetch its composition:
GET /api/catalog/v1/items/{publicCode}/compositeA MULTI_PACK with components lists what is in it and how many. A MULTI_PACK with
allowComponentSubstitution: true and no components is an assortment: the contents vary,
so render it as “assorted” rather than showing an empty list.
A KIT is a bundle of different things rather than a quantity of one thing. Its component
rows carry a roleCode: exactly one PRIMARY, and INCLUDED for everything else that
ships in the box. Render the primary as the headline and the rest as an inventory.
Two things about kits will otherwise look like bugs:
- A component can be a plan. An included registration or membership appears as an
INCLUDEDcomponent pointing at aPLAN-kind item. It is part of the price, not an upsell, so present it as a feature. - A kit can be missing the component you expect. When the thing in the box is not
sold separately, there is no item to point at and the row is absent by design. The
reason is written into the composite’s
regulatoryAggregationNoteText; read that before treating the gap as incomplete data.
Add-ons and recommendations
Section titled “Add-ons and recommendations”Optional extras are relationships, not components:
GET /api/catalog/v1/items/{publicCode}/relationshipsRECOMMENDED_WITH is the one to render as an add-on. Its notesText is written to be
shown to a buyer, so use it as the suggestion copy rather than composing your own.
ACCESSORY_OF and ALTERNATIVE_TO are for “works with” and “compare” placements.
The distinction that matters: a plan reached by a component is included in the price, and
a plan reached by a RECOMMENDED_WITH relationship is a separate purchase. The same
product often does both, including one tier and recommending the higher ones.
Plan items intentionally carry no price points. Price a plan through the plan surface, not by resolving the catalog item.
Resolving price
Section titled “Resolving price”Price does not live on the catalog item. Resolve it against the offer:
POST /api/price/v1/pricepoints/resolve{ "subjectKindCode": "OFFER", "subjectKey": "PP-FR-C11-P01", "subjectId": "…offer id…", "quantity": 1, "buyerCountryCode": "CA", "currencyCode": "CAD"}Pass quantity even when it is 1. Price points can be bound to a quantity range via
minPurchasableQuantity and maxPurchasableQuantity, and add-on services in particular
use that to change rate at a volume break. Resolving without a quantity can return the
wrong tier.
Omit currencyCode to let the platform choose: it resolves seller setting, then the
buyer country’s default currency, then USD.
Availability
Section titled “Availability”An offer’s reach is the intersection of two layers, the catalog item’s regulatory rule and the offer’s own commercial rule:
GET /api/marketplace/v1/offers/{offerId}/availability?country=CABoth layers are default-reach. A country with no rule is not blocked, so the absence of a row means available, not unavailable. Check the endpoint rather than inferring reach from the rows you can see.