> ## Documentation Index
> Fetch the complete documentation index at: https://docs.orbit.devotel.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Next-best-product recommendations: test and rank per profile

> Use the Recommendations tester to rank a candidate catalog for one profile — enter purchase and browse signals, tune the five affinity factors, read the per-factor breakdown, run a weighed A/B experiment, and carry the top picks into a campaign.

# Next-best-product recommendations

Recommendations ranks one question per profile: given the products you could put in front of this person right now, which are they most likely to buy next? You pass a candidate catalog and the profile’s recent purchase and browse signals, and the rank endpoint returns the catalog ordered by blended affinity with a per-factor score breakdown on every item. This guide covers how the engine ranks, what inputs the tester needs, how to read each factor’s contribution, a worked run against a stored profile, and how to carry the top-ranked items into a campaign goal.

The same endpoint backs the dashboard page at **Audience → Recommendations**. The tester and the API run the same ranking — the tester is a rendering of `POST /api/v1/cdp/recommendations/rank`, so anything you validate in the UI transfers verbatim to automation.

Recommendations is one of three per-profile decision surfaces — pick the one that matches your question:

| Surface           | Question                                                      | Endpoint                                |
| ----------------- | ------------------------------------------------------------- | --------------------------------------- |
| Recommendations   | Which **products** will this profile buy next?                | `POST /api/v1/cdp/recommendations/rank` |
| AI decisioning    | Which **action** (variant × channel × time) for this profile? | `POST /api/v1/cdp/decisioning/decide`   |
| Offer decisioning | Which **offer or promo** is this contact eligible for?        | `POST /api/v1/cdp/offers/decide`        |

## 1. How the engine ranks the catalog

The engine is a pure content-affinity scorer — no stored model, no training step, no stored state. Each call rank-scores the candidate catalog you pass against the signals you pass and returns the ranking it derived on the spot. For every in-stock, non-excluded candidate, it blends up to five factors, each normalized to `[0,1]`:

| Factor         | What it measures                                                                                                                                                                   | Default weight |
| -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------- |
| **Category**   | Affinity for the item’s category, built from recency-decayed, intent-weighted signals plus any explicit `category_affinity` CDP trait, normalized across the profile’s categories. | 0.40           |
| **Brand**      | The same computation for the item’s brand.                                                                                                                                         | 0.20           |
| **Tag**        | Overlap of the item’s tags with the profile’s preferred tags (explicit `preferred_tags` trait).                                                                                    | 0.15           |
| **Price fit**  | Gaussian proximity of the item’s price to the profile’s average observed purchase value, so a $40 spender is not steered toward a $400 item.                                       | 0.15           |
| **Popularity** | An optional global prior in `[0,1]` on the catalog item — a cold-start tie-breaker.                                                                                                | 0.10           |

Signals do not all count equally. Each signal carries an intent weight by interaction kind — `purchase` 1.0, `cart` 0.6, `wishlist` 0.5, `view` 0.25, `click` 0.15 — and is decayed by recency: a signal as old as the half-life (default 30 days) counts half as much. Weights are relative, not absolute, and the blend divides by the sum of the *applicable* factors only — so a cold-start profile with no spend history is scored fairly on category and brand rather than penalized for the missing price-fit factor.

Two consequences follow from the stateless design:

* **Deterministic for A/B.** Pass an `experiment` with named `variants`, each carrying its own factor `weights`, and the profile is bucketed by a stable hash of `(experiment.key : profile_id)` — every variant applies the ranking under its own weights, the assigned variant is echoed back in `assigned_variant`, and the assignment is reproducible from the key. There is no randomness to seed.
* **Never dispatches.** The surface ranks; it never sends. The catalog send path (an RCS / WhatsApp / Apple-Messages in-thread carousel) renders the result. Excluded products stay excluded, and out-of-stock candidates never surface.

## 2. Where the tester lives and what it needs

Open **Audience → Recommendations** in the dashboard. Access is role-gated to `owner`, `admin`, and `developer` (the API hierarchy applies to the tester too). The page assembles a rank request in three blocks:

