> ## Documentation Index
> Fetch the complete documentation index at: https://docs.apostra.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Get reporting metrics

> Read delivery metrics across advertisers and campaigns

`GET /api/v2/buyer/reporting/metrics`

Returns delivery metrics rolled up across the **advertiser → campaign → media buy → package** tree. Choose a hierarchical `summary` or flat `timeseries` view, scope with `advertiserId`, `campaignId`, `channelGroupId`, or `mediaBuyId`, and set the window with `days` or an explicit `startDate`/`endDate`. This endpoint is day-grain only — for hourly counts use [Get event summary](/v2/buyer/reporting/tasks/get-event-summary).

## Request

<CodeGroup>
  ```bash Summary theme={null}
  curl "https://api.apostra.com/api/v2/buyer/reporting/metrics?days=14&advertiserId=42&demo=false" \
    -H "Authorization: Bearer $SCOPE3_API_KEY"
  ```

  ```bash Time-series CSV theme={null}
  curl "https://api.apostra.com/api/v2/buyer/reporting/metrics?view=timeseries&days=90&download=true&demo=false" \
    -H "Authorization: Bearer $SCOPE3_API_KEY"
  ```
</CodeGroup>

## Parameters

| Param | Type | Required | Notes |
| - | - | - | - |
| `advertiserId` | string | No | Filter to one advertiser |
| `campaignId` | string | No | Filter to one campaign. Must belong to `advertiserId` if both are passed |
| `channelGroupId` | string | No | Filter to media buys in one saved campaign channel group. Combine with `advertiserId` or `campaignId` to narrow to their intersection |
| `mediaBuyId` | string | No | Filter to one media buy owned by the authenticated Buyer |
| `startDate` | string | No | `YYYY-MM-DD`. Overrides the `days` window when set |
| `endDate` | string | No | `YYYY-MM-DD`. Defaults to today |
| `days` | integer | No | `0`–`90`, default `7`. Use `0` for the full campaign timeframe |
| `view` | enum | No | `summary` (default) or `timeseries` |
| `download` | boolean | No | Default `false`. When `true`, returns a signed CSV URL instead of JSON |
| `demo` | boolean | No | Default `false`. When `true`, returns auto-generated demo data instead of real data |

## Response

<Note>
  The example below uses illustrative, non-zero `conversionValue`, `cpa`, and
  `roas` values to show the response shape. See the note below the response for
  how these fields behave when a seller has not reported conversion data.
</Note>

```json theme={null}
{
  "advertisers": [
    {
      "advertiserId": "42",
      "advertiserName": "Northwind Outdoors",
      "metrics": { "impressions": 12345, "spend": 678.9, "clicks": 210, "views": 9800, "completedViews": 4200, "conversions": 18, "leads": 3, "videoCompletions": 4200, "conversionValue": 950.0, "ecpm": 55.0, "cpc": 3.23, "ctr": 0.017, "completionRate": 0.43, "cpa": 37.72, "roas": 1.4 },
      "campaigns": [
        {
          "campaignId": "cmp_001",
          "campaignName": "Spring Trail Series",
          "management": "managed",
          "metrics": { "impressions": 12345, "spend": 678.9, "clicks": 210, "views": 9800, "completedViews": 4200, "conversions": 18, "leads": 3, "videoCompletions": 4200, "conversionValue": 950.0, "ecpm": 55.0, "cpc": 3.23, "ctr": 0.017, "completionRate": 0.43, "cpa": 37.72, "roas": 1.4 },
          "mediaBuys": []
        }
      ]
    }
  ],
  "totals": { "impressions": 12345, "spend": 678.9, "clicks": 210, "views": 9800, "completedViews": 4200, "conversions": 18, "leads": 3, "videoCompletions": 4200, "conversionValue": 950.0, "ecpm": 55.0, "cpc": 3.23, "ctr": 0.017, "completionRate": 0.43, "cpa": 37.72, "roas": 1.4 },
  "periodStart": "2026-05-24",
  "periodEnd": "2026-06-06"
}
```

Each campaign block (and each timeseries row) carries a `management` state:
`tracked` (a campaign you did not set up through the platform, mirrored
read-only from a connected provider account) or `managed` (authored or adopted
through the platform). This surface currently reports managed campaigns only —
tracked mirror delivery is excluded — so totals never silently mix the two
states.

