# Ads reporting

Read spend, delivery and conversion metrics across every ad network, from rolled-up dashboards to breakdowns, live network queries and sync health events.

Pozy syncs the performance of every ad on every connected ad account, whether it was created through Pozy or in the network's own ads manager. Most reporting reads those synced numbers and costs nothing on the network's side. When you need something the sync does not hold, such as an age split or a custom Google query, Pozy asks the network live.

## Pick the right call

| You want | Call | Source |
| --- | --- | --- |
| A dashboard: spend, CPC, CPM, conversions and ROAS per campaign, ad set and ad | `GET /v1/ads/tree` | Synced, rolled up per level |
| A flat list of ads, or one ad with its metrics | `GET /v1/ads`, `GET /v1/ads/{adId}` | Synced |
| One row per day for an account | `GET /v1/ads/timeline` | Synced |
| One ad or campaign over a range, with optional breakdowns | `GET /v1/ads/{adId}/analytics`, `GET /v1/ads/campaigns/{campaignId}/analytics` | Synced, breakdowns live |
| Any field, breakdown or filter the network supports | `GET /v1/ads/insights` | Live (Meta, Google Ads, TikTok) |
| The same over a long range or many accounts | `POST /v1/ads/insights/reports` | A background job (Meta) |

Network-specific reports live on their pages: Google's search terms and keyword planner in [Google Ads](https://pozy.ai/docs/ads/google-ads), LinkedIn firmographics in [LinkedIn Ads](https://pozy.ai/docs/ads/linkedin-ads).

## Build a dashboard

`GET /v1/ads/tree` returns campaigns with their ad sets and ads nested inside, each with a `metrics` object for the date range. Filter and sort it like a report.

```bash cURL
curl "https://api.pozy.ai/v1/ads/tree?accountId=66b2e19d8c3f5a7e9d0b1c2e&fromDate=2027-01-01&toDate=2027-01-31&hasDelivery=true&sort=spend_desc&compare=previous" \
  -H "Authorization: Bearer $POZY_API_KEY"
```
```ts Node
const res = await fetch("https://api.pozy.ai/v1/ads/tree?accountId=66b2e19d8c3f5a7e9d0b1c2e&fromDate=2027-01-01&toDate=2027-01-31&hasDelivery=true&sort=spend_desc&compare=previous", {
  headers: { Authorization: `Bearer ${process.env.POZY_API_KEY}` },
});
const data = await res.json();
if (!res.ok) throw new Error(data.error);
for (const c of data.campaigns) console.log(c.campaignName, c.metrics?.spend, c.metrics?.roas);
```
```python Python
import os, requests

resp = requests.get(
    "https://api.pozy.ai/v1/ads/tree",
    headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"},
    params={
        "accountId": "66b2e19d8c3f5a7e9d0b1c2e",
        "fromDate": "2027-01-01",
        "toDate": "2027-01-31",
        "hasDelivery": "true",
        "sort": "spend_desc",
        "compare": "previous",
    },
)
for c in resp.json()["campaigns"]:
    print(c["campaignName"], c.get("metrics", {}).get("spend"))
```

Useful filters on the tree: `platform`, `status`, `adAccountId`, `profileId`, `campaignId`, `search`, `minSpend`, `hasDelivery`, `updatedSince` and `source` (`pozy` for ads created through Pozy, `all` by default). `compare=previous` adds the same metrics for the period just before, for "vs last month" views. `timeIncrement=1` with `dailyLevel` (`campaign`, `adset` or `ad`) adds a daily series at that level.

For an account-level chart, `GET /v1/ads/timeline?accountId=...` returns one row per day with spend, impressions, reach, clicks, engagement, conversions and the derived rates.

## One ad over time

`GET /v1/ads/{adId}/analytics` returns a `summary` for the range and a `daily` series.

```bash
curl "https://api.pozy.ai/v1/ads/66f0a1b2c3d4e5f6a7b8c9d0/analytics?fromDate=2027-01-04&toDate=2027-01-31&breakdowns=age,gender" \
  -H "Authorization: Bearer $POZY_API_KEY"
```