**Candidate catalog.** One row per product: `product_id`, `title`, `category`, `brand`, `price`, `tags` (comma-separated), and an `in_stock` toggle. Out-of-stock and excluded items are deducted before scoring, so the score column never rewards an unavailable product.

**Profile signals.** One row per recent interaction: `type` (purchase / cart / wishlist / view / click), the item’s `category` and `brand`, its `product_id`, and `age (days)` for recency decay, plus `value` for purchase signals to build the spend profile. The tester seeds a small outdoor-retail catalog with mixed-intent signals so the first click gets a real ranking; replace the seed with the profile’s rolled-up events.

**Scoring & scope.** The refine block takes four optional inputs:

* **Profile id** — a contact id (`cnt_…`) or an external CDP profile id (a Segment `user_id` / `external_id` token). Entering one ties the ranking to a stored profile and drives A/B bucket assignment; the page resolves the id live and warns when no profile matches. Leave it blank to rank on the entered signals alone.
* **Limit** — how many ranked items to return (default 10, max 100).
* **Half-life days** — the recency decay window (default 30). Raise it for long purchase cycles (furniture), lower it for fast trends (apparel drops).
* **Suppress purchased** — exclude any product id the signals list already shows as bought (on by default; turn off to test replenishment journeys).

Factor weights are tuned per-factor with a blank means “use the engine default”. The draft you build persists at account level — a save banner warns if a refresh or another device would lose your edits.

Validation runs before any request: a malformed profile id, an empty catalog, or an invalid signal row blocks the button with an inline error rather than a failed call.

## 3. Reading the per-factor breakdown

The response echoes the blended `score` and a `factors` map with each of the five factor scores in `[0,1]`. Read the breakdown row-by-row:

* **Category says “what they browse.”** A high category score means the signals and the `category_affinity` trait point at that category — it dominates the blend at 0.40 by default.
* **Brand says “whose \~they buy from.”** Useful for spotting brand lock-in even inside a category the profile otherwise rarely touches.
* **Tag says “which attributes match.”** Tag is the fine-grained differentiator within a category (`lightweight`, `wool`, `2-person`).
* **Price fit says “is this in their spend band.”** A profile averaging $90 with a $39 vs a \$240 candidate will see a price-fit gap between them even if the category and brand scores tie.
* **Popularity says “what the crowd picks.”** The tie-breaker for profiles with thin signals — zeros on the other four factors fall back to it.

The summary line above the result table counts `considered` (candidates that passed the gates), `excluded` (out of stock, `exclude_product_ids`, or suppression), and `returned` (capped by the limit), plus the effective half-life. When every candidate is out of stock or excluded, the tester says so directly instead of showing an empty rank.

## 4. Worked example: rank one production profile

Goal: pick the next-best product to feature in an RCS carousel for a known contact. Start in the tester to see the shape, then automate through the API.

**1. Pull the signals.** From the CDP profile you roll up the recent purchase/browse events into the `signals` array — one row per event with its kind, category, brand, product id, age in days, and (for purchases) value. The tester’s seed shows the intended shape; in production those rows come from your event rollups or the profile API rather than manual entry.

**2. Enter the candidate catalog.** The tenant’s commerce product set — the same products an RCS / WhatsApp carousel would render — one row per SKU.

**3. Set the scope.** Paste the contact’s `cnt_…` id into Profile id. The identity chip resolves the profile; a no-match warning means the ranking would not tie to a stored profile, so fix the id before trusting it for a real send. Leave Limit and Half-life at defaults unless the catalog is large or the purchase cycle is unusual.

**4. Rank.** The result table orders the catalog descending with the top pick highlighted. A typical output row for a runner who bought a summit pack 45 days ago and has browsed basecamp tents:

| Rank | Product               | Score | Category | Brand | Tag | Price fit | Popularity |
| ---- | --------------------- | ----- | -------- | ----- | --- | --------- | ---------- |
| #1   | 2-Person Dome Tent    | 0.71  | 0.9      | 0.8   | 0.5 | 0.4       | 0.3        |
| #2   | Merino Long-Sleeve    | 0.66  | 0.9      | 0.6   | 0.6 | 0.6       | 0.2        |
| #3   | Trail Runner 28L Pack | 0.40  | 0.4      | 0.8   | 0.5 | 0.6       | 0.4        |