`conversionValue` is advertiser-attributed revenue from conversions; unlike
`spend`, it is never grossed up with platform fees. `cpa` (`spend / conversions`)
is `null` when there are no conversions. `roas` (`conversionValue / spend`) is
`null` when spend is zero, or when there is no conversion signal at all — zero
conversions and zero attributed value; it reports a true `0` only when
conversions are real but their attributed value is zero.

These fields depend on the seller reporting conversion data. Where a seller
reports none, `conversionValue` is `0` and `cpa` and `roas` are `null`.

### Goal progress

Every media buy block, and every campaign block, that has something to judge
against carries `goalProgress`: delivery compared with the buyer's goal and the
seller's commitment. A media buy is judged against its own
[goal commitment](/v2/buyer/campaigns/media-buys); a campaign against its
primary goal, with the weakest commitment across its buys. The campaign's
`answeredTarget` is likewise the weakest answer: the highest cost per unit, or
the lowest rate or return. It is `null` when the buys answered in different
kinds. Blocks with neither a goal nor a commitment omit the field. The timeseries view carries the same
judgements, over the whole period, in a `goalProgress` object with `mediaBuys[]`
and `campaigns[]`. What each part means and how to read it is explained in
[Goal-seeking campaigns](/v2/concepts/goal-seeking-campaigns#judging-delivery).

The rows below give the media buy block's path; the campaign block carries
the same object at `advertisers[].campaigns[].goalProgress`.

| Field | Type | Notes |
| - | - | - |
| `advertisers[].campaigns[].mediaBuys[].goalProgress.goal` | object or null | The ask being judged: `kind` (`metric` or `event`), `subject` (the metric name, or the event types joined with `\|`), `eventTypes`, and the `target` the buyer stated. Null when only a fixed outcome price is on record. |
| `advertisers[].campaigns[].mediaBuys[].goalProgress.askedTarget` | object or null | The buyer's target: `cost_per`, `threshold_rate`, or `per_ad_spend` with a `value`, or `maximize_value`. |
| `advertisers[].campaigns[].mediaBuys[].goalProgress.answeredTarget` | object or null | The cost or return the seller's terms commit or aim at, from the media buy's goal commitment. |
| `advertisers[].campaigns[].mediaBuys[].goalProgress.commitment` | string or null | `guaranteed`, `best_effort`, or `report_only`. At campaign level, the weakest across the campaign's buys; a buy with no commitment counts as `report_only`. |
| `advertisers[].campaigns[].mediaBuys[].goalProgress.commitmentSource` | string or null | `proposal` or `campaign` on a media buy; null at campaign level. |
| `advertisers[].campaigns[].mediaBuys[].goalProgress.actual` | object or null | The achieved value for the goal's metric, from the same delivery this response reports: `kind` (`cost_per`: spend ÷ units, per thousand for impressions; `threshold_rate`: units ÷ `denominatorUnits`; `volume` when there is no numeric target), `value`, `unit`, `units` (the delivered count), `denominatorUnits` (what a rate divided by — measurable impressions for a `viewable_rate` goal, impressions for every other rate; null for a cost or a volume), `perUnits` (1,000 for impressions, 1 otherwise), and `currency` for a cost. Uses this surface's denomination, so a buyer's actual cost per click is fee-inclusive like `cpc`. Null when the metric was not reported. |
| `advertisers[].campaigns[].mediaBuys[].goalProgress.verdict` | string or null | `on_track`, `behind`, or `beat`. Cost targets are lower-is-better; rates and returns higher-is-better. `beat` means at least 10% better than the target. Null whenever the evidence does not support a verdict. |
| `advertisers[].campaigns[].mediaBuys[].goalProgress.judgedAgainst` | string or null | `asked` when the verdict compares with your target, `answered` when you stated no target and the seller's price is the yardstick. |
| `advertisers[].campaigns[].mediaBuys[].goalProgress.verdictWithheld` | string or null | Why there is no verdict: `no_goal`, `no_target`, `metric_unsupported`, `metric_not_reported` (no delivery in the period, or the seller never reported a value for the goal's metric, or the seller's reporting commitments leave it out, or the seller reported the units but not what they divide by: spend for a cost-per target, measurable impressions for a `viewable_rate` target, impressions for every other rate target), or `too_few_observations`. A cost or a volume needs at least 100 clicks or completed views, 1,000 impressions, views or viewable impressions, or 20 leads. A **rate** is judged on what it divided by instead, and needs at least 1,000 measurable impressions or impressions — so a low rate over a large, well-measured population is a real verdict even when its own count is small. `metric_unsupported` covers metrics delivery does not carry (such as `reach`), event goals other than lead-only ones, a fixed price on an outcome delivery cannot count (`cpa`, `vcpm`, `cpp`) when the campaign states no goal, and return-on-ad-spend targets: delivery reports one conversion count and one conversion value with no event type, so judging a purchase goal on them would credit it with other events, and that read waits for event-scoped counts. A withheld verdict is not a bad verdict. |
| `advertisers[].campaigns[].mediaBuys[].goalProgress.basis` | object or null | Who counted the number. Delivery here is what the seller reported, so `kind` is `seller_attested`. |
| `advertisers[].campaigns[].mediaBuys[].goalProgress.freshness` | object | `dataThrough` (last date with delivery in the period), the seller's last stated `reportingPeriodEnd`, `nextExpectedAt`, `notificationType` and `sequenceNumber`, `awaitingLaterReport` (the seller has signalled a later report will supersede this one), and `missingMetrics` (the goal's metric when nothing was reported, plus any metric the seller committed to report that this surface does not carry). |
| `advertisers[].campaigns[].mediaBuys[].goalProgress.pace` | object or null | Spend measured against the flight — see **Spend pace** below. Media buy blocks only; campaign blocks carry `null`. |

