Owlmetry
API Reference

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:

ValueDescription
apple_search_adsDefault. 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

ParameterTypeDescription
attribution_sourcestringDefaults to apple_search_ads.
app_idstringScope to users acquired into a single app.
limitnumberMax 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

ParameterTypeDescription
team_idstringRestrict to a single team. Defaults to every team the caller can see.
attribution_sourcestringDefaults to apple_search_ads.
limitnumberMax 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.

Ready to get started?

Connect your agent via MCP or CLI and start tracking.