The tent wins on brand and tag (recent basecamp views outweigh the older summit purchase); the suppressed already-bought pack drops to #3 through `suppress_purchased`. Read the gap between #1 and #2 before you commit a carousel slot — a margin under \~0.05 usually means the A/B experiment is a better call than a single pick.

**5. Same shape over the API.** What the tester sends is exactly what your service sends:

```bash theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/cdp/recommendations/rank \
  -H "X-API-Key: dv_live_sk_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "profile_id": "cnt_01HF8XK...",
    "catalog": [
      { "product_id": "sku_dome_2p", "title": "2-Person Dome Tent", "category": "outdoor", "brand": "basecamp", "price": 240, "tags": ["camping", "hiking"], "in_stock": true },
      { "product_id": "sku_merino_lc", "title": "Merino Long-Sleeve", "category": "apparel", "brand": "summit", "price": 84, "tags": ["wool", "base-layer"], "in_stock": true }
    ],
    "signals": [
      { "type": "purchase", "category": "outdoor", "brand": "summit", "product_id": "sku_old_pack", "age_days": 45, "value": 110 },
      { "type": "view", "category": "outdoor", "brand": "basecamp", "product_id": "sku_dome_2p", "age_days": 3 },
      { "type": "cart", "category": "apparel", "brand": "summit", "product_id": "sku_merino_lc", "age_days": 1 }
    ],
    "limit": 10,
    "suppress_purchased": true
  }'
```

The response carries `recommendations` (rank, `product_id`, `title`, `category`, `brand`, `price`, `score`, and the `factors` breakdown), `considered_count`, `excluded_count`, and the effective `weights` and `half_life_days` — everything the tester renders comes from the same fields.

## 5. Carrying top-ranked items into a campaign goal

The ranking is a targeting input for your campaign, not a send itself:

1. Run `rank` for each profile in the send segment (a nightly batch call per profile is the common shape — the endpoint is rate-limited at 60 calls/min per tenant, so batch rather than burst).
2. Feed the top `product_id`(s) into the campaign’s message template — the RCS / WhatsApp / Apple-Messages catalog carousel renders the same fields the tester shows.
3. For multi-variant optimization, wrap the call in an `experiment` block and let the assigned `variant` name drive the message version recorded for attribution; conversions attributed to a strategy tell you which weight profile wins.

Keep `suppress_purchased` on for replenishment-sensitive journeys, off for “buy again” flows, and let `exclude_product_ids` drop seasonal or embargoed SKUs at call time.

## 6. Quota, rate, and related APIs

* **Rate limit:** 60 requests/min per tenant — the same compute-surface quota every CDP decision endpoint carries. Burst protection so a batch loop doesn’t crowd out interactive tester traffic; batch with a small sleep between items.
* **Limits:** catalog 1–2000 items, signals up to 5000, limit 1–100. A misuse returns a structured `422` before any ranking runs.
* **Role gate:** requests need an `owner`, `admin`, or `developer` identity; the tester hides the surface for other roles and the API enforces the same gate.
* **Id format:** `profile_id` is a plain identifier token (no URLs or free text) — enforced both in UI validation and API schema, with a `422` on violation.

For the full field-level contract see [CDP API reference — predictive models](/api-reference/endpoints/cdp), and for the model-based (trained) alternatives — when you want fit scores rather than content affinity — see [CDP predictive models](/guides/cdp-predictive-models).

## See also

* [AI decisioning](/guides/ai-decisioning) — pick the next-best message action for the same profile
* [CDP predictive models](/guides/cdp-predictive-models) — trained churn / conversion / LTV / fatigue scores behind segment audiences
* [CDP audiences](/guides/cdp-segments) — build the segment the batch rank call serves
* [CDP API reference](/api-reference/endpoints/cdp) — the full endpoint contracts for the recommendations, decisioning, and offers surfaces