A media buy committed at 3.00 per click that has delivered 500 clicks for
2,000 reads `actual: { kind: "cost_per", value: 4, unit: "clicks", units: 500 }`
and `verdict: "behind"`. The same buy on its first day with two clicks reads the
actual and `verdictWithheld: "too_few_observations"`, with no verdict.

<Note>
  A `viewable_rate` goal is judged only from the viewable and measurable
  impression counts in the AdCP `viewability` object. Reporting does not read
  that object yet, so a `viewable_rate` goal currently reads
  `verdictWithheld: "metric_not_reported"` with `viewability` in
  `missingMetrics`. The `views` metric is **content views** — the quantity CPV
  pricing bills on — and is never used as a stand-in for viewable impressions.
</Note>

#### Spend pace

`goalProgress.pace` answers a separate question from the goal: is the money
moving at the rate the flight implies? "Spend 200 a day at 80% viewable" is two
answers, not one — the pace verdict and the goal verdict are reported side by
side. Pace is **not** spend ÷ budget: a buy three days into a thirty-day flight
that has spent a tenth of its budget is exactly on pace, while a buy on its last
day that has spent the same tenth is nine tenths undelivered.

| Field | Type | Notes |
| - | - | - |
| `advertisers[].campaigns[].mediaBuys[].goalProgress.pace.flightElapsed` | number or null | Fraction of the **whole** flight elapsed, 0 to 1. Measured to the end of the **last reporting day with delivery**, not to now, so a seller who reports a day in arrears does not read as a day behind. A buy booked `asap` is measured from when it was created. This tells you where the buy is in its life; it is not what the expectation below is measured over. |
| `advertisers[].campaigns[].mediaBuys[].goalProgress.pace.measuredFraction` | number or null | Fraction of the flight the reported spend actually covers — the window you asked for, clipped to the flight. Equal to `flightElapsed` on a lifetime read, smaller on a windowed one. |
| `advertisers[].campaigns[].mediaBuys[].goalProgress.pace.expectedSpend` | number or null | `budget × measuredFraction` — what an even pace across the flight expects over that same stretch. |
| `advertisers[].campaigns[].mediaBuys[].goalProgress.pace.actualSpend` | number or null | Delivered spend, in the same denomination as the budget (gross, fee-inclusive, like `spend`). |
| `advertisers[].campaigns[].mediaBuys[].goalProgress.pace.ratio` | number or null | `actualSpend ÷ expectedSpend`. 1 is exactly on the even-pace line. |
| `advertisers[].campaigns[].mediaBuys[].goalProgress.pace.spendPerDay` | number or null | Delivered spend per day of the measured interval. A buy still delivering past its booked end counts the days it actually ran. |
| `advertisers[].campaigns[].mediaBuys[].goalProgress.pace.budgetPerDay` | number or null | The even-pace daily rate the budget and flight imply — compare it with `spendPerDay` to read a "200 a day" ask. |
| `advertisers[].campaigns[].mediaBuys[].goalProgress.pace.currency` | string or null | Denomination of the money fields. |
| `advertisers[].campaigns[].mediaBuys[].goalProgress.pace.verdict` | string or null | `on_pace` within 10% of the even-pace line, `behind` under it, `ahead` over it. `ahead` is neither praise nor alarm: at this rate the flight exhausts early. |
| `advertisers[].campaigns[].mediaBuys[].goalProgress.pace.verdictWithheld` | string or null | Why there is no pace verdict: `no_budget`, `no_flight` (no start or end recorded), `flight_not_started`, `window_outside_flight` (the window you asked for covers none of the flight — a recent window of a flight that ended months ago), `too_short_a_window` (the reported spend covers under 5% of the flight, where one late report swings the ratio by multiples), or `spend_not_reported`. The facts already known — elapsed flight, expected spend, budget per day — are still reported. |