```json
{
  "backfillPending": false,
  "ad": { "id": "66f0a1b2c3d4e5f6a7b8c9d0", "name": "Spring sale", "platform": "facebook", "status": "active", "currency": "USD" },
  "analytics": {
    "summary": { "spend": 412.6, "impressions": 98100, "reach": 61200, "clicks": 1530, "ctr": 1.56, "cpc": 0.27, "cpm": 4.21, "conversions": 38, "costPerConversion": 10.86, "purchaseValue": 2140.0, "roas": 5.19 },
    "daily": [{ "date": "2027-01-04", "spend": 14.9, "impressions": 3400, "clicks": 51 }],
    "breakdowns": { "age": [{ "value": "25-34", "spend": 168.2, "impressions": 40100 }] }
  }
}
```

### Breakdowns

`breakdowns` takes a comma-separated list. Each dimension is one live call to the network, so ask only for what you show.

| Network | Per ad | Per campaign | Dimensions |
| --- | --- | --- | --- |
| Meta | Yes | Yes | `age`, `gender`, `country`, `region`, `publisher_platform`, `device_platform` |
| TikTok | Yes | No | `gender`, `age`, `country_code`, `platform`, `ac`, `language` |
| LinkedIn | Yes | Yes (the campaign group) | `job_title`, `job_function`, `seniority`, `industry`, `company`, `company_size`, `country`, `region` |
| Others | No | No | |

A dimension no network defines returns `400` listing the valid ones. A dimension from another network (say `age` on a LinkedIn ad) is accepted and dropped, and the response has no `breakdowns` object. LinkedIn returns firmographics for the whole range only, with no daily split, lags 12 to 24 hours and hides segments with fewer than 3 events.

## Metrics

Every `metrics` and `summary` object uses the same names on every network. Money is in the ad account's currency, in whole units.

| Metric | Meaning |
| --- | --- |
| `spend`, `impressions`, `clicks` | The base counters |
| `reach` | People reached. On most networks it is de-duplicated within a day only, so never add it across days. |
| `ctr`, `cpc`, `cpm` | Derived from the counters. `ctr` is a percentage. |
| `engagement` | Interactions the network counts as engagement |
| `conversions`, `allConversions`, `costPerConversion` | The network's primary conversions, every conversion, and spend per primary conversion |
| `actions`, `actionValues`, `costPerAction` | Counts, values and costs per action type |
| `purchaseValue`, `roas` | Purchase revenue and `purchaseValue / spend`. Rolled up from sums, never averaged. |
| `outboundClicks`, `inlineLinkClicks`, `uniqueClicks` and their CTRs | Click detail, where the network reports it |
| `videoPlayActions`, `video2SecWatchedActions`, `video6SecWatchedActions`, `videoP25WatchedActions` to `videoP100WatchedActions`, `videoThruplayWatchedActions`, `videoAvgTimeWatchedActions`, `costPerThruplay` | Video delivery |
| `funnel` | Landing page views, content views, searches, cart adds, checkouts, payment info, purchases, leads, registrations, app installs and messaging conversations |
| `engagementBreakdown` | Post and page engagement, reactions, comments and more |

On Meta, the same purchase often shows under several action keys (a pixel key, an `omni_` key and a plain one). Prefer the `offsite_conversion.fb_pixel_*` keys, or read `conversions` and `purchaseValue`, which already pick one.

### What each network reports

| Network | Synced metrics |
| --- | --- |
| Meta | Everything above, including reach and ROAS |
| Google Ads | Spend, delivery and conversions; raw GAQL for everything else |
| TikTok | Spend, delivery, conversions and video metrics; de-duplicated reach through live queries |
| LinkedIn | Spend and delivery; conversions per conversion rule |
| Pinterest, X | Spend, impressions, clicks and rates. `reach` reads `0`. |
| Reddit, ChatGPT Ads | Impressions, clicks and spend only, with CTR, CPC and CPM. Everything else reads `0`. |

## Date ranges and history

- Without `fromDate` and `toDate`, ranges default to the last 90 days. The longest range is 730 days.
- When an ad account connects, Pozy imports its last 90 days. Synced summaries hold nothing before that.
- On Meta, asking for older days answers `202` with `backfillPending: true` and the part already covered, while Pozy imports the rest (up to 730 days back) in the background. Repeat the request until it returns `200`. Other networks never return `202`.
- Breakdowns and live queries read the network directly, so they are not limited by the import.
- Days follow the ad account's time zone.

### Currencies

Pozy never converts currencies. Each campaign node and timeline row carries `currency`. When one connection's ad accounts bill in different currencies, the timeline returns one row per currency per day. Do not add spend across them.

