Product Catalog¶
The product catalog is the read-side of everything you sell through DailyStak: supplement products with full supplement-facts panels, label artwork, and — most importantly — a per-partner, per-customer pricing breakdown computed for you on every read. Use it to build product pickers, storefront pages, and margin calculators, and to resolve the productId values you pass to Protocols, Hosted Checkout, and POST /v2/orders (see Orders).
All catalog endpoints are read-only, including POST /v2/products/recommend. They require a v2 API key (see Quickstart) with the order/v2.product.read scope — note this is a separate scope from the general order/v2.read.
Listing the catalog¶
GET /v2/products returns a paginated list. By default you get ACTIVE products only, ten at a time, ordered by last update.
curl "https://api.fulfillment.sandbox.juniperhealth.com/v2/products?limit=10&status=ACTIVE" \
-H "Authorization: Bearer $ACCESS_TOKEN"
{
"products": [
{
"id": 77123129891,
"name": "Vitamin C Complex",
"description": "Buffered vitamin C with citrus bioflavonoids.",
"packDescription": "30 capsules",
"status": "ACTIVE",
"form": "Capsule",
"external": false,
"image": "https://cdn.example.com/vitc.png",
"recommendedUsage": { "morning": 1, "afternoon": 0, "evening": 1, "sleep": 0 },
"allergens": ["gluten_free"],
"certifications": ["vegan"],
"supplementFactsPanel": {
"servingSize": 2,
"pillType": "Capsule",
"otherIngredients": ["Rice Flour", "Gelatin"],
"ingredientInformation": [
{
"name": "Vitamin C",
"value": "500",
"unit": "mg",
"dailyValue": "556",
"ingredientFamily": "vitamin_c",
"amountMcg": "500000",
"amountMcgPerUnit": "250000",
"children": null
}
],
"nutritionInformation": [],
"servingsPerContainer": null,
"caution": null
},
"price": "19.99",
"pricing": {
"retailPrice": "19.99",
"partnerPrice": "11.99",
"customerPrice": "17.99",
"customerDiscount": 10,
"partnerDiscount": 40,
"commission": "6.00",
"commissionRate": 30
},
"createdAt": "2026-05-01T12:00:00Z",
"updatedAt": "2026-05-01T12:00:00Z"
}
],
"cursor": "eyJwayI6ICJQUk9EVUNUIzc3MTIzMTI5ODkxIn0=",
"totalHits": null
}
Money values (price, everything in pricing except the two discount integers) are decimal strings, not JSON numbers. The top-level price field is the retail (MSRP) unit price — the same value as pricing.retailPrice — provided so you can show it next to your partner price (struck through, for example) to make the discount visible.
Pagination and time filters¶
cursoris opaque. To fetch the next page, pass it back verbatim as?cursor=. Keep paging untilcursorisnull. Never construct, decode, or edit a cursor — and don't mix cursors between list mode and search mode; they are not interchangeable.- A returned cursor does not guarantee a non-empty next page, and filtered pages can come back shorter than
limitwhile more results remain. Stop oncursor: null, not on a short or empty page. direction=previouspages backward from the current cursor.updatedAfter/updatedBeforerestrict results by the product'supdatedAttimestamp (ISO 8601, e.g.updatedAfter=2026-05-01T00:00:00). Query parameters the endpoint does not recognize are silently ignored, so double-check spelling: a typo means an unfiltered result set, not an error.
Use updatedAfter with the last timestamp you processed to poll for catalog changes incrementally.
Search mode¶
Adding search= switches the endpoint into relevance-ranked search over product name, search terms, description, and ingredients. Matching is fuzzy — search=magnesum still finds magnesium products.
curl "https://api.fulfillment.sandbox.juniperhealth.com/v2/products?search=vitamin%20c&limit=5" \
-H "Authorization: Bearer $ACCESS_TOKEN"
Search mode differs from list mode in three ways:
totalHitsis populated (it isnullin list mode) with the total match count, so you can render "14 results" and page through them.- Results are ordered by relevance, best first.
direction,updatedAfter, andupdatedBeforeare ignored.statusstill applies (defaultACTIVE).
Product statuses¶
| Status | Meaning |
|---|---|
ACTIVE |
Orderable and shown by default. |
INACTIVE |
Temporarily hidden; not orderable. |
DRAFT |
Not yet published. |
OUT_OF_STOCK |
Listed but currently unavailable. |
DISCONTINUED |
Permanently retired. |
The status filter is case-sensitive and defaults to ACTIVE — you never see non-active products unless you ask for them explicitly. An unrecognized status value returns 422.
Single product¶
GET /v2/products/{productId} returns one product — the same object shape as the list, not wrapped in an envelope. An unknown ID returns 404 with body {"message": "Product with id 999 not found", "code": "PRODUCT_NOT_FOUND"}.
Pricing¶
Every product carries a pricing block computed at read time from your current discount configuration:
retailPrice— the MSRP, before any discount.partnerPrice— what you pay DailyStak per unit:retailPrice × (1 − partnerDiscount / 100).customerPrice— what your customer pays per unit:retailPrice × (1 − customerDiscount / 100).commission— your per-unit margin:customerPrice − partnerPrice.commissionRate—partnerDiscount − customerDiscount, in percentage points of MSRP (not ofcustomerPrice).
customerDiscount defaults to your partner-level default discount. Pass customerId= on either the list or the detail endpoint to price for a specific customer: if that customer has a discount override, it replaces the default in the customerPrice calculation. A customerId that doesn't resolve is not an error — the response quietly falls back to your default discount, so verify the customerDiscount field if you depend on the override.
Two things integrators regularly get wrong:
commissionandcommissionRatecan be negative. If you give a customer a deeper discount than your own partner discount, you sell below your cost and the pricing block reports that honestly. Don't clamp these to zero in your UI.- Catalog pricing is a live quote, not a contract. It reflects your discount configuration at the moment of the request. Prices on an order are frozen separately when the order is placed — see Orders. Never cache a
pricingblock for later billing math.
Products with no price configured return an all-zero pricing block ("retailPrice": "0", …) rather than omitting the field.
Supplement facts and ingredient rows¶
supplementFactsPanel is the machine-readable facts panel. ingredientInformation holds the active-ingredient rows; nutritionInformation holds basic nutrition rows (calories, carbohydrates, …). Each ingredient row gives you the declared label amount (value + unit + dailyValue) plus three normalized fields designed for computation:
| Field | Meaning |
|---|---|
ingredientFamily |
Roll-up slug (e.g. "magnesium") joining the row to the ingredient-families registry. null means the row does not participate in roll-ups. |
amountMcg |
The row's amount per serving, normalized to micrograms, as a decimal string. null when the unit isn't a convertible mass (IU, CFU, enzyme-activity units). |
amountMcgPerUnit |
Micrograms in one pill — use this with recommendedUsage daypart counts. null for powders and liquids. |
children |
Nested rows for proprietary blends; a blend header row lists its member ingredients here. |
Ingredient families and regimen roll-ups¶
When a customer takes several products, practitioners want a combined nutrient total — "how much magnesium per day across this whole regimen?" Naively summing label rows is wrong for many botanical and enzymatic ingredients, so the API ships a registry telling you exactly which totals are safe.
GET /v2/ingredient-families returns every known family:
{
"version": "9c47e1b02a3d",
"families": [
{
"family": "ashwagandha",
"displayName": "Ashwagandha",
"summable": false,
"hazard": "extract-vs-whole-herb",
"note": "Extract and whole-herb amounts are not comparable; totals are not shown."
},
{ "family": "magnesium", "displayName": "Magnesium", "summable": true, "hazard": null, "note": null }
]
}
To compute a regimen roll-up:
- Fetch the registry once and index it by
family. - Walk every product in the regimen; collect every panel row (including
childrenof blend rows) with a non-nullingredientFamilyand non-nullamountMcg. - Group rows by
ingredientFamilyand look each family up in the registry. - If the family exists and
summableistrue: sumamountMcg(per serving) oramountMcgPerUnit × daily units(per day) and display the total under the registry'sdisplayName. - If the family is absent from the registry, or
summableisfalse: do not show a total. Show the family'snoteinstead — it is written for practitioners and explains why amounts aren't comparable (hazardgives you the machine-readable reason:extract-vs-whole-herb,non-additive-units,parent-child-overlap, ornon-equivalent-forms).
Treat rule 5 as a hard contract requirement: an absent family means "not summable", never "sum it anyway".
The registry is constant between service deploys. Responses carry Cache-Control: public, max-age=3600, and version is a content hash that changes only when the registry changes — cache the body keyed on version and re-fetch at most hourly.
Label images¶
GET /v2/products/{productId}/label returns the product's rendered label as a PNG — via a 302 redirect to a presigned download URL valid for 15 minutes. The endpoint never streams image bytes itself.
curl -L -o label.png \
"https://api.fulfillment.sandbox.juniperhealth.com/v2/products/77123129891/label" \
-H "Authorization: Bearer $ACCESS_TOKEN"
- Use a client that follows redirects (
-Lin curl), or read theLocationheader yourself. - Never store or embed the presigned URL — it expires in 15 minutes. Re-request the endpoint whenever you need the image.
404withcode: LABEL_IMAGE_NOT_FOUNDmeans the label has not been rendered for that product yet; it is not proof the product doesn't exist. AtextColorother thanblackorwhitereturns400withcode: INVALID_TEXT_COLOR.
AI recommendations¶
POST /v2/products/recommend matches a natural-language query against the catalog and returns ranked candidates with confidence scores and generated reasoning. Queries can describe conditions ("supplements for joint health"), ingredients ("high-dose vitamin D3"), or similar products ("products like Elite EPA").
Although it is a POST, this is a read-only operation with no side effects: your standard order/v2.product.read scope is all it needs — no write scope.
curl -X POST "https://api.fulfillment.sandbox.juniperhealth.com/v2/products/recommend" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"query": "magnesium for sleep support", "limit": 3, "minConfidence": 0.7}'
{
"query": "magnesium for sleep support",
"matches": [
{
"productId": 77123129894,
"productName": "Magnesium Glycinate",
"score": 0.93,
"reasoning": "Glycinate form is commonly used for sleep and relaxation support."
}
],
"message": "Found 1 products matching your query.",
"clarifications": null,
"predictivePrompts": ["L-Theanine for calm focus", "Melatonin for sleep onset"]
}
Request contract: query is required (1–1000 characters); limit is 1–20 (default 5); minConfidence is 0.0–1.0 (default 0.6). Responses: matches is ranked best-first and only contains ACTIVE products; message is a human-readable summary (nullable); clarifications is a nullable array of follow-up questions present only when matches is empty; predictivePrompts suggests up to two complementary products.
Know the model's behavior before you build on it:
- Non-deterministic. The same query can return different scores and reasoning between calls. Don't cache results as canonical or diff them between runs.
- At most 5 candidates are considered per query, regardless of
limit. Settinglimitabove 5 never yields more than 5 matches. minConfidenceis advisory. When the top candidate matches with very high confidence, ranking is short-circuited and the threshold is not applied to that response.- Empty
matchesis ambiguous. You getmatches: []with200both when nothing genuinely matches and when the recommendation backend is degraded (themessagediffers — "No products matched your query." vs "Unable to process recommendation at this time."). Treat an empty result as "no suggestions right now", not as an authoritative absence, and always give users a manual search path. - A malformed body returns
400with{"message": "Invalid request body", "code": "INVALID_REQUEST_BODY"}— your input is never echoed back, so check field names and types against the contract above. An unavailable pipeline returns503withcode: RECOMMENDATIONS_UNAVAILABLE. Both are retryable from the user's perspective — the endpoint has no side effects.
Errors¶
Bodies are {"message", "code"} — branch on code — except the gateway's own 401/403, which carry message only.
| Status | Where | code |
Meaning |
|---|---|---|---|
401 |
all endpoints | — (gateway) or UNAUTHORIZED |
Missing/unparseable token, or the credential is unknown or inactive. |
403 |
all endpoints | — (gateway) | Token valid but lacks order/v2.product.read, or is expired. |
404 |
detail | PRODUCT_NOT_FOUND |
Product not found. |
404 |
label | LABEL_IMAGE_NOT_FOUND |
Label not rendered yet. |
400 |
label | INVALID_TEXT_COLOR |
textColor is not black or white. |
422 |
list/search | VALIDATION_FAILED |
Invalid query parameter (unknown status value, non-integer limit). details.errors names the failure type, e.g. [{"type": "enum", "loc": []}]. |
400 |
recommend | INVALID_REQUEST_BODY |
Request body failed validation. Your input is not echoed back. |
503 |
recommend | RECOMMENDATIONS_UNAVAILABLE |
Recommendation pipeline temporarily unavailable; retry later. |
Good to know¶
- Query parameters are camelCase, like bodies and responses. Unknown query parameters are dropped silently.
- All catalog reads are side-effect free — no events, no webhooks, no state changes. Retry freely.
- The catalog is eventually consistent: a product edit can take a short time to appear in list results, and slightly longer in search results. If a search unexpectedly returns nothing, fall back to list mode.
allergensandcertificationsvalues on products are lowercase slugs (gluten_free,treenut_free,organic_certified, …).formis display-cased ("Capsule","Veg Capsule","Quick-Dissolve Tablet","Powder","Liquid","Serum","Softgel","Tablet"). Match on exact strings.external: truemarks products shipped in their own container rather than included in daily pill packs; these carryservingsPerContaineron the facts panel. Pack products (external: false) haverecommendedUsagedaypart counts that matter for Protocols line items.- Additional content fields (
images,article,faqs,searchTerms,relatedProducts,caution,label,researchDocumentId) may appear on product objects; build only against the fields documented in the API reference. - Product IDs are plain integers — treat them as opaque identifiers and don't assume anything about their ranges or origin.