# Trillboards Partner API

Supply, on-glass SDK, sensing, device provisioning and federated learning.

Everything you can call with a Trillboards **partner credential** — one
`platform_api_key` (plus `sdk:*` scopes where noted) for the server-side surface,
and a per-device token for the on-glass surfaces.

This is one document covering five previously separate references:

| Section | Credential | What it does |
| --- | --- | --- |
| Partner API | `platform_api_key` (Bearer) | Screens, ads, analytics, earnings, billing, webhooks |
| CTV SDK & CSI | `platform_api_key` + `sdk:*` scope | On-glass measurement, occupancy, QR |
| Sensing | device token | Paired training samples from edge sensing |
| Device Provisioning | device token | OTA manifests and CCTV pairing |
| Federated Learning | device fingerprint | Gradient / performance / taste uploads |

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

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

## Authentication

- `ApiKeyAuth`: API key in header `X-API-Key`. The same platform API key as `BearerAuth`, sent as a raw header value instead of a Bearer credential. `X-Trillboards-API-Key` is accepted as a legacy alias for this header. Scope semantics are identical.
- `BearerAuth`: HTTP bearer. API key authentication. Include your API key as a Bearer token: `Authorization: Bearer trb_partner_xxxxx`
- `BearerAuth__sdk`: HTTP bearer. Platform API key sent as `Authorization: Bearer <key>`. This is the canonical carrier. Scopes listed on an operation are enforced by `middleware/apiKeyAuth.js`; ALL listed scopes must be held. A key holding a namespace wildcard (e.g. `sdk:*`) satisfies any scope in that namespace. An operation listing no scope accepts any valid, unexpired key.
- `DeviceFingerprint`: API key in header `X-Device-Fingerprint`. Device fingerprint for the federated-learning write path, resolved against a known, sensor-bound device. `screenId` in the request body and `screen_id` in the query string are accepted as equivalent carriers for fielded agents that cannot set the header.
- `DeviceToken`: API key in header `X-Device-Token`. The registered device's credential: the device token `check-screen` issues to the partner SDK for a registered device (never a partner API key). Every request also carries `X-Device-Timestamp` (epoch ms, within the replay window) and a single-use `X-Device-Nonce`; a reused nonce is refused 409.
- `DeviceToken__sensing`: API key in header `X-Device-Token`. Per-device token issued at pairing, sent by a fielded device. `X-Trillboard-Device-Token` is accepted as a legacy alias. In production these requests must also carry a replay guard — a timestamp (`X-Device-Timestamp`) and a nonce (`X-Device-Nonce`) — or the request is rejected 400 before authentication completes.
- `UserJWT`: HTTP bearer. User JWT authentication for earner-to-partner upgrade. Used only for the `/v2/earner/upgrade-to-partner` endpoint. This is the same JWT token used for the Earner Portal.

## Operations (241)

### Partners

Partner account management

#### POST /v1/partner/api-key/rotate

Rotate API key

Generates a new API key with a 24-hour grace period for the old key.
Both keys work during the grace period, after which the old key is invalidated.

**Important:** The new API key is only shown once. Store it securely!

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=false, idempotent=false, cost_tier=free
- operationId: `rotateApiKey`

Responses:

- `200` New API key generated
- `401` Unauthorized - Invalid or missing API key

#### POST /v1/partner/billing/cancel

Cancel the partner's subscription at period end

Marks the partner's subscription for cancellation at the end of the
current billing period. The partner retains access until then; no
proration is applied. Idempotent.

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

Responses:

- `200` Subscription marked for cancellation
- `401` Unauthorized - Invalid or missing API key

#### GET /v1/partner/billing/plans

List available partner billing plans

Returns the catalogue of partner billing plans — tier name, monthly
price in USD cents, included quotas (ads, impressions, audience-API
calls), and per-unit overage rates.

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `listBillingPlans`

Responses:

- `200` Plan catalogue

#### POST /v1/partner/billing/subscribe

Subscribe the partner to a billing plan

Starts or upgrades a subscription to the supplied `tier`. Returns the
Stripe checkout-session URL when a card on file is missing; otherwise
records the new plan and returns the active subscription.

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=false, idempotent=false, cost_tier=paid
- operationId: `createBillingSubscription`

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

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `tier` | `string`: one of `developer`, `enterprise` | yes |  |

Responses:

- `200` Subscription started
- `400` Invalid tier
- `401` Unauthorized - Invalid or missing API key

#### GET /v1/partner/billing/subscription

Get the partner's current subscription

Returns the partner's active subscription tier, billing cycle, next
invoice date, current-period usage roll-up, and the Stripe customer
ID. Use this to render an in-product billing-status UI.

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getBillingSubscription`

Responses:

- `200` Subscription
- `401` Unauthorized - Invalid or missing API key

#### GET /v1/partner/billing/usage

Get the partner's current-period usage

Returns counters for impressions, audience-API calls, and ads served
in the current billing period, plus overage projection vs. plan
quota. Refreshed every five minutes from the rollup pipeline.

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getBillingUsage`

Responses:

- `200` Usage payload
- `401` Unauthorized - Invalid or missing API key

#### GET /v1/partner/docs

Browsable HTML documentation index

Returns an HTML page that renders the partner-API OpenAPI spec via
Redoc, with a left-nav of every documented endpoint. Intended for
humans onboarding to the API; agentic clients should fetch the raw
YAML at `/docs/openapi/partner-api.yaml` instead.

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

Responses:

- `200` Redoc-rendered documentation page

#### GET /v1/partner/info

Get partner info

Retrieve information about the authenticated partner

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getPartnerInfo`

Responses:

- `200` Partner info retrieved
- `401` Unauthorized - Invalid or missing API key

#### GET /v1/partner/ping

Unauthenticated liveness probe

Returns a 200 with a tiny JSON payload identifying the partner-API
service. Used by load balancers, uptime monitors, and SDK install
verification. No authentication required.

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

Responses:

- `200` Liveness payload

#### GET /v1/partner/ping/auth

Authenticated liveness probe

Returns a 200 with a JSON payload including the partner ID derived
from the supplied bearer token. Used during partner onboarding to
confirm an API key is valid and to inspect the active tier.

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `partnerPingAuth`

Responses:

- `200` Authenticated liveness payload
- `401` Unauthorized - Invalid or missing API key

#### POST /v1/partner/quick-start

Quick start — register partner and first device in one call

Registers a new partner and creates their first device in a single API call.
Returns API key, device credentials, SDK snippets, and configuration.

**Rate limited:** 5 requests per hour per IP address.

**Important:** The API key is only shown once in the response. Store it securely!

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

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

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `company_name` | `string` | yes | Company or partner name |
| `device_display` | `object` |  | Display configuration for the first device |
| `device_location` | `object` |  | Location for the first device |
| `device_name` | `string` |  | Name for the first device |
| `email` | `string` | yes |  |
| `partner_type` | `string` |  | Optional legacy classification. Omit for the default self-serve Partner API key. |

Responses:

- `201` Partner registered and first device created
- `400` Missing company_name or email
- `409` Partner with this email already exists
- `429` Rate limit exceeded

#### POST /v1/partner/register

Register a new partner

Register once to receive a Partner API key.
Partners are auto-activated for instant onboarding.

**Important:** The API key is only shown once in the response. Store it securely!

The default flow is intentionally one-stop: send your organization name,
slug, and contact email. Trillboards returns a Partner API key that can
authenticate partner account, device, ad-serving, impression, analytics,
CMS, webhook, dashboard, earnings, and team-management endpoints in this
spec.

`partner_type` is optional and kept for backward compatibility with older
integrations. Most developers should omit it — it never forks the key
you get back. Every partner registers once and receives the same
Partner API key, which already carries `sdk:*` for the CTV Measurement
ingestion surface and `mcp:tools` for capability-authorized AI tools.

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

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

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `allowed_domains` | array of `string` |  | Whitelist of allowed referrer domains |
| `contact_email` | `string` | yes |  |
| `contact_name` | `string` |  |  |
| `name` | `string` | yes | Company or partner name |
| `partner_type` | `string`: one of `vending_machine`, `kiosk`, `digital_signage`, `retail_display`, `ctv_publisher`, `other` |  | Optional legacy classification. Omit this field — it does not change which API key you receive. Existing integrations may continue sending historical values (including `ctv_publisher`) for backward compatibility; every partner_type mints the same self-serve Partner API key. |
| `revenue_share_percent` | `number` |  | Partner's revenue share percentage (negotiated per partner) |
| `slug` | `string` | yes | URL-safe identifier (lowercase, no spaces) |

Responses:

- `201` Partner registered successfully
- `400` Bad request
- `409` Partner slug already exists

### Devices

Device registration and management

#### POST /v1/partner/device

Register a device

Register a new device or update an existing one.

If a device with the same `device_id` already exists, it will be updated.
`device_type` is the partner's mutable category. Supplying it updates the
category; omitting it preserves an existing category and defaults a new
device to `other`. Category updates preserve advertising classification.

The response includes an `embed_url` that should be loaded in your device's WebView.

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=false, idempotent=false, cost_tier=free
- operationId: `registerDevice`

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

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `custom_metadata` | `object` |  | Any additional metadata for your records |
| `description` | `string` |  | Screen or venue description for enrichment |
| `device_id` | `string` | yes | Your internal device identifier |
| `device_type` | `string`: one of `tv`, `tablet`, `display`, `kiosk`, `other`, `mobile`, `connected_tv`, `digital_signage`, … |  | Mutable partner category; defaults to other only on a new registration. Omit it to preserve an existing device's category. |
| `display` | `object` |  | Screen display specification. `width`/`height` are pixel resolution; `width_in`/`height_in` are physical inches. For accurate floor pricing we need physical inches — pass `width_in` + `height_in` if you've measured the screen, OR `ppi` so the server can derive inches from the pixel resolution. Pixel-only inputs (no `ppi`, no `*_in`) will NOT be recorded as physical size; they're still used for orientation detection and DSP slot dimensions. |
| `display_settings` | `PartnerRegistrationDisplaySettings` |  |  |
| `location` | `object` |  |  |
| `mobile_settings` | `object` |  | Reporting hints for moving screens (mobility_type mobile/semi_mobile). |
| `mobility_type` | `string`: one of `static`, `mobile`, `semi_mobile` |  | Declare a moving screen. `mobile` (taxi-top, transit) and `semi_mobile` (food truck, pop-up) screens report GPS via the heartbeat `location` field; `static` (default) screens ignore it. |
| `name` | `string` |  | Friendly name for the device |
| `photo_url` | `string` |  | Photo URL for enrichment context |
| `photos` | array of `object` |  | Optional additional photos for enrichment |
| `placement` | `object` |  | Physical placement details (optional) |
| `venue` | `object` |  | Venue classification details (optional). Subcategory is free text and may be auto-enriched by AI. |

Responses:

- `201` Device registered
- `401` Unauthorized - Invalid or missing API key
- `429` Rate limit exceeded

#### GET /v1/partner/device/{deviceId}

Get device info

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getDevice`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `deviceId` | path | `string` | yes | Device ID, fingerprint, or external device ID |

Responses:

- `200` Device info
- `404` Resource not found

#### PATCH /v1/partner/device/{deviceId}

Update device metadata

Partially update device metadata. Only provided fields are updated.
`device_type` changes only the partner category. Advertising type,
hardware identity, and bidding settings remain unchanged. Unsupported
categories return HTTP 400 with `INVALID_DEVICE_TYPE`.
Changes propagate to the linked screen record.
If location changes, venue enrichment is re-triggered.

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

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `deviceId` | path | `string` | yes | Device ID, fingerprint, or external device ID |

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

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `custom_metadata` | `object` |  | Any additional metadata for your records |
| `description` | `string` |  | Screen or venue description |
| `device_type` | `string`: one of `tv`, `tablet`, `display`, `kiosk`, `other`, `mobile`, `connected_tv`, `digital_signage`, … |  | Mutable partner category. Omit it to preserve the current value; changing it does not alter advertising classification. |
| `display` | `object` |  | Screen display specification. `width`/`height` are pixels; pass `width_in`/`height_in` (inches) or `ppi` to record physical screen size for floor pricing. See POST /device for full unit semantics. |
| `external_device_id` | `string` |  | Partner-owned device ID used for idempotent lookup and reconciliation |
| `location` | `object` |  |  |
| `mobile_settings` | `object` |  | Reporting hints for moving screens. Merges with existing settings. |
| `mobility_type` | `string`: one of `static`, `mobile`, `semi_mobile` |  | Update the screen's mobility. Set `mobile`/`semi_mobile` to have the heartbeat `location` field accepted for a moving screen. |
| `name` | `string` |  | Friendly name for the device |
| `photo_url` | `string` |  |  |
| `photos` | array of `object` |  |  |
| `placement` | `object` |  |  |
| `venue` | `object` |  |  |

Responses:

- `200` Device updated
- `401` Unauthorized - Invalid or missing API key
- `404` Resource not found
- `429` Rate limit exceeded

#### DELETE /v1/partner/device/{deviceId}

Delete a device

Soft-delete a device. It will no longer receive ads.

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

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

Responses:

- `200` Device deleted
- `404` Resource not found

#### POST /v1/partner/device/{deviceId}/commands/{commandId}/ack

Acknowledge a device command

Acknowledges receipt and execution of a command sent to a device.
Typically called by the SDK/device after processing a fleet command.

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

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `deviceId` | path | `string` | yes | Device identifier (fingerprint or external ID) |
| `commandId` | path | `string` | yes | Command ID to acknowledge |

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

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `result` | `object` |  | Command execution result |
| `status` | `string` |  | Acknowledgment status |

Responses:

- `200` Command acknowledged
- `403` Command does not belong to this device
- `404` Device or command not found

#### GET /v1/partner/device/{deviceId}/heartbeat

Read live device heartbeat state

Read-side companion to `POST /device/{deviceId}/heartbeat`. Returns
the device twin (Redis-cached, PG-fallback) so ad-serving partners
can check device health before issuing ad requests. Field-level
access control strips MDM compliance, vertex embeddings, identity-
correction internals, partner identifiers, and Mongo `_id`. Pure
read; no side effects.

Authenticated via the partner API key (`Authorization: Bearer
trb_partner_xxx` or `tb_prt_xxx`). Rate limit: 1000 req/min per
API key.

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getDeviceHeartbeat`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `deviceId` | path | `string` | yes | Device fingerprint or external device ID |

Responses:

- `200` Live device heartbeat state.
- `401` Unauthorized - Invalid or missing API key
- `403` Device does not belong to this partner
- `404` Resource not found
- `429` Rate limit exceeded

#### POST /v1/partner/device/{deviceId}/heartbeat

Device heartbeat

Mark a device online and record `lastHeartbeatAt`.

The embeddable agent-core Sensing SDK and CTV Measurement SDK send this
automatically on their configured heartbeat cadence. Custom players
may POST the same payload themselves.
`:deviceId` accepts your `external_id` (the same value you registered)
or the device fingerprint. Read the result back from
`GET /device/{deviceId}/heartbeat` — `lastHeartbeatAt` is NOT included
on the plain `GET /device/{deviceId}` response.

Supports structured telemetry payloads (`telemetry`, `sdk`, `device`, `network`, `ad`)
and legacy flat heartbeat keys for backward compatibility.

The request body accepts legacy flat/nested telemetry
(`PartnerHeartbeatRequest`), agent-core sensing fields, or both together.
The sensing schema (`PartnerDeviceHeartbeatBody`) describes which
sensing fields actually ship in production (BLE, WiFi, mDNS, SSDP,
HTTP probes, skip-reason counts) and explicitly excludes stub fields
with 0% production population (Phase 6 UWB / Auracast / Channel
Sounding, MDM enrollment, native sensors, CSI).

This registered-device heartbeat is intentionally unauthenticated.
Liveness, `advertising_identity`, and `extended_identities` use the same
device binding: `deviceId` must resolve to a device that was registered
already, and every identity envelope must pass the typed validation
below. No partner API key or device JWT is required.

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

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `deviceId` | path | `string` | yes | Device fingerprint or external device ID |

Request body (`application/json`): `PartnerHeartbeatRequest` | `PartnerDeviceHeartbeatBody`

Responses:

- `200` Heartbeat recorded

#### PATCH /v1/partner/device/{deviceId}/impression-multiplier

Update a device's publisher impression multiplier

Update the existing screen's publisher-declared audience impressions per ad play.
Authenticate with the owning partner API key. The device and linked screen must
be active records owned by that partner; another partner's device returns 404.
This writes only the screen multiplier and its update timestamp, preserving its
device IDs and other settings. Repeat requests are idempotent.

Send a JSON number between 0.1 and 100 inclusive, matching the venue-screen
editor's range. Values are stored to four decimal places. Use the measurement
provider's value for the applicable spot duration; a daily traffic total is a
different unit. Repeat this call for each device in your fleet.

The saved value is returned here and in GET /v1/partner/device/{deviceId}
and GET /v1/partner/devices. It feeds the existing publisher-estimate inventory
and uncurated bid paths; measured quantity and billing keep their existing precedence.

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

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `deviceId` | path | `string` | yes | Trillboards device ID, fingerprint, or URL-encoded external_device_id |

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

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `impression_multiplier` | `number` | yes | Publisher-declared audience impressions per ad play; stored to four decimal places |

Responses:

- `200` Saved multiplier, returned as a JSON number
- `400` Missing, nonnumeric, out-of-range multiplier, or unexpected body fields
- `401` Missing or invalid partner API key
- `404` Device or linked screen not found in the authenticated partner's active records
- `500` Canonical Trillboards error envelope for unexpected server errors. Surfaces the request_id so the caller can quote it to support for log correlation.

#### GET /v1/partner/devices

List all devices

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `listDevices`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `status` | query | `string`: one of `pending`, `active`, `offline`, `suspended` |  |  |
| `limit` | query | `integer` |  |  |
| `offset` | query | `integer` |  |  |

Responses:

- `200` Device list

#### POST /v1/partner/devices

Register a device (V2 single-call onboarding)

Register — or idempotently re-register, keyed on `external_id` — a single partner device and provision its screen in one call. Returns the embeddable player URL and the server-derived device identity. Always send the device's real WebView `ua`: it is the single most important field for DSP biddability.

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

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

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `display` | `object` \| `object` \| `object` |  | Optional display dimensions. One of three modes — the `mode` discriminator is required. |
| `external_id` | `string` | yes | Your stable per-device identifier (e.g. "REC00005"). |
| `hardware` | `object` |  | Optional hardware identity — improves DSP device matching. |
| `location` | `object` \| `object` |  | Optional venue location — either lat/lng coordinates or a street address. |
| `ua` | `string` | yes | The device's real WebView User-Agent. Must include a Build fingerprint and be unique per device. |

Responses:

- `200` Device registered (or idempotently re-registered)
- `400` Bad request
- `401` Unauthorized - Invalid or missing API key

#### POST /v1/partner/devices/batch

Bulk register devices

Register up to 100 devices in a single request.

Each device must include `device_id`. Optional `defaults` are applied to every device.

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=false, idempotent=false, cost_tier=free
- operationId: `registerDevicesBatch`

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

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `defaults` | `object` |  | Default fields applied to each device |
| `devices` | array of `object` | yes |  |

Responses:

- `200` Batch processed

#### POST /v1/partner/devices/batch/orientation

Batch update screen orientation

Update `screen_orientation` for multiple devices/screens in one request.
Accepts device identifiers (ObjectId, fingerprint, or partner_external_id).

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

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

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `device_ids` | array of `string` | yes |  |
| `screen_orientation` | `string`: one of `portrait`, `landscape`, `mixed`, `unknown` | yes |  |

Responses:

- `200` Batch update result
- `400` Bad request
- `401` Unauthorized - Invalid or missing API key
- `404` Resource not found
- `429` Rate limit exceeded

#### POST /v1/partner/devices/reconcile

Reconcile device fleet

Read-only diff. Post the `external_ids` you believe you have registered
and get back how they map to the devices Trillboards actually has for
you: `matched`, `missing` (you listed, not registered), `orphan`
(registered, you did not list), `malformed` (registered with an invalid
external id), and `duplicate` (one external id on more than one device),
plus a `summary`. Self-service replacement for hand-rolled fleet-sync
tooling. No writes.

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `reconcileDevices`

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

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `external_ids` | array of `string` | yes | Your device external IDs to reconcile. |

Responses:

- `200` Reconciliation diff
- `400` external_ids missing, empty, or over the limit

### Screen Groups

Create and manage screen groups (fleets) and their shared configuration

#### GET /v1/partner/screen-groups

List screen groups

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `listScreenGroups`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `limit` | query | `integer` |  |  |
| `offset` | query | `integer` |  |  |

Responses:

- `200` A paginated list of screen groups
- `401` Unauthorized - Invalid or missing API key

#### POST /v1/partner/screen-groups

Create a screen group

Create a reusable, named, partner-scoped set of screens. Optionally seed
it with `screen_ids` (each must be a screen this partner owns). Gated by
SCREEN_GROUPS_ENABLED.

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=false, idempotent=false, cost_tier=free
- operationId: `createScreenGroup`

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

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `description` | `string` |  |  |
| `name` | `string` | yes |  |
| `screen_ids` | array of `string` |  |  |

Responses:

- `201` Screen group created
- `400` Bad request
- `401` Unauthorized - Invalid or missing API key
- `403` One or more screens are not owned by this partner

#### GET /v1/partner/screen-groups/{groupId}

Retrieve a screen group

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getScreenGroup`

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

Responses:

- `200` The screen group
- `401` Unauthorized - Invalid or missing API key
- `404` Resource not found

#### POST /v1/partner/screen-groups/{groupId}

Update a screen group

Partial, non-clobbering update of name / description.

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

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

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

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `description` | `string` |  |  |
| `name` | `string` |  |  |

Responses:

- `200` The updated screen group
- `401` Unauthorized - Invalid or missing API key
- `404` Resource not found

#### DELETE /v1/partner/screen-groups/{groupId}

Delete (archive) a screen group

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

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

Responses:

- `200` The screen group was archived
- `401` Unauthorized - Invalid or missing API key
- `404` Resource not found

#### GET /v1/partner/screen-groups/{groupId}/analytics

Get analytics for a screen group

Fleet analytics (last 24h) scoped to the group's member screens. Same shape as
GET /fleet/analytics. Gated by SCREEN_GROUPS_ENABLED.

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getScreenGroupAnalytics`

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

Responses:

- `200` Group analytics
- `401` Unauthorized - Invalid or missing API key
- `404` Resource not found

#### POST /v1/partner/screen-groups/{groupId}/commands

Run a fleet command against a screen group

Fans a device command out across the group's member screens and returns a pollable
fleet-command job (poll GET /fleet-commands/{id}). Max 500 member screens per command.
Gated by SCREEN_GROUPS_ENABLED.

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=false, idempotent=false, cost_tier=free
- operationId: `createScreenGroupCommand`

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

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

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `command` | `object` | yes |  |

Responses:

- `202` Fleet-command job created
- `400` Bad request
- `401` Unauthorized - Invalid or missing API key
- `404` Resource not found
- `429` Rate limit exceeded

#### GET /v1/partner/screen-groups/{groupId}/content-policy

Get a screen group's content policy

The typed content/moderation policy the group carries, or `null` when the
group has none of its own. Member screens inherit it at read/serve time
(screen ?? group ?? partner network default ?? system default). Gated by
SCREEN_GROUPS_ENABLED.

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getScreenGroupContentPolicy`

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

Responses:

- `200` The group's content policy (nullable)
- `401` Unauthorized - Invalid or missing API key
- `404` Resource not found

#### PUT /v1/partner/screen-groups/{groupId}/content-policy

Set a screen group's content policy

