# Trillboards Measurement API

Attribution outcomes and cryptographically signed proof-of-play.

Everything you can call with an **advertiser credential** to find out what a
campaign actually did: attribution and incrementality, attention metrics,
cohort export, and the signed proof-of-play record for every delivered play.

Two previously separate references, merged.

All paths are absolute against `https://api.trillboards.com`.

- Interactive reference: https://api.trillboards.com/docs#measurement
- OpenAPI document: https://api.trillboards.com/docs/openapi/bundled/measurement.json
- Every operation, machine-readable: https://api.trillboards.com/api/discover?surface=measurement

## Authentication

- `bearerAuth`: HTTP bearer. JWT token obtained via POST /v1/user/login
- `bearerAuth__proof`: HTTP bearer. JWT token from /v1/user/login

## Operations (60)

### Measurement Sources

Per-advertiser measurement connections — the places conversion events arrive FROM. First-party portal routes, deliberately outside the external-surface gate; documented here because the credential semantics (a secret shown once, a roll with an overlap window, a URL that IS a credential) are not guessable from the request shape.

#### POST /v1/advertiser/conversion-source

Connect a measurement source

Creates a measurement connection for the authenticated advertiser.

The response is ONE OF ONLY TWO places the full `webhook_secret` ever
appears (the other is the roll). Every later read redacts it to
`webhook_secret_last4`. A client-supplied secret is ignored by contract
— it is generated server-side.

`advertiser_mongo_id` is always the JWT owner and is never taken from
the body.

**This create can turn an existing connection OFF.** One active source
per (advertiser, `source_type`, `domain`) is the model, so a create
REPLACES. An `mmp` source has a NULL domain, so a new provider displaces
every other MMP provider — connecting Adjust deactivates a live
AppsFlyer source, whose postback URL then 404s. Whatever this call
deactivated comes back in `source.deactivated_sources`, and a client
that ignores it leaves the advertiser to discover the outage from their
own failing postbacks.

- Auth: `bearerAuth` HTTP bearer
- Agent: agent_safe=false, idempotent=false, cost_tier=free
- operationId: `createConversionSource`

Request body (`application/json`, required): `object`

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `domain` | `string`, nullable |  | NOT always a domain. For ga4 it is the property resource name, for google_ads the customer id, for meta `act_<id>`. |
| `mmp_provider` | `string`: one of `appsflyer`, `adjust`, `kochava`, `branch`, `singular` |  | Required when source_type is `mmp`. |
| `poi_ids` | array of `string` |  |  |
| `source_type` | `string`: one of `shopify`, `pos_webhook`, `pixel`, `server_postback`, `mmp`, `search_console`, `store_visits`, `qr`, … | yes |  |

Responses:

- `201` Created. Carries the full webhook_secret for source types that sign their ingest, and whatever this create deactivated.
- `400` Invalid request parameters
- `401` Missing or invalid authentication token
- `500` Internal server error

#### GET /v1/advertiser/conversion-source/{sourceId}

Fetch one measurement source

- Auth: `bearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getConversionSource`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `sourceId` | path | `string` | yes |  |

Responses:

- `200` The source, redacted.
- `401` Missing or invalid authentication token
- `403` Not the owner.
- `404` Resource not found

#### PATCH /v1/advertiser/conversion-source/{sourceId}

Update a measurement source

A PATCH never rotates the stored webhook_secret — use the roll endpoint.

- Auth: `bearerAuth` HTTP bearer
- Agent: agent_safe=false, idempotent=true, cost_tier=free
- operationId: `updateConversionSource`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `sourceId` | path | `string` | yes |  |

Request body (`application/json`, required): `object`

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `active` | `boolean` |  |  |
| `domain` | `string`, nullable |  |  |
| `poi_ids` | array of `string` |  |  |

Responses:

- `200` The updated source, redacted.
- `400` Invalid request parameters
- `401` Missing or invalid authentication token
- `404` Resource not found

#### DELETE /v1/advertiser/conversion-source/{sourceId}

Disconnect a measurement source (soft delete)

- Auth: `bearerAuth` HTTP bearer
- Agent: agent_safe=false, idempotent=true, cost_tier=free
- operationId: `deleteConversionSource`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `sourceId` | path | `string` | yes |  |

Responses:

- `200` Soft-deleted.
- `401` Missing or invalid authentication token
- `404` Resource not found

#### GET /v1/advertiser/conversion-source/{sourceId}/capability-url

Get the capability ingest URL for a provider that cannot send a header

Returns a pasteable ingest URL for MMP sources whose provider cannot
send `X-Trill-Signature`. AppsFlyer's Push API accepts the
`Authorization` header only and requires extra parameters to travel in
the URL, so an AppsFlyer advertiser has no other authenticated ingest.

**The URL IS the credential.** Anyone holding it can send conversions
for this source — a capability authenticates the CALLER, not the
PAYLOAD. Prefer the signed postback wherever the provider can send a
header.

Its own endpoint rather than a field on the source read, because that
read is redacted and cached; a live credential must not enter that
cache. Minted on demand from the source's current secret, so rolling
the secret changes what this returns and invalidates the old URL.

Issued for `mmp` sources only.

- Auth: `bearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getConversionSourceCapabilityUrl`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `sourceId` | path | `string` | yes |  |

Responses:

- `200` The capability URL. Treat as a secret.
- `400` This source cannot carry a capability URL (not an MMP source, or it does not sign its ingest).
- `401` Missing or invalid authentication token
- `404` Resource not found

#### GET /v1/advertiser/conversion-source/{sourceId}/deliveries

What this source's provider pushed, and what we answered

The last N inbound deliveries for a source, so "my provider is not
sending" and "you are dropping what I send" can be told apart.

Bodies are redacted of device identifiers at WRITE time, not on read —
redacting on read would mean the unredacted value had been stored. A
redaction marker means the field was PRESENT; an absent value is stored
absent, because "no device id sent" and "we did not keep it" are
different diagnoses.

A delivery that failed authentication has no source and is not listed
here.

- Auth: `bearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `listConversionSourceDeliveries`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `sourceId` | path | `string` | yes |  |
| `limit` | query | `integer` |  |  |
| `days` | query | `integer` |  |  |

Responses:

- `200` Deliveries, newest first.
- `401` Missing or invalid authentication token
- `404` Resource not found

#### POST /v1/advertiser/conversion-source/{sourceId}/roll-secret

Roll a source's signing secret

Mints a new `webhook_secret` and keeps the OLD one valid for a bounded
overlap — 24 hours by default, 1–72 configurable — so deliveries already
in flight, and retries of events signed before the new value was pasted
in, still verify.

The response is the only place the new secret appears.

REFUSED, with a stated reason, where the secret is not ours to rotate.
Shopify verifies against the Trillboards app secret as well as the
source's own, so rolling would revoke nothing; a Square-backed POS
webhook signs with a key Square issued, and rolling it would break
verification permanently. Only `mmp` and `server_postback` may roll.

Rolling also invalidates any capability URL issued for the source.

- Auth: `bearerAuth` HTTP bearer
- Agent: agent_safe=false, idempotent=false, cost_tier=free
- operationId: `rollConversionSourceSecret`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `sourceId` | path | `string` | yes |  |

Request body (`application/json`): `object`

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `overlap_hours` | `integer` |  |  |

Responses:

- `200` Rolled. Carries the new secret in full, once.
- `400` This source type's secret is not ours to rotate; the message says which and why.
- `401` Missing or invalid authentication token
- `404` Resource not found

#### GET /v1/advertiser/conversion-sources

List this advertiser's measurement sources

Redacted, and served from a 60-second cache. Because the cache stores
the redacted shape, the redaction is an ALLOWLIST: a column not named
in it never reaches a client or the cache.

- Auth: `bearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `listConversionSources`

Responses:

- `200` The advertiser's sources.
- `401` Missing or invalid authentication token
- `500` Internal server error

### Campaign Attribution

Campaign-level attribution summaries, timeseries, heatmaps, and journey funnels

#### GET /v1/advertiser/attribution/campaign/{campaignId}

Campaign attribution summary

Returns comprehensive attribution metrics for a campaign including reach,
exposures, footfall visits, incremental lift, cost per visit, and statistical
significance. This is the single endpoint that proves DOOH campaign ROI.

