> ## 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.

# Measurement & Incrementality

> Configure event sources, ingest measurement data, and run incrementality tests with the v2 measurement engine

## Overview

Apostra Measurement Engine turns raw advertising signals into causal evidence about what is working. Instead of treating every conversion as proof, it asks the harder question — *would that conversion have happened anyway?* — and updates a running set of beliefs about each campaign's incremental impact as new data arrives.

The pipeline has four layers:

1. **Event sources** — buyer-registered pixels, SDKs, MMPs, CRMs, or measurement partners that send conversions, impressions, or outcome data.
2. **Measurement data** — privacy-safe outcome records (revenue, conversions, LTV) attached to a campaign, media buy, package, or creative.
3. **Belief state** — a Bayesian summary of what the engine currently believes about each hypothesis (e.g. "audience segment A drives more incremental revenue than segment B"), expressed as posterior distributions with confidence intervals.
4. **Incrementality tests** — explicit treatment / control / observation cohorts and test plans that produce stronger causal estimates than passive observation.

<Note>
  **How belief updating works (high level)**
  The engine starts each hypothesis with a prior — your initial guess about
  size and confidence. As measurement records arrive, the learning cycle
  updates that prior into a posterior using Bayesian inference: high-quality,
  fresh data shifts beliefs faster; sparse or noisy data shifts them less.
  Running an A/B test with proper test and control cohorts produces the
  strongest evidence and tightens the posterior fastest.
</Note>

<Warning>
  **Privacy-first**: the measurement engine never accepts raw PII (plain emails,
  phone numbers, addresses). Send pre-hashed identifiers (SHA-256) or
  pre-resolved identity tokens (RampID, UID2, ID5, etc.) — see the
  [Conversion API guide](/v2/integrations/conversion-api) for hashing rules.
</Warning>

## How evidence stays with the advertiser

Measurement sources, records, hypotheses, test plans, learning records, and
belief state are stored under the advertiser scope. They are not separate
seller-owned histories. When a measured campaign uses more than one seller,
authorized users and agents can read its submitted outcomes from the same
advertiser-scoped endpoints and use that evidence when planning later work
across sellers.

This evidence exists only when measurement is configured and data is submitted.
A seller relationship or campaign by itself does not create outcome evidence,
and seller delivery data is available only when the seller returns it.

<Note>
  Advertiser-scoped evidence is durable product state, not private model memory
  carried by an individual agent. Access follows the customer and advertiser
  permissions on every request. Changing sellers does not move the records into
  a seller account or grant that seller access. Belief state remains read-only:
  new data, a new test, human feedback, or a new hypothesis is required before a
  later learning cycle can change it.
</Note>

## Prerequisites