## Attribution

Each network counts a conversion against an ad within its own windows, so numbers differ between networks for the same sale.

| Network | Where the window is set |
| --- | --- |
| Meta | `attributionSpec` on create: `eventType` (`CLICK_THROUGH`, `VIEW_THROUGH`, `ENGAGED_VIDEO_VIEW`) with `windowDays` of 1, 7 or 28. Live queries take `actionAttributionWindows` and `useUnifiedAttributionSetting`. |
| Google Ads | Per conversion action, plus which actions count toward bidding through conversion goals. See [Conversions](https://pozy.ai/docs/ads/conversions). |
| LinkedIn | Per conversion rule: click and view windows of 1, 7, 30, 90 or 365 days. `GET .../conversion-destinations/{id}/metrics` reads conversions per rule. |
| X | Per conversion event, with click and view windows up to 90 days. |
| Reddit | Fixed 7-day click, 1-day view for conversion goals. |
| ChatGPT Ads | Fixed 30 days. |

## Live queries

`GET /v1/ads/insights` passes your query to the network and returns its rows as they come. The connection's platform decides which parameters apply, and validation is the network's: an unknown field returns `400` with the network's message.

| Network | Send | Paging |
| --- | --- | --- |
| Meta | `objectId` (any insights node: ad account, campaign, ad set, ad), `level`, `fields`, `breakdowns`, `actionBreakdowns`, `filtering`, `datePreset` or `fromDate`/`toDate`, `timeIncrement` | `limit` and `after` |
| Google Ads | `query`: any read-only GAQL `SELECT`. `adAccountId` when the connection has several customers. | 10,000 rows per page; pass `paging.nextPageToken` as `pageToken` |
| TikTok | `adAccountId`, `dataLevel`, `dimensions` (1 to 4), `metrics`, `fromDate`/`toDate` (at most 365 days), optional `filtering` and `reportType` | `page` and `pageSize` (up to 1,000) |

```bash
curl -G https://api.pozy.ai/v1/ads/insights \
  -H "Authorization: Bearer $POZY_API_KEY" \
  --data-urlencode "accountId=66b2e19d8c3f5a7e9d0b1c2f" \
  --data-urlencode "query=SELECT campaign.name, metrics.cost_micros, metrics.clicks FROM campaign WHERE segments.date DURING LAST_7_DAYS"
```

Google returns its own units, so `cost_micros` divides by 1,000,000, and counters arrive as strings. Selecting `segments.date` needs a finite date filter.

### Reach across several campaigns on TikTok

Adding the reach of two campaigns counts a person who saw both twice. To count each person once, filter the set and group by country instead of by campaign:

```text
dataLevel=AUCTION_CAMPAIGN&dimensions=country_code&metrics=reach,impressions,frequency
&fromDate=2027-01-01&toDate=2027-01-31
&filtering=[{"field":"campaign_ids","operator":"IN","value":["1876574798182050","1876575504573650"]}]
```

Reach is then per country; adding countries together double counts people reached in both. Filter at the same level as `dataLevel` or a coarser one.

### Long reports on Meta

For long ranges or many accounts, submit a background job with the same fields as a live Meta query:

```bash
curl -X POST https://api.pozy.ai/v1/ads/insights/reports \
  -H "Authorization: Bearer $POZY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "accountId": "66b2e19d8c3f5a7e9d0b1c2e", "objectId": "act_1234567890", "level": "ad", "fields": "ad_name,spend,impressions,actions", "fromDate": "2026-01-01", "toDate": "2026-12-31", "timeIncrement": "monthly" }'
```

It answers `202` with a `reportRunId`. Poll `GET /v1/ads/insights/reports/{reportRunId}`: it reports `status` and `percentCompletion` while it runs, and once `status` is `Job Completed` it returns the rows, paged with `limit` and `after`.

## Know when numbers are stale

Subscribe to these [webhook](https://pozy.ai/docs/webhooks) events instead of polling sync state:

| Event | When it fires |
| --- | --- |
| `account.ads.initial_sync_completed` | Once per connection, after ad account discovery and the 90-day import. `sync.status` says whether it fully or partly succeeded; on failure, `sync.errorCategory` is `token_invalid`, `permission_denied`, `no_ad_accounts`, `rate_limited`, `discovery_failed` or `unknown`. |
| `account.ads.sync_failed` | Once when an ad account has had no successful sync for 24 hours, or every live ad in it hit the retry limit. Flag its numbers as stale. `sync.errorCategory` is `ad_account_not_listed`, `sync_error` or `stale`. |
| `account.ads.sync_recovered` | Once when that ad account syncs again. |
| `ad_account.status_changed` | An ad account became `active`, `restricted`, `disabled` or `closed` (Meta, Google, TikTok, X). |
| `ad.review.approved`, `ad.review.rejected` | A network's review decision on an ad you already had, within one sync cycle. |
| `ad.delivery_issue` | An ad stopped delivering for a network reason (Meta, TikTok, LinkedIn). |
| `campaign.budget.depleted` | A TikTok campaign ran out of budget. |

A failed initial sync is not final: running the ads connect flow again queues the import again. Reconnecting fixes `token_invalid`, `permission_denied` and `rate_limited`; `no_ad_accounts` needs the person to have an ad account first.

## Endpoints

| Endpoint | What it does |
| --- | --- |
| [`GET /v1/ads/tree`](https://pozy.ai/docs/api/getAdTree) | Get campaign tree |
| [`GET /v1/ads/timeline`](https://pozy.ai/docs/api/getAdsTimeline) | Get daily account metrics |
| [`GET /v1/ads`](https://pozy.ai/docs/api/listAds) | List ads |
| [`GET /v1/ads/{adId}`](https://pozy.ai/docs/api/getAd) | Get ad details |

| Endpoint | What it does |
| --- | --- |
| [`GET /v1/ads/search-terms`](https://pozy.ai/docs/api/getAdsSearchTerms) | Google Ads search terms report |
| [`GET /v1/ads/local-services/leads`](https://pozy.ai/docs/api/listLocalServicesLeads) | Google Local Services Ads leads |
| [`GET /v1/ads/local-services/leads/{leadId}/conversations`](https://pozy.ai/docs/api/listLocalServicesLeadConversations) | List lead conversations |
| [`GET /v1/ads/campaigns/{campaignId}/analytics`](https://pozy.ai/docs/api/getCampaignAnalytics) | Get campaign analytics |
| [`POST /v1/ads/keywords/ideas`](https://pozy.ai/docs/api/generateKeywordIdeas) | Generate keyword ideas |
| [`POST /v1/ads/keywords/historical-metrics`](https://pozy.ai/docs/api/generateKeywordHistoricalMetrics) | Get historical keyword metrics |
| [`GET /v1/ads/insights`](https://pozy.ai/docs/api/queryAdInsights) | Flexible live insights query |
| [`POST /v1/ads/insights/reports`](https://pozy.ai/docs/api/createAdInsightsReport) | Submit async insights report |
| [`GET /v1/ads/insights/reports/{reportRunId}`](https://pozy.ai/docs/api/getAdInsightsReport) | Poll an async insights report run |
| [`GET /v1/ads/{adId}/analytics`](https://pozy.ai/docs/api/getAdAnalytics) | Get ad analytics |
| [`GET /v1/ads/tiktok-smart-plus-materials`](https://pozy.ai/docs/api/getTikTokSmartPlusMaterialReport) | Per-creative performance inside TikTok Smart+ ads |

## Webhook events

| Event | When it fires |
| --- | --- |
| [`account.ads.initial_sync_completed`](https://pozy.ai/docs/webhooks/events#account-ads-initial-sync-completed) | Ads initial sync completed. |
| [`account.ads.sync_failed`](https://pozy.ai/docs/webhooks/events#account-ads-sync-failed) | Ads sync failed. |
| [`account.ads.sync_recovered`](https://pozy.ai/docs/webhooks/events#account-ads-sync-recovered) | Ads sync recovered. |
| [`ad.review.approved`](https://pozy.ai/docs/webhooks/events#ad-review-approved) | Ad review approved. |
| [`ad.review.rejected`](https://pozy.ai/docs/webhooks/events#ad-review-rejected) | Ad review rejected. |
| [`ad.delivery_issue`](https://pozy.ai/docs/webhooks/events#ad-delivery-issue) | Ad delivery issue. |
| [`campaign.budget.depleted`](https://pozy.ai/docs/webhooks/events#campaign-budget-depleted) | Campaign budget depleted. |
| [`ad_account.status_changed`](https://pozy.ai/docs/webhooks/events#ad-account-status-changed) | Ad account status changed. |