The response includes both raw metrics and quality indicators (match confidence,
control group size, significance level) so advertisers can assess data reliability.

- Auth: `bearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getCampaignSummary`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `campaignId` | path | `string` | yes | Campaign identifier |
| `attributionModes` | query | array of `string`: one of `dooh_only`, `cross_screen_tv`, `cross_screen_all` |  | Attribution modes to include in the response. `dooh_only` (default) returns the existing DOOH-only metrics. `cross_screen_tv` adds the `cross_screen_metrics` response field with TV-exposed-household reach + tv_daypart_bucket lift. `cross_screen_all` is reserved for future cross-channel attribution (mobile + CTV streaming + DOOH + TV). Multiple values may be supplied; the response merges metrics for every requested mode. |

Responses:

- `200` Attribution summary for the campaign
- `400` Invalid request parameters
- `401` Missing or invalid authentication token
- `404` Resource not found
- `500` Internal server error

#### GET /v1/advertiser/attribution/campaign/{campaignId}/heatmap

Geospatial exposure heatmap

Returns geospatial clusters of exposure and conversion density.
Answers: "WHERE are people seeing my ads and then visiting my stores?"

Each point represents a geographic cluster with exposure count,
conversion count, and density. Use for map visualizations showing
campaign impact by location.

- Auth: `bearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getCampaignHeatmap`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `campaignId` | path | `string` | yes | Campaign identifier |

Responses:

- `200` Geospatial heatmap data
- `400` Invalid request parameters
- `401` Missing or invalid authentication token
- `500` Internal server error

#### GET /v1/advertiser/attribution/campaign/{campaignId}/journey

Attribution journey funnel

Returns the attribution funnel showing how exposed devices progress
through the conversion journey: Exposure -> Visit -> Transaction.

Use this to understand conversion rates at each stage and identify
where the funnel leaks.

- Auth: `bearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getCampaignJourney`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `campaignId` | path | `string` | yes | Campaign identifier |

Responses:

- `200` Journey funnel data
- `400` Invalid request parameters
- `401` Missing or invalid authentication token
- `500` Internal server error

#### GET /v1/advertiser/attribution/campaign/{campaignId}/timeseries

Daily attribution timeseries

Returns daily attribution metrics over time. Use this to answer:
"Is my campaign's lift improving or degrading?" and "Which days drive
the most store visits?"

Supports date range filtering. If no dates specified, returns the full
campaign duration.

- Auth: `bearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getCampaignTimeseries`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `campaignId` | path | `string` | yes | Campaign identifier |
| `startDate` | query | `string` |  | Start date (ISO 8601, e.g. 2026-02-01) |
| `endDate` | query | `string` |  | End date (ISO 8601, e.g. 2026-02-28) |

Responses:

- `200` Daily timeseries of attribution metrics
- `400` Invalid request parameters
- `401` Missing or invalid authentication token
- `500` Internal server error

### Attention Metrics

Attention-verified impression metrics from FEIN edge AI.
Only Trillboards provides deterministic attention data in DOOH —
measured by on-device computer vision, not estimated.

#### GET /v1/advertiser/attribution/campaign/{campaignId}/attention

Attention metrics summary

Returns attention-verified impression metrics from FEIN edge AI.
This is data that ONLY Trillboards can provide in DOOH.

Unlike CTV viewability which uses probabilistic models ("MRC standard"
= 50% of pixels visible for 2 seconds), Trillboards measures actual
human attention via on-device computer vision:

- **face_count**: How many people were actually in front of the screen
- **attention_score**: What fraction were looking at the screen (gaze detection)
- **dwell_seconds**: How long they stayed in view
- **emotion_engagement**: Facial expression analysis (not recording, edge-only)
- **aCPM**: Cost per 1,000 attention-verified impressions

These are MEASURED signals, not estimates.

- Auth: `bearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getCampaignAttention`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `campaignId` | path | `string` | yes | Campaign identifier |

Responses:

- `200` Attention metrics for the campaign
- `400` Invalid request parameters
- `401` Missing or invalid authentication token
- `404` Resource not found
- `500` Internal server error

#### GET /v1/advertiser/attribution/campaign/{campaignId}/attention/creative

Per-creative attention comparison

Compares attention metrics across creatives within a campaign.
Answers: "Which creative gets more attention AND more store visits?"

This is the holy grail of DOOH optimization — correlating creative
execution with actual human attention and downstream conversion.
No other platform can show this because no other platform measures
attention at the creative level.

- Auth: `bearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getCreativeAttentionComparison`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `campaignId` | path | `string` | yes | Campaign identifier |

Responses:

- `200` Per-creative attention comparison
- `400` Invalid request parameters
- `401` Missing or invalid authentication token
- `500` Internal server error

#### GET /v1/advertiser/attribution/campaign/{campaignId}/attention/timeseries

Attention metrics over time

Returns daily attention quality metrics. Use this to detect
attention fatigue: if attention_score drops over time while
impressions stay flat, the creative is wearing out and needs
refreshing.

- Auth: `bearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getAttentionTimeseries`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `campaignId` | path | `string` | yes | Campaign identifier |
| `startDate` | query | `string` |  |  |
| `endDate` | query | `string` |  |  |

Responses:

- `200` Daily attention metrics timeseries
- `400` Invalid request parameters
- `401` Missing or invalid authentication token
- `500` Internal server error

### Cross-Channel Attribution

Multi-touch attribution, cross-channel journeys, and per-channel ROAS across DOOH, mobile, web, and store

#### GET /v1/advertiser/attribution/campaign/{campaignId}/channel-roas

Per-channel ROAS

Returns return on ad spend (ROAS) broken down by channel.
Answers: "DOOH vs mobile vs web — which channel pulls its weight?"

ROAS = attributed_revenue / media_cost. A ROAS > 1.0 means the
channel generates more revenue than it costs. Use this to
justify DOOH budget allocation vs digital channels.

- Auth: `bearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getChannelROAS`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `campaignId` | path | `string` | yes | Campaign identifier |

Responses:

- `200` Per-channel ROAS
- `400` Invalid request parameters
- `401` Missing or invalid authentication token
- `404` Resource not found
- `500` Internal server error

#### GET /v1/advertiser/attribution/campaign/{campaignId}/cross-channel-journey

Cross-channel journey (Sankey flow)

Returns flow data suitable for Sankey diagrams showing how users
move across channels after DOOH exposure.

Example flow: Billboard (42%) -> Mobile Ad Click (28%) -> Website Visit (18%) -> Store Purchase (12%)

Each node/link includes volume and conversion rate, enabling
visualization of the most common and most valuable paths.

- Auth: `bearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getCrossChannelJourney`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `campaignId` | path | `string` | yes | Campaign identifier |

Responses:

- `200` Sankey flow data
- `400` Invalid request parameters
- `401` Missing or invalid authentication token
- `500` Internal server error

#### GET /v1/advertiser/attribution/campaign/{campaignId}/multi-touch

Multi-touch attribution summary

Returns cross-channel credit allocation across DOOH, mobile, web,
and store touchpoints. Supports multiple attribution models:

- **time_decay**: Recent touchpoints get more credit (7-day half-life default)
- **position_based**: First and last touch get 40% each, middle touches share 20%
- **attention_weighted**: Credit proportional to FEIN attention score at each touchpoint
- **shapley**: Game-theory-based fair value allocation (computationally intensive)

This answers: "How much credit does DOOH deserve vs mobile vs web?"

- Auth: `bearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getMultiTouchAttribution`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `campaignId` | path | `string` | yes | Campaign identifier |

Responses:

- `200` Multi-touch attribution data
- `400` Invalid request parameters
- `401` Missing or invalid authentication token
- `404` Resource not found
- `500` Internal server error

### Incrementality

Bayesian uplift measurement with geo-holdout experiments, ghost ads, and propensity matching

#### GET /v1/advertiser/attribution/campaign/{campaignId}/causal-lift

Per-segment causal lift

Returns heterogeneous treatment-effect (causal lift) estimates broken
down by audience segment — age, gender, weather, daypart, brand
affinity — computed daily via an X-learner model with bootstrap
confidence intervals.

