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; a sticker our sticker catalog lists is priced by the catalog's own sheet instead, its one-unit price on the row and its pack totals in quantity_breaks, a wall decal our catalog lists by its own sheet's price, and a poster it lists at its listed price, each one price each at any quantity, and a magnet our catalog lists by its own price list, a total at every quantity (price_interpolation: formula); for anything else (unlisted 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 (what an order charges at each quantity where it changes, or at each pack quantity of our sticker catalog or each published quantity of our magnet catalog, from the function the quote and the order charge with) with price_interpolation saying how to read between its entries, 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). Our catalog (die-cut and kiss-cut stickers in white, clear, glitter and holographic; three single bumper stickers and six single-sheet variants in white, clear (Transparent), and holographic, each at its own flat single-unit price; wall decals in 36 sizes of Removable Wall Film and Polyester Fabric, each priced by its own sheet, posters in 46 sizes of matte paper, each one price at any quantity, and magnets in 16 sizes, priced by their own price list) is listed whole: our plant's rows come first, then each row of the catalog they do not hold, source: "listed", each with its material (and a sticker's options); a row our plant's catalog does not carry yet says not_orderable_code: plant_unmapped.
- 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://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). Our catalog's rows (its stickers, wall decals, posters and magnets) the book does not hold follow them, source: "listed", as on a fresh answer. 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[].fulfillment_cost_basisstringApproved per-size US standard shipping benchmark; actual postage, manufacturing cost and profit remain unknown.
Always
approved_size_shipping_benchmarkproducts[].goods_cost_centsanyActual manufacturing cost is unknown, never represented by zero.
products[].manufacturing_cost_centsanyproducts[].actual_contribution_centsanyproducts[].maximum_quantityintegerOne product, one SKU, quantity 1 in its own order.
Always
1products[].shipping_constraintsobjectproducts[].shipping_constraints.destination_countrystringrequiredAlways
USproducts[].shipping_constraints.methodstringrequiredAlways
standardproducts[].shipping_constraints.separate_orderbooleanrequiredAlways
trueproducts[].shipping_benchmark_centsintegerThe approved quantity-1 US standard benchmark, packing included.
at least 0
products[].packagingstringAlways
included_in_shipping_benchmarkproducts[].artwork_layoutstringAlways
prepared_combined_allproducts[].artwork_instructionsstringPrepare one combined ALL PNG: flag front and back, or Santa hat front and band, according to the exact guide; remove guide lines.
products[].artwork_guide_urlstringLocal path to the layout guide SVG. This reference is not customer artwork and must not be sent as print_file.
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 itself; absent on every row a facility sent.
price_book: built from our own price book because our plant's catalog had nothing of its own to serve (the answer is thenX-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.listed: a row of our catalog (a sticker, a wall decal, a poster or a magnet) that our plant's catalog, as this read found it, does not hold, listed after the rows read, with the same fields and its price from the catalog;not_orderable_codesays whether our plant carries it.One of
price_booklistedproducts[].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 an unsized sticker 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. A SKU of our sticker catalog is read by its own grammar: the finish it names after the size, else the bare stem's (glossy); there
clearandholographicname amaterial, not a finish. A wall decal of our catalog: its sheet's one finish,matte; a poster of our catalog: its one finish,matte(matte paper). A magnet, bumper sticker or single-sheet sticker of our catalog: null, as it names none.products[].materialstring | nullOur catalog's rows only: the material the SKU is made in, as our catalog names it — an economy die-cut or kiss-cut sticker's white, clear, glitter or holographic adhesive vinyl; a bumper sticker's white surface or a single-sheet sticker's white, clear (Transparent) or holographic surface (no finish inferred); a wall decal's
film(Removable Wall Film, the barewall-decal-stem) orfabric(Polyester Fabric, the Textured Wall Decal), both indoor rated; a poster'spaper(matte paper, the bareposter-stem and its only material); UV DTFtransparent-film, roll labelsbopp, and catalog-only flags and hatspolyester. Sheet packs preserve white, clear or holographic surfaces. Null on every other row, a magnet of our catalog's included: our catalog names no material for one.One of
whiteclearglitterholographicfilmfabricpapertransparent-filmbopppolyesterproducts[].optionsobject | nullEconomy die-cut and kiss-cut stickers and listed magnets: the shapes and finishes an order line may name. Magnet cuts share their size's price and have no finish choice. Null for bumper stickers, single sticker sheets, UV DTF packs, label rolls, sheet packs, wall decals, posters, catalog-only flags and hats, and other rows with no choices. Pack count and surface are distinct SKUs.
products[].options.shapesstring[]requiredThe shapes a SKU may name after its size, the bare SKU's first. Empty for a kiss cut, whose shape is the cut.
products[].options.finishesstring[]requiredThe finishes a SKU may name after its size, the bare SKU's first.
products[].options.default_shapestringrequiredWhat the bare SKU is cut to:
contour(the artwork's outline), orkissfor a kiss cut. A magnet's bare SKU is custom contour.One of
contourcirclesquarerectangleovalkissproducts[].options.default_finishstring | nullrequiredWhat the bare SKU is finished in; null when no finish is offered, including magnets.
One of
glossmatteproducts[].options.variant_skusstring[]requiredEvery SKU an order line may name for this product, canonically spelled, the bare SKU first (
sticker-die-cut-3x3,sticker-die-cut-3x3-matte,sticker-die-cut-3x3-circle, …): the spellings our plant is asked to carry, and so the ones to send. A shape or finish never changes the price, and an order's lines of one product count as one quantity. A test key orders any of them now. A live key orders each once our plant's catalog carries that SKU itself: the row'sorderablespeaks for the row's own SKU only, and a live quote or order of a variant the plant does not carry yet is refused 422sku_not_orderable_yetbefore anything is charged. Any other spelling (-gloss,-glossy, the words in another order) is asked of our plant under that spelling and is not one it was asked to carry.products[].familystringOur plant's rows only: the product family. Modeled rows read pricing and cost under their family. The approved flag and Santa hat policy has bounded fulfillment while actual manufacturing cost remains unknown.
One of
stickersdecalsmagnetspostersflagsheadwearproducts[].catalog_readinessstringPresent on provider-product additions. Modeled sticker goods have estimated cost/weight. owner_exception identifies only the four approved flag sizes and one Santa hat: separate quantity-1 US standard orders with prepared combined ALL artwork and explicit benchmark/unknown-cost disclosures. Both test and live requests require reviewed exact production mappings and confirmed product evidence. catalog_only references cannot be quoted or ordered automatically.
One of
modeledowner_exceptioncatalog_onlyproducts[].selling_unitstringOne order quantity buys this complete selling unit. A roll or pack is never priced or ordered as one individual label or sheet.
One of
piecerollpackproducts[].physical_piecesintegerPhysical pieces inside one selling unit: labels per roll, sheets per sheet pack, pieces per UV DTF pack, or one single product. Two ordered packs contain twice this number; order quantity remains two packs.
at least 1
products[].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: what an order of this SKU charges, from one unit up — computed by the function POST /v1/orders/quote and POST /v1/order charge with (no platform fee is added on any plan). The first entry (
min_qty1) is the row's own price.price_interpolationsays how to read between entries. Withnone, one entry per quantity where the unit price changes, each unit holding until the next entry; a single entry when the SKU climbs no ladder (a SKU our price book does not price, and a wall decal or a poster of our catalog: its listed price, one price each, at any quantity). Withlinear_total(our sticker catalog's rows), one entry at each of the catalog's pack quantities (1, 5, 10, 25, 50, 100, 250, 500, 1,000 and 2,500): an order of exactlymin_qtyis chargedtotal_cents, and any other quantity follows the ruleprice_interpolationstates, to the cent. Withformula(our magnet catalog's rows), one entry at each of 1, 5, 10, 25, 50, 100, 250, 500, 1,000, 2,500, 5,000 and 10,000 units: an order of exactlymin_qtyis chargedtotal_cents, and any other quantity is the price list's own total for it, which POST /v1/orders/quote gives. Counted on an order's whole quantity of the SKU, across its lines (on our sticker catalog's rows, across every shape and finish of one product). 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) or is a catalog-only reference (not_orderable_code: catalog_only), whose displayed reference price cannot be charged.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_centsintegerrequiredWhat an order of
min_qtycharges per unit, in cents. On a row whoseprice_interpolationisnoneit holds until the next entry'smin_qty; on alinear_totalorformularow it istotal_centsovermin_qty, rounded, and the units between entries follow the row's rule instead.products[].quantity_breaks[].total_centsintegerWhat an order of exactly
min_qtyunits of the SKU is charged for the goods, in cents: the quote'sline_total_centsfor that many. On every entry.products[].quantity_breaks[].list_total_centsintegerOur sticker catalog's rows only (
price_interpolation: linear_total): the catalog's own pack total formin_qty, beforemin_unit_centsraises it; equal tototal_centswherever it is not raised. The rule between entries reads these. Absent on every other row, a magnet's (formula) included.products[].price_interpolationstringOur plant's rows only: how an order's goods total moves between two
quantity_breaksentries.none: each entry'sunit_centsholds from itsmin_qtyuntil the next entry's, so the total is that unit times the quantity (a wall decal or a poster of our catalog: its one entry, at any quantity).linear_total(our sticker catalog's rows, priced as a pack total): between two entries the total is the straight line between theirlist_total_cents, rounded half up to the cent — for a quantity q between entries a and b,round_half_up(list_a + (list_b − list_a) × (q − a) ÷ (b − a)); from the last entry on, its unit rate holds:round_half_up(q × list_last ÷ min_qty_last); and never less thanmin_unit_cents× q where the row states one. That is the quote's goods total at every quantity, exactly;unit_centsis the total over the quantity, rounded, and is not a bound on the units between entries.formula(our magnet catalog's rows): the total at every quantity is the magnet price list's own, computed from the size and the quantity, which no entry restates between two of them: quote the quantity. It never falls as the quantity grows. Rounding can make a unit rise slightly between neighboring quantities, but the 85% bulk share continues at 250 and beyond; the discount no longer expires there. Its entries carry nolist_total_cents.One of
nonelinear_totalformulaproducts[].min_unit_centsinteger | nullOur plant's rows only: the least a unit of this SKU is charged, where that raises the goods total of some quantity a line can carry (a large sticker at volume is charged this, not less); its
quantity_breaksentries from there on already show it as theirunit_cents. Null where it raises no quantity, and on every row whoseprice_interpolationisnone.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.catalog_only— a flag or hat whose artwork, production cost and shipping weight remain unconfirmed: automatic test and live quotes/orders are refused, even if the plant feed carries the SKU.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, except a row of our catalog, a sticker's, a wall decal's, a poster's or a magnet's, which a test key orders at its listed price).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_unmapped— a row of our catalog (a sticker's, a wall decal's, a poster's or a magnet's) that our plant's catalog, as this read found it, does not carry yet: a live quote or order of it is refused 422sku_not_orderable_yetbefore anything is asked or charged, and a test key orders it now, at the listed price.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
catalog_onlyfacility_not_configuredplant_unreachableplant_unmappedplant_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": "0.70",
"regular_price": "0.70",
"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.