Non-clobbering upsert — only the fields present in the body are written.
Row existence = "this group carries a content policy". Busts the affected
member caches. Gated by SCREEN_GROUPS_ENABLED.

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

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

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

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `allow_adult_content` | `boolean` |  | Must be a real JSON boolean — the strings `"true"`/`"false"` are rejected with `field_must_be_boolean`, unlike the L-bar PUT which accepts them. Writing `false` adds `IAB25` and `IAB25-2`..`IAB25-5` to the member screens' `bcat`; unset already blocks it via the platform default. |
| `allow_alcohol` | `boolean` |  | Must be a real JSON boolean — the strings `"true"`/`"false"` are rejected with `field_must_be_boolean`, unlike the L-bar PUT which accepts them. Writing `false` both rejects classified alcohol creatives on member screens and adds `IAB8-5`/`IAB8-18` to their outgoing `bcat`. |
| `allow_gambling` | `boolean` |  | Must be a real JSON boolean — the strings `"true"`/`"false"` are rejected with `field_must_be_boolean`, unlike the L-bar PUT which accepts them. Writing `false` adds `IAB9-7` to the member screens' `bcat`; unset already blocks gambling programmatically via the platform default. |
| `allow_political` | `boolean` |  | Must be a real JSON boolean — the strings `"true"`/`"false"` are rejected with `field_must_be_boolean`, unlike the L-bar PUT which accepts them. Writing `false` adds `IAB11-4` to the member screens' `bcat`; the platform default here is `true`, so political ads run unless you set this. |
| `allow_profanity` | `boolean` |  | Must be a real JSON boolean — the strings `"true"`/`"false"` are rejected with `field_must_be_boolean`, unlike the L-bar PUT which accepts them. Writing `false` adds `IAB25-2` to the member screens' `bcat`; unset already blocks it via the platform default. |
| `allow_religious` | `boolean` |  | Must be a real JSON boolean — the strings `"true"`/`"false"` are rejected with `field_must_be_boolean`, unlike the L-bar PUT which accepts them. Writing `false` adds `IAB23` to the member screens' `bcat`; the platform default here is `true`, so religious ads run unless you set this. |
| `allow_tobacco` | `boolean` |  | Must be a real JSON boolean — the strings `"true"`/`"false"` are rejected with `field_must_be_boolean`, unlike the L-bar PUT which accepts them. Writing `false` adds `IAB25-6` to the member screens' `bcat`; leaving it unset already blocks tobacco programmatically via the platform brand-safety default. |
| `allow_violence` | `boolean` |  | Must be a real JSON boolean — the strings `"true"`/`"false"` are rejected with `field_must_be_boolean`, unlike the L-bar PUT which accepts them. Writing `false` adds `IAB25-3`/`IAB25-4` to the member screens' `bcat`; unset already blocks it via the platform default. |
| `allowed_categories` | array of `string` |  | Array of strings, at most 100 items (`array_too_large`); a non-array is `field_must_be_array` and a non-string element is `array_items_must_be_strings`. Writing it REPLACES the stored list — there is no append — and the allow/block pair is conflict-checked case-insensitively against this patch MERGED onto the stored row, so it can 400 with `conflicting_lists` on a term you set in an earlier request. |
| `allowed_keywords` | array of `string` |  | Array of strings, at most 100 items (`array_too_large`); a non-array is `field_must_be_array` and a non-string element is `array_items_must_be_strings`. Writing it REPLACES the stored list — there is no append — and it is conflict-checked against `blocked_keywords`, even though at serve time this list never rejects anything — it only contributes to the content match score. |
| `allowed_subjects` | array of `string` |  | Array of strings, at most 100 items (`array_too_large`); a non-array is `field_must_be_array` and a non-string element is `array_items_must_be_strings`. Writing it REPLACES the stored list — there is no append — and the Subjects allow/block pair is conflict-checked against the stored row merged with this patch. Note these are matched by substring at serve time, not equality. |
| `auto_approve_known_brands` | `boolean` |  | Must be a real JSON boolean — the strings `"true"`/`"false"` are rejected with `field_must_be_boolean`, unlike the L-bar PUT which accepts them. Setting it `true` makes a known-brand ad bypass every category, subject and keyword check on member screens, so treat it as a widening, not a convenience. |
| `blocked_categories` | array of `string` |  | Array of strings, at most 100 items (`array_too_large`); a non-array is `field_must_be_array` and a non-string element is `array_items_must_be_strings`. Writing it REPLACES the stored list — there is no append — and blocking a category an earlier PUT allowed fails with `conflicting_lists` even though this request names only one side of the pair. |
| `blocked_iab_categories` | array of `string` |  | Array of strings capped at 100 by a DIFFERENT constant from the other lists (`PROGRAMMATIC_SETTINGS_LIMITS.max_blocked_iab_categories`), reported as `blocked_iab_categories_too_large`. It is not conflict-checked, because there is no `allowed_iab_categories` counterpart. |
| `blocked_keywords` | array of `string` |  | Array of strings, at most 100 items (`array_too_large`); a non-array is `field_must_be_array` and a non-string element is `array_items_must_be_strings`. Writing it REPLACES the stored list — there is no append — and unlike `allowed_keywords` this one really does reject, by case-insensitive substring match against the ad's analysed keywords. |
| `blocked_subjects` | array of `string` |  | Array of strings, at most 100 items (`array_too_large`); a non-array is `field_must_be_array` and a non-string element is `array_items_must_be_strings`. Writing it REPLACES the stored list — there is no append — and it is a hard reject at serve time, matched by case-insensitive substring — so a short entry blocks more than you intend. |
| `content_filtering_enabled` | `boolean` |  | Must be a real JSON boolean — the strings `"true"`/`"false"` are rejected with `field_must_be_boolean`, unlike the L-bar PUT which accepts them. Omitting the key leaves the stored value untouched; there is no way to reset a key back to "inherit". |
| `max_age_rating` | `string`: one of `G`, `PG`, `PG-13`, `R`, `NC-17` |  | One of `G`, `PG`, `PG-13`, `R`, `NC-17`; anything else is `invalid_age_rating`. Writing `G` does not give you the strictest policy — the matcher later treats `G` as `PG-13`. |
| `preference_level` | `string`: one of `strict`, `moderate`, `lenient`, `permissive` |  | One of `strict`, `moderate`, `lenient`, `permissive`; anything else is rejected with `invalid_preference_level`. Validated against the same constant the per-screen content-preferences PUT uses, so the two surfaces cannot drift. |

Responses:

- `200` The updated content policy
- `400` Bad request
- `401` Unauthorized - Invalid or missing API key
- `404` Resource not found

#### GET /v1/partner/screen-groups/{groupId}/members

List a screen group's members

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `listScreenGroupMembers`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `groupId` | path | `string` | yes |  |
| `limit` | query | `integer` |  |  |
| `offset` | query | `integer` |  |  |

Responses:

- `200` A paginated list of member screens
- `401` Unauthorized - Invalid or missing API key
- `404` Resource not found

#### POST /v1/partner/screen-groups/{groupId}/members

Add members to a screen group

Idempotent. Partial-success: screens not owned by this partner are
returned in `rejected` rather than failing the whole request.

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

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

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

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `screen_ids` | array of `string` | yes |  |

Responses:

- `200` The add result
- `400` Bad request
- `401` Unauthorized - Invalid or missing API key
- `404` Resource not found

#### DELETE /v1/partner/screen-groups/{groupId}/members

Remove members from a screen group

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

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

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

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `screen_ids` | array of `string` | yes |  |

Responses:

- `200` The remove result
- `400` Bad request
- `401` Unauthorized - Invalid or missing API key
- `404` Resource not found

#### GET /v1/partner/screen-groups/{groupId}/sensing-config

Get a screen group's sensing config

The group's sensing profile binding + typed performance overrides, or
`null` when the group has none. Member screens inherit via the sensing
resolver's group step when they have no screen-specific deployment. Gated
by SCREEN_GROUPS_ENABLED.

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getScreenGroupSensingConfig`

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

Responses:

- `200` The group's sensing config (nullable)
- `401` Unauthorized - Invalid or missing API key
- `404` Resource not found

#### PUT /v1/partner/screen-groups/{groupId}/sensing-config

Set a screen group's sensing config

Non-clobbering upsert. `profile_name` must resolve to a builtin or an
existing custom profile (else 400). Busts the affected member caches.
Gated by SCREEN_GROUPS_ENABLED.

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

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

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

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `capture` | `object`, nullable |  | Edge frame-capture cadence block (milliseconds); null clears it. Unknown keys are rejected (400). |
| `capture_interval_ms` | `integer`, nullable |  | Frame-capture cadence override in milliseconds (100..3600000); `null` means inherit from the base profile. Advisory in practice: this legacy scalar does not reach the device capture loop (the edge reads the nested `capture` block), the fleet floors at one analysis per 30 s, and observed cadence was measured at ~12x the declared value. |
| `enabled` | `boolean` |  | Set `false` to make the group step a no-op — members fall through to the next resolver layer while the overrides stay stored, so this is the reversible way to switch group sensing off. |
| `estimated_fps_impact` | `number`, nullable |  | Advisory workload-cost number (must be >= 0); `null` inherits the profile's own value, which the server derives from the selected models' registry `fps_cost`. Purely descriptive — setting it changes what readback and operator surfaces display and throttles nothing. |
| `min_confidence` | `number`, nullable |  | Detection-confidence floor in 0..1 written into the resolved profile's `thresholds.min_confidence`; `null` inherits the base profile's value (built-ins are 0.35-0.45). |
| `profile_name` | `string`, nullable |  | Bind the group to a builtin or existing custom profile BY NAME; a name that resolves to neither is a 400. `null` clears the binding. Never a `profile_id` — that is re-minted per screen on each redeploy. |
| `report_interval_ms` | `integer`, nullable |  | Observation-reporting cadence override in milliseconds (100..3600000); `null` means inherit from the base profile. |
| `vision_budget` | `object`, nullable |  | Per-screen cloud-vision budget block; null clears it. Unknown keys are rejected (400). |

Responses:

- `200` The updated sensing config
- `400` Bad request
- `401` Unauthorized - Invalid or missing API key
- `404` Resource not found

#### GET /v1/partner/screens/{screenId}/resolved-content-policy

Resolve the effective content policy for a screen

Introspection ("why"): the EFFECTIVE content policy for a screen and its
provenance. Whole-layer coalescing — the effective policy comes from
exactly ONE layer (screen ?? group ?? partner network default ?? system
default). Read-only preview. Gated by SCREEN_GROUPS_ENABLED.

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getResolvedContentPolicy`

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

Responses:

- `200` The effective content policy + provenance
- `400` Bad request
- `401` Unauthorized - Invalid or missing API key
- `404` Resource not found

#### GET /v1/partner/screens/{screenId}/resolved-sensing-config

Resolve the effective sensing config for a screen

Introspection: the EFFECTIVE sensing view for a screen and its provenance
(screen deployment ?? group sensing config ?? default). Read-only preview.
Gated by SCREEN_GROUPS_ENABLED.

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getResolvedSensingConfig`

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

Responses:

- `200` The effective sensing config + provenance
- `400` Bad request
- `401` Unauthorized - Invalid or missing API key
- `404` Resource not found

### Creative Moderation

Submit creative URLs for AI classification/moderation and read the structured result

#### POST /v1/partner/creatives

Submit a creative for classification

Point us at a public/CDN image or video URL and we return the same
structured classification the Trillboards venue portal shows for every
served ad: moderation verdict, age rating, confidence, IAB categories,
subjects, keywords, on-screen text (OCR), setting/mood/quality, and
brand/category.

Classification is asynchronous (a few seconds for images, up to ~60s for
video). The response carries a canonical `id` of the form `sha256:<hex>` —
the content hash we computed from the fetched bytes. Poll
`GET /creatives/{ref}` with that id until `status` is terminal
(`approved`, `flagged`, or `blocked`). If we have already classified the
same bytes, the full result is returned immediately with `200`.

Use the returned `id` (or `content_sha256`) as `creative_ref` when you
post log-level plays, so your plays join to this classification.

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

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

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `advertiser_name` | `string` |  | Optional advertiser/brand label to attach. |
| `media_url` | `string` | yes | Public http(s) URL of the creative image or video. Must resolve to a public address — private/internal/cloud-metadata addresses are rejected. |

Responses:

- `200` Content was already classified — full result returned immediately.
- `202` Accepted for classification — poll GET /creatives/{id}.
- `400` Bad request
- `401` Unauthorized - Invalid or missing API key
- `422` media_url could not be fetched as a valid image or video.
- `429` Rate limit exceeded

#### GET /v1/partner/creatives/{ref}

Get a creative classification

Read the classification for a creative you submitted, by its
`sha256:<hex>` reference (or the bare 64-char hex). A partner may only
read classifications for content it has submitted.

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getPartnerCreative`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `ref` | path | `string` | yes | The reference returned by POST /creatives — `sha256:<hex>` or the bare 64-char hex. |

Responses:

- `200` The creative classification (status may still be `pending`).
- `400` Bad request
- `401` Unauthorized - Invalid or missing API key
- `404` Resource not found
- `429` Rate limit exceeded

### Measurement Plays

Post log-level plays for ads you serve yourself; join them to classification + audience moments

#### POST /v1/partner/plays

Record a log-level play

Record a single log-level play for an ad you serve yourself (not through
our programmatic pipes). The play lands in a dedicated NON-billing store
and joins to (a) the creative classification you submitted via
POST /creatives — by `creative_ref` (content_sha256) — and (b) our
audience moments captured on the screen during the play window. It then
surfaces through GET /analytics/content-performance and
/analytics/creative-scenes for your screens.

Idempotent on `play_id`. `screen_id` must be one of your registered
earner screens. Measurement plays are never billed.

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

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

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `ad_id` | `string` |  | Optional ad/line identifier; defaults to the sha-keyed creative. |
| `completed_at` | `string` | yes | ISO 8601 timestamp when the play completed. |
| `creative_ref` | `string` | yes | The classification reference from POST /creatives — `sha256:<hex>` or the bare 64-char content hash. |
| `duration_seconds` | `number` |  | Play duration in seconds (default 30). |
| `play_id` | `string` | yes | Your unique id for this play (idempotency key). |
| `request_id` | `string` |  | Optional partner request identifier. |
| `screen_id` | `string` | yes | One of your registered earner-screen ids. |

Responses:

- `202` Play accepted (or a duplicate of a prior play_id).
- `400` Bad request
- `401` Unauthorized - Invalid or missing API key
- `403` screen_id is not a screen registered to this partner.
- `429` Rate limit exceeded

#### POST /v1/partner/plays/batch

Record a batch of log-level plays

Submit up to 500 plays in one request (each the same shape as POST /plays).
Every play is validated independently; the response reports per-play
rejections (invalid_creative_ref, screen_not_owned).

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

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

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `plays` | array of `PartnerPlay` | yes |  |

Responses:

- `202` Batch accepted; see per-play results.
- `400` Bad request
- `401` Unauthorized - Invalid or missing API key
- `429` Rate limit exceeded

### SDK Host Hooks

Device-token doors the partner SDK's host hooks call (reportPlay, setTripState, reportInteraction); the screen is the device's own binding

#### POST /v1/partner/device/{deviceId}/interactions

Report a rider interaction from the partner SDK (reportInteraction)

The partner SDK's `reportInteraction` host hook: what a rider did with this
device's screen — a tap, a QR code shown or scanned, an attention prompt — with
the creative on screen and how far into it. Stored for measurement only; never
billed, and never an ad-lifecycle event (those go to
`POST /v1/partner/device/{deviceId}/programmatic-event`).

Authenticated with the device token the SDK holds. The screen is this device's
own binding and the partner is that screen's partner; the body may not name a
screen.

Idempotent per device on `interaction_id`: a retried interaction is answered
`duplicate` and stored once, and two devices that use the same id report two
interactions.

`occurred_at` is the device clock and must lie within the last 7 days and at most
5 minutes ahead of the server clock; any other time is refused with 400.

- Auth: `DeviceToken` header `X-Device-Token`
- Agent: agent_safe=false, idempotent=true, cost_tier=free
- operationId: `recordPartnerDeviceInteraction`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `deviceId` | path | `string` | yes | This device, as the SDK addresses it — the external id it was registered with, its canonical fingerprint, or its device id. Must be the device the token was issued to. |
| `X-Device-Timestamp` | header | `string` | yes | Request time in epoch milliseconds; must be within the device replay window. |
| `X-Device-Nonce` | header | `string` | yes | A fresh value per request (the SDK sends a UUID); a reused nonce is refused 409. |

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

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `creative_id` | `string` |  | The creative on screen, as the host names it. |
| `interaction_id` | `string` | yes | The host app's id for this interaction (idempotency key, per device). |
| `occurred_at` | `string` | yes | When it happened (device clock), within the last 7 days and at most 5 minutes ahead of the server clock. |
| `t_ms` | `integer` |  | Milliseconds into the creative's playback when it happened. |
| `type` | `string`: one of `tap`, `qr_shown`, `qr_scanned`, `dwell_prompt`, `seatbelt_reminder_shown` | yes | What happened. seatbelt_reminder_shown = the host app showed a SeatbeltReminder that agent-core 1.24 delivered to its safety-event listener; send the reminder's eventId as interaction_id. Never a programmatic ad-lifecycle event (those are POST /device/{deviceId}/programmatic-event). |

Responses:

- `202` Interaction accepted, or a duplicate of this device's prior interaction_id.
- `400` Bad request
- `401` No device token, a token we did not issue, or a token for no device.
- `403` Refused after authentication: the path names another device (`device_token_path_mismatch`), the device is bound to no screen (`device_not_bound_to_screen`), the screen belongs to no partner (`screen_not_partner_bound`) or the partner is not active (`partner_inactive`); or the device credential does not match its request (`DeviceAuthRefusal`).
- `409` This X-Device-Nonce was already used (a replayed request). Retry the interaction with a new nonce.
- `429` Rate limit exceeded
- `503` The replay guard is unavailable; retry.

#### POST /v1/partner/device/{deviceId}/plays

Report a play from the partner SDK (reportPlay)

The partner SDK's `reportPlay` host hook: one log-level play of an ad the host
app played on this device's screen, into the same non-billing store as
`POST /v1/partner/plays` — the same body without `screen_id`, plus the
Trillboards `impression_id` when the host played an ad Trillboards served.

Authenticated with the device token the SDK holds, never a partner API key.
The screen is this device's own binding and the partner is that screen's
partner, so the body may not name a screen: a body carrying `screen_id`, or any
field not listed, is refused with 400.

Idempotent on `play_id`, shared with `POST /v1/partner/plays`, so a play
reported through both is one play. When `impression_id` matches a
Trillboards-served play on the same screen, on the same or an adjacent UTC day,
or, without one, the same `ad_id` completes within 5 s of one, the Trillboards
record of that play is kept and this report is not counted a second time.
Measurement plays are never billed.

`completed_at` is the device clock and must lie within the last 7 days and at
most 5 minutes ahead of the server clock; any other time is refused with 400.

- Auth: `DeviceToken` header `X-Device-Token`
- Agent: agent_safe=false, idempotent=true, cost_tier=free
- operationId: `recordPartnerDevicePlay`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `deviceId` | path | `string` | yes | This device, as the SDK addresses it — the external id it was registered with, its canonical fingerprint, or its device id. Must be the device the token was issued to. |
| `X-Device-Timestamp` | header | `string` | yes | Request time in epoch milliseconds; must be within the device replay window. |
| `X-Device-Nonce` | header | `string` | yes | A fresh value per request (the SDK sends a UUID); a reused nonce is refused 409. |

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

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `ad_id` | `string` |  | Optional ad/line identifier; defaults to the sha-keyed creative. |
| `completed_at` | `string` | yes | When the play completed (device clock), within the last 7 days and at most 5 minutes ahead of the server clock. |
| `creative_ref` | `string` | yes | The classification reference from POST /creatives — `sha256:<hex>` or the bare 64-char content hash. |
| `duration_seconds` | `number` |  | Play duration in seconds (default 30). |
| `impression_id` | `string` |  | The Trillboards impression id, when the host played an ad Trillboards served. The Trillboards record of that play is then kept and this report is not counted twice. |
| `play_id` | `string` | yes | Your unique id for this play (idempotency key, shared with POST /v1/partner/plays). |
| `request_id` | `string` |  | Optional request identifier. |

Responses:

- `202` Play accepted, or a duplicate of a prior play_id.
- `400` Bad request
- `401` No device token, a token we did not issue, or a token for no device.
- `403` Refused after authentication: the path names another device (`device_token_path_mismatch`), the device is bound to no screen (`device_not_bound_to_screen`), the screen belongs to no partner (`screen_not_partner_bound`) or the partner is not active (`partner_inactive`); or the device credential does not match its request (`DeviceAuthRefusal`).
- `409` This X-Device-Nonce was already used (a replayed request). Retry the play with a new nonce.
- `429` Rate limit exceeded
- `503` The replay guard is unavailable; retry.

#### POST /v1/partner/device/{deviceId}/trips

Report a taxi-meter state change from the partner SDK (setTripState)

The partner SDK's `setTripState` host hook: one meter state change — meter on or
off, fare, tip, payment type, riders, destination — on this device's screen.
Stored for measurement only; never billed.

Authenticated with the device token the SDK holds. The screen is this device's
own binding and the partner is that screen's partner; the body may not name a
screen.

Idempotent per device on (`trip_id`, `event_seq`): `event_seq` increases within a
trip and a retry re-sends the same value, so a retried event is answered
`duplicate` and stored once. Hosts number trips per vehicle, so two devices that
both report trip `1` report two trips.

`state_at` is the device clock and must lie within the last 7 days and at most
5 minutes ahead of the server clock; any other time is refused with 400.

- Auth: `DeviceToken` header `X-Device-Token`
- Agent: agent_safe=false, idempotent=true, cost_tier=free
- operationId: `recordPartnerDeviceTripState`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `deviceId` | path | `string` | yes | This device, as the SDK addresses it — the external id it was registered with, its canonical fingerprint, or its device id. Must be the device the token was issued to. |
| `X-Device-Timestamp` | header | `string` | yes | Request time in epoch milliseconds; must be within the device replay window. |
| `X-Device-Nonce` | header | `string` | yes | A fresh value per request (the SDK sends a UUID); a reused nonce is refused 409. |

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

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `destination` | `string` |  | The host's destination text or zone. |
| `event_seq` | `integer` | yes | The event's position in the trip. Increases within a trip; a retry re-sends the same value (idempotency key with trip_id, per device). |
| `fare_cents` | `integer` |  | Fare so far, in the smallest currency unit. |
| `meter_on` | `boolean` | yes | true while the meter runs (a hired trip). |
| `passenger_count` | `integer` |  | Riders the host counted. |
| `payment_type` | `string` |  | The host's payment type (stored trimmed and lowercased). |
| `state_at` | `string` | yes | When the meter changed state (device clock), within the last 7 days and at most 5 minutes ahead of the server clock. |
| `tip_cents` | `integer` |  | Tip, in the smallest currency unit. |
| `trip_id` | `string` | yes | The host app's trip id. Unique per device, not per partner. |

Responses:

- `202` Event accepted, or a duplicate of this device's prior (trip_id, event_seq).
- `400` Bad request
- `401` No device token, a token we did not issue, or a token for no device.
- `403` Refused after authentication: the path names another device (`device_token_path_mismatch`), the device is bound to no screen (`device_not_bound_to_screen`), the screen belongs to no partner (`screen_not_partner_bound`) or the partner is not active (`partner_inactive`); or the device credential does not match its request (`DeviceAuthRefusal`).
- `409` This X-Device-Nonce was already used (a replayed request). Retry the event with a new nonce.
- `429` Rate limit exceeded
- `503` The replay guard is unavailable; retry.

### Ads

Ad serving endpoints

#### GET /v1/partner/device/{deviceId}/ads

Get ads for device

**SDK Endpoint** - Returns ads for the specified device.

This endpoint is called by the Trillboards Lite SDK. It supports ETag caching
for SDK ad-catalog responses to minimize bandwidth usage and request fan-out.
Browser language is accepted through the optional `lang` query parameter. If
omitted, the server uses the standard `Accept-Language` request header and
emits its ISO-639-1 alpha-2 primary language in the bidder-facing request.

If header bidding is enabled for the screen, the response includes
`header_bidding_settings` with dynamic VAST waterfall URLs for programmatic
playback. Dynamic waterfall catalog responses intentionally preserve the
five-minute SDK cache contract; clients fetch the returned VAST URLs when
they actually attempt playback. Server-side source health may calculate
request-pressure decisions for first-party upstream fetches, but the
catalog keeps SQL/Admin-enabled demand sources eligible and visible.

No authentication required - device is identified by fingerprint or external device ID.

**The response carries TWO independent demand rails.**

**1. `data.ads[]` — DIRECT BOOKINGS ONLY. Never programmatic.**

Everything in this array is a first-party booking against this specific screen:
a paid direct advertiser buy, a partner self-promo creative uploaded through the
partner CMS, or the Trillboards instant-QR house ad. There is no DSP, no
auction, and no bid in `ads[]`. If you are looking for programmatic demand and
reading only this array, you will conclude we have none.

**2. `data.header_bidding_settings.vast_waterfall.sources[]` — ALL programmatic
demand.**

Every DSP/SSP source lives here and nowhere else. **Walk the whole array in
`priority` ASCENDING order** and stop at the first source that actually plays;
advance on every failure class (no-fill, empty VAST, zero-`<Ad>`, transport
error, per-source timeout, IMA load error, IMA playback error). `priority: 0`
is real and valid, and gaps in the values are normal — treat `priority` as an
opaque ascending sort key, never an index.

`vast_tag_url` is **an alias of `sources[0].vast_url`**, present only for
legacy single-tag players. It is not "the" tag: a player that reads it and
stops has skipped every other source in the waterfall. This is the single most
expensive integration bug we see.

The waterfall may also contain a `trillboards_direct` source. That is the same
direct booking as rail 1, bridged into VAST for BYO-IMA players — it is not
programmatic demand.

**Non-IMA / native players must fire their own tracking.**

Google IMA SDK fires the VAST-embedded pixels for you. **A raw `<video>`
wrapper, `video.js` without the VAST plugin, Shaka without the ads plugin, or
any other custom player does not.** If you are not using IMA you must XML-parse
the VAST yourself and fire, per playback and exactly once each: every
`<Impression>`, `<Tracking event="start">`, the quartiles
(`firstQuartile`/`midpoint`/`thirdQuartile`), `<Tracking event="complete">`, and
`<Error>` (with `[ERRORCODE]` substituted) on any load or playback failure.
Preserve `<Error>` at every wrapper level of the chain.