Answers: "The headline says my ads lifted visits — but WHICH audience
actually moved?" Surfaces the most- and least-responsive segments so
budget can shift toward the audiences the campaign genuinely persuades.
Complements the aggregate chi-square result with the segment-level
breakdown. Every segment is returned with its exposed and control
counts.

- Auth: `bearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getCampaignCausalLift`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `campaignId` | path | `string` | yes | Campaign identifier |
| `date` | query | `string` |  | Measurement date (ISO 8601). Defaults to the most recent. |

Responses:

- `200` Per-segment causal lift report
- `400` Invalid request parameters
- `401` Missing or invalid authentication token
- `404` Resource not found
- `500` Internal server error

#### GET /v1/advertiser/attribution/campaign/{campaignId}/downtime-experiments

Screen-downtime natural experiments (not measured)

Screen-downtime natural experiments are NOT MEASURED. They took the
devices seen near a dark screen as a control group and conversion
visits as the outcome; the control group came from a PostgreSQL copy
that stopped taking writes, and conversion visits have no store. No
experiment was ever produced, so no published number is withdrawn.

Always answers zero experiments with no `avgLiftPct` (an average over
no experiments is omitted, never reported as 0), and an `outcome` of
kind `not_measured` saying why.

- Auth: `bearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getDowntimeExperiments`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `campaignId` | path | `string` | yes | Campaign identifier |

Responses:

- `200` Zero experiments; `outcome` says why they are not measured
- `400` Invalid request parameters
- `401` Missing or invalid authentication token
- `500` Internal server error

#### GET /v1/advertiser/attribution/campaign/{campaignId}/incrementality

Incrementality results

Returns Bayesian uplift measurement for a campaign. This is the
gold standard of DOOH attribution — proving that store visits
WOULD NOT have happened without the campaign.

Results include:
- **Bayesian posterior**: P(lift > 0) and 95% credible intervals
- **Frequentist**: chi-square, p-value, confidence intervals
- **Propensity quality**: standardized mean difference for covariate balance

A P(lift > 0) > 0.95 indicates strong evidence of causal effect.

- Auth: `bearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getCampaignIncrementality`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `campaignId` | path | `string` | yes | Campaign identifier |

Responses:

- `200` Incrementality results
- `400` Invalid request parameters
- `401` Missing or invalid authentication token
- `500` Internal server error

#### GET /v1/advertiser/attribution/campaign/{campaignId}/weather-incrementality

Weather-based incrementality (not measured)

Weather-as-natural-experiment lift is NOT MEASURED. It compared visit
rates on good- vs bad-weather days per DMA, and both of its inputs (the
DMA split and the per-screen weather) were PostgreSQL copies that
stopped taking writes, with no ClickHouse equivalent to join. It never
produced a result, so no published number is withdrawn.

Always answers `data: null` with an `outcome` of kind `not_measured`
saying why. `data: null` here does not mean "not computed yet": no
analysis runs.

- Auth: `bearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getWeatherIncrementality`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `campaignId` | path | `string` | yes | Campaign identifier |

Responses:

- `200` No weather incrementality; `outcome` says why it is not measured
- `400` Invalid request parameters
- `401` Missing or invalid authentication token
- `500` Internal server error

#### POST /v1/advertiser/attribution/experiment

Create incrementality experiment

Creates a new incrementality experiment with specified design:

- **geo_holdout**: Withhold ads in control DMAs, compare visit rates
- **ghost_ad**: Serve PSA ads to control group, measure true lift
- **psa_control**: Serve public service announcements as control creatives
- **matched_market**: Match treatment/control markets on covariates

The experiment runs automatically once created. Results populate
in the experiment status endpoint as data accumulates.

- Auth: `bearerAuth` HTTP bearer
- Agent: agent_safe=false, idempotent=false, cost_tier=free
- operationId: `createExperiment`

Request body (`application/json`, required): `CreateExperimentRequest`

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `campaignId` | `string` | yes |  |
| `controlDmas` | array of `string` |  | DMA codes for control (ads withheld) |
| `experimentType` | `string`: one of `geo_holdout`, `ghost_ad`, `psa_control`, `matched_market` | yes |  |
| `holdoutPct` | `number` |  | Fraction of audience to withhold ads from |
| `targetAlpha` | `number` |  | Significance level (probability of false positive) |
| `targetMde` | `number` |  | Minimum detectable effect (e.g. 0.10 = 10% lift) |
| `targetPower` | `number` |  | Statistical power (probability of detecting true effect) |
| `treatmentDmas` | array of `string` |  | DMA codes for treatment (ads shown) |

Responses:

- `201` Experiment created
- `400` Invalid request parameters
- `401` Missing or invalid authentication token
- `500` Internal server error

#### GET /v1/advertiser/attribution/experiment/{experimentId}

Experiment status and results

Returns experiment configuration, status, and results (if available).
Results include both Bayesian posteriors and frequentist p-values.

- Auth: `bearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getExperiment`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `experimentId` | path | `string` | yes | Incrementality experiment UUID |

Responses:

- `200` Experiment details with results
- `400` Invalid request parameters
- `401` Missing or invalid authentication token
- `404` Resource not found
- `500` Internal server error

#### GET /v1/advertiser/attribution/experiment/{experimentId}/power

Power analysis

Returns the required sample size to detect a given minimum detectable
effect (MDE) at specified significance and power levels.

Use this before running an experiment to ensure you'll have enough
data to detect the effect you care about.

- Auth: `bearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getExperimentPower`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `experimentId` | path | `string` | yes | Incrementality experiment UUID |
| `baselineRate` | query | `number` |  | Baseline visit rate (default 2%) |

Responses:

- `200` Power analysis results
- `400` Invalid request parameters
- `401` Missing or invalid authentication token
- `404` Resource not found
- `500` Internal server error

### Creative Attribution

Creative-level performance attribution — which creative x venue x daypart x weather combination drives the most conversions

#### GET /v1/advertiser/attribution/campaign/{campaignId}/creative-attribution

Creative-level attribution performance

Returns attribution outcomes broken down by creative. Correlates
creative execution (attention, dwell, emotion) with downstream
outcomes (store visits, lift, cost per visit).

Includes context interaction analysis: which creative performs best
in which venue type, daypart, and weather condition.

- Auth: `bearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getCreativeAttribution`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `campaignId` | path | `string` | yes | Campaign identifier |

Responses:

- `200` Creative attribution performance data
- `400` Invalid request parameters
- `401` Missing or invalid authentication token
- `500` Internal server error

#### GET /v1/advertiser/attribution/campaign/{campaignId}/creative-attribution/{creativeId}/timeseries

Creative attribution over time

Returns daily attribution metrics for a specific creative.
Use this to detect creative fatigue: if attention degrades while
impressions stay constant, rotate the creative.

- Auth: `bearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getCreativeAttributionTimeseries`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `campaignId` | path | `string` | yes | Campaign identifier |
| `creativeId` | path | `string` | yes | Creative identifier |

Responses:

- `200` Creative timeseries data
- `400` Invalid request parameters
- `401` Missing or invalid authentication token
- `500` Internal server error

### Cohort Export

Export exposed audience cohorts (MAID lists) to DSPs for retargeting activation

#### POST /v1/advertiser/attribution/campaign/{campaignId}/cohort/export

Export cohort to DSP

Exports the exposed audience cohort (SHA-256 hashed MAID list) to
a DSP for retargeting activation. Supported destinations:

- **ttd**: The Trade Desk (1P data segment upload)
- **dv360**: Google Display & Video 360 (Customer Match)
- **meta**: Meta/Facebook (Custom Audiences)

Returns a pre-signed S3 URL for the exported file and the MAID count.

- Auth: `bearerAuth` HTTP bearer
- Agent: agent_safe=false, idempotent=false, cost_tier=free
- operationId: `exportCohort`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `campaignId` | path | `string` | yes | Campaign identifier |

Request body (`application/json`, required): `object`

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `destination` | `string`: one of `ttd`, `dv360`, `meta` | yes | Target DSP platform |

Responses:

- `200` Cohort exported successfully
- `400` Invalid request parameters
- `401` Missing or invalid authentication token
- `404` Resource not found
- `500` Internal server error

