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

# Read campaign ROAS and revenue attribution

> Attribute conversion revenue back to the campaigns, templates, and individual messages that earned it — pick an attribution model, read the send cost / ROAS / ROI, and audit the wallet linkage behind each number.

# Campaign ROAS and revenue attribution

Campaign ROAS answers the marketing-economics question: **did this campaign's spend pay back, and which touches earned the credit?** Orbit computes an attributed-revenue, send-cost, and ROAS/ROI figure per campaign, then breaks that revenue down across the earlier campaigns — and the specific templates or messages — that touched the converting contacts. It runs on the same signal Orbit already records, so the report works without any extra tracking plumbing.

The dashboard renders this on the campaign detail page as the **ROAS & attribution** panel. The same numbers are available over two API endpoints, and this guide explains what each figure means and how to interpret the attribution model you pick. For full request/response schemas, see the [campaigns API reference](/api-reference/endpoints/campaigns).

## The attribution chain it reads

Every figure on the panel comes from data Orbit already persists on a campaign's journey. The pipeline is a five-hop chain:

| Hop              | Where it lives                                                                                      | What it contributes                                                                                              |
| ---------------- | --------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
| **Touch**        | `messages` rows stamped with this campaign's id and the touched contact                             | The multi-touch join: every earlier campaign that reached the converting contact, inside the attribution window. |
| **Sent**         | `messages` rows for this campaign, each carrying its `price`                                        | The spend denominator: summed send cost (wallet deduction) for the campaign.                                     |
| **Clicked**      | `link_clicks` ⋈ `short_links` for this campaign                                                     | The engagement funnel count — how many recipients clicked the tracked link.                                      |
| **Converted**    | `journey_goal_conversions` with a `value_cents` and (optionally) per-conversion `metadata.currency` | The revenue numerator: the purchase value to allocate.                                                           |
| **Wallet order** | The campaign's `messages.price` ledger                                                              | The ROAS spend side: revenue vs the actual wallet cost, never an estimate.                                       |

The response returns the full chain — the join, the funnel count, the attributed revenue, and the wallet-side spend — in one payload, so the report is never a sample; it reconciles to the ledger.

## Attribution models

A converting contact is usually touched by more than one campaign before purchasing. The attribution model decides how each conversion's `value_cents` is split across those touching campaigns.

| Model                  | How it credits                                                                                                                                                                                                                                      |
| ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `last_touch` (default) | 100% of the value to the campaign whose most recent touch is closest to the conversion. The Klaviyo / most-suites default.                                                                                                                          |
| `linear`               | Split equally across every distinct campaign that touched the contact inside the window.                                                                                                                                                            |
| `time_decay`           | Multi-touch with an exponential half-life: a touch `h` hours before the conversion gets weight `2^(-h / half_life)`, with `half_life_hours` defaulting to 168 (one week). A touch right before the purchase earns more than one from two weeks ago. |

The model only changes how credit splits; the total attributed revenue is the same set of conversions either way. Integer-cents splits are allocated with the remainder given to the heaviest-weight campaign so every split sums exactly — no penny is created or lost.

Switch the model in the panel dropdown, or pass `?model=linear|time_decay` on the API. With `model=time_decay` you can also pass `half_life_hours` (1–8760) to tune the decay.

## Reading the response

`GET /campaigns/:id/roas` returns the chain above plus the computed ROAS/ROI:

```bash theme={null}
curl "https://api.orbit.devotel.io/api/v1/campaigns/cmp_abc123/roas?model=linear" \
  -H "X-API-Key: $ORBIT_API_KEY"
```

| Field                        | Meaning                                                                                                                                                                                                   |
| ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `spend_cents`                | Wallet deduction: summed `price` of this campaign's sends. **This is the spend side of ROAS — it reconciles to your wallet ledger.**                                                                      |
| `attributed_revenue_cents`   | The share of converting-contact revenue this campaign earned under the model.                                                                                                                             |
| `roas`                       | `revenue / spend`. Null when the campaign has no recorded spend (the panel renders "No send cost recorded" rather than dividing by zero).                                                                 |
| `roi_pct`                    | `(revenue − spend) / spend × 100`. Null on the same no-spend condition.                                                                                                                                   |
| `clicks`                     | `link_clicks ⋈ short_links` count for the campaign — the engagement funnel.                                                                                                                               |
| `converting_contacts`        | Distinct contacts with a conversion. A contact that converted twice counts once.                                                                                                                          |
| `contributing_campaigns`     | Per-campaign revenue slice across every campaign that touched these contacts, sorted high→low, with a `share_pct` of the attributed total.                                                                |
| `unattributed_revenue_cents` | Conversions whose contact had zero in-window campaign touch. Surfaced, not silently dropped.                                                                                                              |
| `holdout`                    | Incremental-revenue versus the do-nothing control, when the campaign set a holdout. Today the holdout breadcrumb carries arm sizes only, so `lift_cents` is null until per-contact holdout revenue lands. |

`available_models` echoes the three model ids the endpoint accepts, so the panel can render its switcher without hard-coding the list.

### Attribution window

`window_hours` (0–8760, default 168 / one week, 0 = unbounded) bounds how far back a send can count as a touch. A tighter window answers "did the last week's sends drive this purchase?" — useful for a weekly cadence; a longer window captures nurture sequences. The window bounds *time*, not row cardinality — the join is server-side rolled up to one row per conversion and runs behind a statement timeout, so a heavy cross-campaign overlap fails fast instead of wedging the read.

### Holdout lift

