Skip to main content

Campaign ROI bulk export

Insights → Reports carries a Campaign ROI card: launched campaigns, one row each, with Cost / Revenue / Conversions / ROI (%) — the four financial cells every bulk report needs. The same figures are available over a dedicated bulk endpoint, GET /campaigns/roi-summary, so you can export a report without fanning out one heavy attribution call per campaign. This guide covers when to use the bulk endpoint instead of the per-campaign attribution engine, how it attributes revenue, and the exact mapping from API fields to the CSV columns the dashboard downloads. For the full attribution chain behind the per-campaign detail surface, see Campaign ROAS and revenue attribution.

When to use the bulk endpoint vs per-campaign ROAS

Two endpoints answer two different questions. Pick by the question, not by habit: Never call /campaigns/:id/roas in a loop to fill a report. The multi-touch join is guarded by its own query timeout and is heavy by design; running it once per campaign from one report-generation click is the classic N+1 failure a bulk export must avoid. roi-summary exists precisely so the export stays one bounded query. The dashboard’s Campaign ROI card issues exactly one roi-summary call for the launched campaigns it lists — mirror that shape in your integration and the export stays fast regardless of campaign count.

The API

  • Query — one ids parameter: a comma-separated list of campaign ids. Duplicate and empty entries are dropped server-side.
  • Bound — at most 200 ids are honored per call. Anything past 200 is truncated, so batch larger exports into chunks of 200 and merge the results.
  • Timeout — the query runs behind a bounded statement timeout and fails closed (an empty result) rather than hanging your report click; a pathological batch degrades to no-data rows, not a wedged read.
  • Response — a JSON object keyed by campaign id. Campaigns and ids you requested but that did not match stay absent from the result:

Columns returned

The API returns cents, nulls, and an explicit cost status; the dashboard and CSV render dollar columns. Map one entry to one row like this: Both null-safe rules matter for spreadsheet hygiene:
  • cost_status: "no_spend" gives real zeros. A campaign that never recorded spend gives spend_cents: 0, roas: null, roi_pct: null — never an Infinity. The CSV emits an empty cell for a null ROI % so the column stays numeric for SUM/sort.
  • Missing ids stay missing. An id the campaign list named but the summary did not return has no entry; don’t fabricate zeros for it — render it as unavailable until the entry arrives (the dashboard’s only fallback is the campaign’s own spend counter, and only while the summary request is in flight).

Direct-attribution mechanics

roi-summary deliberately performs direct attribution: a campaign’s own conversion events credited to that campaign alone, no cross-campaign model. Two grouped aggregates, both keyed by campaign_id, make up each row:
  1. Revenue and conversions come from the campaign’s own journey_goal_conversions rows — one aggregate count, and a sum of each conversion’s value_cents. A journey campaign’s goal events populate this table when contacts convert; non-journey campaigns (or journeys with no goal) simply have no rows, which becomes an honest 0 for both columns — real zeros, not a fabricated “N/A”.
  2. Cost comes from the campaign’s own messages rows with a recorded price: SUM(price), converted to cents. This is exactly the send-cost ledger the per-campaign /:id/roas detail page uses as its spend denominator, so the bulk export and the detail page can never disagree about what a campaign cost.
ROI % and ROAS are computed with the same math the per-campaign endpoint uses (revenue vs spend, null ratios on no spend), so a campaign’s ROI % in this export matches its detail page’s ROI % — METRICS-CONSISTENCY on both surfaces.

Worked example: fetch → CSV mapping

Fetch the summary once for every campaign id in your export set, then join it onto the campaign list your own query already holds:
Common pitfalls, all caught in the wild:
  • Empty cells in a numeric column beat “N/A” placeholders. Excel and Sheets coerce the whole column to text the moment one cell holds “N/A” or ”-”; SUM and sort then silently stop working. Emit "" for unavailable numeric data and keep sentinels to the text columns only.
  • Don’t trust the campaign list’s spend counter for Cost. An older denormalized counter on the campaign record still surfaces on some list views and is 0 for many launched campaigns whose send path never populated it — reading it for Cost is what used to make every financial cell in the export $0 even on campaigns that genuinely cost money. The roi-summary entry (the per-message send spend) is authoritative; treat the counter as, at most, a transient fallback while the summary call is in flight.
  • A null ROI % is not a zero ROI %. cost_status: "no_spend" means “there is no spend to divide by”, so the ratio is undefined — render the empty cell. A 0.0 ROI % would be a different, and false, statement.
  • Chunk past 200 ids. The cap is server-side and truncates silently. Slice to 200 per call and merge the keyed results (summary.update(...) above); the keyed response makes merging trivial.
  • Campaigns that never launched carry zero signal. The dashboard card filters drafts, pending approvals, and scheduled-but-unsent campaigns out before it queries — they would only pad the export with all-zero rows. Mirror that filter when you assemble the id list yourself.

From the export to the goals behind it

The Revenue and Conversions columns are only as meaningful as the goals you declared. If a campaign’s Revenue reads 0 and you expected purchases: confirm the campaign’s journey has a conversion goal and that goal events are actually recorded — see Defining and reading Conversion Goals. When you graduate from direct attribution to cross-campaign multi-touch credit, the per-campaign Campaign ROAS endpoint teaches the model, window, and currency normalisation you pick up.

See also