Skip to content

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

  • cursor is opaque. To fetch the next page, pass it back verbatim as ?cursor=. Keep paging until cursor is null. 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 limit while more results remain. Stop on cursor: null, not on a short or empty page.
  • direction=previous pages backward from the current cursor.
  • updatedAfter / updatedBefore restrict results by the product's updatedAt timestamp (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:

  • totalHits is populated (it is null in 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, and updatedBefore are ignored. status still applies (default ACTIVE).

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 of customerPrice).

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:

  • commission and commissionRate can 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 pricing block 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:

  1. Fetch the registry once and index it by family.
  2. Walk every product in the regimen; collect every panel row (including children of blend rows) with a non-null ingredientFamily and non-null amountMcg.
  3. Group rows by ingredientFamily and look each family up in the registry.
  4. If the family exists and summable is true: sum amountMcg (per serving) or amountMcgPerUnit × daily units (per day) and display the total under the registry's displayName.
  5. If the family is absent from the registry, or summable is false: do not show a total. Show the family's note instead — it is written for practitioners and explains why amounts aren't comparable (hazard gives you the machine-readable reason: extract-vs-whole-herb, non-additive-units, parent-child-overlap, or non-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 (-L in curl), or read the Location header yourself.
  • Never store or embed the presigned URL — it expires in 15 minutes. Re-request the endpoint whenever you need the image.
  • 404 with code: LABEL_IMAGE_NOT_FOUND means the label has not been rendered for that product yet; it is not proof the product doesn't exist. A textColor other than black or white returns 400 with code: 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. Setting limit above 5 never yields more than 5 matches.
  • minConfidence is advisory. When the top candidate matches with very high confidence, ranking is short-circuited and the threshold is not applied to that response.
  • Empty matches is ambiguous. You get matches: [] with 200 both when nothing genuinely matches and when the recommendation backend is degraded (the message differs — "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 400 with {"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 returns 503 with code: 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.
  • allergens and certifications values on products are lowercase slugs (gluten_free, treenut_free, organic_certified, …). form is display-cased ("Capsule", "Veg Capsule", "Quick-Dissolve Tablet", "Powder", "Liquid", "Serum", "Softgel", "Tablet"). Match on exact strings.
  • external: true marks products shipped in their own container rather than included in daily pill packs; these carry servingsPerContainer on the facts panel. Pack products (external: false) have recommendedUsage daypart 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.