#### POST /v1/advertiser/attribution/campaign/{campaignId}/cohort/refresh

Force cohort re-sync to a DSP

Forces an immediate re-sync of the campaign's exposed cohort to a DSP
destination. The raw encrypted-identifier cohort is routed through
Intuizi, which resolves it and fans out to the requested DSP on their
activation graph.

Answers: "Push my freshest exposed audience to the DSP now — don't wait
for the daily refresh." Returns 404 when no ready cohort exists yet.

- Auth: `bearerAuth` HTTP bearer
- Agent: agent_safe=false, idempotent=false, cost_tier=free
- operationId: `refreshCohort`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `campaignId` | path | `string` | yes | Campaign identifier |

Request body (`application/json`, required): `CohortRefreshRequest`

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `destination` | `string`: one of `ttd`, `dv360`, `meta`, `cadent`, `mediaocean` | yes | Target DSP to re-sync the exposed cohort to (routed via Intuizi). |

Responses:

- `200` Cohort re-sync accepted and routed to Intuizi
- `400` Invalid request parameters
- `401` Missing or invalid authentication token
- `404` Resource not found
- `500` Internal server error

### Signal Transparency

Signal quality, freshness, and methodology transparency endpoints

#### GET /v1/advertiser/attribution/campaign/{campaignId}/methodology

Attribution methodology explanation

Returns a human-readable explanation of how attribution was computed
for THIS specific campaign. Covers exposure matching methodology,
attention enrichment, incrementality measurement approach, and
cross-channel attribution model.

This is RADICAL TRANSPARENCY — no other DOOH platform explains
their methodology at the campaign level.

- Auth: `bearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getMethodology`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `campaignId` | path | `string` | yes | Campaign identifier |

Responses:

- `200` Methodology explanation
- `400` Invalid request parameters
- `401` Missing or invalid authentication token
- `500` Internal server error

#### GET /v1/advertiser/attribution/campaign/{campaignId}/signal-quality

Signal quality and coverage

Returns signal quality metrics showing how complete and fresh the
attribution data is for this campaign. This is radical transparency —
no other DOOH platform exposes their data quality at the campaign level.

Use this to assess confidence in attribution results:
- High attention_coverage_pct = most impressions have FEIN verification
- Low avg_signal_age_seconds = signals are fresh (real-time, not batch)
- High bayesian_prob_positive = strong evidence of causal effect

- Auth: `bearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getSignalQuality`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `campaignId` | path | `string` | yes | Campaign identifier |

Responses:

- `200` Signal quality report
- `400` Invalid request parameters
- `401` Missing or invalid authentication token
- `500` Internal server error

### Event Ingestion

Cross-channel event ingestion — DSP postbacks, conversion pixels, transaction data

#### POST /v1/attribution/event/mobile

DSP mobile postback

Receives mobile ad impression/click postbacks from DSP partners.
These events are ingested as touchpoints in the cross-channel
attribution pipeline, enabling DOOH -> mobile -> conversion chains.

No authentication required — DSP postback URLs are typically
pre-configured and fire-and-forget.

- Auth: none (public)
- Agent: agent_safe=false, idempotent=false, cost_tier=free
- operationId: `ingestMobileEvent`

Request body (`application/json`, required): `MobileEventRequest`

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `advertiserId` | `string` |  |  |
| `campaignId` | `string` | yes |  |
| `clickUrl` | `string` |  |  |
| `creativeId` | `string` |  |  |
| `dspPartner` | `string` |  | DSP name (e.g. "thetradedesk", "dv360") |
| `eventType` | `string`: one of `impression`, `click`, `complete` |  |  |
| `maidHash` | `string` | yes | SHA-256 hash of the mobile advertising ID |
| `placementId` | `string` |  |  |

Responses:

- `201` Event ingested
- `400` Invalid request parameters
- `500` Internal server error

#### POST /v1/attribution/event/transaction/{sourceId}

Transaction data ingestion (source-scoped, signed)

Ingests transaction/purchase data for conversion attribution from a
Point-of-sale or Server-postback conversion source. Sources include POS
receipts and backend purchase events.

Authentication is a per-source HMAC: sign the raw request body with the
source's `webhook_secret` and send it as
`X-Trill-Signature: sha256=<hex>` (timing-safe, fail-closed). The
advertiser is taken from the source, never the body. A stable `eventId`
(order id) makes retries exactly-once via the dedup ledger.

Transaction data is matched to exposed devices via MAID hash
to prove: "This person saw the ad AND made a purchase."

(The legacy unauthenticated bare `/v1/attribution/event/transaction`
is retired and returns 410.)

- Auth: none (public)
- Agent: agent_safe=false, idempotent=true, cost_tier=free
- operationId: `ingestTransactionEvent`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `sourceId` | path | `string` | yes | The conversion source id (pos_webhook or server_postback). |
| `X-Trill-Signature` | header | `string` | yes | sha256=<hex HMAC of the raw body keyed by the source webhook_secret> |

Request body (`application/json`, required): `TransactionEventRequest`

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `advertiserId` | `string` |  |  |
| `basketItems` | array of `object` |  |  |
| `campaignId` | `string` |  |  |
| `maidHash` | `string` |  | SHA-256 hash of the mobile advertising ID |
| `occurredAt` | `string` |  |  |
| `poiId` | `string` |  | Point-of-interest where transaction occurred |
| `transactionAmountCents` | `integer` |  | Transaction amount in cents |

Responses:

- `201` Transaction event ingested. A REPLAY of an already-seen `eventId` also returns 201 — with `touchpointId: null` and `deduped: true`. Treat 201 as "the ledger holds this event", not as "a row was written by this call".
- `400` Invalid request parameters
- `500` Internal server error

#### POST /v1/attribution/event/web

Report a web conversion

Ingests a web conversion observed by the conversion pixel. Unauthenticated;
rate limited to 60 requests/minute per client.

> **Success is not an acknowledgement that the event was stored.** A valid
> request returns `204` immediately and ingestion continues asynchronously;
> an internal ingest failure after validation still returns `204`. Do not
> treat `204` as proof of durability, and do not retry on it.

- Auth: none (public)
- Agent: agent_safe=false, idempotent=false, cost_tier=free
- operationId: `ingestWebConversionEvent`

Request body (`application/json`, required): `object`

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `advertiser_id` | `string` | yes |  |
| `campaign_id` | `string` |  |  |
| `event_type` | `string` | yes | The conversion type being reported |
| `visitor_id` | `string` |  | Pixel-assigned visitor identifier, when available |

Responses:

- `204` Accepted. Empty body.
- `400` `advertiser_id` or `event_type` missing.
- `429` Rate limit exceeded (60/minute).
- `500` Internal error.

#### GET /v1/attribution/pixel.js

Web conversion pixel script

Returns the Trillboards web conversion pixel as JavaScript. Embed it on an
advertiser site to report web conversions to `/v1/attribution/event/web`.

Unauthenticated, `Access-Control-Allow-Origin: *`, cached for one hour.
Served as `application/javascript; charset=utf-8` with
`X-Content-Type-Options: nosniff` — **this endpoint does not return JSON.**

- Auth: none (public)
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getAttributionPixelScript`

Responses:

- `200` The pixel script
- `500` The script could not be read. The body is the JavaScript comment `// Error loading pixel` rather than a JSON error object, so that a `<script>` tag fails silently instead of throwing a syntax error.

#### GET /v1/attribution/pixel/{advertiserId}/{campaignId}

Conversion tracking pixel

Returns a 1x1 transparent GIF for web conversion tracking. Embed as:
```html
<img src="https://api.trillboards.com/v1/attribution/pixel/{advertiserId}/{campaignId}" width="1" height="1" />
```

Each pixel fire creates a touchpoint in the cross-channel attribution
pipeline using a visitor ID (SHA-256 of IP + User-Agent).
No cookies, no JavaScript — pure server-side tracking.

- Auth: none (public)
- Agent: agent_safe=false, idempotent=false, cost_tier=free
- operationId: `getTrackingPixel`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `advertiserId` | path | `string` | yes |  |
| `campaignId` | path | `string` | yes |  |

Responses:

- `200` 1x1 transparent GIF

#### POST /v1/attribution/postback/adjust

