> ## 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.

# Marketing Mix Model: split your budget across channels

> Regress daily channel spend against daily revenue to see each channel's incremental contribution, ROI, a diminishing-returns response curve, and a recommended budget split — and know when to use the top-down model instead of bottom-up attribution or a holdout test.

# Marketing Mix Model: split your budget across channels

The Marketing Mix Model answers the budgeting question that comes *before* attribution: **given my channel spend and my revenue over time, what is each channel contributing, and how should I split the next budget?** It regresses your daily channel spend against your daily revenue, then returns each channel's incremental contribution, ROI, a diminishing-returns response curve, and a recommended budget reallocation.

Because it works on aggregate daily totals, it also covers the channels bottom-up attribution cannot touch — linear TV, out-of-home, sponsorships, or any channel you only track as a daily cost total with no per-contact touch events.

Run it from **Insights → Media Mix Model** in the dashboard, or over the API at `POST /cdp/analytics/marketing-mix-model`. No new instrumentation or storage is needed — it reads the same CDP event stream the attribution reports use. For full request/response schemas, see the [CDP analytics API reference](/api-reference/cdp).

## Where it fits: top-down vs bottom-up vs incrementality

Three measurement surfaces answer three different questions. Pick the one that matches the question:

| Question                                                                                                                           | Use                                                                                       | How it measures                                                            |
| ---------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- |
| "Of this month's conversions, how is credit split across the channels and campaigns that touched the converting contacts?"         | [Multi-touch attribution](/guides/multi-touch-attribution) (bottom-up)                    | Joins each conversion to its per-contact touchpoints and allocates credit. |
| "Did this specific campaign *cause* lift, or would those conversions have happened anyway?"                                        | [Campaign A/B testing](/guides/campaign-ab-testing) with a holdout group (incrementality) | Compares a treated cohort against a withheld control cohort.               |
| "Given my channel spend and revenue over time, what's each channel's incremental contribution — and how should I split my budget?" | **Marketing Mix Model** (top-down, this guide)                                            | Regresses aggregate daily spend against aggregate daily revenue.           |

Use the mix model when you are allocating budget *across* channels, when a channel has no clean per-contact join, or when signal loss makes touch-level attribution thin. Use attribution when you need per-campaign or per-model credit inside the channels you send through Orbit. Use a holdout when you need causal proof for one specific campaign — the mix model estimates, a holdout tests.

## Building a model

The report needs two aligned daily series: **spend per channel** and **total revenue**. You control how each is derived:

| Field                      | Purpose                                                                                                                                                        |
| -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `revenue_event` (required) | The event name whose value carries revenue, e.g. `order_completed`.                                                                                            |
| `value_property`           | JSONB property on the revenue event holding its monetary value (default `"value"`).                                                                            |
| `channel_property`         | JSONB property on spend events holding the channel label (default `"channel"`).                                                                                |
| `cost_property`            | JSONB property on spend events holding the spend amount (default `"cost"`).                                                                                    |
| `channels`                 | Explicit channel whitelist (max 12). When omitted, the top 12 channels by total spend in the window are selected automatically.                                |
| `since` / `until`          | ISO window bounds — default the trailing 90 days, clamped to at most 180 days.                                                                                 |
| `adstock_decay`            | Carryover decay in \[0, 0.95] (default 0.3). Raise it when you believe spend keeps working for longer — brand channels; lower it for direct-response channels. |
| `total_budget`             | Optional per-day budget to reallocate. Defaults to the sum of each channel's own average daily spend — "reallocate what you already spend."                    |

Minimal call:

```bash theme={null}
curl -X POST "https://api.orbit.devotel.io/api/v1/cdp/analytics/marketing-mix-model" \
  -H "X-API-Key: $ORBIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "revenue_event": "order_completed" }'
```

Practical window rules:

* The fit needs **at least 14 aligned days** of spend and revenue — below that it refuses, and the dashboard shows the "Not enough history yet" state. Forty-five days + is where the fit starts to be trustworthy.
* Missing days are treated as explicit zeros, not gaps — the model sees an evenly-spaced series, so a day with no spend is a real data point.
* Both series must share one currency; the endpoint never converts FX.
* More than 12 channels with recorded spend: the model fits the top 12 and flags `truncated_channels: true`. Pass an explicit `channels` whitelist to choose which 12.

