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

# Run multi-touch attribution across channels

> Split conversion credit — and revenue/ROAS — across the email, SMS, push, and RCS touches that preceded each conversion, under first-touch, last-touch, linear, time-decay, or custom models, computed over your CDP event stream.

# Multi-touch attribution across channels

The per-campaign [ROAS and revenue attribution](/guides/campaign-roas-attribution) view answers "how did *this one campaign* perform". The multi-touch attribution endpoints answer the broader question a whole marketing program needs: **of this month's conversions, how is the credit split across the channels — email, SMS, push, RCS — and campaigns that touched the converting contacts?**

Two endpoints compute this on demand over your CDP event stream; neither needs new storage or instrumentation:

* `POST /cdp/analytics/attribution` — allocates each conversion's *credit* (1.0 per conversion) across its touchpoints and rolls it up by channel and campaign.
* `POST /cdp/analytics/revenue-attribution` — allocates each conversion's *monetary value* the same way, then pairs each channel/campaign with the spend recorded on its touch events to report ROAS and ROI.

The dashboard surfaces the credit view at **Insights → Attribution** (model picker + conversion event + lookback); both endpoints require the `contacts:read` scope and an owner/admin/developer role, the same gate as the funnel-analytics read surface.

## How the join works

For every conversion event inside the window (`since` … `until`, defaulting to the trailing 30 days), the endpoint gathers the subject's touchpoints in the `lookback_days` (default 30) before the conversion, and the attribution model allocates credit across them. The subject key is `COALESCE(contact_id, anonymous_id, user_id)` so anonymous pre-purchase touches stitch to the same conversion they led to. Conversions with **zero** in-window touchpoints still surface — as an `unattributed_conversions` counter (and, on the revenue endpoint, `unattributed_revenue`), never silently dropped from the denominator.

## The five attribution models

| Model         | How it credits                                                                                                                                                                                                          |
| ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `first_touch` | 100% to the earliest touchpoint in the lookback window.                                                                                                                                                                 |
| `last_touch`  | 100% to the latest touchpoint.                                                                                                                                                                                          |
| `linear`      | Equal split across every touchpoint (the default when omitted).                                                                                                                                                         |
| `time_decay`  | Exponential half-life: a touch `t` ms before the conversion gets weight `2^(-t/half_life)`, tunable with `half_life_days` (default 7).                                                                                  |
| `custom`      | Position-based U-shape: `custom_weights.first` and `.last` go to the first/last touches; the `.middle` pool splits across the interior touches. Defaults to 40/20/40; weights are normalized so they need not sum to 1. |

The model changes how credit *splits*, not which conversions are counted; totals stay stable across models. The body echoes every figure under the model you asked for, so you can run the same window under two models and compare.

## Request fields

| Field                         | Purpose                                                                                                                                     |
| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| `conversion_event` (required) | The event name treated as the conversion (e.g. `order_completed`).                                                                          |
| `model`                       | One of the five ids above (default `linear`).                                                                                               |
| `lookback_days`               | 1–365, default 30.                                                                                                                          |
| `touchpoint_events`           | Optional whitelist of event names that count as touchpoints (max 50). When omitted, *any* event carrying the `channel_property` key counts. |
| `channel_property`            | JSONB property name holding the channel label (default `"channel"`).                                                                        |
| `campaign_property`           | JSONB property holding the campaign label (default `"campaign_id"`).                                                                        |
| `half_life_days`              | For `time_decay` — 0.1 to 365 (default 7).                                                                                                  |
| `custom_weights`              | For `custom` — `{ first, middle, last }` non-negative numbers.                                                                              |
| `since` / `until`             | ISO window bounds; default trailing 30 days ending now.                                                                                     |

Reads are bounded to 50,000 joined touch rows; if the window exceeds that, the response carries `touch_rows_capped: true` and the report is a lower bound — the dashboard renders a banner rather than a silently-rounded figure.

## Credit report

```bash theme={null}
curl -X POST "https://api.orbit.devotel.io/api/v1/cdp/analytics/attribution" \
  -H "X-API-Key: $ORBIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "conversion_event": "order_completed",
    "model": "linear",
    "lookback_days": 30,
    "touchpoint_events": ["email_sent", "sms_sent", "push_sent", "rcs_sent"]
  }'
```

Per-request response:

| Field                                                 | Meaning                                                                                                                          |
| ----------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| `total_conversions`                                   | All conversions in the window (including unattributed).                                                                          |
| `attributed_conversions` / `unattributed_conversions` | Splits those with vs without an in-window touchpoint.                                                                            |
| `total_touchpoints`                                   | Sum of touchpoints across attributed conversions.                                                                                |
| `touch_rows_capped`                                   | `true` when the 50k join cap truncated the report.                                                                               |
| `credit_by_channel`                                   | One row per channel: `credit` (summed fractional conversions), `conversions` (distinct), `credit_share` of the attributed total. |
| `credit_by_campaign`                                  | The same rollup per campaign key.                                                                                                |

## Revenue + ROAS report

The revenue endpoint extends the credit body with three optional fields — `value_property` (JSONB property on the conversion event carrying its monetary value, default `"value"`), `cost_property` (JSONB property on touch/send events carrying their spend, default `"cost"`), and a `currency` label echoed on the response (no FX; all events in one request must share a currency):

```bash theme={null}
curl -X POST "https://api.orbit.devotel.io/api/v1/cdp/analytics/revenue-attribution" \
  -H "X-API-Key: $ORBIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "conversion_event": "order_completed",
    "model": "time_decay",
    "half_life_days": 7,
    "value_property": "value",
    "cost_property": "cost",
    "currency": "USD"
  }'
```

The response carries the same attribution counts plus `total_revenue`, `attributed_revenue`, `unattributed_revenue`, `total_spend`, the overall `roas` / `roi_pct` / `cost_status`, and per-channel + per-campaign ROAS rows. A channel or campaign that *spent* money but earned no attributed revenue still surfaces (ROAS 0), so wasted spend is visible rather than filtered to winners only. Non-numeric `value_property` rows are guarded — a single dirty row yields 0 on that conversion instead of failing the whole report.

| Field                                        | Meaning                                                                                                                     |
| -------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
| `roas` / `roi_pct`                           | `revenue/spend` and `(revenue − spend)/spend × 100`, 2dp. Null when spend is 0.                                             |
| `cost_status`                                | `"ok"` when spend > 0, `"no_spend"` when the ratios are undefined.                                                          |
| `revenue_by_channel` / `revenue_by_campaign` | Per-key rows with `revenue`, `spend`, `roas`, `roi_pct`, `cost_status`, `conversions`, and `revenue_share` (0–100 percent). |

## Interpretation notes

* **Pick the model that matches how you budget.** First-touch credits discovery channels; last-touch credits closers; linear is the neutral per-touch view; time-decay rewards recency; custom lets you weight position deliberately.
* **The unattributed counter is a signal, not noise.** A large share usually means conversions are arriving from traffic outside your sending channels — or the lookback window is too tight.
* **Spend-side of ROAS comes from `cost_property`.** If your send events don't stamp a cost, channel ROAS reads `no_spend` instead of an infinite ratio.