For a campaign with a holdout, the response's `holdout` block compares treatment revenue against the control cohort to estimate incremental dollars. Today the arm-level breadcrumb carries sizes only, so the block reports `tracking_method: "counts_only"` and `lift_cents: null`. When per-contact holdout revenue is recorded, the same endpoint begins returning a filled `lift_cents`.

## Multi-currency rollup

Multi-region senders record conversions in different currencies (a per-conversion `metadata.currency` on the journey-goal conversion). The endpoint folds foreign currencies into the campaign's reporting currency — the spend currency — using the shared FX source, then allocates. It never sums raw foreign cents at a phantom 1:1, which is what used to make cross-region campaign reports untrustworthy.

Field-level trust signals in the response:

| Field                         | Meaning                                                                                                                                                            |
| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `reporting_currency`          | The currency every figure is denominated in.                                                                                                                       |
| `currency_breakdown`          | Per-source-currency slices — original and normalized cents, the rate applied, and whether the slice was identity (already reporting), converted, or unconvertible. |
| `unconvertible_revenue_cents` | Revenue recorded in a currency with no available exchange rate, excluded from the blended total rather than blended at 1:1.                                        |

A single-currency tenant resolves to one identity slice, and the FX path is not touched — the rollup is always reconcilable back to its inputs.

## Drilling to template / message granularity

`GET /campaigns/:id/roas/touchpoints` runs the same attribution on a finer join key. Where the campaign-level report says "this campaign earned X% of these conversions", the touchpoints report says "which template — or which individual message — earned it."

```bash theme={null}
curl "https://api.orbit.devotel.io/api/v1/campaigns/cmp_abc123/roas/touchpoints?granularity=template" \
  -H "X-API-Key: $ORBIT_API_KEY"
```

Pass `granularity=template` (default) or `granularity=message`. The join groups on the per-message `metadata.template_id` when present, falling back to the touching campaign's configured `message_template`, then to a campaign-scoped sentinel, so untemplated sends still aggregate distinctly rather than collapsing into one bucket. Each touchpoint carries its own attributed revenue, its own send cost over the same converting-contact cohort, and its own ROAS/ROI, so the numerator and denominator stay scoped consistently.

The same multi-currency normalization applies, and `available_granularities` echoes the two ids. The dashboard's per-creative breakdown reads this endpoint and lets the operator toggle the template/message view.

## The funnel view

`GET /campaigns/:id/attribution-funnel` is the model-free sibling of the
ROAS endpoint. Where `/roas` answers "which campaigns earned the credit
under an allocation model", the funnel answers "how many recipients moved
through this campaign, and how far they got."

```bash theme={null}
curl "https://api.orbit.devotel.io/api/v1/campaigns/cmp_abc123/attribution-funnel?window_hours=720" \
  -H "X-API-Key: $ORBIT_API_KEY"
```

It counts four stages per campaign:

| Stage       | What it counts                                                                                                                                                                                                               |
| ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `touched`   | Distinct contacts the campaign sent at least one message to — the funnel base.                                                                                                                                               |
| `clicked`   | Distinct touched contacts who clicked a tracked short link on this campaign (`link_clicks` ⋈ `short_links`).                                                                                                                 |
| `converted` | Distinct touched contacts who converted on the campaign's journey goal.                                                                                                                                                      |
| `ordered`   | The converted subset whose conversion record carries a commerce order linkage (`link_id` / `order_id` in the conversion metadata, stamped by the commerce checkout flows) — the subset a wallet order can reconcile against. |

The response pairs the stage counts with stage-by-stage drop-off
percentages (`touch_to_click`, `click_to_convert`, `convert_to_ordered`),
the direct revenue rollup over the converting cohort (`revenue_cents`),
the matched send spend over that same cohort (`spend_cents`), and the
resulting direct ROAS (`roas`; null with `cost_status: "no_spend"` when
there is no recorded spend, mirroring `/roas`). `window_hours` works the
same way as on `/roas` — 0 (the default) means unbounded; a positive
value bounds the touch set the funnel counts.

Use `/roas` when you need cross-campaign credit; use
`/attribution-funnel` when you need this campaign's through-flow — the
two read the same persisted signals, so they never disagree.

## Dashboard surface

The **ROAS & attribution** panel on a campaign's detail page summarizes all of this: the ROAS headline with ROI subtitle, attributed revenue with converting contacts, send cost with the reporting currency, and the click funnel. Under it, a multi-touch contribution list shows revenue credit by touch campaign; a per-creative table breaks down by template/message; and the holdout-lift panel (when configured) reports the incremental-revenue answer. The panel self-gates: a campaign with no spend, no conversions, and no converting contacts renders nothing, so the detail page stays clean until there's a revenue signal to read.

Switch the model in the dropdown and the panel refetches under the new attribution model with the same window and half-life defaults.

## Interpretation notes

* **ROAS is revenue/spend, not margin.** A `4.2` ROAS is $4.20 back per $1 of wallet spend. `roi_pct` expresses the same ratio as a percentage of spend.
* **Unattributed revenue is a signal, not an error.** It marks conversions by contacts nothing in this campaign touched inside the window — typically direct traffic or a paid ad send Orbit doesn't know about. Large unattributed amounts usually mean the attribution window is too tight, or conversions are arriving for non-campaign audiences.
* **The model changes credit, not reality.** A campaign that underperforms under last-touch sometimes earned real nurturing credit under time-decay. The dashboard makes the switch cheap precisely so you can compare.
* **Spend comes only from `messages.price`.** If a campaign sends through a path that doesn't record a per-message price (uncommon), `cost_status` flips to `no_spend` and ROAS/ROI read null rather than a misleading infinity.