<Note>
  Pace is always measured over the window you asked for. Narrow `days` and both
  `actualSpend` and `expectedSpend` shrink together, so the verdict stays
  comparable; it never compares a week of spend with a month of expectation.
</Note>

When `download=true`, the response is instead `{ downloadUrl, expiresAt, fileName, rowCount }` with a signed URL that expires in 7 days. Treat that URL as a bearer credential.

### Channel-group lineage

When a campaign uses [channel groups](/v2/buyer/campaigns/channel-groups),
reporting carries the group that compiled into each media buy:

* A `summary` response returns
  `advertisers[].campaigns[].mediaBuys[].channelGroup` as
  `{ channelGroupId, name }`.
* A `timeseries` response returns `channelGroupId` and `channelGroupName` on
  every row.
* A CSV export appends `Channel Group ID` and `Channel Group Name` columns.

Legacy or ungrouped media buys return `channelGroup: null` in a summary,
`channelGroupId: null` and `channelGroupName: null` in time-series JSON, and
blank CSV fields. These values are saved media-buy lineage; Interchange does
not infer them from names, products, or delivery.

This REST endpoint accepts `channelGroupId` as a scope filter, but it does not
group rows by that field. For an agent-side rollup, call V3 `get_delivery` with
`report: "campaign_delivery"`, `dimensions: ["channel_group"]`, and, when you
need one group, `filters.channelGroupId`. Rows then return the selected
dimension as `channelGroup: { id, name }`, or `null` for an ungrouped buy.
When you pass `filters.mediaBuyId`, `get_delivery` returns delivery for that
media buy only; it does not include other media buys from the same storefront.

## Errors

* `400 VALIDATION_ERROR` — malformed `startDate`/`endDate`, `days` outside `0`–`90`, or a `campaignId` that does not belong to the given `advertiserId`.
* `422 SPEND_DENOMINATION_UNRESOLVED` — one or more media buys in scope have spend
  that cannot be denominated, so no figure would be correct. The error names them
  in its message and in `details.mediaBuyIds`, alongside `details.unresolvedCount`.
  **Classified `terminal` — do not retry.** The cause is delivery data that is
  wrong at rest (a source reporting a currency it is not paid in, a buy missing
  its cross-currency booking evidence, or currencies mixed within the period), so
  every retry reproduces it. Report the named ids to support; to keep reporting in
  the meantime, scope the request to exclude them (a single campaign, or a period
  they did not deliver in). Unaffected buys report normally, and `view=timeseries`
  can still return rows for buys whose days each carry their own currency.
* `503 FX_RATE_UNAVAILABLE` — the exchange-rate feed could not price a currency
  pair. Classified `transient`; retry shortly.

See [Errors](/v2/reference/errors) for the full error contract, and
[Cross-currency](/v2/concepts/cross-currency) for how delivery spend is
denominated.

## Related

<CardGroup cols={2}>
  <Card title="Reporting tasks" href="/v2/buyer/reporting/tasks" icon="list-check">
    All reporting operations
  </Card>

  <Card title="Reporting overview guide" href="/v2/guides/reporting-overview" icon="book">
    Hierarchy, metrics, CSV export, delivery flow
  </Card>

  <Card title="Get event summary" href="/v2/buyer/reporting/tasks/get-event-summary" icon="clock">
    Hourly event counts by type
  </Card>
</CardGroup>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.