Rank the live catalog against what you are trying to make. Deterministic — a keyword and use-case scorer over the same catalog GET /v1/products serves, with no model anywhere in it, so the same query over the same catalog always returns the same ranking. Every recommendation is priced through the same path POST /v1/order charges from (your negotiated overrides, then your plan's catalog discount), so price_cents is what you would actually pay for one unit; a SKU the catalog cannot price comes back price_cents: null with priced_from: "unavailable" rather than a guess. Provide at least one of query or use_case.
- Credential
- An API key in
x-api-key - Scopes
products:read
Parameters
Idempotency-Keyheader · string (uuid)Unique key for idempotent requests. If you retry a request with the same key within 24 hours, you receive the original response. The key is bound to the request payload: reusing it for a different payload returns 422
idempotency_key_reuserather than the original response, so a changed request is never silently dropped. The binding is over the JSON's content, not its bytes — keys in another order or other whitespace are the same request. Generate a new key for each new request. On an operation that moves money (an order, a batch, an item, a shipping or address change, a cancellation's refund), a key that cannot be checked because the idempotency ledger is unreachable is answered 503idempotency_unavailablewithRetry-After, and nothing runs or is charged: retry with the same key.
Request body application/json, required
querystringFree text describing what you need. Each word scores a point against a product's name, SKU, description and derived category — the same fields GET /v1/products?search= matches.
use_casestringIntended use case; scores products in the catalog categories and keywords that serve it.
One of
brandingpackagingeventsmarketingdecorsizestringDesired size, matched as a substring of the SKU or name — the same way GET /v1/products?size= matches it.
One of
smallmediumlargeindoor_outdoorstringIndoor, outdoor, or both. The catalog has no such field, so this is matched against product copy: a product that says nothing scores nothing here and is never excluded for it.
One of
indooroutdoorbothquantityintegerUnits you expect to order. Prices each recommendation at that quantity and adds estimated_items_total_cents (unit price x quantity). Our own plant's products priced by our price book carry quantity breaks (stickers 40% off from 10, 61% off from 25, 73% off from 50, 76% off from 100; magnets 15% off from 10, 30% off from 50, 40% off from 100; posters 8% off from 5, 15% off from 10, 18% off from 25, except the 18×24, which is $10.71 each from 5 and $9.89 each from 10 and $9.66 each from 25, and the 24×36, which is $19.31 each from 5; wall decals 8% off from 5, 15% off from 10), on the order's whole quantity of a SKU and after any plan discount; a negotiated price takes none. It excludes shipping and the processing fee; POST /v1/pricing/estimate quotes an order total.
1 to 100000
budgetstringPrice preference, relative to the products that matched rather than to a fixed dollar band: low favours the cheapest third of the matches, premium the dearest third. Scores one point, the same as every other signal, and breaks remaining ties in that direction. Products the catalog could not price take no budget point and rank last.
One of
lowmediumpremium
Request
POST /api/v1/products/recommend
curl -X POST "https://portal.printedsimply.com/api/v1/products/recommend" \
-H "x-api-key: ps_test_YOUR_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: recommend-products-$(date +%s)" \
-d '{
"query": "logo stickers for a trade show",
"use_case": "events",
"quantity": 100
}'Response 200
Up to 5 ranked recommendations, plus a catalog block saying what they were ranked over and whether it was stale.
data
recommendationsobject[]requiredBest first, at most 5. Empty means nothing in the catalog matched — check
catalog.stalebefore concluding that.at most 5 entries
recommendations[].skustringrequiredPass straight to
POST /v1/pricing/estimateor an order item.recommendations[].namestringrequiredrecommendations[].categorystringrequiredThe derived catalog category — the same vocabulary as
CatalogProduct.category.recommendations[].pricestring | nullrequiredThe unit price formatted for display, e.g. "$4.50". Null, not "$0.00", when the catalog could not price the SKU — a zero here would be a number nothing backs.
recommendations[].price_centsinteger | nullrequiredYour unit price in cents, already through your negotiated overrides and your plan's catalog discount, with no platform fee (there is none on any plan) — the same path
POST /v1/ordercharges from, so this is what you would actually pay for one. Null when unpriced.recommendations[].priced_fromstringrequiredcatalog— the number came through the order pricing path.unavailable— the lookup failed, so no price is reported rather than a guessed one. There is deliberately no estimate case: the SKU still matched your signals, so it is returned unpriced instead of dropped.One of
catalogunavailablerecommendations[].estimated_items_total_centsintegerPresent only when you sent
quantityAND the SKU is priced. Unit price x quantity — items only. Shipping and the processing fee are NOT in it;POST /v1/pricing/estimatequotes an order total.recommendations[].estimated_items_totalstringThe same number formatted, e.g. "$450.00". Present under the same condition.
recommendations[].scoreintegerrequiredPoints this SKU scored against your signals. Comparable within one response only — it is a count of matched signals, not a rating of the product, and rows scoring zero are never returned.
recommendations[].match_reasonsstring[]requiredWhy it scored, one string per point, e.g. "keyword: waterproof", "use_case: branding (stickers)". This is the whole explanation — the ranking is deterministic and has no model in it.
quantityinteger | nullrequiredEchoed back; null when you sent none.
budgetstring | nullrequiredEchoed back; null when you sent none.
One of
lowmediumpremiumcatalogobjectrequiredWhat this ranking was computed over. An empty
recommendationsoff a stale snapshot is a different fact from an empty one off a healthy catalog, and these four fields are how you tell.catalog.stalebooleanrequiredSame signal as the
X-Catalog-Staleheader, in the body.catalog.reasonstring | nullrequiredSame values as
X-Catalog-Degraded; null when not stale.catalog.sourcestringWhere the ranked rows came from:
catalog(the plant's catalog, live or its last-good copy), orprice_bookwhen our own plant's catalog had nothing of its own to serve and the ranking is over the sizes our price book prices, as GET /v1/products then lists them. Stale either way whenstaleis true.One of
catalogprice_bookcatalog.consideredintegerrequiredSellable rows in the catalog this ranked over.
catalog.matchedintegerrequiredOf those, how many scored above zero. Only the top few are priced and returned, so this is normally larger than
recommendations.length.
meta
pageintegertotalintegerlimitintegerThe page size actually applied, which is not necessarily the one asked for — the operation's own ceiling wins.
pagesintegertotaloverlimit, rounded up.next_cursorstring | nullOn a cursor-paged operation: send this back as
cursorfor the next page. Null on the last page. Absent on operations that do not page by cursor.has_morebooleanOn a list operation whose
metaisListMeta: whether another page follows this one.
{
"status": "success",
"data": {
"recommendations": [
{
"sku": "magnet-3x3",
"name": "Magnet, 3×3 in",
"category": "magnets",
"price": "$1.32",
"price_cents": 132,
"priced_from": "catalog",
"estimated_items_total_cents": 13200,
"estimated_items_total": "$132.00",
"score": 1,
"match_reasons": [
"keyword: a"
]
},
{
"sku": "magnet-4x4",
"name": "Magnet, 4×4 in",
"category": "magnets",
"price": "$1.74",
"price_cents": 174,
"priced_from": "catalog",
"estimated_items_total_cents": 17400,
"estimated_items_total": "$174.00",
"score": 1,
"match_reasons": [
"keyword: a"
]
},
{
"sku": "sticker-die-cut-3x3",
"name": "Die-Cut Sticker, 3×3 in",
"category": "stickers",
"price": "$0.64",
"price_cents": 64,
"priced_from": "catalog",
"estimated_items_total_cents": 6400,
"estimated_items_total": "$64.00",
"score": 1,
"match_reasons": [
"keyword: stickers"
]
},
{
"sku": "wall-decal-24x36",
"name": "Wall Decal, 24×36 in",
"category": "decals",
"price": "$22.03",
"price_cents": 2203,
"priced_from": "catalog",
"estimated_items_total_cents": 220300,
"estimated_items_total": "$2203.00",
"score": 1,
"match_reasons": [
"keyword: a"
]
}
],
"quantity": 100,
"budget": null,
"catalog": {
"stale": false,
"reason": null,
"source": "catalog",
"considered": 6,
"matched": 4
}
},
"request_id": "4e3753e1-48dd-4313-bfd8-48e4af4e116a"
}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).payload_too_large — the request body exceeded this route's byte cap (256 KB unless the route raises it with a written reason). It is refused before it is parsed, so nothing was created. Send fewer rows, or use the endpoint's own asynchronous door where it has one.
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.502The catalog could not be read at all, so there was nothing to rank. Deliberately an error rather than an empty
recommendationslist, which would read as "nothing suits you".X-Catalog-Degradedcarries the reason. Never for our own plant's catalog, which falls back to its price book (catalog.source: "price_book"), as GET /v1/products does.