<Steps>
  <Step title="Apostra API key">
    Generate a key at [app.apostra.com/user-api-keys](https://app.apostra.com/user-api-keys).
    See [Authentication](/v2/authentication) for setup.
  </Step>

  <Step title="Advertiser ID">
    All measurement endpoints are scoped to an advertiser. You'll use this in
    every URL: `/api/v2/buyer/advertisers/:advertiserId/...`.
  </Step>

  <Step title="A campaign or media buy (recommended)">
    Measurement data is most useful when attached to in-flight campaigns.
    See [Campaigns](/v2/object-guides/campaign) for setup.
  </Step>
</Steps>

All examples below use:

```
BASE = https://api.apostra.com/api/v2/buyer
AUTH = Authorization: Bearer scope3_<your_api_key>
```

## Step 1: Register Event Sources

An **event source** is a logical channel through which measurement events flow — a website pixel, a mobile SDK, a CRM export, or an MMP feed. Every event you send must reference a registered `event_source_id`.

Use the ADCP-spec sync endpoint to upsert event sources for an advertiser:

<CodeGroup>
  ```bash cURL theme={null}
  curl -X POST "$BASE/advertisers/12345/event-sources/sync" \
    -H "Authorization: Bearer scope3_<your_api_key>" \
    -H "Content-Type: application/json" \
    -d '{
      "account": { "account_id": "12345" },
      "event_sources": [
        {
          "event_source_id": "website_pixel",
          "name": "Website Pixel",
          "event_types": ["purchase", "add_to_cart", "lead"],
          "allowed_domains": ["shop.example.com", "checkout.example.com"],
          "example_event": {
            "event_type": "purchase",
            "hashed_email": "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8",
            "value": 89.99,
            "currency": "USD"
          }
        },
        {
          "event_source_id": "crm_import",
          "name": "Salesforce CRM Export",
          "event_types": ["purchase", "qualify_lead"],
          "allowed_domains": []
        }
      ],
      "delete_missing": false
    }'
  ```

  <Warning>
    Identifiers like `hashed_email` and `hashed_phone` MUST be SHA-256 hex
    (lowercase, 64 chars). Normalize email to lowercase / trimmed and phone to
    E.164 *before* hashing — see the
    [Conversion API guide](/v2/integrations/conversion-api) for full hashing
    rules. The engine silently drops malformed hashes.
  </Warning>

  <Warning>
    Leaving `allowed_domains` empty (or omitting it) means events from **any
    origin** will be accepted for that source. Always restrict `allowed_domains`
    to the hosts that legitimately fire your pixel; an empty list is appropriate
    only for server-to-server feeds (CRM, MMP) where there is no browser origin.
  </Warning>

  ```json Response theme={null}
  {
    "event_sources": [
      {
        "event_source_id": "website_pixel",
        "action": "created",
        "setup": {
          "snippet_type": "server_only",
          "instructions": "Events are ingested server-to-server. Send accepted events to POST /api/v2/advertisers/12345/log-event using your API credentials. Setup guide: https://docs.apostra.com/v2/guides/log-events"
        }
      },
      { "event_source_id": "crm_import", "action": "created" }
    ]
  }
  ```
</CodeGroup>

<Note>
  Live results (`created`, `updated`, `unchanged`) carry a `setup` object so
  install guidance travels with the response, not only inside the widget.
  Apostra ingests every source server-to-server via `log_event`, so
  `snippet_type` is always `server_only` and no client-side `snippet` is
  returned; `instructions` names the ingestion endpoint and links the setup
  guide. `failed` and `deleted` results omit `setup`.
</Note>

### Request fields

| Field                             | Type      | Required | Description                                                             |
| --------------------------------- | --------- | -------- | ----------------------------------------------------------------------- |
| `account.account_id`              | string    | yes      | Must match the `:advertiserId` in the URL                               |
| `event_sources[].event_source_id` | string    | yes      | Buyer-assigned ID, max 255 chars                                        |
| `event_sources[].name`            | string    | no       | Human-readable label                                                    |
| `event_sources[].event_types`     | string\[] | no       | Restricts which event types this source may send. Omit to accept all.   |
| `event_sources[].allowed_domains` | string\[] | no       | Origin domains authorized for this source                               |
| `delete_missing`                  | boolean   | no       | Archive any buyer-managed sources not in this payload (default `false`) |

Each result's `action` is one of `created`, `updated`, `unchanged`, `failed`, or `deleted`.

### List configured sources

```bash theme={null}
curl "$BASE/advertisers/12345/event-sources?take=50&skip=0" \
  -H "Authorization: Bearer scope3_<your_api_key>"
```

<Tip>
  Events sent to an unregistered `event_source_id` are rejected. Always sync
  sources before turning on a pixel or CRM job.
</Tip>

## Step 2: Configure Measurement

Measurement configuration controls which measurement features are active for an advertiser — Marketing Mix Modeling (MMM), incrementality testing, and brand lift — plus any provider-specific MMM settings.

```bash theme={null}
curl -X PUT "$BASE/advertisers/12345/measurement-config" \
  -H "Authorization: Bearer scope3_<your_api_key>" \
  -H "Content-Type: application/json" \
  -d '{
    "mmmEnabled": true,
    "mmmConfig": {
      "provider": "measured",
      "dataSourceIds": ["ds_revenue", "ds_orders"],
      "reportingFrequency": "weekly"
    },
    "brandLiftEnabled": false,
    "settings": {
      "defaultLookbackDays": 28
    }
  }'
```

### Fields

| Field                          | Type      | Description                                    |
| ------------------------------ | --------- | ---------------------------------------------- |
| `mmmEnabled`                   | boolean   | Enable Marketing Mix Modeling                  |
| `mmmConfig.provider`           | string    | MMM partner name (e.g. `measured`)             |
| `mmmConfig.dataSourceIds`      | string\[] | IDs of upstream data feeds powering MMM        |
| `mmmConfig.reportingFrequency` | enum      | `weekly`, `monthly`, or `quarterly`            |
| `brandLiftEnabled`             | boolean   | Enable brand-lift study integration            |
| `settings`                     | object    | Free-form key/value advertiser-level overrides |

`PUT` is upsert — pass only the fields you want to set; missing fields fall back to defaults / prior values. Read the current config with `GET /advertisers/:advertiserId/measurement-config`.

## Step 3: Validate Configuration

Before relying on incrementality estimates, assess whether your planned spend, geos, and flight length can actually move the needle on a hypothesis. The testability assessment returns power-analysis-style guidance.

```bash theme={null}
curl -X POST "$BASE/advertisers/12345/testability" \
  -H "Authorization: Bearer scope3_<your_api_key>" \
  -H "Content-Type: application/json" \
  -d '{
    "hypothesisIds": ["1f5c0c4e-2c9f-4f0a-9e31-2d4f3c7b1a01"],
    "budget": 250000,
    "weeklyBudget": 25000,
    "flightWeeks": 10,
    "testGeos": ["US-CA", "US-OR", "US-WA"],
    "controlGeos": ["US-AZ", "US-NV", "US-NM"]
  }'
```

The response surfaces, per hypothesis, whether the design has enough cells, whether geos are well-matched, and which gaps would weaken inference.

<Tip>
  Run testability *before* launching media. It's far cheaper to widen your geo
  list or extend the flight than to discover post-flight that the test was
  underpowered.
</Tip>

## Step 4: Send Measurement Data

There are two complementary ways to feed the engine:

* **Conversion events** — fine-grained, per-user actions (purchases, leads, sign-ups). Use the [Conversion API](/v2/integrations/conversion-api) — same identity rules apply.
* **Measurement records** — pre-aggregated outcomes for a time window and geo (e.g. "incremental revenue, US-CA, week of 2026-03-01 = \$8,450"). Use the sync endpoint below.

### Sync aggregated measurement data

```bash theme={null}
curl -X POST "$BASE/advertisers/12345/measurement-data/sync" \
  -H "Authorization: Bearer scope3_<your_api_key>" \
  -H "Content-Type: application/json" \
  -d '{
    "measurements": [
      {
        "start_time": "2026-03-01T00:00:00-05:00",
        "end_time":   "2026-03-07T23:59:59-05:00",
        "metric_id":  "incremental_revenue",
        "metric_value": 8450.75,
        "unit": "currency",
        "currency": "USD",
        "campaign_id": "camp_456",
        "media_buy_id": "mb_789",
        "source": "advertiser",
        "source_platform": "billy_grace",
        "source_metric_name": "Incremental Revenue",
        "external_row_id": "bg_row_001"
      },
      {
        "start_time": "2026-03-01T00:00:00-05:00",
        "end_time":   "2026-03-07T23:59:59-05:00",
        "metric_id":  "purchase_count",
        "metric_value": 142,
        "unit": "count",
        "campaign_id": "camp_456",
        "external_row_id": "bg_row_002"
      }
    ]
  }'
```

| Field                                                         | Type     | Required     | Notes                                                                                                                                                               |
| ------------------------------------------------------------- | -------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `start_time` / `end_time`                                     | ISO 8601 | yes          | Must include offset; `start_time < end_time`                                                                                                                        |
| `metric_id`                                                   | enum     | yes          | `revenue`, `incremental_revenue`, `conversions`, `incremental_conversions`, `page_view_count`, `add_to_cart_count`, `purchase_count`, `ltv_1d`, `ltv_7d`, `ltv_30d` |
| `metric_value`                                                | number   | yes          | The measured value                                                                                                                                                  |
| `unit`                                                        | enum     | yes          | `currency`, `count`, `ratio`, `percentage`                                                                                                                          |
| `currency`                                                    | string   | conditional  | ISO 4217 — required when `unit` is `currency`                                                                                                                       |
| `campaign_id` / `media_buy_id` / `package_id` / `creative_id` | string   | one required | Attaches the measurement to an entity                                                                                                                               |
| `source`                                                      | enum     | no           | `advertiser`, `mmp`, or `measurement_partner`                                                                                                                       |
| `external_row_id`                                             | string   | no           | Idempotency key for re-syncs                                                                                                                                        |

Up to **1,000 measurements per call**. Each result reports `action: created | updated | unchanged | failed`.

### Upload raw measurement records (advanced)

For research-style flows that already produce per-geo outcomes, the learning engine accepts batched records directly:

```bash theme={null}
curl -X POST "$BASE/advertisers/12345/measurement-records" \
  -H "Authorization: Bearer scope3_<your_api_key>" \
  -H "Content-Type: application/json" \
  -d '{
    "records": [
      {
        "outcomeType": "incremental_revenue",
        "geo": "US-CA",
        "timeWindowStart": "2026-03-01",
        "timeWindowEnd":   "2026-03-07",
        "value": 8450.75,
        "baselineValue": 7100.00,
        "confidenceInterval": 0.92,
        "source": "measured",
        "lagDays": 7
      }
    ]
  }'
```

Up to **5,000 records per call**.

### Upload context records

Context records describe market conditions that the learning engine should partial out — promos, weather, competitor activity, seasonality:

```bash theme={null}
curl -X POST "$BASE/advertisers/12345/context-records" \
  -H "Authorization: Bearer scope3_<your_api_key>" \
  -H "Content-Type: application/json" \
  -d '{
    "records": [
      {
        "geo": "US-CA",
        "timeWindowStart": "2026-03-01",
        "timeWindowEnd":   "2026-03-07",
        "promoActive": true,
        "promoType": "site_wide_15_off",
        "seasonalityIndex": 1.12,
        "flightStatus": "active"
      }
    ]
  }'
```

<Warning>
  Never include raw user identifiers (emails, phone numbers, names) in
  measurement or context records. These endpoints accept aggregated outcomes
  only — per-user events go through the [Conversion API](/v2/integrations/conversion-api).
</Warning>

## Step 5: Inspect the Event Summary

Once events are flowing, the event-summary endpoint returns hourly counts per event type so you can confirm ingestion before depending on downstream attribution:

```bash theme={null}
curl "$BASE/advertisers/12345/events/summary?\
eventType=conversion&\
startHour=2026-03-27T14:00:00Z&\
endHour=2026-03-27T20:00:00Z" \
  -H "Authorization: Bearer scope3_<your_api_key>"
```

```json Response theme={null}
{
  "periodStart": "2026-03-27T14:00:00.000Z",
  "periodEnd":   "2026-03-27T20:00:00.000Z",
  "entries": [
    { "eventHour": "2026-03-27T14:00:00.000Z", "eventType": "conversion", "eventCount": 1500 },
    { "eventHour": "2026-03-27T15:00:00.000Z", "eventType": "conversion", "eventCount": 1612 },
    { "eventHour": "2026-03-27T16:00:00.000Z", "eventType": "conversion", "eventCount": 1483 }
  ],
  "totalEventCount": 4595
}
```

| Query param             | Notes                                                                                 |
| ----------------------- | ------------------------------------------------------------------------------------- |
| `eventType`             | One of `conversion`, `click`, `impression`, `measurement`, `mmp`. Omit for all types. |
| `startHour` / `endHour` | Hour-aligned ISO 8601 timestamps. Defaults to the last completed UTC hour.            |

You can also check measurement freshness — gaps in expected geo/time coverage:

```bash theme={null}
curl "$BASE/advertisers/12345/measurement-freshness?\
flightStart=2026-03-01&\
flightEnd=2026-04-30&\
geos=US-CA,US-OR,US-WA" \
  -H "Authorization: Bearer scope3_<your_api_key>"
```

## Step 6: Set Up Test Cohorts and Test Plans

Incrementality tests work by comparing well-defined groups. The v2 model has three layers:

* **Hypotheses** — *what* you're testing: a falsifiable claim about a treatment vs. a comparison ("premium CTV drives more incremental revenue per impression than general CTV"). Hypotheses are persistent — each test plan accumulates evidence against the same hypothesis over time.
* **Test cohorts** — *who* is in each arm (treatment, control, observation). A cohort is a flexible audience definition (geo, segment, zip code, custom).
* **Test plans** — *how* the comparison runs against a hypothesis: which conditions, which dimensions to match on, how cells are sized.

### Create a hypothesis

Every test plan is anchored to a hypothesis. Create one before linking test plans, cohorts, or allocation entries to it.

```bash theme={null}
curl -X POST "$BASE/advertisers/12345/hypotheses" \
  -H "Authorization: Bearer scope3_<your_api_key>" \
  -H "Content-Type: application/json" \
  -d '{
    "category": "channel",
    "statement": "Premium CTV inventory drives more incremental purchases than general CTV at the same CPM.",
    "treatment": "Premium CTV (top-tier publisher list)",
    "comparison": "General CTV (open marketplace)",
    "outcomeMetric": "incremental_revenue",
    "icon": "📺",
    "priorConfidence": 0.4,
    "priorMagnitude": 0.15,
    "incrementalCostCpm": 8.0,
    "measurementSource": "billy_grace",
    "measurementLagWeeks": 1,
    "minimumTestCells": 6,
    "proxyMetrics": [
      { "outcomeType": "add_to_cart_count", "weight": 0.3 },
      { "outcomeType": "purchase_count", "weight": 0.7 }
    ]
  }'
```

```json Response theme={null}
{
  "id": "1f5c0c4e-2c9f-4f0a-9e31-2d4f3c7b1a01",
  "advertiserId": "12345",
  "category": "channel",
  "statement": "Premium CTV inventory drives more incremental purchases than general CTV at the same CPM.",
  "treatment": "Premium CTV (top-tier publisher list)",
  "comparison": "General CTV (open marketplace)",
  "outcomeMetric": "incremental_revenue",
  "icon": "📺",
  "priorConfidence": 0.4,
  "currentConfidence": 0.4,
  "priorMagnitude": 0.15,
  "currentMagnitude": 0.15,
  "incrementalCostCpm": 8.0,
  "measurementSource": "billy_grace",
  "measurementLagWeeks": 1,
  "minimumTestCells": 6,
  "proxyMetrics": [
    { "outcomeType": "add_to_cart_count", "weight": 0.3 },
    { "outcomeType": "purchase_count", "weight": 0.7 }
  ],
  "groundTruth": null,
  "status": "no_buys",
  "confidenceHistory": [],
  "createdBy": "user_98765",
  "sourceName": null,
  "createdAt": "2026-04-26T16:00:00.000Z",
  "updatedAt": "2026-04-26T16:00:00.000Z"
}
```

| Field                 | Type         | Required | Notes                                                                              |
| --------------------- | ------------ | -------- | ---------------------------------------------------------------------------------- |
| `category`            | enum         | yes      | `audience`, `creative`, `channel`, `context`, `timing`, `tactic`                   |
| `statement`           | string       | yes      | The falsifiable claim being tested                                                 |
| `treatment`           | string       | yes      | Description of the treatment arm                                                   |
| `comparison`          | string       | yes      | Description of the comparison / control arm                                        |
| `outcomeMetric`       | string       | yes      | Primary metric (e.g. `incremental_revenue`, `purchase_count`)                      |
| `priorConfidence`     | number (0–1) | yes      | Initial confidence before any data                                                 |
| `priorMagnitude`      | number       | yes      | Expected effect size before any data                                               |
| `icon`                | string       | no       | Emoji for UI display, defaults to `💡`                                             |
| `incrementalCostCpm`  | number       | no       | Cost premium of treatment vs. comparison                                           |
| `measurementSource`   | string       | no       | Source key tying ground-truth records to this hypothesis                           |
| `measurementLagWeeks` | integer      | no       | Expected reporting lag, defaults to `1`                                            |
| `minimumTestCells`    | integer      | no       | Minimum cells required for a meaningful test, defaults to `6`                      |
| `proxyMetrics`        | array        | no       | `{ outcomeType, weight }` proxies that update beliefs alongside the primary metric |
| `groundTruth`         | object       | no       | Free-form expected outcome for sanity checks                                       |

The returned `id` is the `hypothesisId` you'll plug into the test-plan creation step below — and into testability, learning-records, and belief-state queries.

#### Hypothesis status lifecycle

Every hypothesis carries a `status` that the engine advances as evidence accrues. New hypotheses always start at `no_buys`; the rest are reached automatically as media buys link, fire, and produce measurable outcomes.

| Status               | Meaning                                              | Triggered when                                                                                          |
| -------------------- | ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
| `no_buys`            | Hypothesis exists but no media is running against it | Hypothesis is created and no allocation entries are linked yet                                          |
| `in_market`          | At least one linked buy is delivering                | A linked allocation entry / media buy goes active                                                       |
| `partially_measured` | Some data has arrived, but coverage is thin          | Measurement records start landing but cells / geos are below `minimumTestCells` or coverage gaps remain |
| `well_measured`      | Enough data to update beliefs with confidence        | Coverage hits `minimumTestCells` across the matched dimensions and the posterior tightens               |
| `proven`             | Posterior supports the treatment hypothesis          | Belief converges with sufficient confidence in the hypothesized direction                               |
| `disproven`          | Posterior rejects the treatment hypothesis           | Belief converges with sufficient confidence against the hypothesized direction                          |

<Tip>
  You can filter hypotheses by status when listing —
  `GET /advertisers/:advertiserId/hypotheses?status=well_measured` is a useful
  default for "what's worth a stakeholder review this week."
</Tip>

### List hypotheses

```bash theme={null}
curl "$BASE/advertisers/12345/hypotheses?take=50&skip=0" \
  -H "Authorization: Bearer scope3_<your_api_key>"
```

Optional filters: `category`, `status`, `flightId`. Pagination via `take` (max 100) and `skip`.

### Create test cohorts

```bash theme={null}
curl -X POST "$BASE/advertisers/12345/test-cohorts" \
  -H "Authorization: Bearer scope3_<your_api_key>" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "West Coast Treatment",
    "description": "Users in CA, OR, WA receiving full ad exposure",
    "cohortType": "geographic",
    "role": "TREATMENT",
    "definition": {
      "type": "geo_region",
      "regions": ["US-CA", "US-OR", "US-WA"]
    },
    "estimatedSize": 50000
  }'
```

```bash theme={null}
curl -X POST "$BASE/advertisers/12345/test-cohorts" \
  -H "Authorization: Bearer scope3_<your_api_key>" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Mountain West Control",
    "cohortType": "geographic",
    "role": "CONTROL",
    "definition": {
      "type": "geo_region",
      "regions": ["US-AZ", "US-NV", "US-NM"]
    },
    "estimatedSize": 48000
  }'
```

| Field             | Type    | Required | Description                                                               |
| ----------------- | ------- | -------- | ------------------------------------------------------------------------- |
| `name`            | string  | yes      | Cohort label, max 255 chars                                               |
| `cohortType`      | string  | yes      | Free-form classification (e.g. `geographic`, `demographic`, `behavioral`) |
| `role`            | enum    | no       | `TREATMENT`, `CONTROL`, or `OBSERVATION` (default `TREATMENT`)            |
| `definition.type` | string  | yes      | Discriminator — `zip_code`, `user_segment`, `geo_region`, `custom`        |
| `definition.*`    | any     | —        | Additional fields per type                                                |
| `estimatedSize`   | integer | no       | Size estimate for power analysis                                          |

Available cohort operations:

| Method   | Path                                          | Purpose                             |
| -------- | --------------------------------------------- | ----------------------------------- |
| `GET`    | `/advertisers/:advertiserId/test-cohorts`     | List (filter by `role`, `isActive`) |
| `POST`   | `/advertisers/:advertiserId/test-cohorts`     | Create                              |
| `GET`    | `/advertisers/:advertiserId/test-cohorts/:id` | Get one                             |
| `PUT`    | `/advertisers/:advertiserId/test-cohorts/:id` | Update                              |
| `DELETE` | `/advertisers/:advertiserId/test-cohorts/:id` | Archive                             |

### Create a test plan against a hypothesis

A hypothesis is the question being tested (e.g. "Does running on premium CTV inventory drive incremental purchases versus general CTV?"). Test plans are nested under the hypothesis they test:

```bash theme={null}
curl -X POST "$BASE/advertisers/12345/hypotheses/1f5c0c4e-2c9f-4f0a-9e31-2d4f3c7b1a01/test-plans" \
  -H "Authorization: Bearer scope3_<your_api_key>" \
  -H "Content-Type: application/json" \
  -d '{
    "testCondition":    { "inventoryTier": "premium_ctv" },
    "controlCondition": { "inventoryTier": "general_ctv" },
    "matchDimensions":  ["geo", "daypart", "audienceSegment"]
  }'
```

| Field              | Type      | Description                                       |
| ------------------ | --------- | ------------------------------------------------- |
| `testCondition`    | object    | What defines the treatment arm                    |
| `controlCondition` | object    | What defines the control arm                      |
| `matchDimensions`  | string\[] | Dimensions to balance across cells (at least one) |

### Activate a test plan and link media buys

Once a plan is created (status `designed`), patch it to `active` and link the allocation entries (media buys) that fall under each role:

```bash theme={null}
# Link allocations
curl -X POST "$BASE/advertisers/12345/test-plans/<test_plan_id>/link-buys" \
  -H "Authorization: Bearer scope3_<your_api_key>" \
  -H "Content-Type: application/json" \
  -d '{
    "allocationEntryIds": [
      "a1111111-1111-1111-1111-111111111111",
      "a2222222-2222-2222-2222-222222222222"
    ],
    "role": "test"
  }'

# Activate
curl -X PATCH "$BASE/advertisers/12345/test-plans/<test_plan_id>" \
  -H "Authorization: Bearer scope3_<your_api_key>" \
  -H "Content-Type: application/json" \
  -d '{ "status": "active" }'
```

`status` transitions: `designed` → `active` → `complete`. Updating to `complete` (or letting the engine auto-complete on flight end) closes the test for inference.

## Step 7: Trigger Learning and Read Belief State

The learning cycle ingests new measurement records, fits the Bayesian update, and refreshes the belief state. It typically runs on a schedule, but you can trigger it on demand after a large data load:

```bash theme={null}
curl -X POST "$BASE/advertisers/12345/learning-cycle/run" \
  -H "Authorization: Bearer scope3_<your_api_key>"
```

Read the current belief state for an advertiser:

```bash theme={null}
curl "$BASE/advertisers/12345/belief-state" \
  -H "Authorization: Bearer scope3_<your_api_key>"
```

The response summarizes each tracked hypothesis: posterior magnitude, confidence interval, status (`no_buys`, `in_market`, `partially_measured`, `well_measured`, `proven`, `disproven`), and which sources contributed evidence.

To inspect the records that fed the most recent updates:

```bash theme={null}
curl "$BASE/advertisers/12345/learning-records?\
hypothesisId=1f5c0c4e-2c9f-4f0a-9e31-2d4f3c7b1a01&\
take=50&skip=0" \
  -H "Authorization: Bearer scope3_<your_api_key>"
```

You can also query raw measurement records by outcome / geo / date range:

```bash theme={null}
curl "$BASE/advertisers/12345/measurement-records?\
outcomeType=incremental_revenue&\
geos=US-CA,US-OR&\
startDate=2026-03-01&endDate=2026-03-31" \
  -H "Authorization: Bearer scope3_<your_api_key>"
```

<Note>
  Belief state is read-only. To change beliefs, send better data — more
  records, fewer gaps, properly designed test plans — and let the next
  learning cycle re-fit.
</Note>

## Step 8: Review and Acknowledge Test Results

When a test plan reaches `complete`, the engine produces an incrementality result attached to the test plan and hypothesis. Read it via the test plan endpoint:

```bash theme={null}
curl "$BASE/advertisers/12345/test-plans/<test_plan_id>" \
  -H "Authorization: Bearer scope3_<your_api_key>"
```

The response includes the resolved test cell counts, control cell counts, coverage gaps (geos / dimensions where matching was weak), and the posterior delta on the hypothesis. List all plans for a hypothesis with:

```bash theme={null}
curl "$BASE/advertisers/12345/hypotheses/<hypothesis_id>/test-plans?take=50" \
  -H "Authorization: Bearer scope3_<your_api_key>"
```

### Mark results as reviewed

Acknowledge a result so it stops surfacing in unread-results queues and so the audit trail records who signed off. Use the test plan update endpoint to transition status (e.g. recording observed test/control cell counts and final coverage gaps):

```bash theme={null}
curl -X PATCH "$BASE/advertisers/12345/test-plans/<test_plan_id>" \
  -H "Authorization: Bearer scope3_<your_api_key>" \
  -H "Content-Type: application/json" \
  -d '{
    "status": "complete",
    "testCellsCount": 12,
    "controlCellsCount": 11,
    "coverageGaps": []
  }'
```

If the result motivates a new line of inquiry, capture it as human feedback or as a new hypothesis on the advertiser — that becomes the prior for the next round of testing.

## Best Practices

<AccordionGroup>
  <Accordion title="Register event sources before turning on a pixel">
    Events sent to an unknown `event_source_id` are rejected. Always run
    `sync_event_sources` first; for new advertisers, do this in your onboarding
    automation.
  </Accordion>

  <Accordion title="Send aggregated measurement records when you have them">
    If your MMP / CRM / data warehouse can produce weekly per-geo outcomes,
    use `POST /measurement-data/sync` (or `/measurement-records`). Aggregated
    records arrive faster, are cheaper to ingest, and are immune to user-level
    identity-resolution drift.
  </Accordion>

  <Accordion title="Send raw events when you need fine-grained attribution">
    For per-user attribution and click-id matching, use the
    [Conversion API](/v2/integrations/conversion-api). It's the only path that
    can attribute back to a specific impression or click.
  </Accordion>

  <Accordion title="Set good priors">
    Hypothesis `priorConfidence` and `priorMagnitude` are not "the answer" —
    they reflect *what you'd believe before the test*. Calibrated priors make
    early-flight estimates much more useful than uncalibrated ones.
  </Accordion>

  <Accordion title="Match cohorts on the dimensions that move the metric">
    `matchDimensions` should include any factor that meaningfully drives the
    outcome (geo, daypart, segment). Forgetting a strong driver introduces
    confounding even with a clean A/B split.
  </Accordion>

  <Accordion title="Use external_row_id for idempotency">
    `measurement-data/sync` deduplicates on `external_row_id`. Re-running a
    daily export is safe — only changed rows update.
  </Accordion>

  <Accordion title="Hash before sending">
    `hashed_email` and `hashed_phone` must be SHA-256 hex (lowercase, 64
    chars). Normalize email to lowercase / trimmed and phone to E.164 *before*
    hashing. The engine will not match malformed hashes.
  </Accordion>

  <Accordion title="Run testability before launch, freshness during flight">
    `POST /testability` answers "is this design likely to learn anything?"
    before media starts. `GET /measurement-freshness` answers "is data
    actually arriving?" once it has.
  </Accordion>

  <Accordion title="Keep human feedback in the loop">
    Posterior deltas are a guide, not a verdict. When stakeholders disagree
    with a result, capture the disagreement as feedback so the next cycle can
    spawn refined hypotheses rather than re-litigating the old one.
  </Accordion>
</AccordionGroup>

## Endpoint Reference

All paths are relative to `https://api.apostra.com/api/v2/buyer`.

### Event sources

| Method | Path                                            |
| ------ | ----------------------------------------------- |
| `POST` | `/advertisers/:advertiserId/event-sources/sync` |
| `GET`  | `/advertisers/:advertiserId/event-sources`      |

### Measurement config

| Method | Path                                            |
| ------ | ----------------------------------------------- |
| `GET`  | `/advertisers/:advertiserId/measurement-config` |
| `PUT`  | `/advertisers/:advertiserId/measurement-config` |

### Measurement data

| Method | Path                                               |
| ------ | -------------------------------------------------- |
| `POST` | `/advertisers/:advertiserId/measurement-data/sync` |
| `POST` | `/advertisers/:advertiserId/measurement-records`   |
| `GET`  | `/advertisers/:advertiserId/measurement-records`   |
| `POST` | `/advertisers/:advertiserId/context-records`       |

### Events and freshness

| Method | Path                                               |
| ------ | -------------------------------------------------- |
| `GET`  | `/advertisers/:advertiserId/events/summary`        |
| `GET`  | `/advertisers/:advertiserId/measurement-freshness` |

### Learning engine

| Method | Path                                            |
| ------ | ----------------------------------------------- |
| `POST` | `/advertisers/:advertiserId/learning-cycle/run` |
| `GET`  | `/advertisers/:advertiserId/belief-state`       |
| `GET`  | `/advertisers/:advertiserId/learning-records`   |
| `POST` | `/advertisers/:advertiserId/testability`        |

### Measurement sources

| Method  | Path                                                 |
| ------- | ---------------------------------------------------- |
| `GET`   | `/advertisers/:advertiserId/measurement-sources`     |
| `POST`  | `/advertisers/:advertiserId/measurement-sources`     |
| `GET`   | `/advertisers/:advertiserId/measurement-sources/:id` |
| `PATCH` | `/advertisers/:advertiserId/measurement-sources/:id` |

### Hypotheses

| Method   | Path                                        |
| -------- | ------------------------------------------- |
| `GET`    | `/advertisers/:advertiserId/hypotheses`     |
| `POST`   | `/advertisers/:advertiserId/hypotheses`     |
| `GET`    | `/advertisers/:advertiserId/hypotheses/:id` |
| `PATCH`  | `/advertisers/:advertiserId/hypotheses/:id` |
| `DELETE` | `/advertisers/:advertiserId/hypotheses/:id` |

### Test cohorts

| Method   | Path                                          |
| -------- | --------------------------------------------- |
| `GET`    | `/advertisers/:advertiserId/test-cohorts`     |
| `POST`   | `/advertisers/:advertiserId/test-cohorts`     |
| `GET`    | `/advertisers/:advertiserId/test-cohorts/:id` |
| `PUT`    | `/advertisers/:advertiserId/test-cohorts/:id` |
| `DELETE` | `/advertisers/:advertiserId/test-cohorts/:id` |

### Test plans

| Method  | Path                                                             |
| ------- | ---------------------------------------------------------------- |
| `GET`   | `/advertisers/:advertiserId/hypotheses/:hypothesisId/test-plans` |
| `POST`  | `/advertisers/:advertiserId/hypotheses/:hypothesisId/test-plans` |
| `GET`   | `/advertisers/:advertiserId/test-plans/:id`                      |
| `PATCH` | `/advertisers/:advertiserId/test-plans/:id`                      |
| `POST`  | `/advertisers/:advertiserId/test-plans/:id/link-buys`            |

## Related

<CardGroup cols={2}>
  <Card title="Conversion API" href="/v2/integrations/conversion-api" icon="bolt">
    Send per-user purchase, lead, and engagement events with click and identity
    matching.
  </Card>

  <Card title="Campaigns" href="/v2/object-guides/campaign" icon="bullseye-arrow">
    Set up the campaigns and media buys that measurement data attaches to.
  </Card>

  <Card title="Authentication" href="/v2/authentication" icon="key">
    Generate and manage API keys for measurement requests.
  </Card>

  <Card title="Reporting" href="/v2/guides/reporting-overview" icon="chart-bar">
    Pull aggregated performance once measurement data is flowing.
  </Card>
</CardGroup>