## Reading the response

The response concatenates per-channel rows with model-level fit figures. A worked example over a 60-day window with three channels:

```json theme={null}
{
  "status": "fitted",
  "periods": 60,
  "adstock_decay": 0.3,
  "r2": 0.8123,
  "rmse": 412.37,
  "mae": 305.11,
  "baseline_revenue_per_period": 18750.42,
  "total_revenue": 1289340.00,
  "total_attributed_revenue": 164390.20,
  "total_current_spend": 91200.00,
  "total_recommended_budget": 1520.00,
  "truncated_channels": false,
  "channels": [
    {
      "channel": "sms",
      "coefficient": 685.412008,
      "saturation_half_point": 740.00,
      "avg_spend": 680.00,
      "total_spend": 40800.00,
      "contribution": 118740.55,
      "contribution_share": 0.7223,
      "roi": 2.9098,
      "response_curve": [
        { "spend_multiplier": 0, "spend": 0, "expected_revenue": 0 },
        { "spend_multiplier": 0.25, "spend": 170.00, "expected_revenue": 141.03 },
        { "spend_multiplier": 0.5, "spend": 340.00, "expected_revenue": 252.17 },
        { "spend_multiplier": 0.75, "spend": 510.00, "expected_revenue": 342.77 },
        { "spend_multiplier": 1, "spend": 680.00, "expected_revenue": 416.29 },
        { "spend_multiplier": 1.25, "spend": 850.00, "expected_revenue": 475.53 },
        { "spend_multiplier": 1.5, "spend": 1020.00, "expected_revenue": 523.17 },
        { "spend_multiplier": 2, "spend": 1360.00, "expected_revenue": 591.26 }
      ],
      "recommended_spend": 940.00,
      "recommended_spend_share": 0.6184,
      "recommended_spend_delta": 260.00,
      "recommended_contribution": 496.32
    },
    {
      "channel": "email",
      "coefficient": 210.033991,
      "saturation_half_point": 380.00,
      "avg_spend": 420.00,
      "total_spend": 25200.00,
      "contribution": 31450.80,
      "contribution_share": 0.1913,
      "roi": 1.2472,
      "response_curve": [
        { "spend_multiplier": 0, "spend": 0, "expected_revenue": 0 },
        { "spend_multiplier": 0.25, "spend": 105.00, "expected_revenue": 50.63 },
        { "spend_multiplier": 0.5, "spend": 210.00, "expected_revenue": 89.41 },
        { "spend_multiplier": 0.75, "spend": 315.00, "expected_revenue": 118.46 },
        { "spend_multiplier": 1, "spend": 420.00, "expected_revenue": 139.79 },
        { "spend_multiplier": 1.25, "spend": 525.00, "expected_revenue": 155.12 },
        { "spend_multiplier": 1.5, "spend": 630.00, "expected_revenue": 166.06 },
        { "spend_multiplier": 2, "spend": 840.00, "expected_revenue": 179.19 }
      ],
      "recommended_spend": 580.00,
      "recommended_spend_share": 0.3816,
      "recommended_spend_delta": 160.00,
      "recommended_contribution": 157.23
    },
    {
      "channel": "direct_mail",
      "coefficient": 95.501122,
      "saturation_half_point": 420.00,
      "avg_spend": 420.00,
      "total_spend": 25200.00,
      "contribution": 14198.85,
      "contribution_share": 0.0864,
      "roi": 0.5634,
      "response_curve": [
        { "spend_multiplier": 0, "spend": 0, "expected_revenue": 0 },
        { "spend_multiplier": 0.25, "spend": 105.00, "expected_revenue": 21.09 },
        { "spend_multiplier": 0.5, "spend": 210.00, "expected_revenue": 37.54 },
        { "spend_multiplier": 0.75, "spend": 315.00, "expected_revenue": 50.36 },
        { "spend_multiplier": 1, "spend": 420.00, "expected_revenue": 60.17 },
        { "spend_multiplier": 1.25, "spend": 525.00, "expected_revenue": 67.57 },
        { "spend_multiplier": 1.5, "spend": 630.00, "expected_revenue": 73.10 },
        { "spend_multiplier": 2, "spend": 840.00, "expected_revenue": 80.39 }
      ],
      "recommended_spend": 0.00,
      "recommended_spend_share": 0.0000,
      "recommended_spend_delta": -420.00,
      "recommended_contribution": 0.00
    }
  ]
}
```