Adjust MMP postback

Receives Adjust callbacks and ingests them as cross-channel touchpoints,
joining mobile app conversions to DOOH exposures (raw device ids are
hashed at the edge).

Authentication is an HMAC-SHA256 over the JSON request body, sent as
`X-Signature: sha256=<hex>` (the `Authorization` header is also
accepted), keyed by the shared `ADJUST_POSTBACK_SECRET`. Retries are
de-duplicated via `event_token` + `adid` + `created_at`.

- Auth: none (public)
- Agent: agent_safe=false, idempotent=true, cost_tier=free
- operationId: `ingestAdjustPostback`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `X-Signature` | header | `string` | yes | sha256=<hex HMAC-SHA256 of the JSON body keyed by ADJUST_POSTBACK_SECRET>. May instead be sent as the Authorization header. |

Request body (`application/json`, required): `AdjustPostbackRequest`

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `activity_kind` | `string` |  | Adjust activity kind; used as the event-name fallback. |
| `adid` | `string` |  | Adjust device id; part of the dedup key. |
| `campaign_id` | `string` |  |  |
| `campaign_name` | `string` |  | Fallback for campaign_id. |
| `created_at` | `string` |  | Event unix timestamp; part of the dedup key. |
| `event_name` | `string` |  |  |
| `event_token` | `string` |  | Event type token; part of the dedup key. |
| `gps_adid` | `string` |  | Raw GAID (hashed at the edge). |
| `idfa` | `string` |  |  |

Responses:

- `200` Postback accepted and ingested
- `401` Missing or invalid authentication token
- `500` Internal server error
- `503` Provider postback is not configured — the shared signing secret is unset. Use the per-source signed endpoint instead.

#### POST /v1/attribution/postback/appsflyer

AppsFlyer MMP postback

Receives AppsFlyer install/event postbacks and ingests them as
cross-channel touchpoints, joining mobile app conversions to DOOH
exposures for the same device (raw device ids are hashed at the edge).

Authentication is an HMAC-SHA256 over the JSON request body, sent as
`X-Signature: sha256=<hex>` (the `Authorization` header is also
accepted), keyed by the shared `APPSFLYER_POSTBACK_SECRET`. Retries are
de-duplicated via `event_id` (or `appsflyer_id` + `event_time`).

- Auth: none (public)
- Agent: agent_safe=false, idempotent=true, cost_tier=free
- operationId: `ingestAppsflyerPostback`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `X-Signature` | header | `string` | yes | sha256=<hex HMAC-SHA256 of the JSON body keyed by APPSFLYER_POSTBACK_SECRET>. May instead be sent as the Authorization header. |

Request body (`application/json`, required): `AppsflyerPostbackRequest`

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `advertising_id` | `string` |  | Raw GAID (hashed at the edge to maid_hash). |
| `af_c_id` | `string` |  | AppsFlyer campaign id (fallback for campaign_id). |
| `appsflyer_id` | `string` |  | Per-install id; dedup key with event_time when event_id is absent. |
| `campaign_id` | `string` |  |  |
| `event_id` | `string` |  | Per-event id (newer templates); primary dedup key. |
| `event_name` | `string` |  | AppsFlyer event name; defaults to `install` when absent. |
| `event_time` | `string` |  | Event timestamp ("YYYY-MM-DD HH:MM:SS" UTC, or unix seconds/millis). |
| `idfa` | `string` |  | Raw iOS IDFA (hashed at the edge). |

Responses:

- `200` Postback accepted and ingested
- `401` Missing or invalid authentication token
- `500` Internal server error
- `503` Provider postback is not configured — the shared signing secret is unset. Use the per-source signed endpoint instead.

#### POST /v1/attribution/postback/kochava

Kochava MMP postback

Receives Kochava postbacks and ingests them as cross-channel
touchpoints, joining mobile app conversions to DOOH exposures (raw
device ids are hashed at the edge).

Authentication is an HMAC-SHA256 over the JSON request body, sent as
`X-Signature: sha256=<hex>` (the `Authorization` header is also
accepted), keyed by the shared `KOCHAVA_POSTBACK_SECRET`. Retries are
de-duplicated via `event_id`.

- Auth: none (public)
- Agent: agent_safe=false, idempotent=true, cost_tier=free
- operationId: `ingestKochavaPostback`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `X-Signature` | header | `string` | yes | sha256=<hex HMAC-SHA256 of the JSON body keyed by KOCHAVA_POSTBACK_SECRET>. May instead be sent as the Authorization header. |

Request body (`application/json`, required): `KochavaPostbackRequest`

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `adid` | `string` |  |  |
| `campaign_id` | `string` |  |  |
| `campaign_name` | `string` |  |  |
| `device_id` | `string` |  | Raw device id (hashed at the edge). |
| `event_id` | `string` |  | Stable per-event id; dedup key. |
| `event_name` | `string` |  |  |
| `event_time` | `string` |  |  |
| `timestamp` | `string` |  |  |

Responses:

- `200` Postback accepted and ingested
- `401` Missing or invalid authentication token
- `500` Internal server error
- `503` Provider postback is not configured — the shared signing secret is unset. Use the per-source signed endpoint instead.

#### GET /v1/attribution/postback/{provider}/c/{token}

Per-source MMP postback over GET, for providers that default to it

The same ingest, the same capability, the same handler — with the event
fields in the QUERY STRING instead of a JSON body.

Offered because two of the five supported providers default to this
transport (measured from their published documentation 2026-09-19):

- **Adjust** server callbacks default to GET. POST is a per-callback
  setting the advertiser has to find and change.
- **Kochava** documents its custom postback's data as key/value pairs
  in the destination URL's query string and publishes no method
  selector at all.

While the postback routes were POST-only, an advertiser on either
provider's default configured a callback that 404'd — and a 404 on an
unregistered method never reaches the inbound delivery log, so nothing
recorded it. The portal showed "not sending yet" for a postback the
provider believed it was delivering.

**GET is offered ONLY on the capability path.** The signed route
authenticates an HMAC over the request body, and a GET has none, so
accepting GET there would mean accepting an unsigned request on the one
route whose purpose is proving the payload. A capability authenticates
the caller, which is all any GET can do.

Raw device identifiers sent this way (`idfa`, `gps_adid`,
`advertising_id`, …) are stripped from the URL before anything logs it,
and redacted out of the stored delivery body exactly as on the POST
path.

- Auth: none (public)
- Agent: agent_safe=false, idempotent=true, cost_tier=free
- operationId: `ingestCapabilityScopedMmpPostbackViaGet`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `provider` | path | `string`: one of `appsflyer`, `adjust`, `kochava`, `branch`, `singular` | yes | MMP provider; must match the source's configured mmp_provider. |
| `token` | path | `string` | yes | The capability minted for this source. It IS the credential — anyone holding it can send conversions for the source. |
| `event_name` | query | `string` |  | Any field of MmpPostbackRequest may be sent as a query parameter; the recognized union is identical to the POST body's. |

Responses:

- `200` Postback accepted; `deduped` is true when a retry was collapsed
- `400` Invalid request parameters
- `401` Missing or invalid authentication token
- `404` Resource not found
- `500` Internal server error

#### POST /v1/attribution/postback/{provider}/c/{token}

Per-source MMP postback authenticated by a capability URL

The same ingest as the signed per-source postback, for providers whose
configurator cannot send a custom header.

AppsFlyer's Push API accepts the `Authorization` header only, states
that custom headers are unsupported, and requires any extra parameter
to travel in the URL — so an advertiser on AppsFlyer cannot satisfy the
`X-Trill-Signature` contract at all. This route carries the whole
credential in the path instead.

**This is weaker than the signed route and is offered only where that
one is impossible.** A capability authenticates the CALLER, not the
PAYLOAD: anyone holding the URL can post conversions for that source,
whereas an HMAC over the body proves both. Prefer
`/v1/attribution/postback/{provider}/{sourceId}` wherever the provider
can send a header.

The token is minted per source at
`GET /v1/advertiser/conversion-source/{sourceId}/capability-url` and is
signed with that source's own `webhook_secret`, so it authorizes only
that source and **rolling the secret revokes it**. It does not expire.
Treat the URL as a secret.

