Ads
Advertising-insights API — rank campaigns, ad groups, keywords, and ads by lifetime USD revenue from attributed users.
The Ads API surfaces Advertising Insights — a ranked view of ad campaigns, ad groups, keywords, and ads by the lifetime USD revenue they've driven. The data is computed at query time from app_users.properties (attribution) joined to app_users.total_revenue_usd_cents (RevenueCat lifetime revenue).
Most endpoints are project-scoped. The campaigns endpoint also has a team-scoped variant (GET /v1/ads/campaigns?team_id=…) that aggregates the best-performing campaigns across every project in a team — used by the dashboard's "All projects" view. Drill-downs (ad groups, keywords/ads) stay project-scoped. All endpoints require users:read for read paths or users:write for the manual sync trigger.
Common types
interface AdsRow {
id: string; // Network-specific ID (e.g. ASA campaign ID)
name: string | null; // Resolved name; null when not yet enriched
user_count: number;
paid_user_count: number; // Lifetime: total_revenue_usd_cents > 0 (anyone who ever paid; includes churned)
retained_user_count: number; // Right now: rc_subscriber='true' AND rc_period_type != 'trial' (auto-renewing paid; excludes trials)
total_revenue_usd: number;
arpu: number; // total_revenue_usd / user_count, 0 when no users
total_spend_usd: number | null; // null = no integration / non-USD currency
roas: number | null; // total_revenue_usd / total_spend_usd; null when spend null/0
start_date: string | null; // ISO date YYYY-MM-DD
status: string | null; // ENABLED | PAUSED | DELETED | …
}
// Team-scoped campaign rows carry the owning project so the UI can drill in.
interface TeamAdsRow extends AdsRow {
project_id: string;
}attribution_source query parameter values currently supported:
| Value | Description |
|---|---|
apple_search_ads | Default. Captured by the Swift SDK + backfilled by RevenueCat. |
The hierarchy is identical across networks (campaign → ad group → keyword | ad), implemented via ATTRIBUTION_NETWORK_DIMENSIONS in shared. Future networks plug in there.
GET /v1/projects/:projectId/ads/campaigns
List campaigns ranked by total_revenue_usd desc, then user_count desc, then id asc.
Auth required: Yes (users:read)
Query parameters
| Parameter | Type | Description |
|---|---|---|
attribution_source | string | Defaults to apple_search_ads. |
app_id | string | Scope to users acquired into a single app. |
limit | number | Max rows (1–500, default 100). |
Response
{
"attribution_source": "apple_search_ads",
"campaigns": [
{
"id": "542370539",
"name": "Holiday US Campaign",
"user_count": 1234,
"paid_user_count": 218,
"retained_user_count": 142,
"total_revenue_usd": 4598.12,
"arpu": 3.73,
"total_spend_usd": 1820.50,
"roas": 2.53,
"start_date": "2025-11-15",
"status": "ENABLED"
}
],
"total_user_count": 1234,
"total_paid_user_count": 218,
"total_retained_user_count": 142,
"total_revenue_usd": 4598.12,
"total_spend_usd": 1820.50,
"window_days": 360,
"revenue_synced_at": "2026-05-01T03:00:00.000Z",
"ad_metrics_synced_at": "2026-05-01T04:45:00.000Z",
"currency_warning": null
}window_days is the trailing window (in days) that scopes both spend and revenue. Spend is bounded by Apple's Reports API itself (4×90-day chunks); revenue is filtered server-side to users with app_users.first_seen_at inside the same window so users acquired before the spend window's start don't inflate ROAS. Today the value is 360.
revenue_synced_at is the most recent app_users.revenue_synced_at across the project; ad_metrics_synced_at is the most recent last_synced_at across the project's ad_campaign_lifetime rows. currency_warning is non-null (e.g. "EUR") when the project's Apple Search Ads org reports in a non-USD currency — the dashboard renders a banner and total_spend_usd / roas stay null until USD support lands.
GET /v1/ads/campaigns
Team-scoped campaign ranking. Aggregates campaigns across every project the caller can see (or a single team via team_id), so same-named campaigns in different ASA orgs / projects stay distinct rows. Used by the dashboard's "All projects" view at /dashboard/ads. There is no app_id filter — apps are project-scoped, so a multi-project app filter is meaningless. Drill-downs stay on the project-scoped routes; clients use each row's project_id to scope the next call.
Auth required: Yes (users:read)
Query parameters
| Parameter | Type | Description |
|---|---|---|
team_id | string | Restrict to a single team. Defaults to every team the caller can see. |
attribution_source | string | Defaults to apple_search_ads. |
limit | number | Max rows (1–500, default 100). |
Response
Same shape as the project-scoped campaigns response, but each row in campaigns is a TeamAdsRow carrying an extra project_id. Spend / ROAS join per-(project_id, campaign) against each project's own ad_campaign_lifetime rows, so two ASA orgs with same-named campaigns stay distinct rows.
{
"attribution_source": "apple_search_ads",
"campaigns": [
{
"id": "542370539",
"name": "Holiday US Campaign",
"project_id": "11111111-2222-3333-4444-555555555555",
"user_count": 1234,
"paid_user_count": 218,
"retained_user_count": 142,
"total_revenue_usd": 4598.12,
"arpu": 3.73,
"total_spend_usd": 1820.50,
"roas": 2.53,
"start_date": "2025-11-15",
"status": "ENABLED"
}
],
"total_user_count": 1234,
"total_paid_user_count": 218,
"total_retained_user_count": 142,
"total_revenue_usd": 4598.12,
"total_spend_usd": 1820.50,
"window_days": 360,
"revenue_synced_at": "2026-05-01T03:00:00.000Z",
"ad_metrics_synced_at": "2026-05-01T04:45:00.000Z",
"currency_warning": null
}GET /v1/projects/:projectId/ads/campaigns/:campaignId/ad-groups
Drill into a campaign. Same row shape as campaigns.
Auth required: Yes (users:read)
Query parameters
Same as campaigns.
Response
{
"attribution_source": "apple_search_ads",
"campaign_id": "542370539",
"campaign_name": "Holiday US Campaign",
"ad_groups": [ /* AdsRow[] */ ],
"total_spend_usd": 1820.50,
"window_days": 360,
"ad_metrics_synced_at": "2026-05-01T04:45:00.000Z",
"currency_warning": null
}GET /v1/projects/:projectId/ads/campaigns/:campaignId/ad-groups/:adGroupId/leaves
Within a single ad group, returns both keywords and ads arrays side-by-side. Apple Search Ads attributes each user to one or the other.
Auth required: Yes (users:read)
Query parameters
Same as campaigns. (No leaf_type filter — the response always includes both.)
Response
{
"attribution_source": "apple_search_ads",
"campaign_id": "542370539",
"campaign_name": "Holiday US Campaign",
"ad_group_id": "111222333",
"ad_group_name": "Holiday App Install",
"keywords": [ /* AdsRow[] */ ],
"ads": [ /* AdsRow[] */ ],
"window_days": 360
}POST /v1/projects/:projectId/ads/sync
Manually trigger a refresh of advertising insights for the project. Fires revenuecat_sync (refreshes lifetime revenue per user) and apple_ads_sync (resolves any unresolved ASA IDs to readable names AND pulls campaign + ad-group spend / impressions / taps / installs from Apple's Reports API into ad_campaign_lifetime + ad_adgroup_lifetime) in parallel.
Auth required: Yes (users:write, admin role)
Response
{
"syncing": true,
"revenuecat_job_run_id": "uuid",
"apple_ads_job_run_id": "uuid"
}Both jobs also run on a daily cron — revenuecat_sync at 03:00 UTC, apple_ads_sync at 04:45 UTC, both fanning out across every project with the relevant integration. Manual sync is for forcing freshness between cron runs or right after onboarding a new RevenueCat or Apple Search Ads integration.
