Browse the product catalog. Supports filtering by category, size, finish, and free-text search. Pages by cursor (see Pagination): the catalog is not a table, so it is walked in the order the catalog reports it, and a cursor names the last row's place and SKU, and the SKU after it — a product added or removed elsewhere in the catalog while you page moves nothing you have read, and if the last row you were given leaves, the walk resumes at the row that followed it. A request without cursor answers every match unless limit caps it, as it always has. Every price field on a row (price, regular_price, sale_price, base_price) is the price an order charges, the same on every plan: there is no platform fee (platform_fee_pct on GET /v1/pricing/tiers reads 0). For a product our own plant makes that our price book prices (a sticker, a vinyl decal up to 5×5 in, a poster, a magnet or a wall decal, at a size where one unit pays its own handling) the list price is the book's, a base plus a rate per square inch of the size the SKU names, and every price field on its row shows it — the plant's own catalog figure is a cross-check, never charged; for anything else (sticker sheets and rolls, larger vinyl decals, smaller sizes, and every other facility's goods) it is the facility's list price. POST /v1/order charges that list price, with only shipping and card processing added. On a row the price book does not price, a sale_price is the catalog's own and is never what an order charges; on a row it prices, sale_price shows the book's price like every other field. Every row our own plant makes also says what it sells and whether it can be ordered now: size_in, finish, family, minimum_quantity (1: a run of one is an order), quantity_breaks (the all-in unit price an order charges at each quantity where it changes, from the function the quote and the order charge with), and orderable with not_orderable_code and not_orderable_reason (the quote's own refusals, asked of the SKU first, as a code and as a sentence).
- Credential
- An API key in
x-api-key - Scopes
products:read
Parameters
cursorquery · stringOpaque cursor: send back
meta.next_cursorfrom the previous page for the page after it; omit it for the first page. Good only on this list, with a key of the same account and mode (test or live), and only on the catalog it was returned for, served from the source it was returned under (live, the last-good snapshot, the local synced copy or our price book): a cursor read against a page served from another source is refused with 409 cursor_source_changed, and the walk starts again withoutcursor, and with the same filters (onlylimitmay change); an altered or foreign cursor, or one sent with other filters, is refused with 400 validation_error. Wins overpagewhen both are sent.limitquery · integerProducts per page, applied after the filters (
totalstill counts every match). Withoutcursor: no default (every match comes back) and 200 at most — the size this list's page walk always had, so a first answer andpageagree on it. Withcursor: 20 by default and 100 at most, as on every list. Withoutcursora value is read by its leading whole number, as it always was: above 200 it is clamped to 200, and one below 1, or with no leading number, is read as 1. Withcursora value above 100 is clamped to 100 and one below 1 to 1, a fraction is truncated, and a missing, zero or non-numeric value takes the default. None is refused.pagequery · integerdeprecatedDeprecated: walk with
cursor. The facility's own page of the catalog, passed through and answered as before, withlimitas a request withoutcursortakes it;meta.has_moreandmeta.next_cursorsay where the cursor walk continues after this page's last row. Ignored whencursoris sent.Default 1
categoryquery · stringFilter by product category (e.g. 'stickers', 'magnets', 'decals'). Matched generously: a row is kept if its DERIVED category equals this exactly, or if the value appears anywhere in its raw category, SKU or name. So the result can hold rows whose
categoryfield is not this string.searchquery · stringFree-text search across product name, SKU, and description
sizequery · stringFilter by size (e.g. '3x3', '4x4', '6x6')
finishquery · stringFilter by finish (e.g. 'glossy', 'matte', 'holographic')
providerquery · stringWhich catalog to browse:
printedsimply, our own plant's — the stickers, decals, labels, magnets and posters it makes and prices. It is the default and the only value; anything else is a 400.Always
printedsimplyDefault printedsimply
dedupequery · booleanCollapse the OMS's two-SKU-per-product rows to one canonical row per product. Opt-in:
trueenables it, anything else leaves the catalog as the facility reports it.totalstill counts the rows actually returned.Default false
Request
GET /api/v1/products
curl "https://portal.printedsimply.com/api/v1/products" \
-H "x-api-key: ps_test_YOUR_KEY"Response 200
The matching catalog rows. May have been served out of the last-good snapshot — read X-Catalog-Stale on every facility answer, which is sent both ways. When our own plant's catalog (printedsimply) has nothing of its own to serve — no live answer, no snapshot, no local copy — its rows are our price book's: the sizes the book prices, at its list prices (the price an order is charged), each row carrying source: "price_book", with X-Catalog-Stale: true and X-Catalog-Degraded naming why. A size or product the book does not list is missing from that answer, so do not republish it as the plant's whole catalog. Each of those rows says orderable: false and why (facility_not_configured or plant_unreachable). A stale answer is sent Cache-Control: private, no-store, so no shared cache keeps serving an outage's availability after the plant answers again.
data
productsobject[]requiredOur own plant's catalog rows.
products[].skustring | numberThe identifier every other operation takes: an order item's
sku, the{sku}path parameter, a recommendation'ssku. Numeric on some facility catalogs — compare it as a string.products[].namestringproducts[].descriptionstringproducts[].categorystringrequiredThe facility's category if it published one, otherwise derived from the product name: one of swimwear, apparel, outerwear, headwear, bags, drinkware, stickers, decals, magnets, posters, accessories, other —
otherwhen no rule matched.products[].sourcestringPresent only on a row this API built from our own price book because our plant's catalog had nothing of its own to serve (the answer is then
X-Catalog-Stale: true, withX-Catalog-Degradednaming why). Its price is the book's, which is what an order of the SKU is charged; the row carriessku,name,base_priceandcategory. Absent on every row a facility sent.Always
price_bookproducts[].size_innumber[] | nullOur plant's rows only: [width, height] in inches, read from the size the SKU names (the size the price book and an order price). Null when the SKU names no size, as a sticker sheet or roll does.
2 to 2 entries
products[].finishstring | nullOur plant's rows only: the finish the plant's row names, else the one the SKU names (glossy, matte, holographic, clear, satin); null when neither names one. No finish changes the price.
products[].familystringOur plant's rows only: which of its product families the SKU is, the family its quantity ladder and its cost are read under.
One of
stickersdecalsmagnetspostersproducts[].minimum_quantityintegerOur plant's rows only: the fewest units of this SKU one order line takes, which is 1. A run of one is an order, priced at the first
quantity_breaksentry; POST /v1/order refuses a quantity below it with 400 validation_error.Always
1products[].quantity_breaksobject[]Our plant's rows only: the unit price an order of this SKU charges, from one unit up, one entry per quantity where it changes — computed by the function POST /v1/orders/quote and POST /v1/order charge each unit with (no platform fee is added on any plan). The first entry (
min_qty1) is the row's own price. A single entry when the SKU climbs no ladder (a SKU our price book does not price). Counted on an order's whole quantity of the SKU, across its lines. A negotiated price for your account takes no break and replaces these. Empty when the row carries no usable price (not_orderable_code: no_price).products[].quantity_breaks[].min_qtyintegerrequiredFrom this many units of the SKU on one order (counted across its lines).
at least 1
products[].quantity_breaks[].unit_centsintegerrequiredThe unit price, in cents, an order of
min_qtyor more charges, until the next entry'smin_qty.products[].orderablebooleanOur plant's rows only: true when nothing POST /v1/orders/quote checks before pricing would refuse this SKU right now. False says why in
not_orderable_reason(a sentence) andnot_orderable_code(a code). A hint for a cart, read when the catalog was: the quote and the order decide.products[].not_orderable_reasonstring | nullWhy not, in the order path's own words — the same field, meaning the same thing, as on every other row of this endpoint that says
orderable. Null whenorderable. On our plant's rowsnot_orderable_codeis the same answer as a code.products[].not_orderable_codestring | nullOur plant's rows only: null when
orderable; otherwise why not, as a code, in the order the quote meets them.facility_not_configured— the plant has no credentials in this environment (a live quote or order answers 503 provider_unavailable; a test key's degrades to $0 markedpricing_degraded).plant_unreachable— the plant's catalog did not answer this read (the row is a snapshot or the price book's); a quote, estimate or order asks the plant about the SKU, and answers 503 provider_unavailable withRetry-After(details.reason: plant_unreachable) until it does.plant_price_held— the plant's prices are held after one of its launch goods read far above its book price, and this SKU has no book price to show it is not wrong the same way; the quote refuses it until the hold lifts.no_price— the row carries no usable list price and our price book does not price the SKU, so the quote refuses it.One of
facility_not_configuredplant_unreachableplant_price_heldno_pricetotalintegerrequiredHow many products matched the whole query. This is the match count, NOT
products.length:limittruncates the array and leaves this untouched, sototal > products.lengthmeans there is more behind the same filters.providerstringrequiredWhich catalog answered: always
printedsimplyhere.Always
printedsimplyfacetsobjectrequiredfacets.categoryobjectrequiredDerived category to number of matching products — always the value derived from the product name, even for a row that carries the facility's own
category. Categories with no matches are omitted rather than sent as 0, so a missing key means none matched.filtersobjectrequiredThe filters this answer was computed under, echoed back. Null where you sent nothing.
filters.categorystring | nullrequiredfilters.searchstring | nullrequiredfilters.sizestring | nullrequiredfilters.finishstring | nullrequiredfilters.dedupebooleanrequired
meta
pageintegerdeprecatedThe page number of the deprecated offset walk, on the lists that published it (orders and an order's events, on every request without
cursor; products, on a request that sentpage). Absent on a cursor walk, where no page number would be true.totalintegerEvery row matching the request's filters, on the lists that count them (orders, an order's events, assets and products).
limitintegerThe page size applied, which is not necessarily the one asked for: a larger
limitis clamped. Absent only on a GET /products request withoutcursorthat sent nolimit, which answers every match.pagesintegerdeprecatedtotaloverlimit, rounded up, besidepage(orders and an order's events). Absent on a cursor walk.next_cursorstring | nullrequiredSend it back as
cursorfor the next page; null exactly whenhas_moreis false. Opaque: pass it back unchanged. Good only on the list that returned it, with a key of the same account and mode (test or live), and with the same filters.has_morebooleanrequiredWhether another page follows this one. Decided from one row past this page, never from a page that came back short — so a page after
has_more: truecan come back empty only if rows were deleted in between, and the walk still ends at the firsthas_more: false.sincestring | nullOn GET /order/{id}/events only: where to ask from next — the position of the last event in this answer, or, when it has none, the position the request read from. Send it back as
since(withwait_secondsto wait for the next event). Unlikenext_cursorit is not null at the end of the history; it is null only when the order has no events and the request named no position. Opaque, and bound like a cursor to this order, account and key mode.
{
"status": "success",
"data": {
"products": [
{
"sku": "sticker-die-cut-3x3",
"name": "Die-Cut Sticker 3\" x 3\"",
"price": "2.67",
"regular_price": "2.67",
"category": "stickers"
}
],
"total": 1,
"provider": "printedsimply",
"facets": {
"category": {
"stickers": 1
}
},
"filters": {
"category": "stickers",
"search": null,
"size": "3x3",
"finish": null,
"dedupe": false
}
},
"request_id": "4e3753e1-48dd-4313-bfd8-48e4af4e116a",
"meta": {
"total": 1,
"has_more": false,
"next_cursor": null
}
}Errors
Invalid request body or parameters. When the request failed the operation's schema,
detailscarriesformErrors(problems with the request as a whole),fieldErrors(messages keyed by top-level field) anderrors: one{ pointer, code, message }per refused value,pointeran RFC 6901 JSON pointer into the body (or into the object of query parameters, for a query parameter) —/items/0/sku, with~written~0and/written~1, and""for the request as a whole. POST /v1/orders/bulk answerserrorsas one{ index, details }per refused order, eachdetailswith its own pointers from the request body (/orders/3/items/0/sku); POST /v1/orders/multi-ship keepserrorsas the{ formErrors, fieldErrors }it always published; POST /v1/mockups/generate and POST /v1/products/recommend keep their fields at the top ofdetails, witherrorsbeside them. A refusal of a rule the schema cannot state (an asset that does not exist, an expiry too soon) names it inmessageand carries noerrors.Missing, invalid or expired API key:
missing_api_key(none sent),invalid_api_key(unknown or revoked),key_expired(past the expiry set when it was created; create a new key).cursor_source_changed —
cursorwas returned while the catalog was served from one source (live, the last-good snapshot, the local synced copy or our price book;details.issued_under) and this page is served from another (details.source, withX-Catalog-StaleandX-Catalog-Degradedsaying why). Each is another list, so the walk is refused rather than continued with rows skipped or repeated: leavecursorout and start again from the first page. A cursor returned before cursors named their source (details.issued_under: null) is refused the same way.Rate limit exceeded. Two windows can produce this status and
error.codesays which:rate_limit_exceededfor the per-minute sliding window,daily_quota_exceededfor the plan's daily quota. Both carryRetry-After; the daily one also carriesX-Daily-LimitandX-Daily-Remaining. The per-minute limits are plan-based — see Rate Limits above, or call GET /v1/me for this credential's own figures.502Nothing was servable — no live catalog, no snapshot, no local copy (never on our own plant's catalog,
printedsimply, whose last resort is our price book: see 200). Deliberately loud: this endpoint never answers a catalog outage with an empty 200.X-Catalog-Degradedcarries the reason, so a failure is diagnosable rather than indistinguishable from an empty catalog.