- Auth: none (public)
- Agent: agent_safe=false, idempotent=true, cost_tier=free
- operationId: `ingestCapabilityScopedMmpPostback`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `provider` | path | `string`: one of `appsflyer`, `adjust`, `kochava`, `branch`, `singular` | yes | MMP provider; must match the source's configured mmp_provider. |
| `token` | path | `string` | yes | The capability minted for this source. It IS the credential — anyone holding it can send conversions for the source. |

Request body (`application/json`, required): `MmpPostbackRequest`

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `activity_kind` | `string` |  | Adjust's name for the event. |
| `adid` | `string` |  | Adjust device id. Persistent and device-scoped, so it is hashed, never stored. |
| `advertising_id` | `string` |  | GAID (Adjust, Kochava). |
| `af_c_id` | `string` |  | AppsFlyer campaign id. |
| `af_revenue` | `string` \| `number` |  |  |
| `aifa` | `string` |  | GAID (Singular). |
| `appsflyer_id` | `string` |  | AppsFlyer per-install id. Persistent and device-scoped, so it is hashed, never stored. |
| `campaign_id` | `string` |  |  |
| `campaign_name` | `string` |  |  |
| `created_at` | `string` |  |  |
| `currency` | `string` |  | ISO-4217. |
| `device_id` | `string` |  |  |
| `event_id` | `string` |  | Provider-native stable event id. First choice for the dedup key, which is what makes a provider retry exactly-once. |
| `event_name` | `string` |  | Event name. Falls back to `activity_kind`, then to `install`. |
| `event_revenue` | `string` \| `number` |  | AppsFlyer revenue, in the currency's MAIN UNIT (e.g. "12.50"), never cents. |
| `event_revenue_currency` | `string` |  | ISO-4217. Anything other than USD (or absent) stores no revenue. |
| `event_time` | `string` |  |  |
| `google_advertising_id` | `string` |  | GAID (Branch). |
| `gps_adid` | `string` |  | GAID (Adjust's Google Play name). |
| `idfa` | `string` |  | Apple IDFA. Sent under this name by every provider. |
| `revenue` | `string` \| `number` |  | Adjust/Kochava revenue, main unit. |
| `revenue_currency` | `string` |  | ISO-4217. |
| `revenue_float` | `string` \| `number` |  | Adjust revenue, main unit. |
| `sum` | `string` \| `number` |  | Singular revenue, main unit. |
| `timestamp` | `string` |  |  |

Responses:

- `200` Postback accepted; `deduped` is true when a retry was collapsed
- `400` Invalid request parameters
- `401` Missing or invalid authentication token
- `404` Resource not found
- `500` Internal server error

#### POST /v1/attribution/postback/{provider}/{sourceId}

Per-source signed MMP postback

Authenticated per-source MMP postback. Unlike the shared-secret routes,
this consumes the per-source `webhook_secret` the advertiser configures
in their MMP dashboard, so the correct source flips to `receiving` and
events carry that source's advertiser. The provider must match the
source's configured `mmp_provider`.

Authentication is an HMAC-SHA256 over the RAW request body, sent as
`X-Trill-Signature: sha256=<hex>` (timing-safe, fail-closed).
Provider-native stable ids make retries exactly-once via the dedup
ledger; the response `deduped` flag reports when a retry was collapsed.

- Auth: none (public)
- Agent: agent_safe=false, idempotent=true, cost_tier=free
- operationId: `ingestSourceScopedMmpPostback`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `provider` | path | `string`: one of `appsflyer`, `adjust`, `kochava`, `branch`, `singular` | yes | MMP provider; must match the source's configured mmp_provider. |
| `sourceId` | path | `string` | yes | The MMP conversion source id. |
| `X-Trill-Signature` | header | `string` | yes | sha256=<hex HMAC-SHA256 of the RAW request body keyed by the source webhook_secret> |

Request body (`application/json`, required): `MmpPostbackRequest`

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `activity_kind` | `string` |  | Adjust's name for the event. |
| `adid` | `string` |  | Adjust device id. Persistent and device-scoped, so it is hashed, never stored. |
| `advertising_id` | `string` |  | GAID (Adjust, Kochava). |
| `af_c_id` | `string` |  | AppsFlyer campaign id. |
| `af_revenue` | `string` \| `number` |  |  |
| `aifa` | `string` |  | GAID (Singular). |
| `appsflyer_id` | `string` |  | AppsFlyer per-install id. Persistent and device-scoped, so it is hashed, never stored. |
| `campaign_id` | `string` |  |  |
| `campaign_name` | `string` |  |  |
| `created_at` | `string` |  |  |
| `currency` | `string` |  | ISO-4217. |
| `device_id` | `string` |  |  |
| `event_id` | `string` |  | Provider-native stable event id. First choice for the dedup key, which is what makes a provider retry exactly-once. |
| `event_name` | `string` |  | Event name. Falls back to `activity_kind`, then to `install`. |
| `event_revenue` | `string` \| `number` |  | AppsFlyer revenue, in the currency's MAIN UNIT (e.g. "12.50"), never cents. |
| `event_revenue_currency` | `string` |  | ISO-4217. Anything other than USD (or absent) stores no revenue. |
| `event_time` | `string` |  |  |
| `google_advertising_id` | `string` |  | GAID (Branch). |
| `gps_adid` | `string` |  | GAID (Adjust's Google Play name). |
| `idfa` | `string` |  | Apple IDFA. Sent under this name by every provider. |
| `revenue` | `string` \| `number` |  | Adjust/Kochava revenue, main unit. |
| `revenue_currency` | `string` |  | ISO-4217. |
| `revenue_float` | `string` \| `number` |  | Adjust revenue, main unit. |
| `sum` | `string` \| `number` |  | Singular revenue, main unit. |
| `timestamp` | `string` |  |  |

Responses:

- `200` Postback accepted; `deduped` is true when a retry was collapsed
- `400` Invalid request parameters
- `401` Missing or invalid authentication token
- `404` Resource not found
- `500` Internal server error

#### GET /v1/attribution/provider-impression

Provider impression callback (GET)

Impression callback for an external ad provider, callable as a tracking-pixel
URL. Identical semantics to the `POST` form; parameters are read from the
query string instead of the body.

Authentication is the HMAC-signed `t` parameter itself — there is no API key.
Verification is constant-time and fails closed.

> **This intake is disabled by default.** When the intake flag is off the
> endpoint returns `404` without touching the database, so a `404` here means
> "not enabled", not "wrong URL".

Failures are deliberately never surfaced to the calling ad server: an
internal error returns the same empty `204` as success.

- Auth: none (public)
- Agent: agent_safe=false, idempotent=false, cost_tier=free
- operationId: `ingestProviderImpressionViaGet`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `t` | query | `string` | yes | HMAC-signed line reference. This is the credential. |
| `imp` | query | `string` |  | Provider-side impression id. |

Responses:

- `204` Accepted. Empty body. Also returned for a non-attributable line and for an internal error — failures are never surfaced to the ad server.
- `403` The `t` reference is missing or its HMAC did not verify.
- `404` Either the intake is disabled (`{"error":"Not found"}`) or the reference verified but resolved to no line (`{"error":"Unknown ref"}`).

#### POST /v1/attribution/provider-impression

Provider impression callback (POST)

Impression callback for an external ad provider. See the `GET` form for the
shared semantics — the intake flag, the HMAC `t` credential, and the
deliberate `204`-on-error behaviour all apply identically.

- Auth: none (public)
- Agent: agent_safe=false, idempotent=false, cost_tier=free
- operationId: `ingestProviderImpression`

Request body (`application/json`, required): `object`

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `imp` | `string` |  | Provider-side impression id. |
| `t` | `string` | yes | HMAC-signed line reference. This is the credential. |

Responses:

- `204` Accepted. Empty body. Also returned for a non-attributable line and for an internal error — failures are never surfaced to the ad server.
- `403` The `t` reference is missing or its HMAC did not verify.
- `404` Either the intake is disabled (`{"error":"Not found"}`) or the reference verified but resolved to no line (`{"error":"Unknown ref"}`).

#### POST /v1/attribution/qr-signal

Report QR scan device signals

Receives device fingerprint signals for a QR scan, used to link a scan to an
exposure. Unauthenticated; rate limited to 60 requests/minute per client.

Accepts `application/json` and also `text/plain`, so it can be called with
`navigator.sendBeacon()` — a plain-text body is parsed as JSON.

Returns `202` as soon as the body validates; the signals are processed
fire-and-forget, so a `202` does not mean the scan was successfully linked.

- Auth: none (public)
- Agent: agent_safe=false, idempotent=false, cost_tier=free
- operationId: `ingestQrBeaconSignal`

Request body (`application/json`, required): `QrBeaconSignal`

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `canvasHash` | `string` |  |  |
| `fingerprintId` | `string` |  |  |
| `platform` | `string` |  |  |
| `scanId` | `string` | yes | The QR scan this signal belongs to. |
| `screenHeight` | `integer` |  |  |
| `screenWidth` | `integer` |  |  |
| `timezone` | `string` |  |  |

Responses:

- `202` Accepted for processing.
- `400` Either the text body was not parseable JSON (`{ "ok": false }`) or `scanId` was missing (`{ "ok": false, "error": "scanId required" }`).
- `429` Rate limit exceeded (60/minute).

### Transaction & ROAS

Purchase-matched attribution — attributed revenue, ROAS, iROAS, and Bayesian sales lift from POS and server-postback conversion sources

#### GET /v1/advertiser/attribution/campaign/{campaignId}/roas

ROAS breakdown by channel and creative

Returns headline ROAS with attributed revenue broken down by channel
(DOOH, mobile, web) and by creative. Answers: "Which channel and which
creative actually returned the spend?"

- Auth: `bearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getRoasBreakdown`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `campaignId` | path | `string` | yes | Campaign identifier |

Responses:

- `200` ROAS breakdown
- `400` Invalid request parameters
- `401` Missing or invalid authentication token
- `404` Resource not found
- `500` Internal server error

#### GET /v1/advertiser/attribution/campaign/{campaignId}/transactions

Transaction attribution & ROAS summary

Returns purchase-matched attribution for a campaign: total and
attributed transactions, attributed revenue, ROAS, iROAS, Bayesian
sales lift (exposed vs control), and time-to-purchase timing.

Answers: "Did the people who saw my ad actually buy — and was the media
spend worth it?" Transactions are ingested from your POS or
server-postback conversion source and matched to DOOH exposures within
a 14-day window using time-decay weighting.

- Auth: `bearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getTransactionAttribution`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `campaignId` | path | `string` | yes | Campaign identifier |

Responses:

- `200` Transaction attribution summary
- `400` Invalid request parameters
- `401` Missing or invalid authentication token
- `404` Resource not found
- `500` Internal server error

#### GET /v1/advertiser/attribution/campaign/{campaignId}/transactions/timeseries

Daily ROAS timeseries

Returns daily transaction attribution and ROAS across the campaign.
Answers: "Which days actually converted, and is my ROAS trending up or
down?" Supports optional date-range filtering.

- Auth: `bearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getTransactionTimeseries`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `campaignId` | path | `string` | yes | Campaign identifier |
| `startDate` | query | `string` |  | Start date (ISO 8601, e.g. 2026-02-01) |
| `endDate` | query | `string` |  | End date (ISO 8601, e.g. 2026-02-28) |

Responses:

- `200` Daily ROAS timeseries
- `400` Invalid request parameters
- `401` Missing or invalid authentication token
- `500` Internal server error

### Audience Intelligence

Zero-cost audience signals — DSP-consensus segments from the bid stream, venue loyalty curves, and Census-fused demographic over-index

#### GET /v1/advertiser/attribution/campaign/{campaignId}/audience-overindex

Demographic over-index

Returns how the campaign's measured audience skews versus the
surrounding neighborhood (Census ACS × FEIN edge-AI fusion). An
over-index above 1.0 means a segment appears more often at your screens
than the local baseline; above 1.5 is a significant targeting signal.

Answers: "Does this campaign actually over-deliver the audience I'm
buying?" Returns `data: null` when no over-index has been computed yet.

- Auth: `bearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getAudienceOverindex`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `campaignId` | path | `string` | yes | Campaign identifier |

Responses:

- `200` Aggregated over-index across the campaign's screens (null when not yet computed)
- `400` Invalid request parameters
- `401` Missing or invalid authentication token
- `500` Internal server error

#### GET /v1/advertiser/attribution/campaign/{campaignId}/audience-profile

DSP-consensus audience profile

Returns per-screen audience profiles built from the live bid stream:
DSP-consensus audience segments (each confirmed by 2+ demand partners),
a demand score, and the top advertiser categories bidding on each venue.

Answers: "Who does the open market think my venues reach, and how hot is
demand for them?" These signals are inherited from bid intelligence at
zero measurement cost.

- Auth: `bearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getAudienceProfile`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `campaignId` | path | `string` | yes | Campaign identifier |

Responses:

- `200` Per-screen audience profiles
- `400` Invalid request parameters
- `401` Missing or invalid authentication token
- `500` Internal server error

#### GET /v1/advertiser/attribution/campaign/{campaignId}/venue-loyalty

Venue loyalty breakdown

Returns the loyalty-tier breakdown of the exposed audience — drive-by,
occasional, regular, and loyalist — with average visit frequency,
loyal-device counts, and the preferred daypart.

Answers: "Am I reaching one-time passers-by or the regulars who define
this venue?" Loyalty is derived from repeat-exposure patterns over a
90-day window.

- Auth: `bearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getVenueLoyalty`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `campaignId` | path | `string` | yes | Campaign identifier |

Responses:

- `200` Venue loyalty summary
- `400` Invalid request parameters
- `401` Missing or invalid authentication token
- `500` Internal server error

### Optimization

Attribution-driven creative impression weights and venue/daypart bid modifiers, with opt-in auto-optimization

#### GET /v1/advertiser/attribution/campaign/{campaignId}/optimizations

Attribution-driven optimization weights

Returns the current creative impression-share weights and venue/daypart
bid modifiers computed from attribution outcomes, plus a human-readable
explanation and the campaign's auto-optimization settings.

Answers: "Given what actually converted, where should my impressions and
bids go?" Creative weights are proportional to visit-rate × attention
with an exploration floor so no creative is fully suppressed; modifiers
are capped at [-80%, +200%].

- Auth: `bearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getOptimizations`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `campaignId` | path | `string` | yes | Campaign identifier |

Responses:

- `200` Optimization weights and modifiers
- `400` Invalid request parameters
- `401` Missing or invalid authentication token
- `500` Internal server error

#### PUT /v1/advertiser/attribution/campaign/{campaignId}/optimizations/auto

Toggle auto-optimization

Enables or disables automatic attribution-driven optimization for a
campaign, and optionally tunes the minimum sample size and exploration
weight. When enabled, the nightly job recomputes creative weights and
venue/daypart bid modifiers from fresh attribution outcomes.

Answers: "Let the attribution loop steer my impressions and bids
automatically."

- Auth: `bearerAuth` HTTP bearer
- Agent: agent_safe=false, idempotent=true, cost_tier=free
- operationId: `setAutoOptimize`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `campaignId` | path | `string` | yes | Campaign identifier |

Request body (`application/json`, required): `AutoOptimizeRequest`

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `enabled` | `boolean` | yes | Enable (true) or disable (false) auto-optimization. |
| `explorationWeight` | `number` |  | Impression-share floor reserved per creative (must be 0.05–0.50). |
| `minSampleSize` | `integer` |  | Minimum impressions before a creative's data is trusted (default 100). |

Responses:

- `200` Updated auto-optimization configuration
- `400` Invalid request parameters
- `401` Missing or invalid authentication token
- `500` Internal server error

### Public Key

Public key for signature verification (no auth required)

#### GET /v1/advertiser/proof/.well-known/public-key

Get public key for signature verification

Returns the Ed25519 public key used to sign all v2 proofs.
Third parties can use this key to independently verify signatures.

**No authentication required.**

- Auth: none (public)
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getPublicKey`

Responses:

- `200` Public key information

#### GET /v1/advertiser/proof/signature-info

Get signature format documentation

Returns information about the signature format for integrators.

- Auth: none (public)
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getSignatureInfo`

Responses:

- `200` Everything needed to verify a proof without reading this page: the active key, the exact payload construction, and the prefix that identifies each signature version. `publicKey` is the same value returned by `/.well-known/public-key`.

### Proofs

Retrieve signed proof-of-play records

#### GET /v1/advertiser/proof/campaigns/{campaignId}/audience-proof

Get audience-enhanced proofs

Returns per-screen impression counts for the campaign and one
aggregate signature over the roll-up.

- Auth: `bearerAuth__proof` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getAudienceProofs`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `campaignId` | path | `string` | yes |  |
| `startDate` | query | `string` |  |  |
| `endDate` | query | `string` |  |  |

Responses:

- `200` Per-screen impression counts for the campaign, plus one aggregate signature over the whole roll-up. There is no per-impression proof here and no demographic breakdown — `privacyMode` is always `aggregated`. The aggregate signature covers `aggregate.<campaignId>.<totalImpressions>.<generatedAt>`, so it is reproducible only against the `totalImpressions` and `generatedAt` in the same response body.
- `401` Missing or invalid authentication token
- `403` Not authorized to access this resource
- `404` Resource not found
- `422` The requested window (`endDate`) ends before the proof store's retained history begins (`retained_from`; the store keeps the last 90 days). Not retryable with the same window: ask for a window that ends on or after `retained_from`. A window that only STARTS before it is served from `retained_from` on. Proofs are never answered as an empty page for a window the store no longer holds.
- `503` The proof store (ClickHouse) could not be read. Retryable with backoff. Proofs are never answered as an empty page, a zero aggregate or a denied screen when the store could not be read.

#### GET /v1/advertiser/proof/campaigns/{campaignId}/proof

Get proofs for a campaign

Returns cryptographically signed proofs for all completed impressions
in the specified campaign. Supports cursor-based pagination for large datasets.

- Auth: `bearerAuth__proof` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getCampaignProofs`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `campaignId` | path | `string` | yes | Campaign/Advertisement ID |
| `startDate` | query | `string` |  | Filter proofs after this date (ISO 8601) |
| `endDate` | query | `string` |  | Filter proofs before this date (ISO 8601) |
| `limit` | query | `integer` |  | Maximum number of proofs to return (default 1000, max 10000) |
| `cursor` | query | `string` |  | Pagination cursor from the previous response. A cursor beginning `pg~` was issued by the retired PostgreSQL proof path and is refused with 400 `proof_cursor_retired`; restart from page one. |

Responses:

- `200` One page of signed proofs, newest first. `data.totalImpressions` is populated **only on the first page** (a request with no `cursor`); on every later page it is `null`, because the count is not recomputed per page. Read the total once, from page one.
- `400` The `cursor` was issued by the retired PostgreSQL proof path (it begins `pg~`) and cannot be continued: proofs are read from ClickHouse only. Not retryable with the same cursor — restart from page one (omit `cursor`).
- `401` Missing or invalid authentication token
- `403` Not authorized to access this resource
- `404` Resource not found
- `422` The requested window (`endDate`) ends before the proof store's retained history begins (`retained_from`; the store keeps the last 90 days). Not retryable with the same window: ask for a window that ends on or after `retained_from`. A window that only STARTS before it is served from `retained_from` on. Proofs are never answered as an empty page for a window the store no longer holds.
- `503` The proof store (ClickHouse) could not be read. Retryable with backoff. Proofs are never answered as an empty page, a zero aggregate or a denied screen when the store could not be read.

#### GET /v1/advertiser/proof/screens/{screenId}/proof

Get proofs for a screen

Returns proofs for all completed impressions on the specified screen.

- Auth: `bearerAuth__proof` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getScreenProofs`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `screenId` | path | `string` | yes | Screen ID |
| `startDate` | query | `string` |  |  |
| `endDate` | query | `string` |  |  |
| `limit` | query | `integer` |  |  |
| `cursor` | query | `string` |  | Pagination cursor from the previous response. A cursor beginning `pg~` was issued by the retired PostgreSQL proof path and is refused with 400 `proof_cursor_retired`; restart from page one. |

Responses:

- `200` One page of signed proofs for this screen, newest first. `data.totalProofs` counts the proofs **in this page**, not the screen's lifetime total — there is no campaign-style total here. Page with `pagination.nextCursor` until it is `null`.
- `400` The `cursor` was issued by the retired PostgreSQL proof path (it begins `pg~`) and cannot be continued: proofs are read from ClickHouse only. Not retryable with the same cursor — restart from page one (omit `cursor`).
- `401` Missing or invalid authentication token
- `422` The requested window (`endDate`) ends before the proof store's retained history begins (`retained_from`; the store keeps the last 90 days). Not retryable with the same window: ask for a window that ends on or after `retained_from`. A window that only STARTS before it is served from `retained_from` on. Proofs are never answered as an empty page for a window the store no longer holds.
- `503` The proof store (ClickHouse) could not be read. Retryable with backoff. Proofs are never answered as an empty page, a zero aggregate or a denied screen when the store could not be read.

### Verification

Verify proof signatures

#### POST /v1/advertiser/proof/verify-proof

Verify a proof signature

Verify the cryptographic signature of a proof.
Supports both v1 (HMAC) and v2 (Ed25519) signatures.

**No authentication required** - third parties can verify proofs independently.

- Auth: none (public)
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `verifyProof`

Request body (`application/json`, required): `VerifyProofRequest`

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `adId` | `string` |  |  |
| `deviceId` | `string` |  |  |
| `impressionId` | `string` |  |  |
| `screenId` | `string` |  |  |
| `signature` | `string` | yes | Signature to verify (format: ed25519=<hex> or sha256=<hex>) |
| `signatureVersion` | `string`: one of `v1`, `v2` |  | Optional explicit version (auto-detected from prefix) |
| `timestamp` | `integer` \| `string` | yes | **Send epoch milliseconds.** The signed payload is built from the millisecond value, so that is what has to be reproduced here. The date-time string form below is accepted by the endpoint and passed through verbatim, so it rebuilds a different payload and the proof will not verify. Proofs returned by the campaign, screen and export endpoints serialise their `timestamp` as ISO-8601 — convert it back to epoch milliseconds before sending it here. |

Responses:

- `200` The verification ran. **`200` does not mean the proof is valid** — read `verification.valid`. A signature that fails to verify is a successful verification of a bad proof, so it is still a `200`. When `valid` is `false`, `verification.reason` says why: `missing_required_fields`, `signature_mismatch`, `signature_length_mismatch`, `public_key_not_configured`, or `verification_error`.
- `400` Missing required fields

### Export

Batch export proofs

#### POST /v1/advertiser/proof/campaigns/{campaignId}/export-proof

Export proofs in batch format

Export proofs as JSON or CSV for offline verification.

- Auth: `bearerAuth__proof` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `exportProofs`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `campaignId` | path | `string` | yes |  |

Request body (`application/json`): `object`

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `endDate` | `string` |  |  |
| `format` | `string`: one of `json`, `csv` |  |  |
| `startDate` | `string` |  |  |

Responses:

- `200` The export. **The response media type is chosen by the request body**, not by `Accept`: `format: "csv"` returns `text/csv` with a `Content-Disposition` filename and no JSON envelope; anything else returns the JSON envelope below. An export is capped at 10,000 proofs and is not paginated — a campaign with more plays than that is truncated silently. Page `GET /campaigns/{campaignId}/proof` instead when you need all of them. `checksum` is a SHA-256 over the serialised `data` array only. It authenticates the batch you received; it is not an Ed25519 proof signature, and `expiresAt` is advisory metadata rather than an enforced deadline.
- `401` Missing or invalid authentication token
- `403` Not authorized to access this resource
- `404` Resource not found
- `422` The requested window (`endDate`) ends before the proof store's retained history begins (`retained_from`; the store keeps the last 90 days). Not retryable with the same window: ask for a window that ends on or after `retained_from`. A window that only STARTS before it is served from `retained_from` on. Proofs are never answered as an empty page for a window the store no longer holds.
- `503` The proof store (ClickHouse) could not be read. Retryable with backoff. Proofs are never answered as an empty page, a zero aggregate or a denied screen when the store could not be read.