If you skip this we have no record of the impression: no attribution, no
earnings. Working `DOMParser` implementation and fire order:
[Partner native IMA SDK guide §5b — If you're NOT using Google IMA SDK (raw VAST
player)](https://github.com/trillboards/packages/blob/production/trillboard-api/docs/integrations/partner-native-ima-sdk.md#5b-if-youre-not-using-google-ima-sdk-raw-vast-player).

**Testing the wire format.**

Append **`?test=1`** to force a guaranteed-fill test source into that one
response (public alias `src_test`): a direct MP4 with no wrapper chain to
resolve. No allowlist enrollment, per-request only — real devices polling
without the flag are unaffected — and the creative is non-revenue. If `src_test`
completes but real sources do not, your VAST handling is fine and the problem is
in the partner response path.

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

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `deviceId` | path | `string` | yes | Device fingerprint or external device ID |
| `If-None-Match` | header | `string` |  | ETag from previous static direct-ad response. Dynamic programmatic waterfall responses ignore matching ETags and return 200 with a fresh body. |
| `test` | query | `string`: one of `1`, `true` |  | Set to `1` (or `true`) to force a guaranteed-fill diagnostic source (`trillboards_test_vast`, public alias `src_test`, `priority: 0`) into THIS response's `vast_waterfall.sources[]`. A direct MP4 with no wrapper chain. Per-request only — no allowlist enrollment, and devices polling without the flag are unaffected. The creative is non-revenue: use it to prove your VAST handling end to end, never in production playback. |
| `slot_w` | query | `integer` |  | Actual render slot width in pixels (player truth) |
| `slot_h` | query | `integer` |  | Actual render slot height in pixels (player truth) |
| `orientation` | query | `string`: one of `portrait`, `landscape` |  | Slot orientation (portrait or landscape) |
| `muted` | query | `boolean` |  | Whether player is muted (true/false or 1/0) |
| `autoplay` | query | `boolean` |  | Whether autoplay is allowed (true/false or 1/0) |
| `ua` | query | `string` |  | Runtime user agent string from the WebView/browser. Used for CTV device detection when stored device metadata is missing. Enables correct dth (device type hint) in VAST tags — e.g., Fire TV Silk WebView UA triggers dth=4 (CTV) instead of defaulting to dth=2 (desktop). |
| `lang` | query | `string` |  | Runtime browser/player locale. BCP 47 values such as `en-US` are accepted and normalized to the ISO-639-1 alpha-2 `device.language` value required by OpenRTB 2.6. When omitted, the standard `Accept-Language` header is used. |
| `ipd` | query | `string` |  | Inventory Partner Domain for Google Ad Manager VAST requests. This must be a single root domain with no scheme, path, query, or port, and it must match an `inventorypartnerdomain=<domain>` declaration in the relevant ads.txt or app-ads.txt inventory-sharing relationship. Only send this when instructed by Trillboards; the ad server may also append `ipd=trillboards.com` for scoped revenue experiments. |

Responses:

- `200` Ads list
- `304` Not Modified for static direct-ad responses only. Dynamic programmatic waterfall responses always return 200 with a fresh body.
- `404` Resource not found
- `429` Rate limit exceeded

#### POST /v1/partner/device/{deviceId}/programmatic-event

Record programmatic ad event (Server-side Analytics)

**SDK Analytics Endpoint** - Records programmatic ad lifecycle events for
Trillboards' internal analytics and triggers partner webhooks when subscribed.

**Important**: This endpoint is for **server-side event recording only**.
It does NOT push events to your native Android, iOS, or CTV applications.

For receiving real-time events in native applications, use the
**JavaScript Native Bridge Protocol** instead. The SDK automatically sends
events to standard native interfaces (Android WebView, iOS WKWebView,
React Native, Flutter, etc.).

See documentation: https://api.trillboards.com/docs/integrations/partner-native-bridge.md

### Events Recorded:
- `ad_started` - Ad began playing
- `ad_ended` - Ad finished (complete or skipped)
- `no_fill` - No programmatic ad available
- `error` - Ad playback error

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

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `deviceId` | path | `string` | yes | Device fingerprint or external device ID |

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

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `ad_id` | `string` |  |  |
| `error_code` | `string` |  |  |
| `error_message` | `string` |  |  |
| `event` | `string`: one of `ad_started`, `ad_ended`, `no_fill`, `error` | yes |  |
| `reason` | `string` |  |  |
| `request_id` | `string` |  |  |
| `screen_orientation` | `string`: one of `portrait`, `landscape` |  |  |
| `slot_height` | `integer` |  |  |
| `slot_width` | `integer` |  |  |
| `variant_name` | `string` |  |  |

Responses:

- `200` Event recorded
- `400` Bad request
- `404` Resource not found
- `429` Rate limit exceeded

#### POST /v1/partner/programmatic-event

Record programmatic ad event (legacy SDK alias)

**Deprecated.**

Backward-compatible alias for SDKs that send the device identifier in
the request body instead of the `/device/{deviceId}/programmatic-event`
path. New integrations should use the device-scoped endpoint. Runtime
still accepts legacy `deviceId` bodies, but the documented schema uses
snake_case to keep generated SDK models unambiguous.

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

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

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `ad_id` | `string` |  |  |
| `device_id` | `string` |  | Device fingerprint or external device ID |
| `did` | `string` |  | Short legacy device identifier alias |
| `error_code` | `string` |  |  |
| `error_message` | `string` |  |  |
| `event` | `string`: one of `ad_started`, `ad_ended`, `no_fill`, `error` | yes |  |
| `reason` | `string` |  |  |
| `request_id` | `string` |  |  |
| `type` | `string`: one of `ad_started`, `ad_ended`, `no_fill`, `error` |  |  |
| `variant_name` | `string` |  |  |

Responses:

- `200` Event recorded
- `400` Bad request
- `404` Resource not found
- `429` Rate limit exceeded

### Impressions

Impression tracking

#### GET /v1/partner/impression

Record impression (pixel)

Record a single impression. Can be called as a tracking pixel (1x1 image load).

Both GET and POST methods are supported for flexibility.
Duplicate `impid` + `adid` submissions are ignored (idempotent).

**Optional Signature Verification**
Provide signature headers to verify impressions:
```
X-Trillboards-API-Key: trb_partner_xxxxx
X-Trillboards-Timestamp: <unix_seconds>
X-Trillboards-Signature: sha256=<hmac>
```
Signature payload:
```
{timestamp}.{adid}.{impid}.{did}.{sid}.{aid}
```
Signature = HMAC-SHA256(api_key, payload)

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

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `X-Trillboards-API-Key` | header | `string` |  | Partner API key for optional signature verification |
| `X-Trillboards-Timestamp` | header | `integer` |  | Unix timestamp for signature verification |
| `X-Trillboards-Signature` | header | `string` |  | HMAC signature for optional verification |
| `adid` | query | `string` | yes | Advertisement ID |
| `impid` | query | `string` | yes | Unique impression ID |
| `did` | query | `string` |  | Device ID, fingerprint, or external device ID |
| `sid` | query | `string` |  | Screen ID (optional, for analytics linkage) |
| `aid` | query | `string` |  | Allocation ID |
| `duration` | query | `integer` |  | View duration in seconds |
| `completed` | query | `boolean` |  | **Accepted and ignored — this flag does not change what the server records.** Every call to this endpoint from a partner-authenticated device records exactly one completion event, with or without `completed=true`. That makes `tracking.complete_url` and `tracking.impression_url` (which differ only by this parameter) behaviourally identical: **fire exactly one of them per play**, or the play is recorded twice. This endpoint is delivery accounting, not proof of play. Billing-grade, server-verified completion is `tracking.owned_direct_complete_url` on the ad object, which is signed at serve time and cannot be forged or replayed. |
| `display_mode` | query | `string`: one of `fullscreen`, `l-bar`, `pip` |  | Display mode context (fullscreen, l-bar, pip) |
| `display_area_ratio` | query | `number` |  | Screen area ratio used by ad (0.0 to 1.0) |
| `lbar_size` | query | `number` |  | L-bar size percentage (0-100) |
| `lbar_position` | query | `string`: one of `bottom`, `right` |  | L-bar position |
| `lbar_slot_index` | query | `integer` |  | L-bar slot index (0-based) |
| `lbar_slot_count` | query | `integer` |  | Total L-bar slots in rotation |
| `sig` | query | `string` |  | HMAC signature (optional, for signed impressions) |
| `sig_ts` | query | `integer` |  | Signature timestamp in unix seconds |

Responses:

- `200` Impression recorded

#### POST /v1/partner/impression

Record impression (POST)

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

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `X-Trillboards-API-Key` | header | `string` |  | Partner API key for optional signature verification |
| `X-Trillboards-Timestamp` | header | `integer` |  | Unix timestamp for signature verification |
| `X-Trillboards-Signature` | header | `string` |  | HMAC signature for optional verification |

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

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `adid` | `string` | yes |  |
| `aid` | `string` |  |  |
| `completed` | `boolean` |  |  |
| `did` | `string` |  |  |
| `display_area_ratio` | `number` |  |  |
| `display_mode` | `string`: one of `fullscreen`, `l-bar`, `pip` |  |  |
| `duration` | `integer` |  |  |
| `impid` | `string` | yes |  |
| `lbar_position` | `string`: one of `bottom`, `right` |  |  |
| `lbar_size` | `number` |  |  |
| `lbar_slot_count` | `integer` |  |  |
| `lbar_slot_index` | `integer` |  |  |
| `sid` | `string` |  |  |
| `sig` | `string` |  |  |
| `sig_ts` | `integer` |  |  |

Responses:

- `200` Impression recorded

#### POST /v1/partner/impressions/batch

Batch record impressions

Record multiple impressions at once. Useful for syncing offline impressions.

Maximum 100 impressions per request.
Signed impressions can include `sig` and `sig_ts` per impression (see `/impression` signature format).

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

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

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `impressions` | array of `object` | yes |  |

Responses:

- `200` Batch processed

### Analytics

Analytics and reporting

#### GET /v1/partner/analytics

Get partner analytics

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getAnalytics`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `start_date` | query | `string` |  |  |
| `end_date` | query | `string` |  |  |

Responses:

- `200` Analytics data

#### GET /v1/partner/audience-analytics

Get partner-wide audience analytics

Get aggregated audience analytics across all partner screens.
Provides overview of impressions, active screens, and audience metrics.

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getAudienceAnalytics`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `period` | query | `string`: one of `24h`, `7d`, `30d` |  | Time period |

Responses:

- `200` Analytics retrieved
- `401` Unauthorized - Invalid or missing API key

#### GET /v1/partner/audience-export

Export audience data

Export aggregated audience data for multiple screens.
Supports JSON and CSV formats.

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `exportAudienceData`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `screen_ids` | query | `string` | yes | Comma-separated screen IDs (max 50) |
| `start_date` | query | `string` |  | Start date (ISO 8601) |
| `end_date` | query | `string` |  | End date (ISO 8601) |
| `format` | query | `string`: one of `json`, `csv` |  | Export format |
| `aggregation` | query | `string`: one of `hourly`, `daily`, `weekly` |  | Data aggregation level |

Responses:

- `200` Audience data exported
- `400` Bad request
- `401` Unauthorized - Invalid or missing API key

#### GET /v1/partner/data-export/summary

List available datasets and per-dataset record counts

Return per-dataset record counts and date ranges for all datasets exposed
by `GET /data-export/{dataset}`. Restricted to screens owned by the
authenticated partner.

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getDataExportSummary`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `days` | query | `integer` |  | Lookback in days (max 400) |

Responses:

- `200` Per-dataset summary
- `401` Unauthorized - Invalid or missing API key

#### GET /v1/partner/data-export/{dataset}

Export a partner-scoped dataset

Export a partner-scoped dataset for the authenticated partner's screens, every
aggregate row with its counts. Partner screen scoping is enforced via
`pgRepo.getEarnerScreensByPartnerMongoIds`, so a partner can never read another
partner's rows.

Available datasets:
- `audience_metrics` — hourly audience aggregates (face count, attention, demographics, mood, weather)
- `signal_audience` — hourly BLE, WiFi, and mDNS audience rollups (unique, returning,
  and cross-screen devices, scan windows, dwell, and attention)
- `attention_ledger` — per-impression Verified Attention Seconds (VAS) measurements
- `impression_ledger` — settled completed impressions (vast_event / proof_of_play / partner_sdk)
- `waterfall_events` — VAST waterfall request outcomes per ad opportunity
- `intent_signals` — purchase / consideration / awareness signals
- `audience_loyalty_features` — per-device cross-day loyalty features keyed by partner-scoped pseudonymous IDs
  (`tb_` + the first 16 hex of HMAC-SHA256(the partner's salt, canonical device key)): one salt per partner,
  whichever API key calls, so the ID is the same ref the partner's device-level files carry. Same physical
  device produces a DIFFERENT ID for a different partner — partner-specific salt prevents cross-partner identity
  correlation. Every identifiable radio source ships (BLE, mDNS, SSDP, HTTP probe and WiFi),
  each row labelled in `_match_disclosure`. Master switch: the `AUDIENCE_LOYALTY_FEATURES_ENABLED`
  runtime_config row (default true, ~5s propagation).
- `screen_metadata` — per-screen inventory metadata: identity (`screen_id`, `unique_id`, `name`,
  `status`, `online_status`, `last_seen`), location (`country`/`state`/`city`/`zip`,
  `latitude`/`longitude`, `location_source` geocode provenance, `timezone`), venue classification
  (`venue_category`/`venue_parent`/`venue_child`/`venue_subcategory`, `openooh_venue_type_id`,
  `environment_type`, `venue_classification_source`), and projected-traffic / quality signals
  (`projected_daily_impressions`, `places_rating`, `places_review_count`, `places_business_name`,
  `foot_traffic_multiplier`, `commercial_value_score`, `floor_price_cpm`). This is the inventory
  data partners hand to advertisers. No time dimension — `days` is ignored; paginate with
  `limit`/`offset`.

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `exportDataset`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `dataset` | path | `string`: one of `audience_metrics`, `signal_audience`, `attention_ledger`, `impression_ledger`, `waterfall_events`, `intent_signals`, `audience_loyalty_features`, `screen_metadata` | yes | Dataset name |
| `days` | query | `integer` |  | Lookback in days (max 400) |
| `limit` | query | `integer` |  | Max rows to return (hard cap 50000) |
| `offset` | query | `integer` |  | Pagination offset |

Responses:

- `200` Dataset exported
- `400` Unknown dataset (response includes the list of available datasets in `available`)
- `401` Unauthorized - Invalid or missing API key

#### GET /v1/partner/data/geohash-audience-panel

Geographic audience panel (data-licensing)

Camera and radio activity per geohash-6 cell, UTC hour and registered venue category, every
unit placed where its screen WAS: on the map-matched vehicle path at the instant, else at the
screen's last GPS trail fix (looking back 7 days), else, for a fixed screen, at its registered
site. A vehicle's registered coordinate is never used.

Definition 2 (2026-10-19). Source: `trillboards.geohash_activity_hourly`, one UTC day per build
(the partner product `geohash_activity_hourly` v2 reads the same rows), each day at its newest
build. Camera: face- and person-backed windows at their close, with faces, people in view, capped
dwell and the head-pose index. Radio: the 6-minute slots of each screen at their midpoint, with the
visitor-source identifiers (BLE, mDNS, SSDP, HTTP probe) concurrent in each (summed:
`visitor_key_slots`), split near field and far field at the pass-audience method's near-field
boundary (`near_field_rssi_dbm`), the distinct identifiers of the cell-hour (`unique_devices`) and
their pings (`observation_count`). A cell-hour no radio slot fell in (camera windows placed where no
slot's midpoint was) carries its radio readings `null`: the radio was not read there. The approved
metric `radio_identifiers_per_person` (the in-cab panel's passenger near-field Bluetooth identifiers
per person the camera read) converts identifiers of that population to people. Definition 1 filed
every radio ping under the screen's registry cell and carried no camera reading.

Every cell is returned with its measured counts, a page at a time: request pages with `limit` and
`offset` until `truncated` is `false`; `next_offset` is the next page's `offset`. Rows are ordered by
`(hour, geohash6, venue_category)`, which is unique per row. A day is rebuilt while its late camera
windows and trail fixes land: every page names the build it read (`built_at`), and pages that carry
two builds are re-read from offset 0. A day the panel holds no build of is answered `404`
`day_not_built`, never an empty page; an empty page is a built day whose filters matched no cell.

Default date is yesterday (UTC). Radio from 2026-05-24, camera from 2026-06-24.

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=metered
- operationId: `getGeohashAudiencePanel`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `date` | query | `string` |  | Day to export (UTC). Defaults to yesterday. |
| `geohash_prefix` | query | `string` |  | Optional geohash6 prefix filter (base32 alphabet `[0-9a-hjkmnp-z]`, 1–6 chars). Matches rows whose `geohash6` starts with the prefix. |
| `venue_category` | query | `string` |  | Optional filter on the screen's registered venue category (`screens_live.venue_category`). Alphanumeric token, max 64 chars. |
| `limit` | query | `integer` |  | Rows per page. Defaults to 10000, the maximum; a larger value is capped at 10000 (a row is about 1 KB of JSON). |
| `offset` | query | `integer` |  | Rows to skip — the previous page's `next_offset`. Defaults to 0. |

Responses:

- `200` Every cell of the exported day with its counts, one page.
- `400` Invalid `date`, `geohash_prefix`, `venue_category`, `limit` or `offset` parameter (returned by the validation guards in `exportGeohashAudiencePanel`).
- `401` Unauthorized - Invalid or missing API key
- `403` Partner not provisioned for the data-licensing panel (no `partner_id` UUID).
- `404` `day_not_built`: the panel holds no build of the requested `date`. A UTC day is built within about an hour after it closes; the panel starts on 2026-05-24.
- `429` Rate limit exceeded

#### GET /v1/partner/data/outcome-attribution

Outcome-attribution export (data-licensing)

Per `(creative, screen)` "did the ad drive an outcome" attribution over the partner's OWN
screens: how many EXPOSED devices RETURNED (and crossed screens) per creative — fusing sensor
exposure × ad play × return-visit on the canonical device identity. Sensor outcome only;
carries no revenue.

Source-of-record: the ClickHouse `ReplacingMergeTree` spine
`trillboards.device_outcome_exposures`, populated from `device_exposure_events` joined to
`completed_impressions` on screen + completed-at-within-exposure-window.

Every `(creative, screen)` cell is returned with its measured counts, a page at a time.
The first page pins the window to end on its UTC date (`window_end`), and rows are ordered
by `creative_id`, then `screen_mongo_id`. Pass `next_cursor` back as `cursor`, with the
same `days`, `grain` and `creative_id`, until `truncated` is `false`: every page reads the
same window and the cells after the last one returned, so no page repeats or skips a cell
while the table refreshes.

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=metered
- operationId: `getOutcomeAttribution`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `days` | query | `integer` |  | Lookback window in days (1–90). Defaults to 7. |
| `creative_id` | query | `string` |  | Optional single-creative filter. Omit for all creatives. |
| `grain` | query | `string`: one of `person`, `device` |  | Reach grain. `person` (DEFAULT) collapses device-grain reach onto the person identity crosswalk (person keys derived from face-identity templates and linked to device keys) so one human resolved across several devices is counted once (`coalesce(person_id, device_key)`); low-confidence merges (< 0.5) fall back to the device key. `device` is the legacy raw per-device count. Person-grain counts are always ≤ device-grain. See `/measurement-methodology#evidence_grade_ladder.person_resolution`. |
| `limit` | query | `integer` |  | Rows per page. Defaults to 50000, the maximum; a larger value is capped at 50000. |
| `cursor` | query | `string` |  | The previous page's `next_cursor`, passed back unchanged. Omit for the first page. |

Responses:

- `200` One page of per-(creative, screen) outcome aggregates with their counts, plus the reach grain, the pinned window and the page used.
- `400` Invalid `limit` or `cursor` parameter, or a `cursor` sent with other `days`, `grain` or `creative_id` than the first page (returned by the page guard in `exportOutcomeAttribution`).
- `402` Billing not set up for the `data_api` product, or the free quota is exhausted. Body carries machine-readable pricing.
- `403` Partner not provisioned for the data-licensing panel.

#### GET /v1/partner/measurement-methodology

Trillboards measurement methodology disclosure

Returns the versioned document describing how Trillboards captures
out-of-home audience signal, resolves device identity, qualifies an
exposure, and gates every published count. Written for buyer and
procurement due-diligence review; built against MRC OOH Phase 2.

Public — no authentication. Content-negotiated: an `Accept` header
preferring `text/html` returns a rendered page, anything else returns
this JSON. Send `Accept: application/json`, or use
`/measurement-methodology.json`, to pin the JSON from a browser.

`version` changes whenever a disclosure changes and drives the `ETag`,
so it is the canonical change-detector for polling clients.

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

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `format` | query | `string`: one of `json` |  | Force the JSON representation regardless of `Accept`. |

Responses:

- `200` Methodology document (JSON) or rendered page (HTML)

#### GET /v1/partner/measurement-methodology.json

Measurement methodology disclosure (JSON, no negotiation)

Identical payload to `GET /measurement-methodology`, with the JSON
representation pinned. Exists so a browser — whose `Accept` header
always prefers `text/html` — can reach the machine-readable document
without setting headers.

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

Responses:

- `200` Methodology document

#### GET /v1/partner/screens/{screenId}/audience-metrics

Get real-time audience metrics

Get real-time audience metrics for a screen including face detection,
attention tracking, and demographic signals from the Troveworks AI pipeline.

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getAudienceMetrics`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `screenId` | path | `string` | yes |  |
| `period` | query | `string`: one of `1h`, `6h`, `24h` |  | Time period for metrics |
| `include_history` | query | `boolean` |  | Include historical data points |

Responses:

- `200` Audience metrics retrieved
- `401` Unauthorized - Invalid or missing API key
- `404` Resource not found

#### GET /v1/partner/screens/{screenId}/demographics

Get screen demographics

Get demographic summary for a screen including age distribution,
gender split, income profile, and lifestyle segments.

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getScreenDemographics`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `screenId` | path | `string` | yes |  |
| `period` | query | `string`: one of `24h`, `7d`, `30d` |  | Time period |

Responses:

- `200` Demographics retrieved
- `401` Unauthorized - Invalid or missing API key
- `404` Resource not found

### CMS

Content management for partner screens

#### GET /v1/partner/ads/self-promo

List self-promo ads

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `listPartnerSelfPromoAds`

Responses:

- `200` Self-promo ads list
- `401` Unauthorized - Invalid or missing API key

#### POST /v1/partner/ads/self-promo

Create self-promo ad (URL-based)

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=false, idempotent=false, cost_tier=free
- operationId: `createPartnerSelfPromoAd`

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

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `campaign_link` | `string` |  | Click-through / QR destination. Stored as an empty string, not null, when omitted. |
| `description` | `string` |  | Optional; defaults to `name` rather than staying null. |
| `end_date` | `string` |  | Last day the promo may serve, in each screen's own timezone. Omitting it leaves the placement OPEN-ENDED (`preserveOpenEnd: true`) — it runs until you disable the ad — and leaves `end_time` null rather than 23:59. |
| `image_url` | `string` |  | The image creative, stored as a single-element `photos` array. At least one of `image_url` or `video_url` is required (400 `Provide image_url or video_url`); sending both stores both. |
| `name` | `string` | yes | Required — the create is rejected 400 without it. It is also written onto every placement as `advertisement_name`, so it is what appears in delivery bookkeeping. |
| `screen_ids` | array of `string` |  | Which of YOUR screens the promo is placed on. Accepts a real array, a JSON-encoded array string, or a comma-separated string; the list is intersected with the partner's own non-deactivated screens so a foreign id is dropped silently, but if you send ids and NONE survive the create fails 404. Omitting it entirely creates the advertisement with no placements, so it serves nowhere until you call the schedule endpoint. |
| `start_date` | `string` |  | First day the promo may serve, resolved PER SCREEN in that screen's own timezone. A window that is already open lands the placement `active` and fires an immediate `self_promo_active` push to the screen; a future one lands it `scheduled`. Serving times are fixed at 00:00-23:59 local and are not derived from this value. |
| `video_url` | `string` |  | The video creative. Its length is PROBED at create time and returned as `video_duration_seconds`, which is what the served VAST declares as `<Duration>`; a spot longer than the 30-second slot also comes back as a non-fatal entry in the response `warnings` array. The probe never blocks the create — on failure the duration stays null and the VAST falls back to a flat 30s. |

Responses:

- `201` Self-promo ad created
- `400` Bad request
- `401` Unauthorized - Invalid or missing API key

#### GET /v1/partner/ads/self-promo/placements

List self-promo placements by screen

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `listPartnerSelfPromoPlacements`

Responses:

- `200` Self-promo placements
- `401` Unauthorized - Invalid or missing API key

#### POST /v1/partner/ads/self-promo/schedule

Schedule existing self-promo ad to screens

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=false, idempotent=false, cost_tier=free
- operationId: `schedulePartnerSelfPromo`

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

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `ad_id` | `string` | yes |  |
| `end_date` | `string` |  |  |
| `screen_ids` | array of `string` | yes |  |
| `start_date` | `string` |  |  |

Responses:

- `200` Self-promo scheduled
- `400` Bad request
- `401` Unauthorized - Invalid or missing API key
- `404` Resource not found

#### POST /v1/partner/ads/self-promo/upload

Create self-promo ad (upload)

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=false, idempotent=false, cost_tier=free
- operationId: `createPartnerSelfPromoUpload`

Request body (`multipart/form-data`, required): `object`

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `campaign_link` | `string` |  |  |
| `description` | `string` |  |  |
| `media` | `string` | yes |  |
| `name` | `string` | yes |  |
| `screen_ids` | array of `string` |  |  |

Responses:

- `201` Self-promo ad created
- `400` Bad request
- `401` Unauthorized - Invalid or missing API key

#### POST /v1/partner/ads/self-promo/{ad_id}/disable

Disable a self-promo ad

Marks a self-promo creative as disabled. Disabled self-promos do not
enter the waterfall and stop accruing impressions. Idempotent: a
second call returns 200 with the same payload.

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

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `ad_id` | path | `string` | yes | The self-promo creative ID. Note the snake-case path-param spelling. |

Responses:

- `200` Disabled
- `401` Unauthorized - Invalid or missing API key
- `404` Ad not found

#### GET /v1/partner/content-presets

List content presets

List all available content preference presets.
Presets provide pre-configured content filtering rules for common venue types.

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `listContentPresets`

Responses:

- `200` Content presets retrieved
- `401` Unauthorized - Invalid or missing API key

#### GET /v1/partner/content-presets/{presetId}

Get content preset

Get details of a specific content preset.

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getContentPreset`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `presetId` | path | `string` | yes | Preset ID (e.g., "family_friendly", "bar_nightclub") |

Responses:

- `200` Preset retrieved
- `401` Unauthorized - Invalid or missing API key
- `404` Preset not found

#### POST /v1/partner/screens/batch/content-preferences

Batch update content preferences

Update content preferences for multiple screens at once.
Can apply custom preferences or a preset to up to 100 screens.

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

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

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `preferences` | `ContentPreferencesInput` |  |  |
| `preset_id` | `string` |  | Preset ID to apply (alternative to preferences) |
| `screen_ids` | array of `string` | yes | Array of screen IDs to update |

Responses:

- `200` Batch update completed
- `400` Bad request
- `401` Unauthorized - Invalid or missing API key

#### POST /v1/partner/screens/batch/display-settings

Batch update display settings

Update display settings for multiple screens (up to 100).

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

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

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `screen_ids` | array of `string` | yes |  |
| `settings` | `DisplaySettingsInput` | yes |  |

Responses:

- `200` Batch update completed
- `400` Bad request
- `401` Unauthorized - Invalid or missing API key

#### POST /v1/partner/screens/batch/programmatic-settings

Batch update programmatic settings

Update programmatic settings for multiple screens (up to 100).

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

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

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `screen_ids` | array of `string` | yes |  |
| `settings` | `ProgrammaticSettingsInput` | yes |  |

Responses:

- `200` Batch update completed
- `400` Bad request
- `401` Unauthorized - Invalid or missing API key

#### GET /v1/partner/screens/resolve

Resolve screen ID by device identifier

Resolve a partner screen using a device identifier (fingerprint, device_id, or external_device_id).
Use this when you only have device IDs but want to call screen-based CMS endpoints.

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `resolvePartnerScreen`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `device_id` | query | `string` |  | Partner device ID or fingerprint |
| `external_device_id` | query | `string` |  | Partner external device ID (device_id from registration) |
| `fingerprint` | query | `string` |  | Device fingerprint (fp) |

Responses:

- `200` Screen resolved
- `400` Bad request
- `401` Unauthorized - Invalid or missing API key

#### POST /v1/partner/screens/{screenId}/apply-preset

Apply content preset to screen

Apply a predefined content preset to a screen.
This overwrites existing content preferences with the preset values.

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

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

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

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `preset_id` | `string` | yes | ID of the preset to apply |

Responses:

- `200` Preset applied
- `401` Unauthorized - Invalid or missing API key
- `404` Resource not found

#### GET /v1/partner/screens/{screenId}/content-preferences

Get screen content preferences

Get content filtering preferences for a specific screen.
Controls what types of ads can be displayed.

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getContentPreferences`

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

Responses:

- `200` Content preferences retrieved
- `401` Unauthorized - Invalid or missing API key
- `404` Resource not found

#### PUT /v1/partner/screens/{screenId}/content-preferences

Update content preferences (full)

Full update of content filtering preferences for a screen.
Replaces all existing preferences with the provided values.
Triggers real-time update to the screen via WebSocket.

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

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

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

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `blocked_advertisers` | array of `string` |  | Specific advertiser IDs to block |
| `blocked_categories` | array of `string` |  | Ad categories to block (e.g., "alcohol", "gambling", "adult") |
| `max_ad_frequency` | `integer` |  | Max times same ad can show per hour |
| `min_ad_rating` | `string`: one of `G`, `PG`, `PG-13`, `R` |  | Minimum ad content rating |
| `preferred_categories` | array of `string` |  | Preferred ad categories (prioritized in auctions) |

Responses:

- `200` Preferences updated
- `401` Unauthorized - Invalid or missing API key
- `404` Resource not found

#### PATCH /v1/partner/screens/{screenId}/content-preferences

Update content preferences (partial)

Partial update of content preferences. Only updates provided fields.
Triggers real-time update to the screen via WebSocket.

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

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

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

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `blocked_advertisers` | array of `string` |  | Specific advertiser IDs to block |
| `blocked_categories` | array of `string` |  | Ad categories to block (e.g., "alcohol", "gambling", "adult") |
| `max_ad_frequency` | `integer` |  | Max times same ad can show per hour |
| `min_ad_rating` | `string`: one of `G`, `PG`, `PG-13`, `R` |  | Minimum ad content rating |
| `preferred_categories` | array of `string` |  | Preferred ad categories (prioritized in auctions) |

Responses:

- `200` Preferences updated
- `401` Unauthorized - Invalid or missing API key
- `404` Resource not found

#### GET /v1/partner/screens/{screenId}/default-streams

List default streams for a screen

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `listPartnerDefaultStreams`

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

Responses:

- `200` Default streams list
- `401` Unauthorized - Invalid or missing API key
- `404` Resource not found

#### POST /v1/partner/screens/{screenId}/default-streams

Create a default stream (URL-based)

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=false, idempotent=false, cost_tier=free
- operationId: `createPartnerDefaultStream`

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

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

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `canva_url` | `string` |  | Accepted with no required-field check, same as `google_slides_url` — a `canva` create with this omitted succeeds and renders nothing. |
| `description` | `string` |  | Optional. Falls back to `name` and then to `Background content`. |
| `duration_seconds` | `number` |  | Optional explicit duration for iframe-based streams |
| `embed_url` | `string` |  | The URL actually loaded into the iframe. When omitted it is filled from `generic_iframe_url`, so send it only when the embeddable URL differs from the one you want recorded. |
| `generic_iframe_url` | `string` |  | The iframe source. Sending it also derives `stream_type: generic_iframe` and seeds `embed_url`, so this one field is enough to create an iframe stream. |
| `google_slides_url` | `string` |  | The `google_slides` type has NO required-field check, so a create naming that type without this URL is accepted and stores null. Send a published-to-web slides URL. |
| `iframe_config` | `object` |  | Only these five keys are kept — every other key you send is silently dropped, a non-object is dropped whole, and `slide_delay_ms`/`refresh_interval_seconds` must be finite numbers or they are dropped individually. On a later update this object is merged over the stored one rather than replacing it. |
| `image_duration` | `integer` |  | Seconds per image |
| `images` | array of `string` |  | Required and non-empty for an `images` stream. Accepted as a real array, a JSON-encoded array string, or a comma-separated string; combined with `image_duration` it also derives `duration_seconds` when you do not send one. |
| `name` | `string` |  | Optional. Falls back to `description` and then to the literal `Default Stream`, so the created stream always has one. |
| `social_wall_url` | `string` |  | Accepted with no required-field check — a `social_wall` create with this omitted succeeds and renders nothing. |
| `stream_type` | `string`: one of `local`, `youtube`, `uploaded`, `images`, `twitch`, `google_slides`, `canva`, `social_wall`, … |  | Optional — omit it and the server DERIVES it from whichever content field you sent, checking in this order: `images`, `youtube_url`, `twitch_url`, `google_slides_url`, `canva_url`, `social_wall_url`, `generic_iframe_url`/`embed_url`, `video_url` (`uploaded`), `local_file_path` (`local`). If nothing resolves the request is rejected 400 `No supported stream content provided`. |
| `twitch_url` | `string` |  | Required when the resolved `stream_type` is `twitch`; the create is rejected 400 without it. |
| `video_url` | `string` |  | Required when the resolved `stream_type` is `uploaded`. Use `POST .../default-streams/upload` with a multipart file instead if you want us to host the video — that route fills this in for you. |
| `youtube_url` | `string` |  | Required when the resolved `stream_type` is `youtube`; the create is rejected 400 without it. Sending it alone is enough — the type is derived. |

Responses:

- `201` Default stream created
- `400` Bad request
- `401` Unauthorized - Invalid or missing API key
- `404` Resource not found

#### POST /v1/partner/screens/{screenId}/default-streams/images

Upload image carousel default stream

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=false, idempotent=false, cost_tier=free
- operationId: `createPartnerDefaultStreamImages`

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

Request body (`multipart/form-data`, required): `object`

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `description` | `string` |  |  |
| `image_duration` | `integer` |  | Seconds per image |
| `images` | array of `string` | yes |  |
| `name` | `string` |  |  |

Responses:

- `201` Default stream created
- `401` Unauthorized - Invalid or missing API key
- `404` Resource not found

#### PUT /v1/partner/screens/{screenId}/default-streams/order

Reorder default streams

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

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

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

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `order` | array of `string` | yes |  |

Responses:

- `200` Default streams reordered
- `400` Bad request
- `401` Unauthorized - Invalid or missing API key
- `404` Resource not found

#### POST /v1/partner/screens/{screenId}/default-streams/upload

Upload a video default stream

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=false, idempotent=false, cost_tier=free
- operationId: `createPartnerDefaultStreamUpload`

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

Request body (`multipart/form-data`, required): `object`

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `description` | `string` |  |  |
| `name` | `string` |  |  |
| `video` | `string` | yes |  |

Responses:

- `201` Default stream created
- `401` Unauthorized - Invalid or missing API key
- `404` Resource not found

#### PUT /v1/partner/screens/{screenId}/default-streams/{streamId}

Update a default stream

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

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `screenId` | path | `string` | yes |  |
| `streamId` | path | `string` | yes |  |

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

Responses:

- `200` Default stream updated
- `400` Bad request
- `401` Unauthorized - Invalid or missing API key
- `404` Resource not found

#### DELETE /v1/partner/screens/{screenId}/default-streams/{streamId}

Delete a default stream

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

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `screenId` | path | `string` | yes |  |
| `streamId` | path | `string` | yes |  |

Responses:

- `200` Default stream deleted
- `401` Unauthorized - Invalid or missing API key
- `404` Resource not found

#### GET /v1/partner/screens/{screenId}/display-settings

Get display settings

Get display configuration for a screen including orientation,
ad surfaces, and playback policy.

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getDisplaySettings`

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

Responses:

- `200` Display settings retrieved
- `401` Unauthorized - Invalid or missing API key
- `404` Resource not found

#### PUT /v1/partner/screens/{screenId}/display-settings

Update display settings (full)

Full update of display settings for a screen.
Triggers real-time update and screen refresh via WebSocket.

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

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

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

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `ad_duration_default` | `integer` |  | Default ad duration in seconds |
| `brightness` | `integer` |  | Screen brightness percentage |
| `content_fit` | `ContentFitSettings` |  |  |
| `display_mode` | `string`: one of `fullscreen`, `l-bar`, `split` |  | Ad display mode |
| `idle_content` | `string`: one of `default_stream`, `black`, `logo` |  | What to show when no ads |
| `mute` | `boolean` |  | Mute audio |
| `orientation` | `string`: one of `landscape`, `portrait`, `auto` |  | Screen orientation |
| `qr_code_position` | `string`: one of `bottom-left`, `bottom-right`, `top-left`, `top-right`, `none` |  | QR code position on screen |
| `transition_effect` | `string`: one of `fade`, `slide`, `none` |  | Transition effect between ads |
| `volume` | `integer` |  | Audio volume percentage |

Responses:

- `200` Settings updated
- `401` Unauthorized - Invalid or missing API key
- `404` Resource not found

#### PATCH /v1/partner/screens/{screenId}/display-settings

Update display settings (partial)

Partial update of display settings.

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

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

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

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `ad_duration_default` | `integer` |  | Default ad duration in seconds |
| `brightness` | `integer` |  | Screen brightness percentage |
| `content_fit` | `ContentFitSettings` |  |  |
| `display_mode` | `string`: one of `fullscreen`, `l-bar`, `split` |  | Ad display mode |
| `idle_content` | `string`: one of `default_stream`, `black`, `logo` |  | What to show when no ads |
| `mute` | `boolean` |  | Mute audio |
| `orientation` | `string`: one of `landscape`, `portrait`, `auto` |  | Screen orientation |
| `qr_code_position` | `string`: one of `bottom-left`, `bottom-right`, `top-left`, `top-right`, `none` |  | QR code position on screen |
| `transition_effect` | `string`: one of `fade`, `slide`, `none` |  | Transition effect between ads |
| `volume` | `integer` |  | Audio volume percentage |

Responses:

- `200` Settings updated
- `401` Unauthorized - Invalid or missing API key
- `404` Resource not found

#### GET /v1/partner/screens/{screenId}/l-bar

Get L-Bar settings

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getPartnerLBarSettings`

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

Responses:

- `200` L-Bar settings
- `401` Unauthorized - Invalid or missing API key
- `404` Resource not found

#### PUT /v1/partner/screens/{screenId}/l-bar

Update L-Bar settings

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

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

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

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `display_preferences` | `PartnerLBarDisplayPreferences` |  |  |
| `l_bar_content` | `PartnerLBarContent` |  |  |

Responses:

- `200` L-Bar settings updated
- `400` Bad request
- `401` Unauthorized - Invalid or missing API key
- `404` Resource not found

#### PUT /v1/partner/screens/{screenId}/l-bar/live-updates

Update L-Bar live updates

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

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

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

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `custom_status` | `string` |  |  |
| `promotion` | `string` |  |  |
| `queue_number` | `string` |  |  |
| `wait_time_minutes` | `number` |  |  |

Responses:

- `200` L-Bar live updates applied
- `400` Bad request
- `401` Unauthorized - Invalid or missing API key
- `404` Resource not found

#### GET /v1/partner/screens/{screenId}/programmatic-settings

Get programmatic settings

Get programmatic/header bidding settings for a screen.
Controls Google IMA, Prebid, and other programmatic ad sources.

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getProgrammaticSettings`

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

Responses:

- `200` Programmatic settings retrieved
- `401` Unauthorized - Invalid or missing API key
- `404` Resource not found

#### PUT /v1/partner/screens/{screenId}/programmatic-settings

Update programmatic settings (full)

Full update of programmatic settings.

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

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

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

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `enabled` | `boolean` |  | Enable programmatic ads |
| `floor_price_cpm` | `number` |  | Minimum CPM in dollars |
| `google_ima` | `object` |  | Accepted by this schema but silently discarded. The write path keeps only an explicit whitelist — `enabled`, `provider`, `vast_tag_url`, `timeout_ms`, `programmatic_allocation_percent`, `floor_price_cpm`, `currency`, `dynamic_floor_pricing`, `blocked_advertisers`, `blocked_iab_categories`, `preferred_deal_ids` — and `google_ima` is not in it. A body containing only this key persists nothing and still returns 200. |
| `prebid` | `object` |  | Accepted by this schema but silently discarded — not in the write whitelist, never persisted, and never read back (`header_bidding_settings.prebid` is JSON `null` on every row). A body containing only this key persists nothing and still returns 200. |
| `priority` | `string`: one of `direct_first`, `programmatic_first`, `highest_bid` |  | Ad source priority |

Responses:

- `200` Settings updated
- `401` Unauthorized - Invalid or missing API key
- `404` Resource not found

#### PATCH /v1/partner/screens/{screenId}/programmatic-settings

Update programmatic settings (partial)

Partial update of programmatic settings.

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

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

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

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `enabled` | `boolean` |  | Enable programmatic ads |
| `floor_price_cpm` | `number` |  | Minimum CPM in dollars |
| `google_ima` | `object` |  | Accepted by this schema but silently discarded. The write path keeps only an explicit whitelist — `enabled`, `provider`, `vast_tag_url`, `timeout_ms`, `programmatic_allocation_percent`, `floor_price_cpm`, `currency`, `dynamic_floor_pricing`, `blocked_advertisers`, `blocked_iab_categories`, `preferred_deal_ids` — and `google_ima` is not in it. A body containing only this key persists nothing and still returns 200. |
| `prebid` | `object` |  | Accepted by this schema but silently discarded — not in the write whitelist, never persisted, and never read back (`header_bidding_settings.prebid` is JSON `null` on every row). A body containing only this key persists nothing and still returns 200. |
| `priority` | `string`: one of `direct_first`, `programmatic_first`, `highest_bid` |  | Ad source priority |

Responses:

- `200` Settings updated
- `401` Unauthorized - Invalid or missing API key
- `404` Resource not found

### Webhooks

Webhook subscriptions for real-time event notifications

#### POST /v1/partner/billing/webhook

Stripe webhook callback

Stripe POSTs subscription, invoice, and customer events to this
endpoint. The request is verified via the `Stripe-Signature` header
against the configured webhook secret; unsigned or invalid
requests are rejected with 400. NOT for partner use — listed here
only to keep the OpenAPI surface complete.

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

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `Stripe-Signature` | header | `string` | yes |  |

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

Responses:

- `200` Event processed
- `400` Missing or invalid signature

#### GET /v1/partner/webhooks

List webhooks

Get all webhook subscriptions for your account.

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `listWebhooks`

Responses:

- `200` Webhooks list
- `401` Unauthorized - Invalid or missing API key

#### POST /v1/partner/webhooks

Create webhook subscription

Subscribe to webhook events. Webhooks deliver real-time notifications
when events occur on your devices.

**Available Events:**
- `device.online` - Device comes online after being offline
- `device.offline` - Device goes offline (no heartbeat for 5+ minutes)
- `device.requirements_updated` - A screen's `monetization_status` changed (e.g. → ready when classification completes, or → blocked when it permanently fails). Payload carries the full `monetization_status` + `requirements` + `location_capability` so you can react without polling.
- `impression.recorded` - Ad impression recorded on a device
- `creative.rewards_ready` - Native measured play states changed for your funded creative experiment. The hint carries a reader URL and date scope; fetch the canonical reward endpoint with your own idempotency key.
- `campaign.allocated` - New campaign allocated to a device
- `payout.processed` - Revenue payout processed
- `sensing.threshold_crossed` - A Sense Anything Route's threshold was crossed on one of your screens (custom sensed field)
- `audience.spike` - A Route on a viewer-count signal crossed its threshold
- `audience.venue_busy` - A Route on an occupancy signal crossed its threshold
- `audience.purchase_intent` - A Route on a purchase-intent signal crossed its threshold
- `audience.demographics_update` - A Route on a demographics signal crossed its threshold
- `venue.traffic_summary` - A scheduled-digest Route's window elapsed (periodic traffic summary)
- `programmatic.ad_started` - Programmatic ad started
- `programmatic.ad_ended` - Programmatic ad ended
- `programmatic.no_fill` - Programmatic no-fill response
- `programmatic.error` - Programmatic error
- `screen_group.created` - A Screen Group was created
- `screen_group.updated` - A Screen Group's name/description was updated
- `screen_group.deleted` - A Screen Group was deleted
- `screen_group.member.added` - One or more screens were added to a Screen Group
- `screen_group.member.removed` - One or more screens were removed from a Screen Group
- `screen_group.content_policy.updated` - A Screen Group's content policy was changed
- `screen_group.sensing_config.updated` - A Screen Group's sensing config was changed
- `fleet_command.completed` - A group fleet command job completed
- `fleet_command.failed` - A group fleet command job terminated with an error

**Security:**
- HTTPS required in production
- HMAC-SHA256 signature verification
- Secret shown only once in response - store it securely!

**Signature Verification:**

Every webhook delivery carries a Stripe-style compound signature header:

```
X-Trillboards-Signature: t=<unix_seconds>,v0=<hex>,v1=<hex>
X-Trillboards-Timestamp: <unix_seconds>
```

Where:
- `v1 = HMAC-SHA256(webhook_secret, "${t}.${payload}")` — canonical, replay-protected
- `v0 = HMAC-SHA256(webhook_secret, payload)` — legacy body-only digest emitted
  during the migration window for partners on pre-0.3.0 verifiers.

Recommended: use `@trillboards/api-client@>=0.3.0`:

```js
import { Webhooks } from '@trillboards/api-client';
const event = Webhooks.constructEvent(req.body, req.headers, process.env.WEBHOOK_SECRET);
```

Full contract + replay-window guidance: https://api.trillboards.com/docs/integrations/webhook-signature-verification.md

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=false, idempotent=false, cost_tier=free
- operationId: `createWebhook`

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

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `events` | array of `string`: one of `device.online`, `device.offline`, `device.requirements_updated`, `impression.recorded`, `creative.rewards_ready`, `campaign.allocated`, `payout.processed`, `sensing.threshold_crossed`, … | yes | Events to subscribe to |
| `url` | `string` | yes | Webhook endpoint URL (HTTPS required in production) |

Responses:

- `201` Webhook created
- `400` Bad request
- `401` Unauthorized - Invalid or missing API key

#### PATCH /v1/partner/webhooks/{webhookId}

Update webhook

Update webhook URL, events, or status.

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

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

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

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `events` | array of `string`: one of `device.online`, `device.offline`, `device.requirements_updated`, `impression.recorded`, `creative.rewards_ready`, `campaign.allocated`, `payout.processed`, `sensing.threshold_crossed`, … |  |  |
| `status` | `string`: one of `active`, `inactive` |  |  |
| `url` | `string` |  |  |

Responses:

- `200` Webhook updated
- `404` Resource not found

#### DELETE /v1/partner/webhooks/{webhookId}

Delete webhook

Remove a webhook subscription.

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

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

Responses:

- `200` Webhook deleted
- `404` Resource not found

#### GET /v1/partner/webhooks/{webhookId}/deliveries

Get delivery history

Get recent webhook delivery attempts for debugging.

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getWebhookDeliveries`

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

Responses:

- `200` Delivery history
- `404` Resource not found

#### POST /v1/partner/webhooks/{webhookId}/test

Test webhook

Send a test event to verify your webhook endpoint is working.
The test event uses the `test` event type with a sample payload.

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=false, idempotent=false, cost_tier=free
- operationId: `testWebhook`

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

Responses:

- `200` Test result
- `404` Resource not found

### Dashboard

Dashboard overview and screen-level analytics

#### GET /v1/partner/dashboard

Get dashboard overview

Get comprehensive dashboard overview including partner info, screen stats,
earnings summary, top performing screens, and recent activity.

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getDashboard`

Responses:

- `200` Dashboard data retrieved
- `401` Unauthorized - Invalid or missing API key

#### GET /v1/partner/screens/{screenId}/analytics

Get per-screen analytics

Get detailed analytics for a specific screen including impression trends,
daily timeline, and performance metrics over a configurable period.

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getScreenAnalytics`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `screenId` | path | `string` | yes | Screen ID |
| `period` | query | `string`: one of `7d`, `30d`, `90d` |  | Time period for analytics |

Responses:

- `200` Screen analytics retrieved
- `401` Unauthorized - Invalid or missing API key
- `403` Screen does not belong to this partner
- `404` Resource not found

### Earnings

Earnings, transactions, and payout management

#### GET /v1/partner/earnings

Get earnings summary

Get comprehensive earnings summary including lifetime earnings, current month,
available balance, breakdown by source, and payout information.

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getEarnings`

Responses:

- `200` Earnings summary retrieved
- `401` Unauthorized - Invalid or missing API key

#### GET /v1/partner/earnings/breakdown

Get detailed earnings breakdown

Get earnings breakdown grouped by day, week, or screen.
Useful for detailed financial reporting and analysis.

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getEarningsBreakdown`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `start_date` | query | `string` |  | Start date (ISO 8601 format) |
| `end_date` | query | `string` |  | End date (ISO 8601 format) |
| `group_by` | query | `string`: one of `day`, `week`, `screen` |  | How to group the breakdown |

Responses:

- `200` Earnings breakdown retrieved
- `401` Unauthorized - Invalid or missing API key

#### POST /v1/partner/earnings/payout

Request a payout

Request a payout of available earnings to your connected Stripe account.
Requires Stripe Connect onboarding to be complete.

**Requirements:**
- Stripe account must be connected and active
- Available balance must meet minimum threshold ($100 default)
- If `amount_cents` not specified, full available balance is requested

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=false, idempotent=false, cost_tier=free
- operationId: `requestPayout`

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

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `amount_cents` | `integer` |  | Amount to payout in cents (optional, defaults to available balance) |

Responses:

- `200` Payout request submitted
- `400` Payout requirements not met
- `401` Unauthorized - Invalid or missing API key

#### GET /v1/partner/earnings/transactions

Get transaction history

Get paginated list of earnings transactions and payouts.
Transactions are aggregated by day showing impressions and calculated earnings.

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getTransactions`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `limit` | query | `integer` |  | Maximum transactions to return |
| `offset` | query | `integer` |  | Offset for pagination |
| `type` | query | `string`: one of `all`, `earning`, `payout` |  | Filter by transaction type |

Responses:

- `200` Transactions retrieved
- `401` Unauthorized - Invalid or missing API key

### Team

Team member management for partner organizations

#### GET /v1/partner/team

List team members

List all team members for the partner organization.
Returns member info, roles, and permissions.

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `listTeamMembers`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `status` | query | `string`: one of `active`, `pending`, `all` |  | Filter by membership status |

Responses:

- `200` Team members retrieved
- `401` Unauthorized - Invalid or missing API key

#### POST /v1/partner/team/accept

Accept team invitation

Accept a team invitation using the token from the invitation email.
Can be called with or without authentication.

**Without auth:** User with the invited email must exist
**With auth:** Links invitation to the authenticated user's account

- Auth:  or `BearerAuth` HTTP bearer
- Agent: agent_safe=false, idempotent=true, cost_tier=free
- operationId: `acceptInvitation`

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

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `token` | `string` | yes | Invitation token from email link |

Responses:

- `200` Invitation accepted
- `400` Token missing or user account required
- `404` Invalid or expired invitation token
- `409` Already a member of this organization

#### POST /v1/partner/team/invite

Invite a team member

Invite a new team member to join your partner organization.
The invitee will receive an email with instructions to join.

**Authorization:** Requires both a Partner API key and a portal user JWT
for an owner/admin in `X-User-Token`. API keys alone cannot invite users;
team management is role-based so access changes are tied to a signed-in
human owner or admin. Portal users can do the same action in
**Settings → Team** after claiming the owner account created during
registration.

**Roles:**
- `admin` - Can manage screens, view analytics, configure webhooks
- `viewer` - Can only view screen list and status

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=false, idempotent=false, cost_tier=free
- operationId: `inviteTeamMember`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `X-User-Token` | header | `string` | yes | User JWT token for authorization |

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

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `email` | `string` | yes | Email address of the invitee |
| `name` | `string` |  | Optional name of the invitee |
| `role` | `string`: one of `admin`, `viewer` |  | Role to assign to the team member |

Responses:

- `201` Invitation sent
- `400` Bad request
- `401` Unauthorized - Invalid or missing API key
- `403` Only owners can invite team members
- `409` User already has membership or pending invitation

#### GET /v1/partner/team/invite/{token}

Get invitation details

Get details about an invitation by token.
This is a public endpoint used during the signup flow.

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

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `token` | path | `string` | yes | Invitation token from email link |

Responses:

- `200` Invitation details retrieved
- `404` Invalid or expired invitation

#### POST /v1/partner/team/{membershipId}/resend

Resend invitation

Resend the invitation email to a pending team member.
Generates a new invitation token with extended expiry.

**Authorization:** Requires both partner API key AND user JWT with owner role.

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=false, idempotent=false, cost_tier=free
- operationId: `resendInvitation`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `membershipId` | path | `string` | yes | Membership ID of the pending invitation |
| `X-User-Token` | header | `string` | yes | User JWT token for authorization |

Responses:

- `200` Invitation resent
- `401` Unauthorized - Invalid or missing API key
- `403` Only owners can resend invitations
- `404` Pending invitation not found

#### DELETE /v1/partner/team/{userId}

Remove a team member

Remove a team member from the partner organization.
The owner cannot be removed.

**Authorization:** Requires both partner API key AND user JWT with owner role.

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

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `userId` | path | `string` | yes | User ID, membership ID, or email of the member to remove |
| `X-User-Token` | header | `string` | yes | User JWT token for authorization |

Responses:

- `200` Team member removed
- `400` Cannot remove owner
- `401` Unauthorized - Invalid or missing API key
- `403` Only owners can remove team members
- `404` Resource not found

#### PATCH /v1/partner/team/{userId}/role

Change team member role

Change the role of a team member. Cannot change owner's role.

**Authorization:** Requires both partner API key AND user JWT with owner role.

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

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `userId` | path | `string` | yes | User ID of the member |
| `X-User-Token` | header | `string` | yes | User JWT token for authorization |

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

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `role` | `string`: one of `admin`, `viewer` | yes |  |

Responses:

- `200` Role changed
- `400` Cannot change owner's role or invalid role
- `401` Unauthorized - Invalid or missing API key
- `403` Only owners can change roles
- `404` Resource not found

### VAST Integration

Direct VAST integration endpoints for partners who want to fetch ads directly from Google Ad Manager.

This is the recommended approach for enterprise partners with existing video players.
Partners cache the VAST config, build URLs client-side, and report impressions in batch.

**Architecture:** Trillboards provides config + tracking only. Partners fetch VAST directly from Google.

#### GET /v1/partner/vast/config

Get VAST configuration for direct Google integration

Returns a VAST tag URL template that partners use to fetch ads directly from Google Ad Manager.

**This is NOT a VAST proxy.** Trillboards provides the configuration; partners fetch VAST directly from Google.

**Architecture:**
1. Partner calls this endpoint (cached for 1 hour)
2. Partner builds VAST URL using the template
3. Partner fetches VAST directly from Google (`pubads.g.doubleclick.net`)
4. Partner plays video in their player
5. Partner reports impressions via `/tracking/batch`

**Scale:** At 35,000 screens with 1-hour cache, this is ~10 requests/second (trivial load).

**Security:** Partners only allowlist 2 domains: `api.trillboards.com` and `pubads.g.doubleclick.net`.

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getVastConfig`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `device_id` | query | `string` |  | Device identifier for targeting optimization |
| `screen_id` | query | `string` |  | Screen ID for targeting (alternative to device_id) |

Responses:

- `200` VAST configuration returned successfully
- `401` Unauthorized - Invalid or missing API key
- `429` Rate limit exceeded

### Batch Tracking

High-volume impression tracking with cryptographic proof generation.

Designed for partners with 10,000+ screens who need efficient batch reporting.
Each impression receives an Ed25519 signature for third-party verification.

#### GET /v1/partner/tracking/.well-known/public-key

Get public key for proof verification

Returns the Ed25519 public key used to sign impression proofs.

**No authentication required.** This is a public endpoint to enable third-party verification.

**Verification Process:**
1. Fetch public key from this endpoint
2. Reconstruct canonical payload: `v2.{timestamp}.{adId}.{impressionId}.{screenId}.{deviceId}`
3. Verify Ed25519 signature using the public key

See `/tracking/batch` response for signature format.

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

Responses:

- `200` Public key information

#### POST /v1/partner/tracking/batch

Record batch impressions with proof generation

High-volume endpoint for reporting multiple impressions in a single request.

**Features:**
- Process up to 100 impressions per batch
- Ed25519 cryptographic proof for each impression
- Duplicate detection (impressions with same impression_id + ad_id are ignored)
- Optional audience metrics (face count, attention)

**Batching Strategy:**
- Buffer impressions client-side
- Flush every 1-5 minutes or when buffer reaches 100
- Handle failures with local retry queue

**Proof Verification:**
Each proof can be independently verified using the public key from `/tracking/.well-known/public-key`.

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

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

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `impressions` | array of `object` | yes |  |

Responses:

- `200` Batch processed successfully
- `400` Invalid request (empty array, too many impressions, etc.)
- `401` Unauthorized - Invalid or missing API key
- `429` Rate limit exceeded

### Fleet Management

Fleet-wide device management for partners with large screen networks.

Push commands, update settings, and view analytics across your entire fleet.
Supports filtering by venue type, country, and specific screen IDs.

#### GET /v1/partner/fleet-commands/{id}

Poll a fleet-command job

Returns a fleet-command job created via POST /screen-groups/{groupId}/commands, including
per-screen results. Gated by SCREEN_GROUPS_ENABLED.

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getFleetCommand`

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

Responses:

- `200` The fleet-command job
- `401` Unauthorized - Invalid or missing API key
- `404` Resource not found

#### GET /v1/partner/fleet/analytics

Get fleet analytics

Returns fleet-wide performance metrics for the last 24 hours including
total impressions, fill rate, average CPM, and attention score.

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getFleetAnalytics`

Responses:

- `200` Fleet analytics
- `401` Unauthorized - Invalid or missing API key

#### POST /v1/partner/fleet/command

Push command to fleet

Sends a command to all devices in the fleet (or a filtered subset).
Commands expire after 10 minutes. Maximum 500 devices per batch.

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=false, idempotent=false, cost_tier=free
- operationId: `pushFleetCommand`

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

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `command` | `object` | yes |  |
| `filter` | `object` |  | Optional filter to target specific devices |

Responses:

- `200` Command queued
- `400` Missing command.type
- `401` Unauthorized - Invalid or missing API key
- `429` Rate limit exceeded

#### GET /v1/partner/fleet/overview

Get fleet overview

Returns a summary of all devices in the partner's fleet including
online/offline counts, venue type distribution, and country breakdown.

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getFleetOverview`

Responses:

- `200` Fleet overview
- `401` Unauthorized - Invalid or missing API key

#### POST /v1/partner/fleet/settings

Update fleet-wide settings

Updates settings across all devices in the fleet (or a filtered subset).
Settings are merged with existing values, not replaced.

**Allowed settings:** `header_bidding_settings`, `content_preferences`, `display_settings`

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

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

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `filter` | `object` |  |  |
| `settings` | `object` | yes | Settings to update (merged with existing) |

Responses:

- `200` Settings updated
- `400` Empty settings or invalid keys
- `401` Unauthorized - Invalid or missing API key
- `429` Rate limit exceeded

### Stripe Connect

Stripe Connect integration for partner payouts.

Create a Stripe Connect account, complete onboarding, and receive payouts
directly to your bank account. Minimum payout is $5.00.

#### POST /v1/partner/stripe/connect

Create Stripe Connect account

Creates a Stripe Connect Express account for the partner. If the partner already
has a Stripe account, returns the existing account details with HTTP 200.

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=false, idempotent=false, cost_tier=free
- operationId: `createStripeConnectAccount`

Responses:

- `200` Stripe account already exists
- `201` Stripe Connect account created
- `401` Unauthorized - Invalid or missing API key
- `500` Stripe API error

#### GET /v1/partner/stripe/login-link

Get Stripe dashboard login link

Returns a temporary link to the partner's Stripe Express dashboard.

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

Responses:

- `200` Login link generated
- `400` Partner has no Stripe account
- `401` Unauthorized - Invalid or missing API key

#### GET /v1/partner/stripe/onboarding-link

Get Stripe onboarding link

Returns a temporary Stripe-hosted onboarding URL where the partner can
complete identity verification and bank account setup.

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

Responses:

- `200` Onboarding link generated
- `400` Partner has no Stripe account — call POST /stripe/connect first
- `401` Unauthorized - Invalid or missing API key

#### POST /v1/partner/stripe/payout

Request a payout

Initiates a payout transfer to the partner's Stripe Connect account.
Minimum payout is $5.00 (500 cents). The partner must have an active
Stripe account and sufficient available balance.

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=false, idempotent=false, cost_tier=free
- operationId: `requestStripePayout`

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

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `amount_cents` | `integer` | yes | Payout amount in cents (minimum $5.00) |
| `description` | `string` |  | Optional description for the payout |
| `idempotency_key` | `string` |  | Deduplication key to prevent duplicate payouts |

Responses:

- `200` Payout initiated
- `400` Invalid amount, insufficient balance, or account not active
- `401` Unauthorized - Invalid or missing API key

#### GET /v1/partner/stripe/payouts

Get payout history

Returns a paginated list of past Stripe payouts for the partner.

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getStripePayoutHistory`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `limit` | query | `integer` |  | Maximum number of payouts to return |

Responses:

- `200` Payout history
- `401` Unauthorized - Invalid or missing API key

#### GET /v1/partner/stripe/status

Get Stripe account status

Returns the current status of the partner's Stripe Connect account including
onboarding requirements and payout readiness.

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getStripeAccountStatus`

Responses:

- `200` Account status
- `401` Unauthorized - Invalid or missing API key

#### POST /v1/partner/stripe/webhook

Stripe webhook receiver

Receives webhook events from Stripe for Connect account updates and transfer status changes.
This endpoint is called by Stripe, not by the partner. It uses Stripe signature verification.

**Handled events:** `account.updated`, `transfer.created`, `transfer.paid`, `transfer.reversed`

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

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

Responses:

- `200` Webhook acknowledged

### Connect Dashboard

SDK partner dashboard API for earnings, screen health, and analytics.

Provides pre-aggregated data optimized for embedding in partner dashboards.

#### GET /v1/partner/connect/dashboard

Get partner dashboard overview

Returns a comprehensive dashboard with screen counts, impression stats,
earnings data, daily charts, and top-performing screens.
Optimized for embedding in partner SDK dashboards.

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getConnectDashboard`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `period` | query | `string`: one of `7d`, `30d`, `90d` |  | Time period for analytics data |

Responses:

- `200` Dashboard data
- `401` Unauthorized - Invalid or missing API key

#### GET /v1/partner/connect/earnings

Get earnings chart data

Returns daily or weekly earnings breakdown for charting.

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getConnectEarningsChart`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `period` | query | `string`: one of `7d`, `30d`, `90d` |  |  |
| `group_by` | query | `string`: one of `day`, `week` |  |  |

Responses:

- `200` Earnings chart data
- `401` Unauthorized - Invalid or missing API key

#### GET /v1/partner/connect/health

Get screen health status

Returns the online/offline status of all partner devices with heartbeat
timestamps, uptime percentages, and location data.

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getConnectScreenHealth`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `status` | query | `string`: one of `online`, `offline`, `all` |  | Filter by screen status |

Responses:

- `200` Screen health data
- `401` Unauthorized - Invalid or missing API key

### Venue Intelligence

Real-world venue intelligence from CTV audience sensing.

Access foot traffic patterns, atmosphere data, nearby venues, and network-wide trends.
Data is aggregated from on-device face detection, audio analysis, and environmental sensors.

#### GET /v1/partner/network/trends

Network-wide audience and inventory trends

Returns daily-aggregate audience trends across the Trillboards
network — venue-type breakdowns, audience composition shifts,
device-density changes — for the partner's licensed regions.
Network-level aggregates; no per-screen rows.

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getNetworkTrends`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `days` | query | `integer` |  | Lookback window |

Responses:

- `200` Trend payload
- `401` Unauthorized - Invalid or missing API key

#### GET /v1/partner/venues/nearby

List nearby Trillboards venues

Returns venues within `radius_km` of `lat`/`lng`, scoped to venues
the partner has access to. Used for context-aware ad targeting and
place-of-interest analytics.

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getVenuesNearby`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `lat` | query | `number` | yes |  |
| `lng` | query | `number` | yes |  |
| `radius_km` | query | `number` |  |  |

Responses:

- `200` List of nearby venues
- `401` Unauthorized - Invalid or missing API key

#### GET /v1/partner/venues/{screenId}/atmosphere

Real-world atmosphere signals for a venue

Returns aggregated atmosphere signals captured on-device at the
screen — ambient noise level, lighting band, motion intensity,
dominant ambient class (cafe / lobby / outdoor / gym / retail). All
signals are venue-level aggregates.

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getVenueAtmosphere`

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

Responses:

- `200` Atmosphere payload
- `401` Unauthorized - Invalid or missing API key
- `404` Screen not found or not accessible to this partner

#### GET /v1/partner/venues/{screenId}/traffic

Foot-traffic profile for a venue

Returns daily and hourly foot-traffic histograms for the screen,
derived from on-device face counts and dwell times. Includes
rolled-up busiest-hour, average-dwell, and weekday-vs-weekend
breakdowns.

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getVenueTraffic`

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

Responses:

- `200` Traffic payload
- `401` Unauthorized - Invalid or missing API key
- `404` Screen not found

#### GET /v1/partner/venues/{venueId}/intelligence

Get venue intelligence report

Returns aggregated venue intelligence including multi-screen deduplication,
timeseries data with demographics, weather context, and audience metrics.
Maximum date range is 30 days.

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getVenueIntelligence`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `venueId` | path | `string` | yes | Venue ID (MongoDB ObjectId) |
| `from` | query | `string` | yes | Start date (ISO 8601) |
| `to` | query | `string` | yes | End date (ISO 8601) |
| `interval` | query | `string`: one of `5min`, `15min`, `1h`, `1d` |  | Time bucket interval |

Responses:

- `200` Venue intelligence data
- `400` Missing or invalid parameters (from/to required, max 30 days)
- `401` Unauthorized - Invalid or missing API key
- `404` Venue not found or not authorized

### Audience Intelligence

Advanced audience analytics including live data, heatmaps, lookalike screens, and predictions.

Lookalike and prediction endpoints require usage-based billing (`requireBilling('data_api')`).

#### GET /v1/partner/analytics/audience-continuity

Visitor-flow continuity graph across the partner's screens

Returns the top inter-screen audience paths (from-screen / to-screen,
median lag, signals, confidence). Backed by
`audienceContinuityService.getAudienceContinuity`.

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getAudienceContinuity`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `timeRange` | query | `string`: one of `today`, `last_7d`, `last_30d` |  |  |
| `windowMinutes` | query | `integer` |  |  |

Responses:

- `200` Continuity payload
- `401` Unauthorized - Invalid or missing API key

#### GET /v1/partner/analytics/audience-profile

Audience composition for the partner's screens

Returns the audience-profile composition (demographic segments,
age/gender distribution, lifestyle bands, confidence scores). Backed
by `sceneIntelligenceService.getAudienceProfile`.

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

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `timeRange` | query | `string`: one of `today`, `last_7d`, `last_30d` |  |  |

Responses:

- `200` Audience profile payload
- `401` Unauthorized - Invalid or missing API key

#### GET /v1/partner/analytics/ble-summary

BLE device discovery summary

Returns `{ uniqueDevices, totalObservations }` aggregated across the
partner's screens for `timeRange`. Backed by the same
`conversationIntelligenceService.getBleDeviceSummary` the venue
portal uses.

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getBleSummary`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `timeRange` | query | `string`: one of `today`, `last_7d`, `last_30d` |  |  |

Responses:

- `200` BLE summary
- `401` Unauthorized - Invalid or missing API key

#### GET /v1/partner/analytics/content-performance

Content performance — ad lineage and measured audience reactions

Returns the data shown in the venue-portal "Content Performance" modal —
per-creative ad lineage (advertiser, brand, moderation status, IAB
classification, age rating, confidence, served-window) plus measured
audience reactions (plays, attention, viewers, attentive-time) and the
creative-classification chip cloud (subjects, setting, mood, quality).

Backed by the same `audienceMetricsService.getContentPerformance` that
powers the venue portal — the partner endpoint just scopes the
screenIds to the partner's fleet.

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getContentPerformance`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `timeRange` | query | `string`: one of `today`, `last_7d`, `last_30d` |  |  |
| `groupBy` | query | `string`: one of `canonical`, `wrapper` |  | `canonical` collapses DSP wrappers under the canonical logical content (recommended). `wrapper` preserves per-creative_id rows for billing/debug. |
| `limit` | query | `integer` |  |  |
| `offset` | query | `integer` |  |  |
| `includeUnclassified` | query | `boolean` |  | By default unclassified creatives (advertiserName=NULL / generic / moderation_status=metadata_only) are hidden. Pass `true` to include them. |

Responses:

- `200` Content performance payload
- `401` Unauthorized - Invalid or missing API key

#### GET /v1/partner/analytics/creative-catalog

Free-text search across the partner's creative catalogue

Searches the partner's creative catalogue by advertiser name, brand,
creative title, or IAB category. Returns lightweight creative
descriptors (no audience metrics — use `/analytics/content-performance`
for those). Backed by the same `audienceMetricsService.searchCreativeCatalog`
used by the venue portal.

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `searchCreativeCatalog`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `q` | query | `string` |  | Free-text query. Empty string returns the most-recent creatives. |
| `limit` | query | `integer` |  |  |
| `offset` | query | `integer` |  |  |

Responses:

- `200` Creative catalogue results
- `401` Unauthorized - Invalid or missing API key

#### GET /v1/partner/analytics/creative-scenes

Scene-level observations for a played creative

Returns the timestamped scene observations captured while a specific
creative (`adId`) was playing on a specific screen (`screenId`). Each
scene includes the audience-class chip cloud, attention metrics,
engagement narrative, and the cloud-emitted `ad_fit_verdict`. Cursor-
paginated. Backed by `audienceMetricsService.getCreativeSceneObservations`.

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getCreativeScenes`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `adId` | query | `string` | yes |  |
| `screenId` | query | `string` | yes | Must belong to the partner's accessible screens. |
| `timeRange` | query | `string`: one of `today`, `last_7d`, `last_30d` |  |  |
| `cursor` | query | `string` |  | Opaque pagination cursor returned by the previous response. |
| `limit` | query | `integer` |  |  |

Responses:

- `200` Scene observations
- `400` Missing `adId` or `screenId`
- `401` Unauthorized - Invalid or missing API key
- `403` `screenId` not accessible to this partner

#### GET /v1/partner/analytics/cross-screen-flow

Per-pair flow directionality + transition heatmap

Returns the directional flow between every pair of partner screens
(Sankey-friendly). Includes `edges` (from, to, flow_count, weight)
and `screens` (position lookup). Backed by
`crossScreenFlowService.getCrossScreenFlow`.

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getCrossScreenFlow`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `timeRange` | query | `string`: one of `today`, `last_7d`, `last_30d` |  |  |

Responses:

- `200` Cross-screen flow payload
- `401` Unauthorized - Invalid or missing API key

#### GET /v1/partner/analytics/dashboard

Unified portfolio dashboard (earnings, ads, audience, screens, insights)

Returns the same unified dashboard payload the venue portal renders
at `/v2/earner/analytics/dashboard`, scoped to the partner's fleet.
Delegates to `earnerAnalyticsService.getDashboard` via the
partner-context `screenIdsOverride` path so the shared service skips
the owner/manager lookup and operates on the pre-authorized screen
list. Earnings figures reflect the partner's revenue share where
applicable.

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getPartnerAnalyticsDashboard`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `period` | query | `string`: one of `7d`, `30d`, `90d`, `custom` |  |  |
| `startDate` | query | `string` |  | ISO date (required when period=custom) |
| `endDate` | query | `string` |  | ISO date (required when period=custom) |

Responses:

- `200` Dashboard payload
- `400` Custom period requires startDate and endDate
- `401` Unauthorized - Invalid or missing API key

#### GET /v1/partner/analytics/device-suite-signature

Device-type / OS / browser-class distribution among repeat visitors

Returns the device-class breakdown observed across the partner's
screens — useful for ad-creative format/codec decisions and
partner-audience targeting. Backed by
`deviceSuiteSignatureService.getDeviceSuiteSignature`.

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getDeviceSuiteSignature`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `timeRange` | query | `string`: one of `today`, `last_7d`, `last_30d` |  |  |

Responses:

- `200` Device-suite signature
- `401` Unauthorized - Invalid or missing API key

#### GET /v1/partner/analytics/export

CSV export of partner-scope analytics (summary / daily / per-screen)

Mirrors the venue-portal earner export at
`/v2/earner/analytics/export`, scoped to the partner's fleet via
the `screenIdsOverride` path through
`earnerAnalyticsService.getExportData`. Response body is the CSV
text directly; `Content-Disposition: attachment` is set so a
browser will download instead of rendering.

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `exportPartnerAnalytics`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `period` | query | `string`: one of `7d`, `30d`, `90d`, `custom` |  |  |
| `type` | query | `string`: one of `summary`, `daily-breakdown`, `screen-breakdown` |  |  |
| `startDate` | query | `string` |  | ISO date (required when period=custom) |
| `endDate` | query | `string` |  | ISO date (required when period=custom) |

Responses:

- `200` CSV download
- `400` Custom period requires startDate and endDate
- `401` Unauthorized - Invalid or missing API key

#### GET /v1/partner/analytics/export/ads-by-day

Per-ad daily breakdown for the partner fleet

Returns one row per (date, screen, ad) for the partner's fleet
across the supplied date range. Reads from the live
`ScreenDailyAnalyticsByAd` store scoped to the partner's screen
list (the aggregation cron is still Mongo-only; reading the PG
mirror would silently empty recent days). Default response is
CSV; `format=json` returns the structured rows for programmatic
consumption.

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `exportPartnerAdsByDay`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `startDate` | query | `string` | yes | ISO date (YYYY-MM-DD) |
| `endDate` | query | `string` | yes | ISO date (YYYY-MM-DD) |
| `format` | query | `string`: one of `csv`, `json` |  |  |

Responses:

- `200` CSV download (or JSON when format=json)
- `400` startDate and endDate are required
- `401` Unauthorized - Invalid or missing API key

#### GET /v1/partner/analytics/fleet-hourly

24-hour distribution across the partner's fleet

Returns hourly visitor counts and average attention for each hour of
the day, aggregated across the partner's fleet for `timeRange`.
Backed by `audienceMetricsService.getFleetHourlyBreakdown`.

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getFleetHourly`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `timeRange` | query | `string`: one of `today`, `last_7d`, `last_30d` |  |  |

Responses:

- `200` Fleet hourly payload
- `401` Unauthorized - Invalid or missing API key

#### GET /v1/partner/analytics/halo-funnel

4-stage discovery → awareness → engagement → conversion funnel

Returns the partner-fleet funnel measured from venue audience
signals: stage counts, drop-off ratios, conversion signals.
Backed by `haloFunnelService.getHaloFunnel`.

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getHaloFunnel`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `timeRange` | query | `string`: one of `today`, `last_7d`, `last_30d` |  |  |

Responses:

- `200` Halo funnel payload
- `401` Unauthorized - Invalid or missing API key

#### GET /v1/partner/analytics/loyalty-clock

24-hour returning-vs-first-timer audience clock

Returns the 24-hour radial showing the share of returning vs first-
timer audience per hour over the `timeRange` window. "Returning" is
defined as the same resolved_device_id_hash seen on a different
calendar day within the lookback window. Backed by
`loyaltyClockService.getLoyaltyClock`.

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getLoyaltyClock`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `timeRange` | query | `string`: one of `today`, `last_7d`, `last_30d` |  |  |

Responses:

- `200` Loyalty clock payload
- `401` Unauthorized - Invalid or missing API key

#### GET /v1/partner/analytics/network-summary

Fleet-wide audience summary for the partner's screens

Returns the fleet-wide audience summary for the partner's screens
for the given `timeRange`. Equivalent to the non-live path of
`GET /v2/earner/analytics/network-summary`. Backed by
`audienceMetricsService.getNetworkSummaryByScreenIds`. The
WebSocket-style live mode is not exposed to partners via this
endpoint; partners poll the standard rollup.

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getNetworkSummary`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `timeRange` | query | `string`: one of `today`, `last_7d`, `last_30d` |  |  |

Responses:

- `200` Network audience summary
- `401` Unauthorized - Invalid or missing API key

#### GET /v1/partner/analytics/network-trend

Daily/hourly fleet-wide audience timeseries

Returns a timeseries of fleet-wide visitor counts + average attention
over `timeRange`, bucketed by `interval`. Backed by
`audienceMetricsService.getNetworkTrend`.

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getNetworkTrend`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `timeRange` | query | `string`: one of `today`, `last_7d`, `last_30d` |  |  |
| `interval` | query | `string`: one of `1h`, `1d` |  |  |

Responses:

- `200` Network trend payload
- `401` Unauthorized - Invalid or missing API key

#### GET /v1/partner/analytics/proximity-summary

Cross-screen proximity-graph summary

Returns the same proximity-graph summary the venue-portal Insights
pane shows — `uniqueSignals`, `repeatSignals`, `crossScreenSignals`,
per-source breakdown (BLE / WiFi / mDNS / gateway), distance-aware
gate diagnostics, continuity edges, and top paths. Backed by
`conversationIntelligenceService.getProximitySummary`. Every count is
an aggregate over the partner's screen set.

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getProximitySummary`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `timeRange` | query | `string`: one of `today`, `last_7d`, `last_30d` |  |  |

Responses:

- `200` Proximity-graph summary
- `401` Unauthorized - Invalid or missing API key

#### GET /v1/partner/analytics/revenue-attention-correlation

Per-creative scatter of avg-attention vs revenue-per-impression

Returns scatter-plot data showing the relationship between average
attention and revenue per impression, point-per-creative, plus a
trend line. Backed by
`audienceMetricsService.getRevenueAttentionCorrelation`.

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getRevenueAttentionCorrelation`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `timeRange` | query | `string`: one of `today`, `last_7d`, `last_30d` |  |  |

Responses:

- `200` Correlation payload
- `401` Unauthorized - Invalid or missing API key

#### GET /v1/partner/analytics/scene-feed

Paginated chronological feed of scene observations

Returns a paginated time-ordered list of scene observations across
the partner's screens — timestamp, scene tags, face_count, confidence,
thumbnail_url. Backed by `sceneIntelligenceService.getSceneFeed`.

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getSceneFeed`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `limit` | query | `integer` |  |  |
| `offset` | query | `integer` |  |  |

Responses:

- `200` Scene feed payload
- `401` Unauthorized - Invalid or missing API key

#### GET /v1/partner/analytics/scene-intelligence

Latest scene description per screen

Returns the most-recent scene description for each of the partner's
screens — venue-class chip cloud, dominant mood, occupancy band,
ambient signals. Backed by
`sceneIntelligenceService.getLatestSceneDescriptionsWithDiagnostic`.
Returns at most one row per screen.

`scenes: []` is ambiguous on its own — it is returned both when the
fleet genuinely has no recent scenes and when the read could not be
performed (deadline, PG read failure, dependency disabled). Read
`sceneDiagnostic.measured` to tell the two apart; do NOT infer an
outage from an empty array, and do not treat a `measured: false`
response as evidence about the fleet.

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getSceneIntelligence`

Responses:

- `200` Scene intelligence payload
- `401` Unauthorized - Invalid or missing API key

#### GET /v1/partner/audience/heatmap

Get audience heatmap data

Returns geospatial audience heatmap data across partner screens.
Available to all tiers.

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getAudienceHeatmap`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `screen_ids` | query | `string` |  | Comma-separated screen IDs to include (omit for all screens) |
| `days` | query | `integer` |  | Lookback period in days |

Responses:

- `200` Heatmap data
- `401` Unauthorized - Invalid or missing API key

#### GET /v1/partner/audience/live/{screenId}

Get live audience data for a screen

Returns real-time audience data including face count, attention score, mood,
demographics, purchase intent, and crowd density. Available to all tiers.

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getLiveAudience`

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

Responses:

- `200` Live audience data
- `401` Unauthorized - Invalid or missing API key
- `404` Screen not found

#### GET /v1/partner/audience/lookalikes/{screenId}

Find lookalike screens

Finds screens with similar audience profiles using vector similarity search.
Requires usage-based billing (`requireBilling('data_api')`).

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=metered
- operationId: `getLookalikeScreens`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `screenId` | path | `string` | yes | Source screen to find lookalikes for |
| `limit` | query | `integer` |  | Maximum number of matches |
| `country` | query | `string` |  | Filter by country |
| `venue_type` | query | `string` |  | Filter by venue type |
| `min_similarity` | query | `number` |  | Minimum similarity score (0-1) |

Responses:

- `200` Lookalike matches
- `401` Unauthorized - Invalid or missing API key
- `402` Free tier exhausted — add a payment method to continue
- `404` Screen not found

#### GET /v1/partner/audience/predict/{screenId}

Predict screen audience

Predicts audience composition for a specific hour and day of week
using historical data. Requires usage-based billing (`requireBilling('data_api')`).

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=metered
- operationId: `predictScreenAudience`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `screenId` | path | `string` | yes |  |
| `hour` | query | `integer` | yes | Hour of day (UTC) |
| `day` | query | `integer` | yes | Day of week (0=Sunday, 6=Saturday) |
| `lookback_days` | query | `integer` |  | Historical lookback period in days |

Responses:

- `200` Audience prediction
- `400` Invalid hour or day values
- `401` Unauthorized - Invalid or missing API key
- `402` Free tier exhausted — add a payment method to continue
- `404` Screen not found

#### GET /v1/partner/audience/resolved-household-reach/{campaignId}

Resolved household reach for a campaign

Returns the unique household count reached by a campaign across the
Trillboards network, with a confidence interval, audience composition
breakdown, and overlap with the partner's first-party audience (when
a `pseudo_id` list was supplied at campaign creation). Underpins
cross-channel reach reporting.

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getResolvedHouseholdReach`

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

Responses:

- `200` Household reach payload
- `401` Unauthorized - Invalid or missing API key
- `404` Campaign not found

#### GET /v1/partner/audience/stats

Get audience data statistics

Returns high-level statistics about audience data availability for the partner's screens.
Available to all tiers.

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getAudienceStats`

Responses:

- `200` Audience stats
- `401` Unauthorized - Invalid or missing API key

#### GET /v1/partner/data-export/proximity-graph-edges

Export the cross-screen proximity graph edge list

Returns the partner's cross-screen proximity-graph edges — pairs of screens, at least one of
them the partner's own, that the same device was seen moving between in the lookback window —
every edge with its counts: `edge_count` (distinct transitions), `unique_devices` and
`lag_p50` (median minutes between the two sightings). Used by data-licensing buyers (DSP
audience-modelling pipelines).

A page at a time: the first page pins the window to end at its UTC second (`window_end`), and
edges are ordered by `source_screen_id`, then `destination_screen_id`. Pass `next_cursor` back
as `cursor`, with the same `days`, until `truncated` is `false`.

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `exportProximityGraphEdges`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `days` | query | `integer` |  | Lookback window in days (1–90). Defaults to 7. |
| `limit` | query | `integer` |  | Edges per page. Defaults to 50000, the maximum; a larger value is capped at 50000. |
| `cursor` | query | `string` |  | The previous page's `next_cursor`, passed back unchanged. Omit for the first page. |

Responses:

- `200` One page of edges with their counts, and the pinned window.
- `400` Invalid `limit` or `cursor` parameter, or a `cursor` sent with other `days` than the first page.
- `401` Unauthorized - Invalid or missing API key

#### GET /v1/partner/screen/{external_code}/audience-segments

OpenRTB SDA payload for a screen

Returns the OpenRTB 2.6 Supply-Side Audience (SDA) payload for the
screen, indexed by the partner-issued `external_code`. The payload
encodes segment IDs from IAB Audience Taxonomy 1.1 and the
Trillboards segtax=600 namespace, with current confidence values.
Bidders use this to apply per-screen targeting decisions.

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getScreenAudienceSegments`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `external_code` | path | `string` | yes | Partner-issued opaque screen identifier. |

Responses:

- `200` OpenRTB SDA payload
- `401` Unauthorized - Invalid or missing API key
- `404` Screen not found

#### GET /v1/partner/screens/{screenId}/analytics/atmosphere

Current atmosphere (mood, noise, crowd density)

Returns the current venue atmosphere read for a screen — mood,
noise level, crowd density, current viewers, engagement. Tries
live data first, falls back to last hour of Postgres data.
Same backing service as the earner endpoint.

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getPartnerScreenAtmosphere`

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

Responses:

- `200` Atmosphere payload
- `401` Unauthorized - Invalid or missing API key
- `403` Access denied for this screen

#### GET /v1/partner/screens/{screenId}/analytics/intelligence

Time-bucketed venue intelligence with Lincoln-Petersen dedup

Returns time-bucketed audience intelligence for a screen with
Lincoln-Petersen multi-screen dedup. Same backing service as the
earner endpoint (`venueAnalyticsService.computeScreenIntelligence`);
the partner controller fetches the screen row so the service can
do its sibling-screen lookup the same way the earner does.

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getPartnerScreenIntelligence`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `screenId` | path | `string` | yes |  |
| `from` | query | `string` | yes |  |
| `to` | query | `string` | yes |  |
| `interval` | query | `string`: one of `5min`, `15min`, `1h`, `1d` |  |  |

Responses:

- `200` Intelligence payload
- `400` Missing or invalid from / to / interval, or date range exceeds 30 days
- `401` Unauthorized - Invalid or missing API key
- `403` Access denied for this screen

#### GET /v1/partner/screens/{screenId}/analytics/taste-profile

Per-screen taste profile + top-5 creative categories

Returns the screen's FEIN Phase 6 taste profile — sample count,
venue type, daypart distribution, computed-from window, plus a
top-5 projection of creative categories that have played on this
screen recently. Same backing service as the earner endpoint
(`screenTasteAnalyticsService.computeScreenTasteProfile`). Returns
`ready: false` if the snapshotter hasn't filled the screen yet.

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getPartnerScreenTasteProfile`

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

Responses:

- `200` Taste profile payload
- `401` Unauthorized - Invalid or missing API key
- `403` Access denied for this screen
- `503` Taste profile temporarily unavailable

#### GET /v1/partner/screens/{screenId}/analytics/traffic

Hourly foot-traffic patterns for a screen

Returns the per-hour foot-traffic histogram for a single screen
(avg viewers, occupancy band, measurement count) plus a 0-10
busy-score. Same shape as
`GET /v2/earner/screens/{screenId}/analytics/traffic` because both
surfaces delegate to `venueAnalyticsService.computeScreenTraffic`
(extracted in PR #5265).

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getPartnerScreenTraffic`

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

Responses:

- `200` Traffic payload
- `401` Unauthorized - Invalid or missing API key
- `403` Access denied for this screen

#### GET /v1/partner/screens/{screenId}/analytics/vas

Verified Attention Seconds + CPM tier for a screen

Returns VAS summary statistics (weighted average, median, p90, face
count, attention score, measurement totals) plus the screen's
current CPM tier and tier multiplier. Same backing service as the
earner endpoint (`venueAnalyticsService.computeScreenVAS`).

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getPartnerScreenVAS`

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

Responses:

- `200` VAS payload
- `401` Unauthorized - Invalid or missing API key
- `403` Access denied for this screen

#### GET /v1/partner/screens/{screenId}/sensing/chips

Read the custom sensing chips a screen is producing

Returns the custom observation chips emitted by the screen's active Sense
Anything profile(s). The returned field set is derived from the screen's
profile (the fields you defined via interpret-intent), bounded to custom
provenances. Scoped to a screen the authenticated partner owns.

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getScreenSensingChips`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `screenId` | path | `string` | yes |  |
| `days` | query | `integer` |  | Lookback window in days (max 400) |
| `limit` | query | `integer` |  | Max rows to return |
| `offset` | query | `integer` |  |  |

Responses:

- `200` Custom sensing chips for the screen
- `401` Unauthorized - Invalid or missing API key
- `404` Resource not found

#### POST /v1/partner/screens/{screenId}/sensing/interpret-intent

Generate a custom sensing profile from a natural-language intent

"Sense Anything" for partners. Given a natural-language intent, an LLM
generates a custom sensing profile — a schema of custom fields the screen
will sense. This only GENERATES the profile (it is not persisted); POST the
returned profile to /screens/{screenId}/sensing/profile to deploy it.
Scoped to a screen the authenticated partner owns.

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `interpretScreenSensingIntent`

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

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

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `deviceTier` | `string` |  | Optional device-capability hint for model selection |
| `intent` | `string` | yes | Natural-language description of what to sense |

Responses:

- `200` Generated sensing profile (not yet deployed)
- `400` Missing or empty intent
- `401` Unauthorized - Invalid or missing API key
- `404` Resource not found

#### POST /v1/partner/screens/{screenId}/sensing/profile

Deploy a custom sensing profile to a screen

Persist + activate a custom sensing profile (from interpret-intent) on a
screen you own. Once active, the audience-analyze pipeline emits the
profile's custom chips for this screen's observations. Scoped to a screen
the authenticated partner owns.

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

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

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

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `profile` | `object` | yes | The sensing profile object returned by interpret-intent |

Responses:

- `200` Profile deployed and pushed to the device
- `400` Missing or invalid profile
- `401` Unauthorized - Invalid or missing API key
- `404` Resource not found
- `409` Device fingerprint could not be resolved for this screen

#### GET /v1/partner/screens/{screenId}/sensing/settings

Read a screen's AI sensing performance settings

Returns the screen's desired/effective sensing performance mode, active
cadence, read-only device capability telemetry, safety warnings, and the
last applied sensing profile. Device hardware facts are telemetry-derived
and cannot be overwritten through this API.

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getScreenSensingSettings`

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

Responses:

- `200` Sensing performance settings for the screen
- `401` Unauthorized - Invalid or missing API key
- `404` Resource not found

#### PATCH /v1/partner/screens/{screenId}/sensing/settings

Update a screen's AI sensing performance mode

Applies a high-level sensing performance mode to a screen you own. The
server translates the mode into the canonical sensing profile, cadence,
program spec, and device command. Use this instead of editing raw model
ids, cadence, or device tier.

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

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

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

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `performance_mode` | `SensingPerformanceMode` | yes |  |

Responses:

- `200` Sensing performance mode applied through the profile pipeline
- `400` Unsupported performance mode
- `401` Unauthorized - Invalid or missing API key
- `404` Resource not found
- `409` Device identity unresolved or safety checks blocked the requested workload

#### GET /v1/partner/sensing/routes

List your Sense Anything Routes

List the Routes owned by the authenticated partner.

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `listPartnerSensingRoutes`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `includeDisabled` | query | `boolean` |  | Include disabled routes. Pass `false` to list only enabled routes (default true). |

Responses:

- `200` Your routes
- `401` Unauthorized - Invalid or missing API key
- `404` Resource not found

#### POST /v1/partner/sensing/routes

Create a Sense Anything Route (threshold or scheduled-digest alert)

Create a Route: when a sensed field crosses a threshold on a set of your
screens (threshold mode), or on a fixed schedule (digest mode), fire one or
more channel actions (webhook / managed WhatsApp / SMS / Slack / email).
Every screen in `screenMongoIds` must be a screen this partner owns — a
foreign screen returns 403. Anti-noise (sustain/debounce) and an active
window are first-class. Gated by the platform `SENSING_ROUTES_ENABLED`
flag (404 when the feature is disabled).

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=false, idempotent=false, cost_tier=free
- operationId: `createPartnerSensingRoute`

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

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `actions` | array of `SensingRouteAction` | yes | The channel actions fired on a match; at least 1 and at most 10, each carrying exactly the destination its channel requires. A route with zero actions cannot exist, so use a `webhook` action for anything you do not want delivered to a person. |
| `activeDaysMask` | `integer` |  | Seven-bit weekday mask evaluated in `activeTz`: bit 0 Monday through bit 6 Sunday, so the default 127 is every day and 0 means the route never fires. Requires `activeTz`. |
| `activeEndMinute` | `integer` |  | Inclusive end of the daily active window, minutes past local midnight in `activeTz` (0..1439). Set it BELOW `activeStartMinute` for an overnight window — 1200 to 360 is 20:00 through 06:00. |
| `activeStartMinute` | `integer` |  | Inclusive start of the daily active window, minutes past local midnight in `activeTz` (0..1439; 1200 = 20:00). Has no effect without `activeTz`, and omitting either end makes the window whole-day. |
| `activeTz` | `string` |  | IANA zone name (e.g. `America/New_York`) the active window is evaluated in, trimmed to 64 characters. Omitting it makes `activeStartMinute`, `activeEndMinute` and `activeDaysMask` inert — the route is then always active — and an unparseable zone fails open. |
| `condition` | `SensingRouteCondition` |  |  |
| `debounceSeconds` | `integer` |  | Minimum gap in seconds between alerts for the same (route, screen) episode; clamped to 0..86400, default 300. It also bounds the falling-edge clear window at 60 s, so a value below that re-arms the route faster too. |
| `digest` | `object` |  | Required when mode=digest. `field` is required for avg/max. |
| `enabled` | `boolean` |  | Only the JSON literal `false` creates the route disabled — any other value, including `0` and the string `"false"`, creates it enabled. Note the update endpoint inverts this test, so send a real boolean on both. |
| `mode` | `string`: one of `threshold`, `digest` |  | `threshold` (the default) needs a `condition` and evaluates every incoming observation. `digest` needs a `digest` block instead, sends on a schedule, and rejects `screenFocusRegions` and explicit review timing. Only the exact string `digest` selects digest mode; anything else falls back to `threshold`. |
| `name` | `string` | yes | Operator-supplied label, required, trimmed and capped at 200 characters. It is delivered as `route_name` on every fire and shown in operator surfaces; it is not the alert text. |
| `profileName` | `string` |  | Bind to a named Sense Anything profile (stable across redeploys). |
| `reviewTimingMode` | `SensingRouteReviewTimingMode` |  |  |
| `reviewWaitSeconds` | `SensingRouteReviewWaitSeconds` |  |  |
| `screenFocusRegions` | array of `SensingRouteScreenFocusRegion` |  | Optional per-camera rectangles or polygon lassos. Every entry must reference screenMongoIds. Supported only for visual threshold alerts using once_per_episode, on_escalation, or nag_until_resolved cadence; not digest, group-targeted, non-visual, or per_new_entity routes. |
| `screenMongoIds` | array of `string` |  | Screens to watch — all owned by this partner (foreign → 403). Provide EITHER screenMongoIds OR targetGroupId, not both. |
| `sustainMinSamples` | `integer` |  | DEPRECATED and rejected on write with 400. A duration expressed as N readings inside a fixed window can only be satisfied by a screen reporting faster than window/(N-1) seconds, and nothing knows that rate when the route is saved - so the route saves cleanly and then never fires at all. Send sustainSeconds instead. Still honoured for rows that already carry it. |
| `sustainSeconds` | `integer` |  | How long the condition must hold before the FIRST alert, in seconds (600 = ten minutes). Omit to alert on the first true reading. Cadence-independent, because the evaluator compares timestamps - correct however often the screen reports, including when that rate changes while the condition holds. |
| `sustainWindowSeconds` | `integer` |  | DEPRECATED and rejected on write with 400. A duration expressed as N readings inside a fixed window can only be satisfied by a screen reporting faster than window/(N-1) seconds, and nothing knows that rate when the route is saved - so the route saves cleanly and then never fires at all. Send sustainSeconds instead. Still honoured for rows that already carry it. |
| `targetGroupId` | `string` |  | A sg_ Screen Group id (owned by this partner) to target instead of an explicit screen set. The route fires on the group's live members. Requires SENSING_ROUTES_GROUP_TARGET_ENABLED. |

Responses:

- `201` Route created
- `400` Bad request
- `401` Unauthorized - Invalid or missing API key
- `403` One or more screens are not owned by this partner
- `404` Resource not found

#### GET /v1/partner/sensing/routes/{routeId}

Get one Sense Anything Route

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getPartnerSensingRoute`

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

Responses:

- `200` The route
- `401` Unauthorized - Invalid or missing API key
- `404` Resource not found

#### PATCH /v1/partner/sensing/routes/{routeId}

Update a Sense Anything Route (partial, non-clobbering)

Update only the fields present in the body; omitted fields are left
unchanged. Re-targeting `screenMongoIds` re-authorizes the new screen set
against the partner's owned screens (403 on a foreign screen).

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

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

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

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `actions` | array of `SensingRouteAction` |  | REPLACES the entire action list (1..10 entries); the existing rows are deleted and reinserted. Omit the key to leave actions untouched — there is no way to add or remove a single action. |
| `activeDaysMask` | `integer` |  | Seven-bit weekday mask evaluated in `activeTz`: bit 0 is Monday through bit 6 is Sunday, so 127 is every day and 0 never fires. Requires `activeTz`, like the rest of the active window. |
| `activeEndMinute` | `integer` |  | Inclusive end of the daily active window, minutes past local midnight in `activeTz` (0..1439). A value below `activeStartMinute` is read as an overnight window, not an empty one. |
| `activeStartMinute` | `integer` |  | Inclusive start of the daily active window, minutes past local midnight in `activeTz` (0..1439). Requires `activeTz` to have any effect. |
| `activeTz` | `string` |  | IANA zone name the active window is evaluated in. Setting the minute/day fields without this leaves the window inert, and an unparseable zone fails open (always active) rather than suppressing alerts. |
| `condition` | `SensingRouteCondition` |  |  |
| `debounceSeconds` | `integer` |  | Minimum gap in seconds between alerts for the same (route, screen) episode, clamped to 0..86400. `0` means fire on every rising edge with no spacing at all. |
| `digest` | `object` |  | Replaces the digest block and, as a side effect, rewrites `condition` to `{ field }` or `null`. `field` is required for `avg` and `max`; `intervalMinutes` must be at least 5 because the digest cron only runs every 5 minutes. |
| `enabled` | `boolean` |  | Only the JSON literal `true` enables the route here — every other value, including the string `"true"` and `1`, is written as `false`. This is the mirror image of create, where only the literal `false` disables, so send a real boolean. |
| `name` | `string` |  | Replaces the label. Must be a non-empty string after trimming and at most 200 characters; sending an empty or whitespace-only name is a 400 rather than a clear. |
| `profileName` | `string`, nullable |  | Rebinds the route to a different named sensing profile; send `null` (or an empty/whitespace string) to unbind it. Always a profile NAME — a `profile_id` is re-minted on every redeploy and would orphan the binding. |
| `reviewTimingMode` | `SensingRouteReviewTimingMode` |  |  |
| `reviewWaitSeconds` | `SensingRouteReviewWaitSeconds` |  |  |
| `screenFocusRegions` | array of `SensingRouteScreenFocusRegion` |  | Replace all selected rectangles or polygon lassos for the route; send [] to return every camera to whole view. Entries must target the route's explicit screenMongoIds. |
| `screenMongoIds` | array of `string` |  | REPLACES the watched screen set (1..250, deduped); mutually exclusive with `targetGroupId` in one request. Focus regions for cameras that survive the swap are PRESERVED unless you also send `screenFocusRegions` (including `[]` to clear them). |
| `sustainMinSamples` | `integer` |  | DEPRECATED and rejected on write with 400. A duration expressed as N readings inside a fixed window can only be satisfied by a screen reporting faster than window/(N-1) seconds, and nothing knows that rate when the route is saved - so the route saves cleanly and then never fires at all. Send sustainSeconds instead. Still honoured for rows that already carry it. |
| `sustainSeconds` | `integer` |  | How long the condition must hold before the FIRST alert, in seconds (600 = ten minutes). Omit to alert on the first true reading. Cadence-independent, because the evaluator compares timestamps - correct however often the screen reports, including when that rate changes while the condition holds. |
| `sustainWindowSeconds` | `integer` |  | DEPRECATED and rejected on write with 400. A duration expressed as N readings inside a fixed window can only be satisfied by a screen reporting faster than window/(N-1) seconds, and nothing knows that rate when the route is saved - so the route saves cleanly and then never fires at all. Send sustainSeconds instead. Still honoured for rows that already carry it. |
| `targetGroupId` | `string`, nullable |  | Set to a sg_ group id to (re)target the group's live members, or null to switch back to an explicit screenMongoIds set. Mutually exclusive with screenMongoIds in one request. |

Responses:

- `200` The updated route
- `400` Bad request
- `401` Unauthorized - Invalid or missing API key
- `403` One or more screens are not owned by this partner
- `404` Resource not found

#### DELETE /v1/partner/sensing/routes/{routeId}

Delete (soft) a Sense Anything Route

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

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

Responses:

- `200` The route was soft-deleted
- `401` Unauthorized - Invalid or missing API key
- `404` Resource not found

### Audience Segments

Custom audience segment targeting for programmatic campaigns.

Create segments based on demographics, venue type, geography, and audience behavior.
Match segments to screens and generate targeted VAST tags.

Requires usage-based billing (`requireBilling('data_api')`).

#### GET /v1/partner/audience/segments

List audience segments

Returns all audience segments for the partner.
Requires usage-based billing (`requireBilling('data_api')`).

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=metered
- operationId: `listAudienceSegments`

Responses:

- `200` Segment list
- `401` Unauthorized - Invalid or missing API key
- `402` Free tier exhausted — add a payment method to continue

#### POST /v1/partner/audience/segments

Create audience segment

Creates a custom audience segment based on rules (demographics, venue type, geography, etc.).
Requires usage-based billing (`requireBilling('data_api')`).

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=false, idempotent=false, cost_tier=metered
- operationId: `createAudienceSegment`

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

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `description` | `string` |  |  |
| `name` | `string` | yes | Segment name |
| `rules` | array of `object` | yes |  |

Responses:

- `201` Segment created
- `400` Validation error (missing name, invalid rules)
- `401` Unauthorized - Invalid or missing API key
- `402` Free tier exhausted — add a payment method to continue
- `409` Duplicate segment name
- `429` Segment limit exceeded

#### GET /v1/partner/audience/segments/{segmentId}

Get audience segment

Returns a single audience segment by ID.
Requires usage-based billing (`requireBilling('data_api')`).

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=metered
- operationId: `getAudienceSegment`

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

Responses:

- `200` Segment details
- `401` Unauthorized - Invalid or missing API key
- `402` Free tier exhausted — add a payment method to continue
- `404` Segment not found

#### PATCH /v1/partner/audience/segments/{segmentId}

Update audience segment

Partially updates an audience segment. All fields are optional.
Requires usage-based billing (`requireBilling('data_api')`).

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=false, idempotent=true, cost_tier=metered
- operationId: `updateAudienceSegment`

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

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

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `description` | `string` |  |  |
| `is_active` | `boolean` |  |  |
| `name` | `string` |  |  |
| `rules` | array of `object` |  |  |

Responses:

- `200` Segment updated
- `400` Validation error
- `401` Unauthorized - Invalid or missing API key
- `402` Free tier exhausted — add a payment method to continue
- `404` Segment not found
- `409` Duplicate segment name

#### DELETE /v1/partner/audience/segments/{segmentId}

Delete audience segment

Permanently deletes an audience segment.
Requires usage-based billing (`requireBilling('data_api')`).

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=false, idempotent=true, cost_tier=metered
- operationId: `deleteAudienceSegment`

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

Responses:

- `200` Segment deleted
- `401` Unauthorized - Invalid or missing API key
- `402` Free tier exhausted — add a payment method to continue
- `404` Segment not found

#### POST /v1/partner/audience/segments/{segmentId}/match

Match screens to segment

Finds screens whose audience matches the segment rules.
Requires usage-based billing (`requireBilling('data_api')`).

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=false, idempotent=true, cost_tier=metered
- operationId: `matchAudienceSegment`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `segmentId` | path | `string` | yes |  |
| `limit` | query | `integer` |  |  |
| `own_screens_only` | query | `string`: one of `true`, `false` |  | Only match screens owned by this partner |

Responses:

- `200` Matching screens
- `401` Unauthorized - Invalid or missing API key
- `402` Free tier exhausted — add a payment method to continue
- `404` Segment not found

#### POST /v1/partner/audience/segments/{segmentId}/reach

Estimate segment reach

Estimates the potential reach of a segment including matched screens,
projected daily impressions, and estimated revenue.
Requires usage-based billing (`requireBilling('data_api')`).

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=metered
- operationId: `estimateSegmentReach`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `segmentId` | path | `string` | yes |  |
| `own_screens_only` | query | `string`: one of `true`, `false` |  |  |

Responses:

- `200` Reach estimation
- `401` Unauthorized - Invalid or missing API key
- `402` Free tier exhausted — add a payment method to continue
- `404` Segment not found

#### POST /v1/partner/audience/segments/{segmentId}/vast-tag

Generate VAST tag for segment

Generates a VAST tag URL targeting screens that match the segment rules.
Requires usage-based billing (`requireBilling('data_api')`).

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=false, idempotent=true, cost_tier=metered
- operationId: `generateSegmentVastTag`

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

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

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `click_through` | `string` |  | Click-through landing page URL |
| `creative_url` | `string` | yes | Video creative URL |
| `duration` | `integer` |  | Video duration in seconds |
| `own_screens_only` | `boolean` |  |  |

Responses:

- `200` VAST tag generated
- `400` Missing creative_url
- `401` Unauthorized - Invalid or missing API key
- `402` Free tier exhausted — add a payment method to continue
- `404` Segment not found

### Intent Catalog

Intent-based pricing catalog for audience purchase intent signals.

Browse intent categories and get real-time intent pricing for screens
based on live audience signals (speech, emotion, behavior).

#### GET /v1/partner/intent/categories

List intent categories

Returns the catalog of purchase intent categories with CPM multipliers
and matching thresholds.

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `listIntentCategories`

Responses:

- `200` Intent categories
- `401` Unauthorized - Invalid or missing API key

#### GET /v1/partner/intent/categories/{categoryId}

Get intent category details

Returns detailed information about a specific intent category including triggers.

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getIntentCategory`

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

Responses:

- `200` Intent category details
- `401` Unauthorized - Invalid or missing API key
- `404` Intent category not found

#### GET /v1/partner/intent/pricing/{screenId}

Get intent-based pricing for a screen

Returns real-time intent-based pricing for a specific screen based on
current audience signals (speech, emotion, behavior). Includes the detected
intent category, purchase stage, and premium CPM.

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getIntentPricing`

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

Responses:

- `200` Intent pricing data
- `400` Invalid screen ID
- `401` Unauthorized - Invalid or missing API key
- `404` Screen not found

### Sandbox

Testing tools for partner API integration.

Create sandbox screens, generate test events, and validate webhook integrations
without affecting production data. Sandbox data expires after 24 hours.

#### POST /v1/partner/sandbox/event

Generate a test event

Generates a realistic test event that can be used to validate webhook integrations.
If an SSE connection is active, the event is also delivered via SSE.

**Supported event types:** `device.online`, `device.offline`, `impression.recorded`,
`impression.verified`, `campaign.allocated`, `payout.processed`, `sensing.threshold_crossed`, `audience.spike`,
`audience.demographics_update`, `audience.purchase_intent`, `audience.venue_busy`,
`audience.context_update`, `venue.traffic_summary`, `auction.completed`, `bid.won`,
`bid.lost`, `creative.performance`, `screen.anomaly_detected`

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=false, idempotent=false, cost_tier=free
- operationId: `generateSandboxEvent`

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

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `event_type` | `string` | yes | Event type to generate |
| `screen_id` | `string` |  | Target sandbox screen (auto-selects first if omitted) |

Responses:

- `201` Test event generated
- `400` Missing or invalid event_type
- `401` Unauthorized - Invalid or missing API key

#### POST /v1/partner/sandbox/reset

Reset sandbox data

Clears all sandbox screens and events for the partner.

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

Responses:

- `200` Sandbox reset
- `401` Unauthorized - Invalid or missing API key

#### POST /v1/partner/sandbox/screen

Create a sandbox screen

Creates a synthetic test screen with realistic data for integration testing.
Maximum 10 sandbox screens per partner. Sandbox data expires after 24 hours.

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=false, idempotent=false, cost_tier=free
- operationId: `createSandboxScreen`

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

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `name` | `string` |  | Custom screen name |
| `venue_type` | `string` |  | Override venue type |

Responses:

- `201` Sandbox screen created
- `401` Unauthorized - Invalid or missing API key
- `429` Maximum 10 sandbox screens per partner

#### GET /v1/partner/sandbox/status

Get sandbox status

Returns current sandbox screens, event counts, and recent activity.

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getSandboxStatus`

Responses:

- `200` Sandbox status
- `401` Unauthorized - Invalid or missing API key

### Usage

API usage tracking, rate limit status, and usage dashboards

#### GET /v1/partner/usage

Get API usage summary

Returns API usage statistics including total calls, error rates,
top endpoints, and daily breakdown. Default range is 30 days, maximum 90 days.

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getUsageSummary`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `start_date` | query | `string` |  | Start date (ISO 8601). Defaults to 30 days ago. |
| `end_date` | query | `string` |  | End date (ISO 8601). Defaults to now. |

Responses:

- `200` Usage summary
- `401` Unauthorized - Invalid or missing API key

#### GET /v1/partner/usage/dashboard

Get real-time usage dashboard

Returns a comprehensive usage dashboard with today's stats, rate limit status,
and last 7 days breakdown. Combines real-time Redis data with historical MongoDB data.

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getUsageDashboard`

Responses:

- `200` Usage dashboard
- `401` Unauthorized - Invalid or missing API key

#### GET /v1/partner/usage/rate-limit

Get rate limit status

Returns the partner's billing-tier rate limit metadata and their current
observed call rate.

> **The `max_rate` in this response is billing-tier metadata, not the limit the
> server enforces.** Enforcement for authenticated partner endpoints is a flat
> **1000 requests/minute per API key**, applied identically to every tier. A
> `basic` partner is therefore *not* throttled at 200/min, and an `enterprise`
> partner does not get 5000/min. Size your client against 1000/min and treat
> `429` + `Retry-After` as the only authoritative signal.

`max_rate` is resolved from the partner's effective tier, which is derived from
billing status (`enterprise` when pay-per-use billing is configured, otherwise
`basic`); the `tier` field echoes the stored `data_access_tier` separately.

| Tier | Reported `max_rate` | Actually enforced |
|------|--------------------|-------------------|
| Basic | 200/min | 1000/min |
| Developer | 1,000/min | 1000/min |
| Enterprise | 5,000/min | 1000/min |

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getRateLimitStatus`

Responses:

- `200` Rate limit status
- `401` Unauthorized - Invalid or missing API key

### Events

Server-Sent Events (SSE) for real-time event streaming.

Subscribe to live events from your screens including audience updates,
device status changes, and impression notifications.
Maximum 5 concurrent SSE connections per partner.

#### GET /v1/partner/events/connections

List active SSE connections

Returns the number of active SSE connections and their details.

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getActiveSSEConnections`

Responses:

- `200` Active connections
- `401` Unauthorized - Invalid or missing API key

#### GET /v1/partner/events/stream

Subscribe to real-time events (SSE)

Opens a Server-Sent Events (SSE) connection for real-time event streaming.
Maximum 5 concurrent connections per partner.

`creative.rewards_ready` carries a free readiness hint for an owned funded experiment.
Its data uses `CreativeRewardsReadyEvent`: no reward rows are included. Follow the
canonical `reward_reader_url` with your own `idempotency_key`; that page uses the
existing data_api/api_calls tariff. Rewind the fixed date scope and deduplicate by
canonical play key plus reward revision. A hint is not a reward cursor or snapshot.

Supports `Last-Event-ID` header for reconnection and missed event replay.
The server sends heartbeat comments (`:`) every 30 seconds to keep the connection alive.

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `subscribeToEvents`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `events` | query | `string` |  | Comma-separated list of event types to subscribe to. Empty subscribes to all events. |

Responses:

- `200` SSE event stream
- `401` Unauthorized - Invalid or missing API key
- `429` Maximum SSE connections reached (5 per partner)

### Network Config

White-label network configuration for content preferences, waterfall settings, and branding

#### GET /v1/partner/network-config

Get network configuration

Returns only partner-safe network controls. Internal demand names and
seller configuration are never exposed. Runtime ad-delivery changes
apply to the next `/device/{deviceId}/ads` response; a response already
in progress keeps its original timeout and source order.

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getNetworkConfig`

Responses:

- `200` Network configuration
- `401` Unauthorized - Invalid or missing API key

#### PUT /v1/partner/network-config

Update network configuration

Full update of network configuration. Each provided section is merged
with the existing configuration (not replaced entirely).

`default_waterfall.source_order` accepts only opaque source names copied
from an `/ads` response. Unlisted sources remain enabled and are appended
in their existing order. CPM sorting is intentionally unsupported because
configured CPM is not realized yield. An explicit per-screen waterfall
override takes precedence over this partner default.

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

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

Responses:

- `200` Configuration updated
- `401` Unauthorized - Invalid or missing API key
- `409` `branding` was supplied but no white-label buyer portal is linked to this partner, so the values would not render anywhere. Nothing was saved — including the other sections of the same request. Ask Trillboards to link one.

#### PATCH /v1/partner/network-config

Partial update network configuration

Partial update of specific fields within the network configuration.
Only the provided nested keys are updated; unmentioned keys are preserved.

**Accepted top-level keys:** `default_content_preferences`, `default_waterfall`, `branding`, `storefront`, `ad_delivery`

Source order, timeout, polling guidance, and cache changes apply on the
next `/ads` request. Clients should snapshot `total_timeout_ms` and the
ordered `sources` array for the current ad break instead of changing a
loading deadline midway through playback.

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

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

Responses:

- `200` Configuration updated
- `400` No valid fields to update
- `401` Unauthorized - Invalid or missing API key
- `409` `branding` was supplied but no white-label buyer portal is linked to this partner, so the values would not render anywhere. Nothing was saved — including the other sections of the same request. Ask Trillboards to link one.

### Billing

Usage-based billing and credit management.

Trillboards uses a pay-per-use model with generous free tiers. No subscriptions required.
When free tier limits are exceeded, endpoints return 402 Payment Required until billing
is set up via Stripe. Credits can be purchased at volume discounts.

#### POST /v1/partner/billing/credits

Purchase committed-use credits

Creates or retrieves the stable credit-purchase operation and confirms its Stripe payment.
Credits are applied only after verified successful capture. If requires_action
is true, complete Stripe confirmation with client_secret and read the committed
balance after settlement; HTTP201 alone does not prove funded credit.

Available tiers:
| Tier | Price | Credits | Bonus credit |
|------|-------|---------|----------|
| tier_500 | $500 | $625 | 25% |
| tier_2000 | $2,000 | $3,100 | 55% |
| tier_5000 | $5,000 | $10,000 | 100% |

Requires an active billing setup (payment method attached).

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=false, idempotent=false, cost_tier=paid
- operationId: `purchaseCredits`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `Idempotency-Key` | header | `string` | yes | Stable caller-generated key for one purchase; reuse it on retries with the same body |

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

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `tier` | `string`: one of `tier_500`, `tier_2000`, `tier_5000` | yes | Credit purchase tier |

Responses:

- `201` Credits purchased
- `400` Invalid tier or billing not set up
- `401` Unauthorized - Invalid or missing API key
- `402` Free tier exhausted — add a payment method to continue

#### POST /v1/partner/billing/credits/checkout

Create Stripe Checkout session for credits

Creates a Stripe Checkout session for purchasing committed-use credits.
Redirect the user to the returned checkout_url to complete payment.
Credits are automatically applied after successful payment via webhook.

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=false, idempotent=false, cost_tier=paid
- operationId: `createCreditCheckout`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `Idempotency-Key` | header | `string` | yes | Stable caller-generated key for one purchase; reuse it on retries with the same body |

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

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `cancel_url` | `string` |  | URL to redirect if payment is cancelled |
| `success_url` | `string` |  | URL to redirect after successful payment |
| `tier` | `string`: one of `tier_500`, `tier_2000`, `tier_5000` | yes |  |

Responses:

- `201` Checkout session created
- `400` Invalid tier
- `401` Unauthorized - Invalid or missing API key

#### POST /v1/partner/billing/setup

Set up usage-based billing

Saves a Stripe payment method on your existing partner billing account.
Paid API usage requires funded credit or an explicitly approved postpay
allowance. Saving a payment method does not add credit or fund media.

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=false, idempotent=false, cost_tier=free
- operationId: `setupBilling`

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

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `payment_method_id` | `string` | yes | Stripe payment method token obtained from Stripe.js or Stripe Elements |

Responses:

- `201` Billing activated
- `400` Invalid or missing payment method
- `401` Unauthorized - Invalid or missing API key

#### GET /v1/partner/billing/usage-summary

Get current billing period usage

Returns committed primary-ledger balances and per-product usage for the
current UTC calendar month. Monetary cents and quantities are decimal
strings; preserve them as decimals rather than JavaScript floating point.
period_end is exclusive. Saving a payment method or opening Checkout
does not add credit: read this balance after verified payment settlement.

- Auth: `BearerAuth` HTTP bearer
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getBillingUsageSummary`

Responses:

- `200` Usage summary

#### GET /v1/partner/pricing

Get machine-readable pricing

Returns full pricing information for all Trillboards products including
graduated usage tiers, free tier thresholds, committed-use discounts,
and billing setup URLs. No authentication required — designed for
programmatic price discovery by AI agents and DSPs.

Response is cached for 1 hour (Cache-Control: public, max-age=3600).

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

Responses:

- `200` Pricing information

### Agent Registration

Self-service registration for AI agents and automated systems.

Agents can register without human intervention, receive an API key and sandbox screens,
and begin making API calls immediately within free tier limits.

#### POST /v1/partner/agent/register

Register an AI agent

Self-service registration for AI agents and automated systems. Returns a
centralized platform API key, the exact MCP endpoint, sandbox screens for
testing, and free tier information. The same returned key can be supplied
to `POST /openrtb/v2/onboard` to add buyer capabilities without minting a
second credential.

No authentication required — designed for fully autonomous agent onboarding.
Rate limited to 5 registrations per hour per IP.

Programmatic buyer flow:
```bash
curl -sS -X POST https://api.trillboards.com/v1/partner/agent/register \
  -H 'Content-Type: application/json' \
  -d '{"agent_type":"dsp_agent","name":"Example buyer agent","email":"programmatic@example.com","company":"Example Buyer"}'

curl -sS -X POST https://api.trillboards.com/openrtb/v2/onboard \
  -H 'Authorization: Bearer <api_key_from_registration>' \
  -H 'Content-Type: application/json' \
  -d '{"company_name":"Example Buyer","seat_id":"example_buyer","contact_email":"programmatic@example.com"}'
```
The second call links the buyer seat and capabilities to the original key.
It does not mint a DSP key.

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

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

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `agent_type` | `string`: one of `dsp_agent`, `ssp_agent`, `trading_desk`, `analytics_platform`, `developer` | yes | Type of agent being registered |
| `company` | `string` |  | Company name (optional) |
| `description` | `string` |  | Brief description of the agent's purpose |
| `email` | `string` | yes | Contact email |
| `name` | `string` | yes | Agent or company name |
| `sandbox` | `boolean` |  | Mint an AdCP **sandbox account** (`account.sandbox` in `get_adcp_capabilities`). Media buys made with the returned key validate end to end — screen resolution, published CPM/floor, flight parsing, derived impression goal, every error path — and return a well-formed response marked `sandbox: true`, but persist **no advertisements and no placements**. Nothing can serve, deliver, bill, or pay a venue. Use this for conformance and no-spend testing; register without it for a live account. |

Responses:

- `201` Agent registered successfully
- `400` Missing required fields or invalid agent_type
- `429` Rate limit exceeded

### CTV SDK & CSI

On-glass CTV measurement: CSI occupancy, enrichment, impression and QR-scan reporting. Requires an `sdk:*` scope on the partner key.

#### GET /v2/csi/venue/{screenId}/history

Historical venue occupancy in 10-minute CSI buckets.

Returns CSI occupancy aggregated into 10-minute buckets over a look-back window (default 24 hours, max 168 hours / 7 days). Each bucket carries the average and peak occupant counts, the average motion score, and the number of CSI windows aggregated. Buckets are ordered most-recent-first. Requires the `sdk:csi` API-key scope.

- Auth: `BearerAuth__sdk` HTTP bearer (`sdk:csi`) or `ApiKeyAuth` header `X-API-Key` (`sdk:csi`)
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `csiVenueHistory`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `screenId` | path | `string` | yes | Mongo ObjectId of the screen / venue sensing node (24-char lowercase hex). |
| `hours` | query | `integer` |  | Look-back window in hours. Absent or non-positive values fall back to 24; values above 168 are capped to 168 (7 days). |

Responses:

- `200` Occupancy history for the requested screen and window.
- `400` Canonical Trillboards error envelope.
- `401` Canonical Trillboards error envelope.
- `403` Canonical Trillboards error envelope.
- `429` Canonical Trillboards error envelope.
- `500` Canonical Trillboards error envelope.

#### GET /v2/csi/venue/{screenId}/occupancy

Real-time venue occupancy from WiFi CSI sensing.

Returns the most recent CSI occupancy window (within the last 2 minutes) for a screen, derived from ESP32-S3 WiFi Channel State Information. The response is Redis-cached for 30s to support high-frequency polling. When an active window exists the body carries occupantCount, motionScore, signalQuality, hardwareType, the window bounds, and dataAge; otherwise it returns `status: "no_data"` with a human-readable message. Requires the `sdk:csi` API-key scope.

- Auth: `BearerAuth__sdk` HTTP bearer (`sdk:csi`) or `ApiKeyAuth` header `X-API-Key` (`sdk:csi`)
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `csiVenueOccupancy`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `screenId` | path | `string` | yes | Mongo ObjectId of the screen / venue sensing node (24-char lowercase hex). |

Responses:

- `200` Latest occupancy window, or a no_data envelope when nothing is available.
- `400` Canonical Trillboards error envelope.
- `401` Canonical Trillboards error envelope.
- `403` Canonical Trillboards error envelope.
- `429` Canonical Trillboards error envelope.
- `500` Canonical Trillboards error envelope.

#### POST /v2/sdk/enrich

Census + demographic enrichment from the request IP.

Resolves the client IP into IAB income/age segments, education tier, connection type, and a stable HMAC household identifier. The `sdk:enrich` scope is required.

- Auth: `BearerAuth__sdk` HTTP bearer (`sdk:enrich`) or `ApiKeyAuth` header `X-API-Key` (`sdk:enrich`)
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `sdkEnrich`

Request body (`application/json`): `SdkApi.EnrichBody`

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `geohash6` | `string` |  | Geohash precision-6 cell (~1.2 km). |

Responses:

- `200` Enriched demographic segments.
- `400` Canonical Trillboards error envelope.
- `401` Canonical Trillboards error envelope.
- `403` Canonical Trillboards error envelope.
- `429` Canonical Trillboards error envelope.
- `500` Canonical Trillboards error envelope.

#### POST /v2/sdk/impression

Ingest an enriched CTV/DOOH impression.

Writes a sdk_impressions row with GIVT dedup, frequency counting, and reach cache updates. The `sdk:impression` scope is required.

- Auth: `BearerAuth__sdk` HTTP bearer (`sdk:impression`) or `ApiKeyAuth` header `X-API-Key` (`sdk:impression`)
- Agent: agent_safe=false, idempotent=false, cost_tier=free
- operationId: `sdkImpression`

Request body (`application/json`, required): `SdkApi.ImpressionBody`

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `adId` | `string` | yes |  |
| `audioSampleRate` | `integer`, nullable |  |  |
| `bleDeviceCount` | `integer` |  |  |
| `bleProximityConfidence` | `number` |  |  |
| `bleTopDeviceRssi` | `integer`, nullable |  |  |
| `canvasHash` | `string`, nullable |  |  |
| `channel` | `string`: one of `ctv`, `dooh`, `web` |  |  |
| `completionRatio` | `number` |  |  |
| `connectionType` | `string`: one of `residential`, `mobile`, `commercial`, `vpn`, `proxy`, `unknown`, `null`, nullable |  |  |
| `csiMotionScore` | `number`, nullable |  |  |
| `csiOccupantCount` | `integer`, nullable |  |  |
| `csiSignalQuality` | `number`, nullable |  |  |
| `deviceFingerprint` | `string`, nullable |  |  |
| `downlinkMbps` | `number`, nullable |  |  |
| `durationMs` | `integer`, nullable |  |  |
| `educationTier` | `string`, nullable |  |  |
| `effectiveType` | `string`, nullable |  |  |
| `householdId` | `string`, nullable |  |  |
| `iabAgeSegmentId` | `string`, nullable |  |  |
| `iabAgeSegmentName` | `string`, nullable |  |  |
| `iabIncomeSegmentId` | `string`, nullable |  |  |
| `iabIncomeSegmentName` | `string`, nullable |  |  |
| `imaImpressionConfirmed` | `boolean` |  |  |
| `impressionId` | `string` | yes |  |
| `placementId` | `string` | yes |  |
| `qrBeaconSent` | `boolean` |  |  |
| `qrScanId` | `string`, nullable |  |  |
| `rttMs` | `integer`, nullable |  |  |
| `schemaVersion` | `string` |  |  |
| `sdkVersion` | `string` |  |  |
| `sessionId` | `string` |  |  |
| `timestamp` | `string` |  |  |
| `viewabilityReason` | `string` |  |  |
| `viewable` | `boolean` |  |  |
| `webglRenderer` | `string`, nullable |  |  |
| `webglVendor` | `string`, nullable |  |  |

Responses:

- `201` Impression ingested.
- `400` Canonical Trillboards error envelope.
- `401` Canonical Trillboards error envelope.
- `403` Canonical Trillboards error envelope.
- `429` Canonical Trillboards error envelope.
- `500` Canonical Trillboards error envelope.

#### POST /v2/sdk/qr/beacon

Process beacon signals associated with a prior QR scan.

Attaches device fingerprinting signals (canvas hash, WebGL renderer, network info, etc.) to an already-recorded scan. The `sdk:qr` scope is required.

- Auth: `BearerAuth__sdk` HTTP bearer (`sdk:qr`) or `ApiKeyAuth` header `X-API-Key` (`sdk:qr`)
- Agent: agent_safe=false, idempotent=false, cost_tier=free
- operationId: `sdkQrBeacon`

Request body (`application/json`, required): `SdkApi.QrBeaconBody`

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `batteryCharging` | `boolean` |  |  |
| `batteryLevel` | `number` |  |  |
| `canvasHash` | `string` |  |  |
| `colorDepth` | `integer` |  |  |
| `connectionDownlink` | `number` |  |  |
| `connectionRtt` | `number` |  |  |
| `connectionType` | `string` |  |  |
| `deviceMemory` | `number` |  |  |
| `fingerprintId` | `string` |  |  |
| `hardwareConcurrency` | `integer` |  |  |
| `languages` | array of `string` |  |  |
| `localIpRange` | `string` |  |  |
| `maxTouchPoints` | `integer` |  |  |
| `mediaDeviceCount` | `integer` |  |  |
| `pixelRatio` | `number` |  |  |
| `platform` | `string` |  |  |
| `scanId` | `string` | yes |  |
| `screenHeight` | `integer` |  |  |
| `screenWidth` | `integer` |  |  |
| `timezone` | `string` |  |  |
| `webglRenderer` | `string` |  |  |
| `webglVendor` | `string` |  |  |

Responses:

- `200` Beacon signals merged.
- `400` Canonical Trillboards error envelope.
- `401` Canonical Trillboards error envelope.
- `403` Canonical Trillboards error envelope.
- `429` Canonical Trillboards error envelope.
- `500` Canonical Trillboards error envelope.

#### POST /v2/sdk/qr/scan

Record a QR-code scan for physical-to-digital attribution.

Writes a qr_scan_events row tying a scan back to a specific screen, ad, and placement. The `sdk:qr` scope is required.

- Auth: `BearerAuth__sdk` HTTP bearer (`sdk:qr`) or `ApiKeyAuth` header `X-API-Key` (`sdk:qr`)
- Agent: agent_safe=false, idempotent=false, cost_tier=free
- operationId: `sdkQrScan`

Request body (`application/json`, required): `SdkApi.QrScanBody`

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `adId` | `string` | yes |  |
| `destinationUrl` | `string` | yes |  |
| `placementId` | `string` | yes |  |
| `scanId` | `string` | yes |  |
| `screenId` | `string` | yes | Publisher screen identifier (≤24 chars) — ties the scan to the rendering screen. |

Responses:

- `201` Scan recorded.
- `400` Canonical Trillboards error envelope.
- `401` Canonical Trillboards error envelope.
- `403` Canonical Trillboards error envelope.
- `429` Canonical Trillboards error envelope.
- `500` Canonical Trillboards error envelope.

### Sensing

Edge sensing ingestion. Authenticated with a per-device token, not the partner key.

#### POST /v2/sensing/paired-training-samples

Upload a batch of paired camera/CSI training samples.

Used by the Android `CsiPairedDataCollector` to upload ~500 paired samples per call. Authentication is device-token (`X-Device-Token`). Idempotent on `sampleId` — duplicate samples in-batch and across batches are silently dropped.

- Auth: `DeviceToken__sensing` header `X-Device-Token`
- Agent: agent_safe=false, idempotent=true, cost_tier=free
- operationId: `sensingPairedTrainingUpload`

Request body (`application/json`, required): `SensingApi.PairedTrainingBody`

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `samples` | array of `SensingApi.PairedTrainingSample` | yes | Batch of paired samples. At most 1000 samples per request. |
| `screenId` | `string` |  | Optional screen binding hint. The collector may upload samples before the device is bound to a screen, in which case this field is omitted. |

Responses:

- `200` Batch accepted; counts describe the per-category outcome.
- `400` Canonical Trillboards error envelope.
- `401` Canonical Trillboards error envelope.
- `403` Canonical Trillboards error envelope.
- `429` Canonical Trillboards error envelope.
- `500` Canonical Trillboards error envelope.

### Device Provisioning

OTA model manifests and CCTV connector pairing. Authenticated with a per-device token.

#### GET /v2/device/ota/manifest

Signed OTA manifest for managed-device update clients.

Returns an Ed25519-signed manifest describing the set of agent APKs available for over-the-air delivery. The `agent` query parameter filters to a specific agent type; when omitted the caller receives the full bundle.

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

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `agent` | query | `string` |  | Optional agent type filter (`agent-core`, `agent-core-lite`, etc.). When omitted the caller receives the default manifest bundle. |

Responses:

- `200` Signed OTA manifest.
- `400` Canonical Trillboards error envelope.
- `404` Canonical Trillboards error envelope.
- `500` Canonical Trillboards error envelope.

#### GET /v2/earner/cameras/sensing-profile

Active sensing profile for the calling connector's bound screen.

The BYO-CCTV connector polls this per feed to learn which fields its on-device VLM must emit, reaching parity with the cloud VLM's profile-driven schema. The screen is taken from the presented device token, never from the request, so a caller can only ever read its own screen. RTSP credentials and internal profile config never leave the server. A screen with no deployed profile — and a transient profile-read failure — both answer 200 with an empty profile, so sensing degrades to the connector's default schema instead of going silent.

- Auth: `DeviceToken__sensing` header `X-Device-Token`
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `deviceSensingProfile`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `X-Device-Token` | header | `string` |  | Device JWT minted at pair time; binds the caller to exactly one screen. REQUIRED, but `getDeviceToken` also accepts `X-Trillboard-Device-Token`, a `deviceToken` / `device_token` body field, or a `device_token` query param. Absent from all of them -> 401 `device_token_required`. |
| `X-Device-Timestamp` | header | `string` |  | Unix epoch milliseconds. REQUIRED, but `getRequestTimestampMs` also accepts `X-Request-Timestamp`, a `requestTimestamp` / `timestamp` / `sentAt` body field, a `timestamp` query param, or the standard `Date` header. Absent -> 400 `missing_device_timestamp`; outside the replay skew window -> 401 `stale_device_request`. |
| `X-Device-Nonce` | header | `string` |  | Per-request UUID. REQUIRED, but `getRequestNonce` also accepts `X-Request-Nonce`, `X-Request-Id`, a `nonce` / `requestNonce` / `requestId` / `request_id` / `event_id` body field, or a `nonce` query param. Absent -> 400 `missing_device_nonce`; a repeat within the skew window -> 409 `replayed_device_request`. |

Responses:

- `200` The active profile's device-emittable fields, its capture cadence, and the absence-watched subset. Same shape whether or not a profile is deployed.
- `400` Replay guard incomplete — `missing_device_timestamp` or `missing_device_nonce` (replay guard is mandatory in production).
- `401` `device_token_required`, `invalid_device_token`, `device_not_found`, or `stale_device_request` (timestamp outside the replay skew window). Also the handler's own `DEVICE_AUTH_REQUIRED` envelope when middleware admitted the request but left no bound screen on it.
- `403` `device_identity_mismatch` (the token did not resolve the device) or `device_token_subject_mismatch` (the token's subject is not the resolved device).
- `409` `replayed_device_request` — this nonce was already seen inside the skew window.
- `500` The auth middleware itself threw (an identity or persistence dependency refused) and `requireDeviceRequestAuth`'s catch served the same rejection envelope. Note its `error` carries the raw exception message, NOT a machine-readable reason code like the refusals above. The HANDLER cannot produce a 500: its own catch answers 200 with an empty profile so a profile-read failure never silences sensing.
- `503` `replay_guard_unavailable` — Redis could not answer, so the request is refused rather than admitted unguarded (there is no local nonce fallback).

### Federated Learning

Federated model updates from the fleet — gradient, performance and taste uploads, plus the public model-read endpoints.

#### POST /v1/federated/gradients

Upload sparsified gradients for multi-slice federated aggregation.

Used by the on-device `FederatedTrainer` to upload top-K-sparsified gradients every ~6 hours. The server buffers gradients per slice (modelType × venueType × venueSubtype × daypart × geo × deviceProfile) and triggers aggregation when the slice buffer reaches the configured threshold. Authentication is device-fingerprint (`X-Device-Fingerprint` header or `screenId` in the body).

- Auth: `DeviceFingerprint` header `X-Device-Fingerprint`
- Agent: agent_safe=false, idempotent=false, cost_tier=free
- operationId: `federatedGradientUpload`

Request body (`application/json`, required): `FederatedApi.GradientUploadPayload`

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `daypart` | `string` |  |  |
| `deviceId` | `string` |  | Optional device identifier — Kotlin agent-core also emits this alongside screenId. |
| `deviceProfile` | `string` |  |  |
| `fingerprint` | `string` |  | Device-stable fingerprint for attestation context. |
| `geo` | `string` |  |  |
| `gradientIndices` | array of `integer` |  | Optional indices into the dense parameter vector. When omitted, gradients[] is treated as dense. |
| `gradients` | array of `number` | yes | Sparsified gradient values (top-K-by-magnitude). Aligned 1:1 with gradientIndices when present. |
| `localLoss` | `number` |  | Average local training loss across the contributing samples. |
| `modelType` | `string` | yes | Federated model name (e.g. vas_weights, taste_classifier). Routes the gradient buffer. |
| `modelVersion` | `integer` |  | Client-side currently-applied model version. Server may signal an update. |
| `publicKeyId` | `string` |  | Identifier of the Ed25519 public key registered for VAS attestation. |
| `sampleCount` | `integer` | yes | Number of training samples contributing to these gradients. |
| `screenId` | `string` |  | Device fingerprint or screen identifier. May also arrive via the X-Device-Fingerprint header. |
| `screenMongoId` | `string` |  | Optional MongoDB ObjectId for the bound screen (legacy compatibility). |
| `signature` | `string` |  | Base64-encoded Ed25519 signature over the canonical gradient payload. |
| `timestamp` | `integer` |  | Unix-ms timestamp captured when the signature was produced. |
| `venueSubtype` | `string` |  |  |
| `venueType` | `string` | yes | Venue category for slice aggregation. |

Responses:

- `200` Gradients accepted; response reports current slice buffer state.
- `400` Canonical Trillboards error envelope.
- `401` Canonical Trillboards error envelope.
- `422` Canonical Trillboards error envelope.
- `429` Canonical Trillboards error envelope.
- `500` Canonical Trillboards error envelope.

#### GET /v1/federated/models/{modelType}

Fetch aggregated weights for the device-specific slice.

Returns the most-specific aggregated weights matching the requested slice context. When the exact slice is empty, the server walks up the slice hierarchy (cold-start inheritance). When no parent slice is available, hard-coded defaults are returned with `isDefault=true`.

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

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `modelType` | path | `string` | yes | Federated model name. |
| `venue_type` | query | `string` | yes | Venue category for slice lookup. Snake_case query alias of venueType. |
| `venue_subtype` | query | `string` |  |  |
| `daypart` | query | `string` |  |  |
| `geo` | query | `string` |  |  |
| `device_profile` | query | `string` |  |  |

Responses:

- `200` Weights snapshot for the resolved slice.
- `400` Canonical Trillboards error envelope.
- `404` Canonical Trillboards error envelope.
- `500` Canonical Trillboards error envelope.

#### GET /v1/federated/models/{modelType}/latest

Fetch the latest aggregated model descriptor (download URL + sha256 + version).

Returns metadata describing the latest aggregated model artifact for the requested slice. The client uses `downloadUrl` to pull the weights blob from object storage and `sha256` to verify integrity before applying. Inline `weights` may be returned for lightweight models.

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

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `modelType` | path | `string` | yes | Federated model name. |
| `venue_type` | query | `string` | yes | Venue category (required). |
| `venue_subtype` | query | `string` |  |  |
| `daypart` | query | `string` |  |  |
| `geo` | query | `string` |  |  |
| `device_profile` | query | `string` |  |  |

Responses:

- `200` Latest model descriptor (or `available=false` when none exists).
- `400` Canonical Trillboards error envelope.
- `404` Canonical Trillboards error envelope.
- `500` Canonical Trillboards error envelope.

#### POST /v1/federated/performance

Report on-device model performance metrics.

Fire-and-forget INSERT into `federated_model_performance`. Tracks accuracy / latency / anomaly counts per (device, modelType, venueType, modelVersion) so the operator can spot drift before it shows up in revenue.

- Auth: `DeviceFingerprint` header `X-Device-Fingerprint`
- Agent: agent_safe=false, idempotent=false, cost_tier=free
- operationId: `federatedPerformanceUpload`

Request body (`application/json`, required): `FederatedApi.PerformanceUploadPayload`

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `accuracy` | `number` |  | Classifier accuracy on the local validation window. 0–1. |
| `anomalyCount` | `integer` |  |  |
| `deviceId` | `string` |  |  |
| `fingerprint` | `string` |  |  |
| `latencyMs` | `number` |  |  |
| `modelType` | `string` | yes |  |
| `modelVersion` | `integer` |  |  |
| `sampleCount` | `integer` |  |  |
| `screenId` | `string` |  | Screen identifier. May arrive via X-Device-Fingerprint header instead. |
| `variant` | `string` |  |  |
| `venueType` | `string` | yes | Venue category for slice scoping of the performance report. |

Responses:

- `200` Datapoint queued for persistence.
- `400` Canonical Trillboards error envelope.
- `401` Canonical Trillboards error envelope.
- `500` Canonical Trillboards error envelope.

#### POST /v1/federated/taste

Upload a screen-taste embedding for content personalization aggregation.

Phase 3 endpoint. The device computes a fixed-length screen-taste embedding from accumulated content-impression observations and uploads it for slice aggregation. Embeddings are signed with the device Ed25519 attestation key.

- Auth: `DeviceFingerprint` header `X-Device-Fingerprint`
- Agent: agent_safe=false, idempotent=false, cost_tier=free
- operationId: `federatedTasteUpload`

Request body (`application/json`, required): `FederatedApi.TasteUploadPayload`

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `daypart` | `string` |  |  |
| `deviceProfile` | `string` |  |  |
| `fingerprint` | `string` | yes | Device-stable fingerprint for attestation binding. |
| `geo` | `string` |  |  |
| `publicKeyId` | `string` | yes | Ed25519 public-key identifier for the signing device. |
| `screenId` | `string` | yes | Bound screen identifier. Required for taste-slice routing. |
| `screenMongoId` | `string` | yes | MongoDB ObjectId for the screen (legacy compatibility). |
| `signature` | `string` | yes | Base64-encoded Ed25519 signature over the canonical taste payload. |
| `tasteEmbedding` | array of `number` | yes | Fixed-length screen-taste embedding vector. Length must equal 64. |
| `tasteSampleCount` | `integer` | yes | Number of content-impression samples that contributed to the embedding. |
| `tasteVersionHash` | `string` | yes | Stable hash of the taste-model version that produced the embedding. |
| `timestamp` | `integer` | yes | Unix-ms timestamp captured when the signature was produced. |
| `venueType` | `string` |  |  |

Responses:

- `200` Embedding accepted into the taste aggregation buffer.
- `400` Canonical Trillboards error envelope.
- `401` Canonical Trillboards error envelope.
- `422` Canonical Trillboards error envelope.
- `429` Canonical Trillboards error envelope.
- `500` Canonical Trillboards error envelope.

### API Meta

The self-describing surface: the machine-readable error catalog, the changelog, the request-metadata lookup and the schema playground. Unauthenticated except where noted.

#### GET /v1/changelog

Query API changelog

Returns structured changelog entries parsed from CHANGELOG-API.md. Filter by date and change type. Unauthenticated, publicly cacheable.

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

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `since` | query | `string` |  | Only entries dated on or after this date (YYYY-MM-DD). Unreleased entries always included. |
| `type` | query | `string` |  | Filter by change category: "breaking" (changed+removed), "additive" (added), "deprecation" (deprecated), "fix" (fixed). Comma-separated for multiple. |

Responses:

- `200` List of matching changelog entries.
- `500` Canonical Trillboards error envelope.

#### GET /v1/errors

List all error codes

Returns the full error catalog as a JSON list. Each entry includes the stable code string, error type, HTTP status, human description, and doc URL.

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

Responses:

- `200` Full error catalog.
- `500` Canonical Trillboards error envelope.

#### GET /v1/errors/{code}

Look up a single error code

Returns metadata for a single error code, including an example of the canonical error envelope the API returns when this code is triggered.

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

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `code` | path | `string` | yes | A stable error code from the catalog. See GET /v1/errors for the full list. |

Responses:

- `200` Error code details with example response.
- `404` Canonical Trillboards error envelope.
- `500` Canonical Trillboards error envelope.

#### POST /v1/playground/{operation_id}

Dry-run validate a request body against an operation schema

Validates the request body against the Zod schema registered for the given operationId WITHOUT executing the actual endpoint. Returns validation results and operation metadata. Useful for AI agents and developers to pre-validate requests before sending them.

- Auth: `BearerAuth__sdk` HTTP bearer or `ApiKeyAuth` header `X-API-Key`
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `playgroundDryRun`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `operation_id` | path | `string` | yes | The operationId of the endpoint to validate against. |

Responses:

- `200` Validation result with operation metadata.
- `401` Canonical Trillboards error envelope.
- `404` Canonical Trillboards error envelope.
- `429` Canonical Trillboards error envelope.

#### GET /v1/requests/{request_id}

Look up metadata for a recent API request

Returns metadata about a recent API request made with the caller's API key. Backed by an in-memory cache with ~7 minute lookback. Returns 404 if the request ID is unknown, expired from cache, or belongs to a different API key.

- Auth: `BearerAuth__sdk` HTTP bearer or `ApiKeyAuth` header `X-API-Key`
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getRequestMetadata`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `request_id` | path | `string` | yes | Server-generated request ID to look up. |

Responses:

- `200` Request metadata found and ownership verified.
- `401` Canonical Trillboards error envelope.
- `404` Canonical Trillboards error envelope.

### Creative Feedback

Funded native creative optimization with measured play feedback.

#### POST /v1/partner/creative-experiments

Create an experiment on a funded native media-buy placement

Uses the existing native paid media-buy, generated creative, experiment and policy authorities. Partner and advertiser identity are derived from the authenticated credential.

- Auth: `BearerAuth__sdk` HTTP bearer (`campaigns:write`) or `ApiKeyAuth` header `X-API-Key` (`campaigns:write`) or `BearerAuth__sdk` HTTP bearer (`bid:write`) or `ApiKeyAuth` header `X-API-Key` (`bid:write`) or `BearerAuth__sdk` HTTP bearer (`partner:write`) or `ApiKeyAuth` header `X-API-Key` (`partner:write`)
- Agent: agent_safe=false, idempotent=true, cost_tier=free
- operationId: `createCreativeFeedbackExperiment`

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

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `expected_policy_version` | `string` |  | Exact decimal policy version; zero enrolls an unclaimed native policy. |
| `idempotency_key` | `string` | yes | Caller-owned key for this exact operation; do not reuse for changed inputs. |
| `media_buy_id` | `string` | yes | Your funded native AdCP media buy ID. |
| `name` | `string` | yes | Name for this funded creative feedback experiment. |
| `objective` | `string`: one of `gaze_share`, `gaze_overlap_ms`, `dwell_ms` | yes | Measured objective: gaze share 0–1, gaze overlap milliseconds, or capped mean dwell milliseconds. |
| `package_id` | `string` | yes | Native package ID within that media buy. |

Responses:

- `200` Native operation result.
- `400` Canonical Trillboards error envelope.
- `401` Canonical Trillboards error envelope.
- `403` Canonical Trillboards error envelope.
- `404` Canonical Trillboards error envelope.
- `429` Canonical Trillboards error envelope.
- `500` Canonical Trillboards error envelope.

#### GET /v1/partner/creative-experiments/{experiment_id}

Read your native experiment, policy and latest 500 owned creative recovery records

Read your resolved native buyer’s latest 500 generated/imported creative ledger records with explicit creatives_truncated. Same-buyer byte dedupe preserves original advertisement_mongo_id; linked_to_experiment_ad identifies current native membership. Moderation status and byte hash are present only when the global review URL exactly matches the ledger CDN URL. Brand review and global moderation remain separate. Generation/import committed retry receipts retain creativeId/renderKey so a lost response does not require another paid generation.

- Auth: `BearerAuth__sdk` HTTP bearer (`reports:read`) or `ApiKeyAuth` header `X-API-Key` (`reports:read`) or `BearerAuth__sdk` HTTP bearer (`stats:read`) or `ApiKeyAuth` header `X-API-Key` (`stats:read`) or `BearerAuth__sdk` HTTP bearer (`partner:read`) or `ApiKeyAuth` header `X-API-Key` (`partner:read`)
- Agent: agent_safe=true, idempotent=true, cost_tier=free
- operationId: `getCreativeFeedbackExperiment`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `experiment_id` | path | `string` | yes | Native experiment ID returned by create_creative_experiment. |

Responses:

- `200` Native operation result.
- `400` Canonical Trillboards error envelope.
- `401` Canonical Trillboards error envelope.
- `403` Canonical Trillboards error envelope.
- `404` Canonical Trillboards error envelope.
- `429` Canonical Trillboards error envelope.
- `500` Canonical Trillboards error envelope.

#### POST /v1/partner/creative-experiments/{experiment_id}/brand

Configure your native brand profile and enable its booked reactive campaign

Uses the existing native paid media-buy, generated creative, experiment and policy authorities. Partner and advertiser identity are derived from the authenticated credential.

- Auth: `BearerAuth__sdk` HTTP bearer (`campaigns:write`) or `ApiKeyAuth` header `X-API-Key` (`campaigns:write`) or `BearerAuth__sdk` HTTP bearer (`bid:write`) or `ApiKeyAuth` header `X-API-Key` (`bid:write`) or `BearerAuth__sdk` HTTP bearer (`partner:write`) or `ApiKeyAuth` header `X-API-Key` (`partner:write`)
- Agent: agent_safe=false, idempotent=false, cost_tier=free
- operationId: `configureCreativeFeedbackBrand`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `experiment_id` | path | `string` | yes | Native experiment ID returned by create_creative_experiment. |

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

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `brand_assets` | array of `object` |  | Native brand reference assets used by creative generation. |
| `brand_intent_text` | `string`, nullable |  | Optional brand intent supplied to the native creative generator. |
| `brand_name` | `string` | yes | Advertiser brand name for the experiment’s native campaign. |
| `brand_rules` | `object` | yes | Native brand colours, typography, voice, products and required or prohibited elements. |
| `profile_id` | `string` |  | Optional existing brand profile owned by this advertiser and advertisement. |
| `stance` | `string`: one of `adversarial`, `complementary`, `same_brand`, `intent_capture` |  | Native reactive relationship used to generate or stage this creative. |

Responses:

- `200` Native operation result.
- `400` Canonical Trillboards error envelope.
- `401` Canonical Trillboards error envelope.
- `403` Canonical Trillboards error envelope.
- `404` Canonical Trillboards error envelope.
- `429` Canonical Trillboards error envelope.
- `500` Canonical Trillboards error envelope.

#### POST /v1/partner/creative-experiments/{experiment_id}/creatives/generate

Generate a native brand creative for explicit review and moderation

Uses the experiment’s native advertiser and advertisement. AI moderation and explicit brand approval remain separate native steps. One operation uses the existing data_api/api_calls tariff; native ad spend remains separate. An in-progress or committed idempotency key returns 409 OPERATION_IN_PROGRESS or OPERATION_ALREADY_COMMITTED; committed retry details retain error.help.usage_receipt.

- Auth: `BearerAuth__sdk` HTTP bearer (`campaigns:write`) or `ApiKeyAuth` header `X-API-Key` (`campaigns:write`) or `BearerAuth__sdk` HTTP bearer (`bid:write`) or `ApiKeyAuth` header `X-API-Key` (`bid:write`) or `BearerAuth__sdk` HTTP bearer (`partner:write`) or `ApiKeyAuth` header `X-API-Key` (`partner:write`)
- Agent: agent_safe=false, idempotent=true, cost_tier=metered
- operationId: `generateCreativeFeedbackCreative`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `experiment_id` | path | `string` | yes | Native experiment ID returned by create_creative_experiment. |

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

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `aspectRatio` | `string` |  | Image aspect ratio, such as 16:9, matched to your booked screens. |
| `brief` | `string` |  | Creative direction added to your configured native brand rules. |
| `idempotency_key` | `string` | yes | Unique caller-owned generation key; committed retries expose creativeId and renderKey in the receipt. |
| `stance` | `string`: one of `adversarial`, `complementary`, `same_brand`, `intent_capture` |  | Native reactive relationship used to generate or stage this creative. |

Responses:

- `200` Native operation result.
- `400` Canonical Trillboards error envelope.
- `401` Canonical Trillboards error envelope.
- `403` Canonical Trillboards error envelope.
- `404` Canonical Trillboards error envelope.
- `429` Canonical Trillboards error envelope.
- `500` Canonical Trillboards error envelope.

#### POST /v1/partner/creative-experiments/{experiment_id}/creatives/import

Import an AI model’s image through native bytes, moderation and brand review

Provide exactly one of asset_url, source_creative_id or image_base64. Inline bytes require content_type. PNG, JPEG and WebP use the native image staging and actual MIME/hash validation. source_creative_id is resolved within the authenticated partner. Brand approval and AI moderation remain explicit native steps. One operation uses the existing data_api/api_calls tariff.

- Auth: `BearerAuth__sdk` HTTP bearer (`campaigns:write`) or `ApiKeyAuth` header `X-API-Key` (`campaigns:write`) or `BearerAuth__sdk` HTTP bearer (`bid:write`) or `ApiKeyAuth` header `X-API-Key` (`bid:write`) or `BearerAuth__sdk` HTTP bearer (`partner:write`) or `ApiKeyAuth` header `X-API-Key` (`partner:write`)
- Agent: agent_safe=false, idempotent=true, cost_tier=metered
- operationId: `importCreativeFeedbackCreative`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `experiment_id` | path | `string` | yes | Native experiment ID returned by create_creative_experiment. |

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

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `asset_url` | `string` |  | Public HTTPS image URL; provide exactly one image source. |
| `content_type` | `string`: one of `image/png`, `image/jpeg`, `image/jpg`, `image/webp` |  | Actual image MIME; required for inline bytes and checked against decoded content. |
| `headline` | `string` |  | Optional headline stored with the native imported creative. |
| `idempotency_key` | `string` | yes | Unique caller-owned import key; a committed retry returns the existing usage receipt. |
| `image_base64` | `string` |  | Base64 PNG, JPEG or WebP bytes; provide exactly one source and content_type. |
| `source_creative_id` | `string` |  | Existing creative ID owned by the authenticated partner; provide exactly one image source. |
| `stance` | `string`: one of `adversarial`, `complementary`, `same_brand`, `intent_capture` |  | Native reactive relationship used to generate or stage this creative. |

Responses:

- `200` Native operation result.
- `400` Canonical Trillboards error envelope.
- `401` Canonical Trillboards error envelope.
- `403` Canonical Trillboards error envelope.
- `404` Canonical Trillboards error envelope.
- `429` Canonical Trillboards error envelope.
- `500` Canonical Trillboards error envelope.

#### POST /v1/partner/creative-experiments/{experiment_id}/creatives/review

Approve or reject your generated creative using native brand review

Uses the existing native paid media-buy, generated creative, experiment and policy authorities. Partner and advertiser identity are derived from the authenticated credential.

- Auth: `BearerAuth__sdk` HTTP bearer (`campaigns:write`) or `ApiKeyAuth` header `X-API-Key` (`campaigns:write`) or `BearerAuth__sdk` HTTP bearer (`bid:write`) or `ApiKeyAuth` header `X-API-Key` (`bid:write`) or `BearerAuth__sdk` HTTP bearer (`partner:write`) or `ApiKeyAuth` header `X-API-Key` (`partner:write`)
- Agent: agent_safe=false, idempotent=false, cost_tier=free
- operationId: `reviewCreativeFeedbackCreative`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `experiment_id` | path | `string` | yes | Native experiment ID returned by create_creative_experiment. |

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

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `creative_id` | `string` | yes | Owned native creative ID awaiting your explicit brand review. |
| `decision` | `string`: one of `approve`, `reject` | yes | Approve or reject brand use; global AI moderation remains a separate gate. |

Responses:

- `200` Native operation result.
- `400` Canonical Trillboards error envelope.
- `401` Canonical Trillboards error envelope.
- `403` Canonical Trillboards error envelope.
- `404` Canonical Trillboards error envelope.
- `429` Canonical Trillboards error envelope.
- `500` Canonical Trillboards error envelope.

#### PUT /v1/partner/creative-experiments/{experiment_id}/policy

Replace native creative weights at the expected policy version

Uses the existing native paid media-buy, generated creative, experiment and policy authorities. Partner and advertiser identity are derived from the authenticated credential.

- Auth: `BearerAuth__sdk` HTTP bearer (`campaigns:write`) or `ApiKeyAuth` header `X-API-Key` (`campaigns:write`) or `BearerAuth__sdk` HTTP bearer (`bid:write`) or `ApiKeyAuth` header `X-API-Key` (`bid:write`) or `BearerAuth__sdk` HTTP bearer (`partner:write`) or `ApiKeyAuth` header `X-API-Key` (`partner:write`)
- Agent: agent_safe=false, idempotent=false, cost_tier=free
- operationId: `updateCreativeFeedbackPolicy`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `experiment_id` | path | `string` | yes | Native experiment ID returned by create_creative_experiment. |

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

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `expected_version` | `string` | yes | Exact decimal policy version from the latest experiment; stale updates return 409. |
| `weights` | array of `object` | yes | Replacement weights for registered variants; omitted variants are inactive. |

Responses:

- `200` Native operation result.
- `400` Canonical Trillboards error envelope.
- `401` Canonical Trillboards error envelope.
- `403` Canonical Trillboards error envelope.
- `404` Canonical Trillboards error envelope.
- `429` Canonical Trillboards error envelope.
- `500` Canonical Trillboards error envelope.

#### GET /v1/partner/creative-experiments/{experiment_id}/rewards

Read a metered page of latest measured native play states

from/to are inclusive YYYY-MM-DD dates, at most 31 days. The cursor reads latest states and tombstones; a completed scan cursor rewinds for the next poll. Deduplicate by canonical_key and reward_revision_id. build_stamp and builder_version are provenance; a physical rebuild may retain the same logical revision. It does not freeze prior database versions. Each page requires a caller-owned idempotency_key and uses one existing data_api/api_calls operation; retries with an in-progress or committed key return 409, retaining the committed receipt in error.help.usage_receipt. gaze_share maps to gaze_pct_on_screen (0–1); gaze_overlap_ms maps to its overlap in milliseconds; dwell_ms maps to dwell_capped_ms_mean. unmeasured fields remain NULL.

- Auth: `BearerAuth__sdk` HTTP bearer (`reports:read`) or `ApiKeyAuth` header `X-API-Key` (`reports:read`) or `BearerAuth__sdk` HTTP bearer (`stats:read`) or `ApiKeyAuth` header `X-API-Key` (`stats:read`) or `BearerAuth__sdk` HTTP bearer (`partner:read`) or `ApiKeyAuth` header `X-API-Key` (`partner:read`)
- Agent: agent_safe=true, idempotent=true, cost_tier=metered
- operationId: `getCreativeFeedbackRewards`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `experiment_id` | path | `string` | yes | Native experiment ID returned by create_creative_experiment. |
| `from` | query | `string` | yes | Inclusive first play date, YYYY-MM-DD; date range is at most 31 days. |
| `to` | query | `string` | yes | Inclusive last play date, YYYY-MM-DD; date range is at most 31 days. |
| `limit` | query | `integer` |  | Maximum native play states returned on this metered page. |
| `cursor` | query | `string` |  | Opaque next_cursor from this same date scope; rewind after scan_complete. |
| `idempotency_key` | query | `string` | yes | Unique caller-owned billing key for this page; committed retries return the receipt, not another read. |

Responses:

- `200` Native operation result.
- `400` Canonical Trillboards error envelope.
- `401` Canonical Trillboards error envelope.
- `403` Canonical Trillboards error envelope.
- `404` Canonical Trillboards error envelope.
- `429` Canonical Trillboards error envelope.
- `500` Canonical Trillboards error envelope.

#### POST /v1/partner/creative-experiments/{experiment_id}/stop

Stop the native experiment at the expected policy version

Uses the existing native paid media-buy, generated creative, experiment and policy authorities. Partner and advertiser identity are derived from the authenticated credential.

- Auth: `BearerAuth__sdk` HTTP bearer (`campaigns:write`) or `ApiKeyAuth` header `X-API-Key` (`campaigns:write`) or `BearerAuth__sdk` HTTP bearer (`bid:write`) or `ApiKeyAuth` header `X-API-Key` (`bid:write`) or `BearerAuth__sdk` HTTP bearer (`partner:write`) or `ApiKeyAuth` header `X-API-Key` (`partner:write`)
- Agent: agent_safe=false, idempotent=false, cost_tier=free
- operationId: `stopCreativeFeedbackExperiment`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `experiment_id` | path | `string` | yes | Native experiment ID returned by create_creative_experiment. |

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

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `expected_version` | `string` | yes | Exact decimal policy version from the latest experiment; stale updates return 409. |

Responses:

- `200` Native operation result.
- `400` Canonical Trillboards error envelope.
- `401` Canonical Trillboards error envelope.
- `403` Canonical Trillboards error envelope.
- `404` Canonical Trillboards error envelope.
- `429` Canonical Trillboards error envelope.
- `500` Canonical Trillboards error envelope.

#### POST /v1/partner/creative-experiments/{experiment_id}/variants

Register approved native generated creative variants

Uses the existing native paid media-buy, generated creative, experiment and policy authorities. Partner and advertiser identity are derived from the authenticated credential.

- Auth: `BearerAuth__sdk` HTTP bearer (`campaigns:write`) or `ApiKeyAuth` header `X-API-Key` (`campaigns:write`) or `BearerAuth__sdk` HTTP bearer (`bid:write`) or `ApiKeyAuth` header `X-API-Key` (`bid:write`) or `BearerAuth__sdk` HTTP bearer (`partner:write`) or `ApiKeyAuth` header `X-API-Key` (`partner:write`)
- Agent: agent_safe=false, idempotent=false, cost_tier=free
- operationId: `registerCreativeFeedbackVariants`

| Parameter | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `experiment_id` | path | `string` | yes | Native experiment ID returned by create_creative_experiment. |

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

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `expected_version` | `string` | yes | Exact decimal policy version from the latest experiment; stale updates return 409. |
| `variants` | array of `object` | yes | Approved native variants; identity and bytes remain immutable. |

Responses:

- `200` Native operation result.
- `400` Canonical Trillboards error envelope.
- `401` Canonical Trillboards error envelope.
- `403` Canonical Trillboards error envelope.
- `404` Canonical Trillboards error envelope.
- `429` Canonical Trillboards error envelope.
- `500` Canonical Trillboards error envelope.
