Skip to content

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:

  • siteId restricts to a single site, which must be one you hold. A siteId outside your grant is a 400, never an empty page. GET /v1/products?siteId=222 and GET /v1/sites/222/products are the same request - same handler, same result.
  • serviceType restricts to STOCK or POD. Any other value is a 400 invalid_service_type, again rather than an empty page - including DIGITAL, 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:

  • visibility is 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 - or HIDDEN.
  • orderable is whether an order line for this entry is promised to be accepted: true exactly when the product has a fulfillment center, is STOCK or POD, and is VISIBLE_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/segments lists 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 as GET /v1/sites/{siteId}/segments - same handler, same result, same errors. A siteId outside your grant answers 400 invalid_site_id.
  • GET /v1/products/{productId} embeds the product’s assignments as segments: [{segmentId, name}], the same ids the list above carries. An empty array means no assignments, which only matters when visibility consults 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.

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.

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.

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.

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.

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.