Catalog and products
An order line has to name a productId and a variantId, and the catalog is the only place
either one comes from. That is what this endpoint set is for - but since 2026-08-04 it is more
than an ordering feed: it publishes your granted sites’ whole active catalog, hidden and
segment-gated products included, with each entry’s visibility as data. What it still
deliberately leaves out is prices.
| Endpoint | Gives you |
|---|---|
GET /v1/products |
every active product on your granted sites, one entry per variant |
GET /v1/products/{productId} |
one product, with every variant grouped under it and its segment assignments embedded |
GET /v1/segments |
the customer segments your granted sites define |
POST /v1/products/{productId}/quote |
a price for one product at one quantity - see Pricing |
GET /v1/products/{productId}/template |
the design template a TEMPLATE product needs filled in |
All of these need partner-api/products:read, which is a separate scope from the one that reads
orders. If GET /v1/products answers 403 insufficient_scope while GET /v1/orders works,
that is why - ask Partner Integrations to add it.
Key your own catalog on (productId, variantId)
Section titled “Key your own catalog on (productId, variantId)”Neither the sku nor the productCode on an existing order line can be matched back to a
catalog entry. There is no lookup that goes that direction, and building one out of string
matching will appear to work on your test data and then quietly mis-resolve in production. Page
the catalog, store it, and key it on the pair.
Results are keyset-paginated and ordered by (productId, variantId). Follow nextCursor until
it is absent, exactly as with orders - see the pagination loop.
The default page size is 100 and the maximum is 500; a limit above 500 is capped rather than
rejected.
Two optional filters narrow the result:
siteIdrestricts to a single site, which must be one you hold. AsiteIdoutside your grant is a400, never an empty page.GET /v1/products?siteId=222andGET /v1/sites/222/productsare the same request - same handler, same result.serviceTyperestricts toSTOCKorPOD. Any other value is a400 invalid_service_type, again rather than an empty page - includingDIGITAL, which is listed but cannot be ordered here, so it is not a filter this endpoint accepts.
Every active product is listed - orderable says which can be ordered
Section titled “Every active product is listed - orderable says which can be ordered”An entry appears for every ACTIVE product on your granted sites, whatever its visibility -
including products the site has hidden or restricted to particular customer segments, and
including DIGITAL products, which cannot be ordered through this API at all. Retired and
deleted products stay out, and productStatus says so explicitly rather than leaving it
implied.
Two fields carry what being listed used to imply:
visibilityis who can see the product on its own storefront, always by name, never a raw id:VISIBLE_TO_ALL,VISIBLE_TO_SOME- segment-gated, shown only to customers in one of the product’s segments - orHIDDEN.orderableis whether an order line for this entry is promised to be accepted:trueexactly when the product has a fulfillment center, isSTOCKorPOD, and isVISIBLE_TO_ALL.
orderable: false means not supported and not promised - which is not the same as
rejected. We traced the submission path on 2026-08-04, so we can now be precise about the
difference:
| condition | orderable |
what submission actually does |
|---|---|---|
| no fulfillment center | false |
rejects (400) |
DIGITAL |
false |
rejects (400) |
retired SLS_ product |
not listed | rejects (400) |
HIDDEN |
false |
accepts, prices and fulfills |
VISIBLE_TO_SOME |
false |
accepts, prices and fulfills |
Submission applies no visibility or segment check at all. So the flag is the boundary of what
this API supports, and the platform will not enforce that boundary for you: treat
orderable as your own gate rather than relying on a rejection that will not come.
Making orderable authoritative is not a change we can make on our own side. The check would
have to move into the platform’s submission path, which is shared by every external-order
integration - so it is a platform change with a much wider blast radius than this API, and it is
not planned. Build your own gate.
Beyond the two flags, each entry carries the storefront content the site wrote for the
product - namePlural, excerpt, metaDescription, metaKeywords, urlPattern,
excludeFromSearch, and the primary image’s imageUrl and thumbnailUrl - along with the
variant’s attributes pairs, the inventory breakdown below, and ordering context
(defaultQuantity, stockUnitOfMeasure, requiresApproval, supplierReference,
fulfillmentCenterName). The API reference documents every field; none of them changes how
an order is placed.
An SLS_ product is a share of a product the platform maintains centrally, and its description,
metaDescription and metaKeywords are inherited from that central product unless the site
chose to write its own. What you read here is what the storefront renders, either way - you never
need to resolve the inheritance yourself.
If you have the products:write scope you can change much of this - see
Changing the catalog, which also explains the one switch
(overrideDisplay) that decides whether a share shows its own content or the central product’s.
Segments name who VISIBLE_TO_SOME is visible to
Section titled “Segments name who VISIBLE_TO_SOME is visible to”A segment is a named group of a site’s customers, and it is the vocabulary behind
visibility: VISIBLE_TO_SOME: the storefront shows such a product only to customers in one of
the product’s assigned segments. Two places publish them, both under products:read - segments
are catalog vocabulary, not a separate grant:
GET /v1/segmentslists every segment your granted sites define -segmentId,name,description- whether or not any product is assigned to it. No pagination: sites define a handful each, so a single response is always the complete answer. Narrow to one site with?siteId=, or address it directly asGET /v1/sites/{siteId}/segments- same handler, same result, same errors. AsiteIdoutside your grant answers400 invalid_site_id.GET /v1/products/{productId}embeds the product’s assignments assegments: [{segmentId, name}], the same ids the list above carries. An empty array means no assignments, which only matters whenvisibilityconsults them.
The variant-grained list does not carry segment arrays - assignments are published on the detail endpoint only. If per-product detail calls become the expensive part of your sync, raise it with Partner Integrations rather than scraping around it.
STOCK and POD publish different things
Section titled “STOCK and POD publish different things”serviceType is STOCK - warehoused inventory - POD - print-on-demand, produced per
order - or DIGITAL, which is listed because it is catalog but can never be ordered through
this API, so it is never orderable. Several fields are null for POD, and that is
structural rather than missing data:
| Field | STOCK |
POD |
|---|---|---|
variantId |
the variant’s id, one entry per variant | always null - a POD product has no variant until the job is configured |
sku |
the SKU | null |
weight |
per-unit weight, a decimal string | null |
availableInventory |
stock on hand, or null when untracked |
always null |
weight is a string for the same reason order totals are: the underlying value carries more
precision than a JSON number holds. Parse it as a decimal, not a float, if you do arithmetic
on it.
Inventory: null means unknown, not none
Section titled “Inventory: null means unknown, not none”availableInventory is only meaningful when inventoryTracked is true. When it is false,
the product does not track inventory at all: no quantity can be stated, and null means no
quantity can be stated, not that the product has none.
The same rule covers the breakdown published beside it - actualInventory (on hand, before
reservations), reservedInventory (held by open orders) and backorderInventory. All four
are numbers together or null together; an untracked product never mixes the two.
A product is listed whatever the number says. Zero does not remove it, because a product that vanished at zero would be indistinguishable from one that was discontinued.
Whether a shortfall actually blocks an order is decided at submission, from the site’s
backorder setting - not from this field. A null inventory is not a promise of orderability,
and a healthy-looking number is not a reservation. See
validation failures
for what a rejection looks like and what it tells you.
artworkSource decides what an order line must carry
Section titled “artworkSource decides what an order line must carry”For POD products, artworkSource tells you how artwork is supplied, and therefore what
POST /v1/orders needs from you for that line:
artworkSource |
What the order line needs |
|---|---|
USER_UPLOAD |
a file at order time, as artworkUrl |
STORED |
nothing - artwork is already on file |
TEMPLATE |
templateVariables, filled in from the template endpoint below |
PLUGIN |
driven by a storefront plugin outside this API’s model |
null |
null for every STOCK product, and for a small fraction of POD products with no classification on file |
Treat null on a POD product as not classified, not as “no artwork needed”. It is the one
value that does not tell you what to do, and guessing STORED because it is the cheapest branch
is how a line gets submitted without the artwork it needed.
Templated products
Section titled “Templated products”When artworkSource is TEMPLATE, call GET /v1/products/{productId}/template for the
vendor, the template id, and the variable definitions to fill in:
{ "vendor": "CHILI", "templateId": "a45e4954-15cf-4629-91b0-a8cd8615c3e3", "variables": [ { "name": "Start Date", "displayName": "Start Date", "dataType": "date", "listItems": null }, { "name": "End Time", "displayName": "End Time", "dataType": "list", "listItems": ["9:00 AM", "5:00 PM"] } ]}Send those name keys back as templateVariables on the order line. A dataType of list
means the value must be one of listItems.
This call is real-time, not cached - it reaches the template service live on every request, so the answer always matches what the template currently requires. There is no sync delay to design around, and equally no reason to poll it faster than you need the answer.
There is no incremental catalog sync
Section titled “There is no incremental catalog sync”updatedOn tells you when a catalog entry last changed, and it is informational only. There is
no updatedSince filter on this endpoint, so a refresh is a full page-through every time -
the same constraint that applies to orders, for the same reason.
Page at a large limit and run the refresh on a schedule you actually need rather than
continuously; see Rate limits and quotas for the budget it
consumes.
Prices are not published here
Section titled “Prices are not published here”Deliberately. What a product costs depends on variant adjustments, site markup and per-share pricing overrides, so any single column in this response would be a base rate rather than what you pay.
Two things do give you a real number: POST /v1/products/{productId}/quote for a quote at a
given quantity, and the submission response itself, which carries the real per-line and order
totals. See Pricing for what a quote does and does not account for.
- Submitting orders - turning a catalog entry into an order.
- Pricing - quoting a product, and the discount a quote cannot see.
- Tenancy and access - why a product outside your grant is a
404.