Read the model-level fields as fit quality and context, per-channel rows as the decision:

* **`r2` / `rmse` / `mae`** — goodness of fit of the regression. Below roughly 0.5, treat every per-channel number as a hypothesis; above 0.8 the model is tracking revenue well. There is no separate confidence-interval output — `r2` and the error terms are the confidence signal.
* **`baseline_revenue_per_period`** — the per-day revenue the model expects with *zero* marketing: organic demand. The intercept stays visible so "we would have made it anyway" is explicit, not folded into channel credit.
* **`total_attributed_revenue` vs `total_revenue`** — the share of revenue the model allocates to channels versus everything. A small attributed share means revenue is mostly baseline — or a spend channel with real effect is missing from the model.
* **`contribution` / `contribution_share`** — modelled revenue from that channel over the window, and its share of the attributed total.
* **`roi`** — `contribution / total_spend`. Above, SMS returns \~2.9 per 1.0 of spend; `direct_mail` returns \~0.56 — it loses money on the fitted numbers.
* **`coefficient`** — the raw regression weight in "revenue per unit of saturated spend." It can come back ≤ 0 (multicollinearity or genuinely wasted spend); such channels are clamped to zero effect for contribution and reallocation, but the raw value stays visible so you can see it.
* **`saturation_half_point`** — the adstocked spend level at \~63% of the channel's maximum effect. Spending far above it buys little; it marks where the curve flattens.

## The response curve

Each channel's `response_curve` samples hypothetical steady per-day spend levels at 0, 0.25, 0.5, 0.75, 1, 1.25, 1.5, and 2× its current average spend, with the modelled revenue at each. Because saturation is concave, **marginal return shrinks with every point you move right**.

Read it as "expected revenue removed from the previous point": SMS above gains \~141 going from 0 to 170/day, but only \~68 from 1020 to 1360. The 1× point is where you sit today; points below it show what you give up by cutting, points above show what further spend would add. A channel with no historical spend still gets an illustrative curve off a nominal 100/day base, so its shape renders once spend starts.

## The recommended split — and its caveats

`total_recommended_budget` defaults to your current average daily spend summed across channels, so the answer is "how to re-split what I already spend," not "how much to spend overall." Pass `total_budget` to model a bigger or smaller total. The allocator shifts every channel's budget to the level where the *marginal* return is equal across channels — you always fund the channel with the highest next-dollar return. Channels with a non-positive fitted effect are funded zero, exactly like `direct_mail` above.

Per channel, `recommended_spend` is the suggested per-day level, `recommended_spend_delta` is the shift versus today (positive = spend more), and `recommended_contribution` is the modelled daily revenue at that level.

Caveats before moving real budget:

* **Correlation is not causation.** The regression associates spend movement with revenue movement; it does not prove the spend caused it. Treat the split as a hypothesis and validate the big moves with a [holdout test](/guides/campaign-ab-testing).
* **Confounding lives in the window.** Promotions, seasonality, and price changes that land in the fitted window get absorbed into whichever channels moved at the same time. Refit periodically rather than trusting one vintage.
* **Fit quality gates trust.** Low `r2` or a window under \~45 days means the split is a rough guide, not a mandate.
* **Three channels can share the credit.** Highly correlated channels split effect between themselves — compare `coefficient` values before deciding which one the model believes.

## Prerequisites and gating

* **History**: at least 14 aligned days of spend and revenue in the window; the endpoint returns 422 with `status: "insufficient_data"` below that, and the dashboard renders the history prompt.
* **Data shape**: spend events must carry both the channel property and the cost property; revenue must arrive on the named event with a numeric value property. Days with no matching events count as explicit zeros.
* **Role and scope**: an owner, admin, or developer key with the `contacts:read` scope — the same gate as the funnel and attribution read surfaces, since the model reads the PII-adjacent event stream.
* **Rate limit**: 20 fits per minute per tenant — the fit is heavier than a plain read.
