Skip to main content

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.

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: 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. 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:
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: 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.”
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.”
It counts four stages per campaign: 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.20backper4.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.