Advertising Insights
Rank acquisition campaigns, ad groups, keywords, and ads by the lifetime USD revenue they've driven. Apple Search Ads attribution joined to RevenueCat lifetime revenue.
Advertising Insights ranks your ad campaigns by the lifetime USD revenue from users they brought in — and, when an Apple Search Ads integration is connected, by spend / ROAS alongside, so you can see which campaigns earn back what they cost. The hierarchy drills down from campaign → ad group → keyword and ad — surfaced as /dashboard/ads, the owlmetry ads CLI subcommand, and the list-ad-* MCP tools.
For v1, the only attribution network populating this surface is Apple Search Ads (captured automatically by the Swift SDK and backfilled from RevenueCat). The schema is generic over an attribution_source dimension so adding Meta / Google Ads / TikTok later is a data-only change — no API rewrites.
What you need
Two integrations, both per-project:
- Apple Search Ads attribution — captured for free by the Owlmetry Swift SDK on
Owl.configure(). No code, no setup. See Attribution. - RevenueCat — the lifetime revenue numbers come from RevenueCat's V2 customer data (
total_revenue_in_usd, summed across all of a customer's subscriptions, refunds netted by RC). Add a RevenueCat integration under/dashboard/integrationsfor any project where you want revenue rollups.
The Apple Ads integration (Campaign Management API) is what pulls in spend numbers — without it, the dashboard ranks campaigns by revenue alone, with numeric IDs instead of human-readable names and no spend / ROAS / start date / status. With it connected, the same apple_ads_sync job that resolves IDs to "Holiday US Campaign" also pulls campaign + ad-group spend / impressions / taps / installs from Apple's Reports API into ad_campaign_lifetime + ad_adgroup_lifetime (filtered by each campaign's adamId against your apps' apple_app_store_id, so projects sharing an Apple Developer account see only their own campaigns). See Integrations → Apple Search Ads → Spend & ROAS for the data flow detail.
How the numbers stay fresh
Revenue comes from two refresh paths, both writing into the same total_revenue_usd_cents typed column on app_users:
- Webhook-triggered (real-time): every RevenueCat subscription webhook (
INITIAL_PURCHASE,RENEWAL,PRODUCT_CHANGE,CANCELLATION, etc.) fires a fire-and-forget per-user resync against RC's V2 API. The user's lifetime revenue updates within seconds of the transaction. - Daily reconciliation (03:00 UTC): the
revenuecat_syncsystem job fans out across every project with an active RC integration and refreshes everyone. Catches anyone whose webhook was dropped (deploys, network blips), and backfills users on first deploy.
Spend comes from one path — the apple_ads_sync job that runs daily at 04:45 UTC (and on every manual sync). It refreshes a rolling 12-month total per (project, network, campaign | ad_group) from Apple's Reports API in 90-day chunks. Numbers stay within ~5–10% of ads.apple.com's UI for mature campaigns (drift is settled vs. pending billing on Apple's side — the UI lags realtime by a few days; the API is the source of truth). For young campaigns the variance can be larger because a few days of un-settled spend is a bigger fraction of a smaller lifetime total.
Trailing window — both sides scoped together
Both sides of the ROAS calculation are scoped to the same trailing 12-month window:
- Spend is bounded by Apple's Reports API itself — a single request can span at most ~90 days, and the sync issues 4×90-day chunks for ~360 days of lifetime totals.
- Revenue is filtered server-side by
app_users.first_seen_at >= now() - 360 days, so users acquired before the spend window's start don't contribute revenue against zero matchable spend.
Without this symmetric filter, ROAS would inflate at the boundary — a paying user acquired 13 months ago through Campaign X would still count toward revenue today, but Apple's Reports API has already aged out the spend that acquired them, leaving the ratio mathematically broken.
The window in days is echoed back as window_days on every campaigns / ad-groups / leaves response, so dashboards and clients can label the time range without hard-coding it. Today the value is 360; both the sync chunk count and the revenue filter derive from the same shared constant (ADS_INSIGHTS_WINDOW_DAYS) so they can never drift.
Manual triggers fire both jobs in parallel (POST /v1/projects/:id/ads/sync, owlmetry ads sync, sync-ads MCP tool) — useful right after onboarding a new RevenueCat or Apple Search Ads integration, or to force-refresh without waiting for the cron. Admin-only.
Hierarchy & metrics
Each level returns:
| Field | Meaning |
|---|---|
user_count | Distinct attributed users at this level |
paid_user_count | Subset where total_revenue_usd_cents > 0 — anyone who has ever paid (lifetime fact, includes churned users). Surfaced as the Conversions column in the dashboard. |
retained_user_count | Subset where properties.rc_subscriber = 'true' AND properties.rc_period_type is not 'trial' — users on an auto-renewing paid subscription right now. Matches the paid billing tier exactly. Trials are excluded (no revenue yet); cancelled-but-still-in-period users are also excluded (their last cycle is running out). Surfaced as the Retained column. |
total_revenue_usd | Sum of lifetime revenue across attributed users |
arpu | total_revenue_usd / user_count, 0 when no users |
total_spend_usd | Spend over the rolling 12-month window. null when no Apple Search Ads integration is connected, no row in ad_*_lifetime matches, or the org reports in a non-USD currency |
roas | total_revenue_usd / total_spend_usd. null when spend is null or zero (we don't surface Infinity) |
start_date | ISO YYYY-MM-DD of when the campaign / ad group started running, when known |
status | Network-side status snapshot — ENABLED / PAUSED / ON_HOLD / DELETED for Apple Search Ads. Surfaces with a tone-coded badge in the dashboard and CLI |
The campaigns and ad-groups responses also expose total_spend_usd (sum across visible rows), ad_metrics_synced_at, and currency_warning at the top level. currency_warning is non-null (e.g. "EUR") when at least one row's spend was reported in a non-USD currency — the dashboard surfaces a banner, leaves the spend column blank, and ROAS stays null until USD support lands.
The leaves endpoint at the deepest level returns two arrays side-by-side — keywords and ads. Apple Search Ads attributes a user to one or the other depending on whether the install came from a search-keyword campaign or an auto-driven ad placement; the dashboard renders both so you don't miss either dimension. Spend isn't broken out at keyword / ad granularity — Apple's Reports API stops at the ad-group level — so leaf rows still carry total_spend_usd: null and roas: null.
ROAS is tone-coded the same way across web, CLI, and iOS via shared helpers (roasTone, formatRoasLabel in @owlmetry/shared): green when ≥ 1.0× (earning back), amber when ≥ 0.5× (recovering half), red below that, muted "—" when null.
All projects vs. single project
The dashboard's /dashboard/ads page opens in All projects mode when the team has 2+ projects — campaigns are ranked across the whole team, with each row tagged by its owning project so you can compare acquisition performance side-by-side without flipping the project picker. Switch to a single project to see its campaigns in isolation. The CLI mirrors this with owlmetry ads campaigns --team-id <id> (mutually exclusive with --project-id); the MCP list-ad-campaigns tool accepts an optional team_id. Drill-downs (ad groups, keywords/ads) stay project-scoped — clicking into a row uses its project_id.
Project + app filtering
In single-project mode, an optional app filter scopes the results to users acquired into a single app — useful for cross-app projects where one app dominates and you want to see attribution per-platform. The filter has no analogue in All projects mode (apps are project-scoped, so a multi-project app filter is meaningless).
The app filter is a user scope filter, not a campaign filter: it joins app_user_apps and only counts users who've been seen from that app. A campaign that sent users to multiple apps shows once per app filter, which is usually what you want — the same campaign can perform very differently per platform.
When the page is empty
A project with no users carrying attribution_source = 'apple_search_ads' shows an empty state. Things to check:
- Apple Search Ads attribution requires the Swift SDK at version supporting AdServices capture. Free for iOS / iPadOS / macOS; auto on
Owl.configure(). Web and Android apps don't capture ASA — those installs come up asattribution_source = 'none'. - TestFlight / Xcode / simulator installs show as
attribution_source = 'apple_test_install'and are deliberately excluded from this page (Apple's AdServices API returns a fixed dummy payload for non-production builds). - Brand-new projects: it can take ~24 hours from a real install for Apple's AdServices record to be ready. Until then, a captured user shows
attribution_source = 'none'(or'apple_search_ads'with noasa_*IDs while the SDK is still in pending-retry).
If users do carry the right attribution but revenue is zero across the board, RevenueCat hasn't synced yet — manually trigger via the dashboard "Sync now" button, the CLI, or the MCP tool.
If you recently shipped the Owl SDK into an app that already has paying users in RevenueCat, run the revenuecat_user_backfill job to import those historical RC customers as app_users rows so their lifetime revenue can attribute against the campaigns that originally acquired them — without it, only users seen by the SDK after install (or surfaced via live RC webhooks) appear here.
API reference
See Ads API reference for endpoint shapes, query parameters, and response types.
