@trillboards/api-client 2.1.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -17,6 +17,7 @@ type Ad = {
17
17
  impression_id?: string;
18
18
  /** **Deprecated alias of `tracking.impression_url`.** These two fields are the same string, byte for byte, produced from one value server-side — they are not two different hooks and firing both records the same call twice. Read `tracking.impression_url`; this flat field is retained only for clients that predate the `tracking` object and will be removed once they are off it. */
19
19
  impression_url?: string;
20
+ /** The advertiser's own creative label from the booking record (its `title`, falling back to its `name`), carried through for your logs and QA. Omitted when the booking has neither. It is an internal label, not reviewed display copy, so do not surface it to viewers. */
20
21
  title?: string;
21
22
  /** Structured proof-of-play URLs for clients that prefer named tracking hooks over legacy flat fields. **Fire exactly one of `impression_url` / `complete_url` per play.** They resolve to the same endpoint and differ only by the `completed` query parameter, which that endpoint does not act on (see `GET /impression`) — calling both records the play twice. */
22
23
  tracking?: {
@@ -27,6 +28,7 @@ type Ad = {
27
28
  /** **Server-signed proof of play. Present only on owner-billable direct bookings; absent on self-promo, house/QR, demo and fully-delivered placements.** When present, fire it VERBATIM — including the `sig`, `request_id` and `impid` parameters — once the ad has finished playing. Do not rewrite, re-sign, re-order, or drop any query parameter: the signature covers the values and any edit invalidates it. This is the only completion signal on the `data.ads` rail that settles; `complete_url` above is delivery accounting and is not billing-grade. Replays of the same play are collapsed on the SIGNED `request_id`, which carries this serve's `impression_id`. Because it is covered by `sig`, a replay that alters it to claim a fresh dedup key fails verification and records nothing — one served play can settle at most once. Firing it in addition to `complete_url` is correct and does not double-count — they land in different stores. */
28
29
  owned_direct_complete_url?: string;
29
30
  };
31
+ /** `video` or `image`. On the partner/IMA rail an image instant-ad creative is substituted with its pre-rendered MP4 and reports `video`; seeing `image` here means that MP4 was not ready on this poll, so a video-only player should skip the entry and pick it up on a later poll rather than treat it as a still. */
30
32
  type?: "video" | "image";
31
33
  /** Media URL (CDN) */
32
34
  url?: string;
@@ -37,8 +39,11 @@ type AdSettings = {
37
39
  content_fit?: ContentFitSettings;
38
40
  /** Seconds to display image ads; independent from ad rotation cadence. */
39
41
  image_duration?: number;
42
+ /** An ADVISORY delivery cap the client SDK is expected to honour — the server reports it and never enforces it anywhere. Sourced from the partner's `network_config.ad_delivery.max_ads_per_hour` (an integer 1-120), falling back to `sdk_config.max_ads_per_hour`; when neither is set the key is ABSENT from the response rather than null or defaulted. */
40
43
  max_ads_per_hour?: number;
44
+ /** Label for the tap-to-continue overlay the partner Lite SDK draws over an ad. Read from `sdk_config.overlay_button_text` only — there is no runtime-config path for it — and the key is ABSENT when unset, in which case the SDK falls back to `TAP TO CONTINUE` client-side. */
41
45
  overlay_button_text?: string;
46
+ /** Whether ad audio plays. It reads the typed `sound_enabled` column when that is non-null, otherwise `display_preferences.sound_enabled !== false` — so an unconfigured screen reports `true` and only an explicit `false` mutes. */
42
47
  sound_enabled?: boolean;
43
48
  };
44
49
  /**
@@ -50,6 +55,7 @@ type AdSettings = {
50
55
  *
51
56
  */
52
57
  type AdvertisingIdentity = {
58
+ /** DISCARDED whenever `status` is `available` — the server stores `null` in that case, deliberately, because a resettable OS advertising ID stays valid until the OS rotates it and a collector's refresh or cache cadence is not identity expiry. It is only persisted for the non-`available` states. */
53
59
  expires_at?: string | null;
54
60
  /** Present only when status is `available`. */
55
61
  id?: string | null;
@@ -57,9 +63,13 @@ type AdvertisingIdentity = {
57
63
  id_type?: "gaid" | "aaid" | "afai" | "rida" | "tifa" | "idfa" | null;
58
64
  /** Must be false for `available` and true for `limited`. */
59
65
  limit_ad_tracking: boolean | null;
66
+ /** When the device read the identifier from the OS. It is REQUIRED and strictly parsed — an unparseable or missing timestamp causes the entire identity envelope to be dropped without an error response, which is the most common silent rejection on this endpoint. */
60
67
  observed_at: string;
68
+ /** Which platform API produced the identifier. `host_supplied` is the value the SERVER itself stamps when it synthesises an envelope from the legacy flat `advertisingId`/`limitAdTracking` fields or from a registration hardware tuple, so seeing it in a read does not mean the device claimed it. An unrecognised source rejects the whole envelope. */
61
69
  source: "google_play_services" | "amazon_fire_os" | "roku_os" | "samsung_tizen" | "lg_webos" | "apple_platform" | "host_supplied";
70
+ /** Version of the platform component or SDK that read the identifier (e.g. the Play Services version), up to 128 characters, kept for provenance only. Trimmed, with a whitespace-only value stored as `null`. */
62
71
  source_version?: string | null;
72
+ /** The state machine, not a label. `available` is accepted only with a valid `id`, an `id_type` and `limit_ad_tracking: false` — any other combination makes the server DISCARD the whole envelope behind a 200 and keep the previously stored identity. Every other value CLEARS a stored identifier, and `limited` additionally forces `limit_ad_tracking` to true. */
63
73
  status: "available" | "limited" | "consent_denied" | "unavailable" | "unsupported" | "error";
64
74
  };
65
75
  /**
@@ -67,6 +77,7 @@ type AdvertisingIdentity = {
67
77
  *
68
78
  */
69
79
  type AnalyticsData = {
80
+ /** **Never emitted** by the analytics endpoint, which returns impression counts and cached revenue only. */
70
81
  average_cpm: number;
71
82
  /** Optional per-screen breakdown. */
72
83
  by_screen?: Array<{
@@ -79,12 +90,17 @@ type AnalyticsData = {
79
90
  fill_rate: number;
80
91
  /** The window this summary covers (e.g. day, week, month). */
81
92
  period: string;
93
+ /** **Not emitted under this name.** The analytics endpoint returns `data.partner.active_devices`, derived from the screen's `online_status` via the partner device-status mapping, plus a `devices_by_status` breakdown. */
82
94
  screens_active: number;
95
+ /** **Not emitted under this name.** The analytics endpoint returns `data.partner.total_devices`, a count of registered DEVICES rather than of screens — a device with no screen row still counts. */
83
96
  screens_total: number;
97
+ /** **Not emitted.** `GET /v1/partner/analytics` returns `data.partner.total_revenue_cents` (integer US cents, read from the `partners.stats` cache) instead; that endpoint has no dollar-denominated earnings field. */
84
98
  total_earnings: number;
99
+ /** Declared for the SDK type; `GET /v1/partner/analytics` does not return this shape. It returns `data.partner.total_impressions` — an ALL-TIME deduped `completed_impressions` count that ignores the `start_date`/`end_date` query parameters (those bound only `impressions_by_day`). */
85
100
  total_impressions: number;
86
101
  };
87
102
  type AudienceAnalyticsResponse = {
103
+ /** `overview.total_impressions` counts `event_tracking` `start`/`impression` events — NOT the billable `completed_impressions` the dashboard and earnings endpoints use — and `active_screens` counts screens with ANY event in the window rather than screens that are online. **`trends.impressions_trend` and `trends.audience_trend` are hardcoded to the literal `stable`**, so the `increasing`/`decreasing` values are unreachable. `period` echoes `?period=` (`24h`/`7d`/`30d`; anything else silently means 7 days). */
88
104
  data?: {
89
105
  audience?: {
90
106
  avg_attention?: number;
@@ -104,7 +120,9 @@ type AudienceAnalyticsResponse = {
104
120
  impressions_trend?: "increasing" | "stable" | "decreasing";
105
121
  };
106
122
  };
123
+ /** Per-request identifier (`req_` plus 32 hex), also returned on the `X-Request-Id` response header. */
107
124
  request_id?: string;
125
+ /** Always `true` on a 2xx; failures return the error envelope (`success: false` plus an `error` object) at a 4xx/5xx status. */
108
126
  success?: boolean;
109
127
  };
110
128
  /**
@@ -150,10 +168,13 @@ type AudienceData = {
150
168
  demographics?: Record<string, unknown>;
151
169
  /** Average dwell time for the sample. */
152
170
  dwell_time_avg: number;
171
+ /** Declared for the SDK type. `GET /v1/partner/audience/live/{screenId}` puts `screen_id` at the TOP level of an `object: audience_live` envelope alongside `status` and `audience`, not inside this shape. */
153
172
  screen_id: string;
173
+ /** **Not emitted under this name.** The live audience endpoint returns `last_updated` — the time of the newest underlying observation, nullable — and returns `audience: null` with `status: offline` when the screen has no live data at all. */
154
174
  timestamp: string;
155
175
  };
156
176
  type AudienceExportResponse = {
177
+ /** One entry per RESOLVED screen (max 50 ids per call), emitted even for screens with no audience data — those get a zeroed `audience` block rather than being omitted. `impressions` counts `event_tracking` `start`/`impression` events, which are NOT the billable `completed_impressions` the dashboard and earnings endpoints use, and every row repeats the same request-level `period`. */
157
178
  data?: Array<{
158
179
  audience?: {
159
180
  avg_attention?: number;
@@ -172,8 +193,11 @@ type AudienceExportResponse = {
172
193
  screen_id?: string;
173
194
  screen_name?: string;
174
195
  }>;
196
+ /** Per-request identifier (`req_` plus 32 hex), also on the `X-Request-Id` header. Absent from the `format=csv` variant, which returns a bare five-column body and no envelope. */
175
197
  request_id?: string;
198
+ /** Always `true` — a failure returns the error envelope (`success: false` plus an `error` object) at a 4xx/5xx status, so this field carries nothing the status code does not. */
176
199
  success?: boolean;
200
+ /** `total_screens` counts the screens that RESOLVED, so it can be smaller than the id list you sent. `total_impressions` sums the same non-billable `start`/`impression` event counts. `aggregation` echoes the query parameter unvalidated (default `daily`) and does not change the shape of the response. */
177
201
  summary?: {
178
202
  aggregation?: string;
179
203
  period?: Record<string, unknown>;
@@ -190,10 +214,12 @@ type AudienceExportResponse = {
190
214
  *
191
215
  */
192
216
  type AudienceLoyaltyFeaturesRow = {
217
+ /** How the cross-day device key was formed. `primary_signal_source` is picked by a fixed precedence (`fixed_mac` > `mdns_vendor_model` > `ble_continuity` > `ble_mac_stable`) and `match_confidence` is a direct relabelling of it (first two `high`, `ble_continuity` `medium`, `ble_mac_stable` `low`) — not a computed probability. */
193
218
  _match_disclosure?: {
194
219
  match_confidence?: "high" | "medium" | "low";
195
220
  primary_signal_source?: "ble_continuity" | "ble_mac_stable" | "mdns_vendor_model" | "fixed_mac";
196
221
  };
222
+ /** NOT a per-device inference: it is the aggregate audience profile of the first screen in the device's visited screen set that has one, copied onto every device that touched that screen. All six sub-fields are `null` when no visited screen carries a profile. */
197
223
  demographic_inference?: {
198
224
  age_bracket?: string;
199
225
  attention_score_avg?: number;
@@ -204,12 +230,17 @@ type AudienceLoyaltyFeaturesRow = {
204
230
  };
205
231
  /** Partner-scoped HMAC of cross-day device key (16 hex chars + tb_ prefix). Different partners get different IDs for the same physical device — preventing collusion. */
206
232
  device_id_pseudonymous: string;
233
+ /** Distinct calendar days (ClickHouse server timezone) on which this device key was observed. This is the quantity behind the published `returning_visitor` qualification of two or more days. */
207
234
  distinct_days_in_window: number;
235
+ /** Distinct SCREENS, not venues — a site with several screens contributes more than one, so this over-counts locations for any multi-screen venue. */
208
236
  distinct_venues_in_window?: number;
209
237
  /** Days since this device was first observed at any of the partner-scoped screens. */
210
238
  first_seen_relative_days: number;
239
+ /** Whole days between now and the newest observation in the window, floored at 0 — `0` means seen today. An unparseable source timestamp also yields `0`, so it cannot be told apart from a same-day sighting. */
211
240
  last_seen_relative_days: number;
241
+ /** Tier derived from the Bluetooth-SIG company identifier in the device's advertisement, not from any model string. Only five vendors carry an explicit tier (Apple / Samsung / Google `premium`, Microsoft `mainstream`, Sonos `audio_iot`); every other registered vendor, and every unresolvable id, falls to `other`. */
212
242
  manufacturer_class: "premium" | "mainstream" | "audio_iot" | "other";
243
+ /** Vendor name resolved from the same Bluetooth-SIG company id. An id that resolves to no vendor returns the literal `Other tracked devices` rather than `null`, so an unknown device is not distinguishable by a null check. */
213
244
  manufacturer_label?: string;
214
245
  /** Raw cross-day-stable identifiers when available (NULL for randomized phone MACs without Continuity). */
215
246
  raw_identifiers?: {
@@ -221,13 +252,17 @@ type AudienceLoyaltyFeaturesRow = {
221
252
  mdns_hostname?: string | null;
222
253
  wifi_bssid?: string | null;
223
254
  };
255
+ /** Same arbitrary-row behaviour as `top_hour_of_day`, and the value comes from ClickHouse `toDayOfWeek` — **1 = Monday through 7 = Sunday**, which does not fit the declared 0..6 range. */
224
256
  top_day_of_week?: number;
257
+ /** Produced by `argMax(toHour(observed_at), 1)`; because the ordering expression is the constant `1` every row ties, so this is the hour of an ARBITRARY observation in the window, not the modal hour. Do not read a peak out of it. */
225
258
  top_hour_of_day?: number;
226
259
  /** List of venue_category labels (Bar, Restaurant, etc.) the device was observed at. */
227
260
  venue_archetypes?: Array<string>;
261
+ /** Count of raw radio OBSERVATIONS, not of visits — one dwelling device emits many rows per minute. It is also the quantity the export's k-anonymity `HAVING` clause tests. */
228
262
  visit_count_in_window: number;
229
263
  };
230
264
  type AudienceMetricsResponse = {
265
+ /** **Currently always the zero/`unknown` fallback.** The handler reads `metrics.live`, `metrics.aggregated` and `metrics.history`, but the underlying `getScreenMetrics` takes a time-range STRING and returns a FLAT object carrying none of those keys — so all three are undefined on every call and the hardcoded zeros are served. `period` echoes `?period=` (default `1h`) and is likewise not honoured downstream; `timestamp` is the response time, not a measurement time. */
231
266
  data?: {
232
267
  aggregated?: {
233
268
  avgAttention?: number;
@@ -258,24 +293,37 @@ type AudienceMetricsResponse = {
258
293
  screen_name?: string;
259
294
  timestamp?: string;
260
295
  };
296
+ /** Per-request identifier (`req_` plus 32 hex), also returned on the `X-Request-Id` response header. */
261
297
  request_id?: string;
298
+ /** Always `true` on a 2xx; failures return the error envelope (`success: false` plus an `error` object) at a 4xx/5xx status. */
262
299
  success?: boolean;
263
300
  };
264
301
  type AudienceSegment = {
302
+ /** Creation time of the authoritative PG row; the 201 create response falls back to the server clock only if the insert returned no row. */
265
303
  created_at?: string;
304
+ /** Free text, trimmed on write. Defaults to the empty string, never `null`, when omitted at create. */
266
305
  description?: string;
306
+ /** 24-character hex ObjectId minted by the API at create time and stored as the PG row's `mongo_id` — not the table's integer primary key. This is the value the get / patch / delete / match routes take as `:segmentId`. */
267
307
  id?: string;
308
+ /** Stored and echoed but **nothing reads it** — `POST /segments/{id}/match` runs a segment regardless, and the list endpoint does not filter on it. Treat it as a caller-owned label, not a gate. */
268
309
  is_active?: boolean;
310
+ /** When `POST /segments/{id}/match` last ran. The update is fire-and-forget after the response is sent, so a failed write leaves this stale or `null` with no error surfaced to the caller. */
269
311
  last_matched_at?: string;
312
+ /** Number of SCREENS returned by the most recent `POST /segments/{id}/match`, capped by that call's `limit` (default 50, max 100) — so it is the size of the last result page, not the segment's reach. `null` until the first match call. */
270
313
  last_matched_count?: number;
314
+ /** Trimmed on write, max 100 characters, and unique per partner — a repeat name is rejected `409 duplicate_segment_name` rather than upserting the existing segment. */
271
315
  name?: string;
316
+ /** Always the literal `audience_segment`. `GET /audience/segments` wraps these in an `object: list` envelope, so the discriminator appears on both the envelope and each element. */
272
317
  object?: string;
318
+ /** Per-request identifier, `req_` plus 32 hex characters, minted server-side and also returned on the `X-Request-Id` response header. A client-supplied `X-Request-Id` is NOT adopted — it is echoed back on `X-Client-Request-Id` instead. */
273
319
  request_id?: string;
320
+ /** Predicates combined with AND, at most 20 per segment. `field` is restricted to a 17-name allowlist (`income_level`, `lifestyle_segment`, `attention_score`, `face_count`, `dwell_time_ms`, `crowd_density`, `ad_receptivity`, `emotional_engagement`, `purchase_intent`, `occupancy`, `noise_level`, `venue_type`, `country`, `state`, `city`, `hour_of_day`, `day_of_week`) and `operator` to `eq`/`neq`/`gt`/`gte`/`lt`/`lte`/`in`/`nin`/`contains`; any other key on a rule object is stripped on write. */
274
321
  rules?: Array<{
275
322
  field?: string;
276
323
  operator?: string;
277
324
  value?: unknown;
278
325
  }>;
326
+ /** Last write to the segment, INCLUDING the fire-and-forget match-stats update — so running a match bumps this even though name, rules and `is_active` did not change. Omitted entirely from the 201 create response. */
279
327
  updated_at?: string;
280
328
  };
281
329
  /**
@@ -294,11 +342,14 @@ type AudienceSocialAttention = {
294
342
  type BatchResult = {
295
343
  /** Number that failed */
296
344
  failed_count?: number;
345
+ /** Not emitted. The live response names this array `errors`, and each entry carries `screen_id`, a human `error` string, and an additional machine `code`: `screen_not_found` for an id that is not 24-hex or that your partner account does not own, `write_failed` for a database failure. Read `errors`. */
297
346
  failed_screens?: Array<{
298
347
  error?: string;
299
348
  screen_id?: string;
300
349
  }>;
350
+ /** Constant discriminator `batch_result`, shared verbatim by every partner batch endpoint (content preferences, display settings, programmatic settings) — it does not identify which one you called. */
301
351
  object?: string;
352
+ /** Server-minted per HTTP request in the form `req_<32 hex>`, also returned in the `X-Request-Id` response header; omitted if no request id was assigned to the call. Quote it when reporting a partial batch failure — it is the only handle that ties your call to the per-screen error rows. */
302
353
  request_id?: string;
303
354
  /** Number of screens requested to update */
304
355
  total_requested?: number;
@@ -313,8 +364,11 @@ type BatchResult = {
313
364
  *
314
365
  */
315
366
  type BillingInfo = {
367
+ /** **Never emitted**, so its unit is undefined. The credits that do exist are bought through `POST /billing/credits`, which reports `credits_added_cents` — integer US cents, not an abstract credit count. */
316
368
  credits_remaining: number;
369
+ /** **Never emitted.** Consumption is reported by `GET /billing/usage` (`object: billing_usage`), which returns the billing service's usage summary rather than a credit counter. */
317
370
  credits_used: number;
371
+ /** **No producer.** There is no `GET /v1/partner/billing` route; the live billing surface is `/billing/plans`, `/billing/subscribe`, `/billing/cancel`, `/billing/usage` and `/billing/subscription`, each returning a Stripe-style envelope tagged with an `object` marker plus `request_id`. */
318
372
  plan: string;
319
373
  /** Whether the partner has a connected Stripe account. */
320
374
  stripe_connected: boolean;
@@ -322,16 +376,27 @@ type BillingInfo = {
322
376
  usage_this_month: Record<string, number>;
323
377
  };
324
378
  type BleScanResultData = {
379
+ /** Despite the name this is a GENERALISED advert-subtype code, and the server stores it as `manufacturer_subtype`. Values 0x00-0x1F are genuine Apple Continuity subtypes; 0x80 and above are SYNTHETIC markers this parser assigns to non-Apple formats — 0x80 iBeacon, 0x81 Eddystone-UID, 0x82 Fast Pair, 0x83 Tile, 0x85 Eddystone-URL, 0x86 AltBeacon. Note an Apple iBeacon reports 0x80, not its literal Apple subtype 0x02. */
325
380
  appleContinuitySubtype?: number | null;
381
+ /** The Android `BluetoothDevice.getType()` constant: 0 UNKNOWN, 1 CLASSIC, 2 LE, 3 DUAL. Treat `0` as "unknown OR the read threw" — the producer catches any exception and substitutes 0 rather than omitting the field. */
326
382
  deviceType?: number | null;
383
+ /** iBeacon major value, an unsigned 16-bit integer (0-65535) parsed BIG-endian from the advert. `null` unless a valid iBeacon frame was found, and meaningless without the accompanying `ibeaconUuid`. */
327
384
  ibeaconMajor?: number | null;
385
+ /** iBeacon minor value, unsigned 16-bit big-endian (0-65535). `null` outside a valid iBeacon frame; major and minor are only comparable within one `ibeaconUuid` namespace. */
328
386
  ibeaconMinor?: number | null;
387
+ /** The 16-byte iBeacon proximity UUID as canonical dashed lowercase, parsed BIG-endian. Present only for a well-formed Apple iBeacon frame — an Apple subtype 0x02 whose inner length is not 0x15 is explicitly rejected rather than partially parsed. */
329
388
  ibeaconUuid?: string | null;
389
+ /** The Bluetooth SIG assigned company identifier from the advert's Manufacturer Specific Data structure (AD type 0xFF), read little-endian — e.g. 76 (0x004c) Apple, 6 Microsoft, 224 (0x00e0) Google. `null` when the record carries no such structure. One synthetic exception: a Google Fast Pair service-data advert is forced to the Google id even though it has no 0xFF structure. */
330
390
  manufacturerCompanyId?: number | null;
391
+ /** The BLE advertiser address exactly as Android reports it, unhashed on the device — it is sent over TLS and HMAC-hashed server-side. On most modern phones this is a resolvable private address that ROTATES roughly every 15 minutes, so it is not a stable device identity; the sibling `addr_type` (`public`/`random`/`anonymous`) is what tells the two apart. */
331
392
  rawAddress: string;
393
+ /** Received signal strength in dBm (negative, bounded -120..0). It is the MAXIMUM RSSI across every advertisement from this address inside the scan window — the closest observed reading, not a mean — because the scanner groups repeated adverts per address and keeps the strongest as the representative. */
332
394
  rssi: number;
395
+ /** Advertised GATT service UUIDs, de-duplicated. 16-bit UUIDs are rendered as FOUR lowercase hex characters (e.g. `feaa`), not expanded to the full `0000feaa-0000-1000-8000-00805f9b34fb` base form; 128-bit UUIDs are canonical dashed lowercase. `null` rather than `[]` when the advert carries none. */
333
396
  serviceUuids?: Array<string> | null;
397
+ /** Lowercase hex, no separators, of ONLY the advert sub-bytes that survive address rotation (an iBeacon UUID+major+minor, an Eddystone namespace+instance, a Fast Pair model id…), prefixed with the synthetic subtype byte. Calibration bytes such as TX power are deliberately excluded. It is `null` far more often than it is present: 13 rotating Apple subtypes are dropped outright and unknown Apple subtypes fail closed, so a populated `appleContinuitySubtype` with a null payload here is normal. */
334
398
  stableManufacturerPayloadHex?: string | null;
399
+ /** Advertised transmit power in dBm (bounded -127..20) taken from the advert itself, NOT from `ScanResult.getTxPower()`. For iBeacon and Eddystone frames it is the format's calibrated 1-metre reference power, which overwrites the generic AD-type-0x0A value; `null` when the advert carries neither. */
335
400
  txPowerDbm?: number | null;
336
401
  [key: string]: unknown;
337
402
  };
@@ -466,14 +531,23 @@ type ContentPreferencesInput = {
466
531
  preferred_categories?: Array<string>;
467
532
  };
468
533
  type ContentPreferencesResponse = {
534
+ /** NOT EMITTED. No handler on this endpoint produces this key — neither `formatContentPreferences` nor the service feeding it has such a field. Advertiser blocking lives on the programmatic-settings resource instead, as `blocked_advertisers` on `GET/PUT /v1/partner/screens/{screenId}/programmatic-settings`. */
469
535
  blocked_advertisers?: Array<string>;
536
+ /** The screen's own blocked list, `[]` when unset. Matched at approval time against the ad's typed `iab_categories` with `categoriesMatch`, so IAB codes and human-readable names both work. It is the only one of this schema's five list/rating fields the server actually emits. */
470
537
  blocked_categories?: Array<string>;
538
+ /** NOT EMITTED, and the identifier does not occur anywhere in the API source. The only delivery cap this platform exposes to partners is the advisory `max_ads_per_hour` in the `/ads` response settings. */
471
539
  max_ad_frequency?: number;
540
+ /** NOT EMITTED, and the identifier `min_ad_rating` does not occur anywhere in the API source. The response carries `max_age_rating` instead — an UPPER bound on the ad classification (`G`..`NC-17`), defaulting to `PG-13`, not a minimum. */
472
541
  min_ad_rating?: string;
542
+ /** Always the literal `content_preferences`. This response is FLAT — there is no `success`/`data` envelope, unlike the L-bar and default-stream routes — and `POST .../content-preferences/apply-preset` returns this same object with an extra `preset_applied` field appended. */
473
543
  object?: string;
544
+ /** NOT EMITTED, and the identifier does not occur anywhere in the API source. The nearest real control is `allowed_categories`, which the formatter does return and which acts as a scoring boost (+10 per match) rather than a hard preference. */
474
545
  preferred_categories?: Array<string>;
546
+ /** Echo of the server-assigned `req.requestId`. The formatter attaches it only when one exists, so the key is ABSENT rather than null on a request that carried none. */
475
547
  request_id?: string;
548
+ /** Echoed straight back from the `{screenId}` path parameter — the formatter is handed the path value, not a column — so it always equals what you asked for. */
476
549
  screen_id?: string;
550
+ /** ISO-8601, but it is the SCREEN row's `updatedAt`, not a preferences-specific timestamp, so unrelated screen edits move it. When the screen has none the formatter substitutes the current time, so this field is never null and never proves a write landed. */
477
551
  updated_at?: string;
478
552
  };
479
553
  type ContentPreset = {
@@ -493,12 +567,18 @@ type ContentPreset = {
493
567
  preferences?: Record<string, unknown>;
494
568
  };
495
569
  type CreatePartnerSensingRouteRequest = {
570
+ /** 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. */
496
571
  actions: Array<SensingRouteAction>;
572
+ /** 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`. */
497
573
  activeDaysMask?: number;
574
+ /** 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. */
498
575
  activeEndMinute?: number;
576
+ /** 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. */
499
577
  activeStartMinute?: number;
578
+ /** 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. */
500
579
  activeTz?: string;
501
580
  condition?: SensingRouteCondition;
581
+ /** 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. */
502
582
  debounceSeconds?: number;
503
583
  /** Required when mode=digest. `field` is required for avg/max. */
504
584
  digest?: {
@@ -507,8 +587,11 @@ type CreatePartnerSensingRouteRequest = {
507
587
  metric?: "count" | "avg" | "max";
508
588
  windowMinutes?: number;
509
589
  };
590
+ /** 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. */
510
591
  enabled?: boolean;
592
+ /** `threshold` (the default) needs a `condition` and evaluates every incoming observation. `digest` needs a `digest` block instead, sends on a schedule, and rejects `screenFocusRegions`. Only the exact string `digest` selects digest mode; anything else falls back to `threshold`. */
511
593
  mode?: "threshold" | "digest";
594
+ /** 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. */
512
595
  name: string;
513
596
  /** Bind to a named Sense Anything profile (stable across redeploys). */
514
597
  profileName?: string;
@@ -529,9 +612,11 @@ type CreatePartnerSensingRouteRequest = {
529
612
  * Structured creative classification — the same shape the Trillboards venue portal renders for a served ad.
530
613
  */
531
614
  type CreativeClassification = {
615
+ /** The name whoever FIRST submitted these bytes passed on `POST /creatives`, carried through the moderation queue. It is unverified free text, and because the review row is keyed on the content hash a later submitter of the same bytes reads the first submitter's value. `null` when none was supplied. */
532
616
  advertiser_name?: string | null;
533
617
  /** One of G, PG, PG-13, R, NC-17 (null until classified). */
534
618
  age_rating?: string | null;
619
+ /** Descriptive output of the same classifier pass — visible subjects, keywords, scene setting, mood, a quality grade, and `text_content` recovered by OCR. Every field is `null`/empty until classification finishes. This is scene description, not the moderation verdict; that is `status` plus `moderation.flags`. */
535
620
  analysis?: {
536
621
  keywords?: Array<string>;
537
622
  mood?: string | null;
@@ -541,6 +626,7 @@ type CreativeClassification = {
541
626
  /** On-screen text (OCR). */
542
627
  text_content?: string | null;
543
628
  };
629
+ /** Classifier output: `brand`, `category` and IAB category codes. All three are `null`/empty while `status` is `pending`, so an empty `iab_categories` means not yet classified rather than 'no categories apply'. */
544
630
  categorization?: {
545
631
  brand?: string | null;
546
632
  category?: string | null;
@@ -548,18 +634,24 @@ type CreativeClassification = {
548
634
  };
549
635
  /** Classification confidence, 0..1. */
550
636
  confidence?: number | null;
637
+ /** SHA-256 of the bytes the SERVER fetched from your `media_url` — lowercase 64-hex, never a hash you declare — and `id` is this same value prefixed `sha256:`. Classification is content-addressed and global, so two partners submitting identical bytes share one record, and this is the byte-identity join key into `completed_impressions` and `vast_requests`. */
551
638
  content_sha256?: string;
552
639
  /** Canonical reference — `sha256:<hex>` of the creative bytes. Use as `creative_ref` when posting plays. */
553
640
  id?: string;
641
+ /** `approved` is not an independent verdict — it is derived as `status === 'approved'`, so it is false for both `pending` and `blocked` and adds no information beyond `status`. `flags` is the classifier's reason list and is empty until classification finishes. */
554
642
  moderation?: {
555
643
  approved?: boolean;
556
644
  flags?: Array<string>;
557
645
  };
646
+ /** Constant discriminator `creative_classification`. It is returned identically on the 202 (queued) and 200 (already classified) forms of this response, so it tells you nothing about whether classification has finished — read `status` for that. */
558
647
  object?: string;
648
+ /** `pending` means queued and not yet classified. `approved`, `flagged` and `blocked` are terminal: re-submitting the same bytes then returns 200 immediately without re-running the classifier or consuming your daily classification quota, whereas a still-pending submit returns 202. The value is passed through from the shared review row unmapped, so a state outside this enum (e.g. `metadata_only`) can surface. */
559
649
  status?: "pending" | "approved" | "flagged" | "blocked";
650
+ /** Always `null` in practice: the only writer of the underlying `creative_reviews.thumbnail_url` column has no remaining callers, so nothing populates it. When it did carry a value it was constrained to a Trillboards CDN host, so an off-host thumbnail was dropped rather than passed through. */
560
651
  thumbnail_url?: string | null;
561
652
  };
562
653
  type DashboardResponse = {
654
+ /** All amounts are integer US cents. `total_earned_cents` is lifetime earner revenue across every status (estimated + confirmed + paid); `available_payout_cents` is only the Net-60-matured payable balance minus anything already reserved, floored at 0; `next_payout_date` is simply the 1st of next month, computed in the handler with no reference to a scheduled transfer. `revenue_share_percent` is 0-100 and reads `60` when no `publisher_payout_rates` rule applies. */
563
655
  earnings?: {
564
656
  available_payout_cents?: number;
565
657
  next_payout_date?: string;
@@ -567,18 +659,21 @@ type DashboardResponse = {
567
659
  revenue_share_percent?: number;
568
660
  total_earned_cents?: number;
569
661
  };
662
+ /** Identity block copied off the partner row, where `created_at` is the registration timestamp. The row is read through a 10-minute Redis cache, so a just-changed name or status can lag by that much. */
570
663
  partner?: {
571
664
  created_at?: string;
572
665
  name?: string;
573
666
  slug?: string;
574
667
  status?: "pending" | "active" | "suspended" | "terminated";
575
668
  };
669
+ /** The ten newest `impression_ledger` rows for the partner, newest first. `type` is always the literal `impression` — no other activity kind is ever emitted. */
576
670
  recent_activity?: Array<{
577
671
  ad_id?: string;
578
672
  screen_id?: string;
579
673
  timestamp?: string;
580
674
  type?: string;
581
675
  }>;
676
+ /** `active_screens`/`offline_screens` count `online_status` `online`/`offline` from the live heartbeat mirror — a screen that has never heartbeated reads `unknown` and lands in NEITHER bucket, so the two need not sum to `total_screens`. `pending_screens` is the separate approval status and overlaps both. The three impression totals are deduped, GIVT-passed `completed_impressions` over midnight-today / the last 7 days / month-to-date, all cut in the API server's local timezone rather than the screen's. */
582
677
  stats?: {
583
678
  active_screens?: number;
584
679
  offline_screens?: number;
@@ -588,10 +683,12 @@ type DashboardResponse = {
588
683
  total_impressions_week?: number;
589
684
  total_screens?: number;
590
685
  };
686
+ /** `connect_account_id` is **redacted**: it is the literal string `***` when an account is connected and `null` when it is not, so it can never be used as an identifier. `account_status` defaults to `pending` and must be `active` before a payout request is accepted. */
591
687
  stripe_status?: {
592
688
  account_status?: string;
593
689
  connect_account_id?: string;
594
690
  };
691
+ /** The five screens with the most completed impressions since midnight today (server timezone). `name` falls back to the literal `Unknown Screen`, and `location` is a `City, State` string or `null` when either part is missing. */
595
692
  top_screens?: Array<{
596
693
  impressions_today?: number;
597
694
  location?: string;
@@ -605,6 +702,7 @@ type DashboardResponse = {
605
702
  *
606
703
  */
607
704
  type DataExportSummaryResponse = {
705
+ /** `lookback_days` is `min(?days, 90)` and defaults to 7. `screen_count` is how many screens the export is SCOPED to, not how many hold data. **`k_anonymity_threshold` is the constant `0`** — the dataset exports apply no suppression despite the field name; the `>= 5` floor quoted in the published methodology applies only to the cross-network proximity/household exports. Each `datasets` entry is a `total_records`/`earliest`/`latest` triple, and a dataset whose count query FAILED is reported as `0` with null bounds rather than as an error. */
608
706
  data?: {
609
707
  /** Map of dataset name to record summary */
610
708
  datasets?: Record<string, {
@@ -616,6 +714,7 @@ type DataExportSummaryResponse = {
616
714
  lookback_days?: number;
617
715
  screen_count?: number;
618
716
  };
717
+ /** Always `true`; a failure is raised to the global error handler and returns a canonical error envelope at a 4xx/5xx status. Note this response carries **no `request_id`**, unlike the sibling audience endpoints. */
619
718
  success?: boolean;
620
719
  };
621
720
  /**
@@ -634,6 +733,7 @@ type DemandPartners = {
634
733
  updated_at: string;
635
734
  };
636
735
  type DemographicsResponse = {
736
+ /** `demographics.age_distribution` and `gender_split` are **hardcoded zeros even on the success path** — nothing computes them. `income_profile` and `group_composition` are shares in 0-1, not percentages, and `lifestyle_segments` is emitted as `{segment, percentage}` objects rather than the plain strings declared here. `data_quality.confidence` is a step function of sample size (>=100 gives 0.9, >=50 gives 0.7, >=20 gives 0.5, >=5 gives 0.3, else 0.1), not a statistical confidence, and `sample_size` counts measurement WINDOWS, not people. `period` echoes `?period=`; an unrecognised value silently falls back to 7 days while still being echoed verbatim. */
637
737
  data?: {
638
738
  data_quality?: {
639
739
  confidence?: number;
@@ -670,12 +770,17 @@ type DemographicsResponse = {
670
770
  screen_name?: string;
671
771
  timestamp?: string;
672
772
  };
773
+ /** Per-request identifier (`req_` plus 32 hex), also returned on the `X-Request-Id` response header. */
673
774
  request_id?: string;
775
+ /** Always `true` on a 2xx; failures return the error envelope (`success: false` plus an `error` object) at a 4xx/5xx status. */
674
776
  success?: boolean;
675
777
  };
676
778
  type Device = {
779
+ /** Creation time of the DEVICE row (`earner_screen_devices`), not of the linked screen, which may be created later in the same request or on a subsequent re-registration. */
677
780
  created_at?: string;
781
+ /** Trillboards-minted 24-char Mongo ObjectId hex for the `earner_screen_devices` row (`mongo_earnerscreen_device_id`). This is NOT the `device_id` you sent at registration — that comes back as `external_device_id`; the batch endpoint calls this same value `trillboard_id`. All three of `device_id`, `fingerprint` and `external_device_id` are accepted as the `{deviceId}` path parameter, resolved in that order. */
678
782
  device_id?: string;
783
+ /** Round-tripped through the OpenRTB `devicetype` integer, so it is LOSSY and will often not equal what you registered: `tv`, `connected_tv`, `display` and any unrecognised value all collapse to `digital_signage`, and `vending_machine` collapses to `kiosk`. Only `mobile`, `tablet`, `digital_signage` and `kiosk` are reachable from a partner registration; `other` appears only for a device whose `devicetype` was written by a non-partner path. */
679
784
  device_type?: string;
680
785
  /** Screen display specification. `width`/`height` are pixel resolution; `width_in`/`height_in` are physical inches. Pixel-only inputs are NOT recorded as physical size — pass `width_in`/`height_in` (most accurate) or `ppi` so the server can derive inches from pixels. */
681
786
  display?: {
@@ -691,20 +796,33 @@ type Device = {
691
796
  /** Physical screen width in inches */
692
797
  width_in?: number;
693
798
  };
799
+ /** Static player URL of the form `https://screen.trillboards.com?fp=<fingerprint>`. It is deterministic and permanent — not a signed or expiring link — so it never rotates and anyone holding it can load the player. `null` until a screen is linked. */
694
800
  embed_url?: string;
801
+ /** Venue-enrichment state. `status` is `complete` only when a classification source exists AND `location` actually resolved (coordinates or a city), so it never reports complete over placeholder geo; a partner-supplied venue category stamps the source as `partner` at registration, which means `complete` does not imply AI classification ran. `confidence` is dead — `venue_classification_confidence` has no column and no writer anywhere in the API, so it is always undefined and omitted from the JSON. */
695
802
  enrichment?: Record<string, unknown>;
803
+ /** Verbatim echo of the `device_id` you sent in the registration body (`earner_screen_devices.partner_external_id`). It is the idempotency key scoped to your partner account: re-POSTing the same value updates the existing device instead of creating a second one. */
696
804
  external_device_id?: string;
805
+ /** Deterministic device key: `P_` followed by the first 24 hex chars of `md5("<your partner id>_<external_device_id>")`. It never rotates — the same partner plus the same `device_id` always regenerates it — and it is both the heartbeat/player routing key and the token embedded in `embed_url`. */
697
806
  fingerprint?: string;
807
+ /** Resolved geo, read from the enriched screen row first and falling back to the raw registration-time device row, with placeholder strings ("City", "Vending Machine Location") filtered out. `lat`/`lng` coalesce as a PAIR — a half-set or null-island (0,0) screen pair is discarded whole in favour of the device's coordinates — and `venue_type` here is the OpenOOH CHILD slug, while `venue.category` carries the parent. `timezone` is the one exception to screen-first: it is read device-first. */
698
808
  location?: Record<string, unknown>;
809
+ /** The linked screen's name, falling back to the raw `external_device_id` when the screen has none. Omitting `name` at registration seeds the screen as `Partner Device: <device_id>`, so that literal string is what you get back rather than a null. */
699
810
  name?: string;
811
+ /** The linked screen's Mongo ObjectId hex (`earner_screens.mongo_earnerscreen_id`), minted server-side at registration. This — not `device_id` — is the id the non-device partner endpoints take (display settings, screen groups, fleet commands, analytics). Absent when no screen is linked to the device. */
700
812
  screen_id?: string;
701
- status?: "pending" | "active" | "offline" | "suspended";
813
+ /** Provisioning state of the underlying screen row, not a liveness signal — a device that has stopped reporting still reads `approved`. Partner registration stamps `approved` on create (`partnerDeviceRegistrationService.js`), which is what almost every live partner device returns; `pending` is the formatter's fallback when the screen row carries no status of its own. */
814
+ status?: "pending" | "approved" | "active" | "offline" | "suspended";
815
+ /** `earner_screen_devices.updated_at`, which the device-mirror write buffer stamps `NOW()` on every flush — including heartbeat telemetry. It therefore tracks device liveness, not configuration changes; do not poll it to detect that a registration or PATCH landed. */
702
816
  updated_at?: string;
817
+ /** OpenOOH classification of the screen, rebuilt from the typed `screen_venue_taxonomy` row. `category` is the PARENT slug and `subcategory` the child, with `openooh_venue_type_id` the unambiguous numeric child id — so a granular input like `hotel` reads back as a parent/child pair, not as what you sent. Empty until classification lands, unless your registration itself carried a resolvable `venue.category`/`subcategory`. */
703
818
  venue?: Record<string, unknown>;
704
819
  };
705
820
  type DeviceCapabilityPayload = {
821
+ /** Runtime family of the agent (`fire-tv`, `android-tv`, `tablet`, `tablet-lite`, `webos`, `vidaa`, `web-browser`, `brightsign`). It is NOT read from here on the partner path — the server derives `agent_type` from `telemetry.agentType` plus hardware signals, and overrides a self-declared `web-browser` when the model/UA say otherwise, because partner SDKs running in a WebView historically reported `web-browser` for physical DOOH tablets. */
706
822
  agentType?: string | null;
823
+ /** Agent build version. Do not use it to identify an artifact: three different Android build constants populate this one field across three unrelated numbering schemes, so the same string can mean different code. It is also not read from here on the partner path — the version the server keeps comes from the body `agent_version`/`sdk_version`, and only for heartbeats that self-identify as Trillboards SDKs. */
707
824
  agentVersion?: string | null;
825
+ /** Camera/microphone sensing state. On the partner path ONLY the nested `cameraHealth` block is persisted (to the typed `camera_health_*` columns) — `cameraAvailable`, `cameraType`, `cameraCount`, `microphoneAvailable` and `sensingMode` reach no column on this path. The presence of `cameraHealth` is also an in-band signal that the heartbeat came from a Trillboards SDK, which is what allows `agent_version` to be stored. `cameraAvailable` self-devolves to false after a camera has been failed for over 24 h. */
708
826
  audienceSensing?: {
709
827
  cameraAvailable?: boolean | null;
710
828
  cameraCount?: number | null;
@@ -721,23 +839,29 @@ type DeviceCapabilityPayload = {
721
839
  sensingMode?: string | null;
722
840
  [key: string]: unknown;
723
841
  } | null;
842
+ /** Declares volume and mute control. Hardcoded `true`/`true` on the Android agents — it is not probed against `AudioManager`, so it is a claim about the agent, not proof the hardware obeys. Parsed by the shared capability builder but then DISCARDED on the partner heartbeat path: it is not in the partner column whitelist, which is a deliberate security boundary — partner SDKs must not be able to drive device management or power. */
724
843
  audioControl?: {
725
844
  mute?: boolean | null;
726
845
  volume?: boolean | null;
727
846
  [key: string]: unknown;
728
847
  } | null;
848
+ /** Schema version of this capability block, persisted to `earner_screen_devices.capabilities_version` for drift attribution. Every shipped Android and Vega agent emits the constant `1` and nothing overrides it, so it carries no information today — but the server treats it as an arbitrary integer, not a bounded enum. Note the server only records it when an `mlCapabilities` block is present or when it is sent with no `mlCapabilities` at all. */
729
849
  capabilitiesVersion?: number | null;
850
+ /** Declares an ambient-light and a temperature sensor. Only `ambientLight` is a real probe (`SensorManager.getDefaultSensor(TYPE_LIGHT) != null`, false on any throw); `temperature` is hardcoded `false` on the Android agents, and the actual temperature reading that does ship comes from the battery sensor via telemetry, not from here. Parsed by the shared capability builder but then DISCARDED on the partner heartbeat path: it is not in the partner column whitelist, which is a deliberate security boundary — partner SDKs must not be able to drive device management or power. */
730
851
  environmentSensors?: {
731
852
  ambientLight?: boolean | null;
732
853
  temperature?: boolean | null;
733
854
  [key: string]: unknown;
734
855
  } | null;
856
+ /** Declares HDMI input switching and app switching. Also hardcoded per agent build rather than probed (a tablet reports `hdmi: false`, `appSwitch: true`). Parsed by the shared capability builder but then DISCARDED on the partner heartbeat path: it is not in the partner column whitelist, which is a deliberate security boundary — partner SDKs must not be able to drive device management or power. */
735
857
  inputControl?: {
736
858
  appSwitch?: boolean | null;
737
859
  hdmi?: boolean | null;
738
860
  [key: string]: unknown;
739
861
  } | null;
862
+ /** Whether the agent holds Android Device Owner (`DevicePolicyManager.isDeviceOwnerApp`) — the privilege that gates reboot, kiosk lock, APK install and wipe. Parsed by the shared capability builder but then DISCARDED on the partner heartbeat path: it is not in the partner column whitelist, which is a deliberate security boundary — partner SDKs must not be able to drive device management or power, precisely so a partner cannot flip it and unlock those commands. */
740
863
  isDeviceOwner?: boolean | null;
864
+ /** Declares remote restart/reboot/screenshot/cache-clear/kiosk/APK-install/wipe. On the first-party Android agents four of the seven are simply `isDeviceOwner` repeated, and the partner SDK reports the whole block `false` because "partner hosts own their device management". Parsed by the shared capability builder but then DISCARDED on the partner heartbeat path: it is not in the partner column whitelist, which is a deliberate security boundary — partner SDKs must not be able to drive device management or power. */
741
865
  managementCapabilities?: {
742
866
  clearCache?: boolean | null;
743
867
  installApk?: boolean | null;
@@ -748,6 +872,7 @@ type DeviceCapabilityPayload = {
748
872
  wipe?: boolean | null;
749
873
  [key: string]: unknown;
750
874
  } | null;
875
+ /** The hardware manifest that decides which on-device ML models a screen is offered, and one of the few capability blocks the partner path DOES persist. Two traps: every nullable integer must be sent as JSON `null`, never 0 — the server treats 0 as a probe-failure sentinel and drops it so the COALESCE upsert preserves the prior value — and `screenWidthPx`/`screenHeightPx` below 100 are rejected outright, after agents reporting inches corrupted 940+ rows. A `recommendedModelTier` of `UNKNOWN` (or NULL) excludes the device from on-device demographics entirely. */
751
876
  mlCapabilities?: {
752
877
  availableRamMb?: number | null;
753
878
  chipsetName?: string | null;
@@ -767,12 +892,14 @@ type DeviceCapabilityPayload = {
767
892
  totalRamMb?: number | null;
768
893
  [key: string]: unknown;
769
894
  } | null;
895
+ /** Declares HDMI-CEC, wake-on-LAN and soft-blackout support. On the Android agents these are HARDCODED per build, not probed (a tablet always reports `cec: false`, `wol: false`, `softBlackout: true`), so they describe the agent variant rather than the panel. Parsed by the shared capability builder but then DISCARDED on the partner heartbeat path: it is not in the partner column whitelist, which is a deliberate security boundary — partner SDKs must not be able to drive device management or power. */
770
896
  powerControl?: {
771
897
  cec?: boolean | null;
772
898
  softBlackout?: boolean | null;
773
899
  wol?: boolean | null;
774
900
  [key: string]: unknown;
775
901
  } | null;
902
+ /** Counts ESP32-S3 Wi-Fi CSI sensor nodes discovered on the LAN — it is a statement about the venue's network, not about this device's own radio (`csiHardwareType` is the literal `"esp32-s3"` when any node is found, `"none"` otherwise). Parsed by the shared capability builder but then DISCARDED on the partner heartbeat path: it is not in the partner column whitelist, which is a deliberate security boundary — partner SDKs must not be able to drive device management or power; it is also the one CSI field the ingest schema deliberately keeps, since it is a startup capability counter rather than a streaming data field. */
776
903
  wifiCsiSensing?: {
777
904
  csiAvailable?: boolean | null;
778
905
  csiHardwareType?: string | null;
@@ -782,19 +909,33 @@ type DeviceCapabilityPayload = {
782
909
  [key: string]: unknown;
783
910
  };
784
911
  type DeviceSummary = {
912
+ /** Creation time of the DEVICE row, not of the linked screen (which can be created later). */
785
913
  created_at?: string;
914
+ /** Trillboards-minted 24-char Mongo ObjectId hex for the `earner_screen_devices` row (`mongo_earnerscreen_device_id`). Not the `device_id` you sent at registration — that is returned as `external_device_id`. Accepted as the `{deviceId}` path parameter, as are `fingerprint` and `external_device_id`. */
786
915
  device_id?: string;
916
+ /** Round-tripped through the OpenRTB `devicetype` integer and therefore lossy: `tv`, `connected_tv`, `display` and unrecognised values all read back as `digital_signage`, and `vending_machine` reads back as `kiosk`. */
787
917
  device_type?: string;
918
+ /** Emits `orientation` (from the screen, defaulting to `landscape`) and `ppi` (from the device row). `width`/`height` still read `dimension_width`/`dimension_height`, columns DROPPED from `earner_screen_devices` by migration `20260527180000`, so they resolve to undefined and are omitted from the JSON entirely — registered pixels now live in `screen_width_px`/`screen_height_px`. Physical inches you send as `width_in`/`height_in` are stored but never echoed here. */
788
919
  display?: Record<string, unknown>;
920
+ /** Static, unsigned player URL `https://screen.trillboards.com?fp=<fingerprint>`. It never expires or rotates, and is `null` until a screen is linked. */
789
921
  embed_url?: string;
922
+ /** Venue-enrichment state. `status` reads `complete` only when a classification source exists AND `location` actually resolved, and a partner-declared venue category alone sets that source — so `complete` does not mean AI classification ran. `confidence` is dead: `venue_classification_confidence` has no column and no writer in the API, so it is always omitted. */
790
923
  enrichment?: Record<string, unknown>;
924
+ /** Verbatim echo of the `device_id` you sent in the registration body (`partner_external_id`). It is the per-partner idempotency key: re-registering the same value updates the existing device rather than creating another. */
791
925
  external_device_id?: string;
926
+ /** Deterministic device key: `P_` plus the first 24 hex chars of `md5("<your partner id>_<external_device_id>")`. It never rotates, and it is the token embedded in `embed_url` as well as the heartbeat routing key. */
792
927
  fingerprint?: string;
928
+ /** Resolved geo: the enriched screen row first, falling back to the registration-time device row, with placeholder strings coalesced away. `lat`/`lng` coalesce as a pair, `venue_type` is the OpenOOH child slug, and `timezone` alone is read device-first. */
793
929
  location?: Record<string, unknown>;
930
+ /** The linked screen's name, falling back to the raw `external_device_id` when the screen has none. A registration that omits `name` seeds the screen as `Partner Device: <device_id>`. */
794
931
  name?: string;
932
+ /** The linked screen's Mongo ObjectId hex (`earner_screens.mongo_earnerscreen_id`). This is the id every non-device partner endpoint takes — display settings, screen groups, fleet commands, analytics. Absent when no screen is linked. */
795
933
  screen_id?: string;
934
+ /** The linked screen's `earner_screens.status` verbatim. Partner registration auto-approves, so the live value is `approved` (`deactivated` after a DELETE, `pending` only while no screen is linked) — not the `pending`/`active`/`offline`/`suspended` vocabulary the `Device` schema declares, whose mapper has no production call site. */
796
935
  status?: string;
936
+ /** `earner_screen_devices.updated_at`, stamped `NOW()` by the device-mirror write buffer on every flush including heartbeats — a liveness signal, not a configuration-change marker. */
797
937
  updated_at?: string;
938
+ /** OpenOOH classification rebuilt from the typed `screen_venue_taxonomy` row: `category` is the PARENT slug, `subcategory` the child, `openooh_venue_type_id` the numeric child id. Empty until classification lands, unless registration supplied a resolvable venue category. */
798
939
  venue?: Record<string, unknown>;
799
940
  };
800
941
  type DisplaySettingsInput = {
@@ -819,30 +960,45 @@ type DisplaySettingsInput = {
819
960
  volume?: number;
820
961
  };
821
962
  type DisplaySettingsResponse = {
963
+ /** NOT EMITTED and NOT WRITABLE — the identifier does not occur anywhere in the API source. The nearest real controls are `ad_interval_seconds` on this resource (rotation cadence, 30-300s) and the slot duration the playback policy derives (clamped 5-60s). */
822
964
  ad_duration_default?: number;
965
+ /** NOT EMITTED by this endpoint. `buildAdSurfaces` is computed on a display-settings write, but only to populate the `display_settings_updated` socket event pushed to the device — the HTTP body is the formatter's output and contains no `ad_surfaces`. The declared item shape is wrong too: real surfaces carry `id`, `surface_type`, `enabled`, `display_area_ratio`, `dimensions` and `allowed_ad_types`, never `position`/`width_percent`/`height_percent`. The L-bar PUT is the one HTTP response that does return them. */
823
966
  ad_surfaces?: Array<{
824
967
  height_percent?: number;
825
968
  id?: string;
826
969
  position?: string;
827
970
  width_percent?: number;
828
971
  }>;
972
+ /** NOT EMITTED and NOT WRITABLE. The writer's ten-key allow-list has no `brightness`, so a PUT silently drops it, and the formatter never returns one. Brightness exists in this product only as inbound device telemetry on the heartbeat, which this resource cannot set. */
829
973
  brightness?: number;
830
974
  content_fit?: ContentFitSettings;
975
+ /** Real and emitted: `fullscreen` (default), `l-bar` or `pip`, read from the typed `display_mode` column in preference to the legacy `display_preferences` JSONB. Note the writer validates a THREE-value enum including `pip`, while the L-bar resource's own `display_mode` accepts only `fullscreen`/`l-bar`. */
831
976
  display_mode?: string;
977
+ /** NOT EMITTED and NOT WRITABLE — the identifier does not occur anywhere in the API source. What actually plays when no ad is available is the screen's default streams, managed through the default-streams routes. */
832
978
  idle_content?: string;
979
+ /** NOT EMITTED and NOT WRITABLE. Use `sound_enabled: false` instead — that key the formatter does return, defaulting to `true`. */
833
980
  mute?: boolean;
981
+ /** Always `display_settings`. The response is FLAT — no `success`/`data` envelope — and it comes from exactly one formatter shared by the GET, PUT and PATCH, so all three return an identical shape. */
834
982
  object?: string;
983
+ /** NOT EMITTED under this name — the formatter returns `screen_orientation` instead (`landscape`, `portrait`, `mixed` or `unknown`, defaulting to `landscape`). The writer's allow-list also only accepts `screen_orientation`, so an `orientation` key in a PUT body is silently dropped. */
835
984
  orientation?: string;
985
+ /** NOT EMITTED by this endpoint, for the same reason as `ad_surfaces` — it is computed on a write only to fill the device socket event. The declared `max_duration_seconds`/`min_break_between_ads`/`allow_skip` keys do not exist in the real policy either; `buildPlaybackPolicy` produces `policy_version`, millisecond intervals, `max_paid_share`, `surface_policy`, `schedule_policy`, `pip_settings` and `lbar_settings`. */
836
986
  playback_policy?: {
837
987
  allow_skip?: boolean;
838
988
  max_duration_seconds?: number;
839
989
  min_break_between_ads?: number;
840
990
  };
991
+ /** NOT EMITTED and NOT WRITABLE — the identifier does not occur anywhere in the API source. */
841
992
  qr_code_position?: string;
993
+ /** Echo of the server-assigned `req.requestId`, attached only when one exists — the key is absent, not null, otherwise. */
842
994
  request_id?: string;
995
+ /** Echoed from the `{screenId}` path parameter rather than read from a column. */
843
996
  screen_id?: string;
997
+ /** NOT EMITTED and NOT WRITABLE — the identifier `transition_effect` does not occur anywhere in the API source. There is no transition control on this resource. */
844
998
  transition_effect?: string;
999
+ /** ISO-8601 of the SCREEN row's `updatedAt`, not a display-settings-specific timestamp; when the screen has none the formatter substitutes the current time, so it is never null and never proves a write landed. */
845
1000
  updated_at?: string;
1001
+ /** NOT EMITTED and NOT WRITABLE — the string `volume` does not appear in the display-settings service or the formatter at all. The only audio control on this resource is the boolean `sound_enabled`. */
846
1002
  volume?: number;
847
1003
  };
848
1004
  /**
@@ -1008,6 +1164,7 @@ type EarnerScreens = {
1008
1164
  width_inches?: number | null;
1009
1165
  };
1010
1166
  type EarningsBreakdownResponse = {
1167
+ /** Keyed by `date` for `group_by=day|week` and by `screen_id` (plus a `screen_name`) for `group_by=screen`; the unused key is absent rather than null. Week rows are labelled `<year>-W<ISO week>`. `earnings_cents` is allocated cumulatively so the rows sum exactly to `summary.total_cents`. */
1011
1168
  breakdown?: Array<{
1012
1169
  /** Date or screen_id depending on group_by */
1013
1170
  date?: string;
@@ -1016,15 +1173,18 @@ type EarningsBreakdownResponse = {
1016
1173
  /** Screen ID (when grouped by screen) */
1017
1174
  screen_id?: string;
1018
1175
  }>;
1176
+ /** **Both fields are hardcoded** — `estimated_cents` is always `0` and `confirmation_date` always `null`. Read `EarningsResponse.pending_confirmation_cents` for the real Net-60 pending figure. */
1019
1177
  pending_programmatic?: {
1020
1178
  confirmation_date?: string;
1021
1179
  estimated_cents?: number;
1022
1180
  };
1181
+ /** `start`/`end` echo the request; with neither supplied the window is the last 30 days ending now. The end bound is EXCLUSIVE in the underlying query (`period_date < end`), so passing today's date omits today. */
1023
1182
  period?: {
1024
1183
  end?: string;
1025
1184
  group_by?: "day" | "week" | "screen";
1026
1185
  start?: string;
1027
1186
  };
1187
+ /** Integer US cents. **`direct_cents` is a hardcoded `0`** and `programmatic_cents` is simply `total_cents`, so the split conveys nothing — everything this rail reports is booked as programmatic. */
1028
1188
  summary?: {
1029
1189
  direct_cents?: number;
1030
1190
  programmatic_cents?: number;
@@ -1038,6 +1198,7 @@ type EarningsBreakdownResponse = {
1038
1198
  *
1039
1199
  */
1040
1200
  type EarningsData = {
1201
+ /** **Never emitted.** No partner earnings endpoint computes a CPM; the earnings chart returns impressions and cents only, so a caller must divide for itself. */
1041
1202
  average_cpm: number;
1042
1203
  /** Optional per-day earnings/impressions series. */
1043
1204
  daily?: Array<{
@@ -1046,22 +1207,29 @@ type EarningsData = {
1046
1207
  earnings: number;
1047
1208
  impressions: number;
1048
1209
  }>;
1210
+ /** **Never emitted**, so its unit is undefined. Every money field the partner earnings endpoints actually return is integer US cents and carries a `_cents` suffix. */
1049
1211
  last_payout_amount?: number;
1212
+ /** **Never emitted.** No handler populates it — read `GET /v1/partner/earnings/transactions?type=payout`, whose rows carry `created_at` and `amount_cents`. */
1050
1213
  last_payout_date?: string;
1051
1214
  /** Balance not yet paid out. */
1052
1215
  pending_payout: number;
1216
+ /** Declared for the SDK type but **not emitted**: `GET /v1/partner/connect/earnings` returns `{summary, chart, period}` where `period` is an OBJECT (`days`/`start`/`end`), not this string. */
1053
1217
  period: string;
1218
+ /** **Not emitted under this name**, so its unit is undefined. The live earnings chart returns `summary.total_earnings_cents` — integer US cents — summed over the requested window from the payout-accounting rollup of `programmatic_earnings_daily`. */
1054
1219
  total_earnings: number;
1220
+ /** **Not emitted under this name.** The live earnings chart returns `summary.total_impressions`, the impression count carried on the same daily earner-revenue rows the cents are summed from. */
1055
1221
  total_impressions: number;
1056
1222
  };
1057
1223
  type EarningsResponse = {
1058
1224
  /** Amount available for immediate payout */
1059
1225
  available_for_payout_cents?: number;
1226
+ /** Integer US cents, and only one component is real: **`direct_campaigns` and `programmatic_floodgates` are hardcoded `0`**, while `programmatic_google` is ALL programmatic earner revenue for the current calendar month regardless of provider — it is not Google-specific, and unlike the lifetime figures above it is month-to-date. */
1060
1227
  breakdown?: {
1061
1228
  direct_campaigns?: number;
1062
1229
  programmatic_floodgates?: number;
1063
1230
  programmatic_google?: number;
1064
1231
  };
1232
+ /** `amount_cents` is integer US cents and simply repeats `available_for_payout_cents`; `date` is the 1st of next month, computed in the handler with no reference to any scheduled transfer; `stripe_status` is the partner's Connect account status, which must be `active` before a payout request is accepted. */
1065
1233
  next_payout_estimate?: {
1066
1234
  amount_cents?: number;
1067
1235
  date?: string;
@@ -1120,16 +1288,21 @@ type ErrorCodeResponse = {
1120
1288
  type ErrorEntry = {
1121
1289
  /** Stable machine error code (e.g. not_found). */
1122
1290
  code: string;
1291
+ /** Derived mechanically as `https://api.trillboards.com/docs/errors#<code>`, so it is always well-formed whether or not that anchor exists. It appears on live error envelopes and catalog entries; no per-partner error-log endpoint returns it. */
1123
1292
  doc_url: string;
1124
1293
  /** The API path that produced the error. */
1125
1294
  endpoint: string;
1295
+ /** **No producer.** `GET /v1/errors` returns the static error CATALOG (code definitions in the abstract), not per-partner occurrences, and the per-request metadata the API does retain is keyed by `request_id` and carries no `id`. */
1126
1296
  id: string;
1297
+ /** **Never emitted as part of a logged occurrence.** Human-readable text appears only on a live error envelope or a catalog entry; the retained per-request record stores `error_code`/`error_type` and no message. */
1127
1298
  message: string;
1128
1299
  /** The HTTP method of the failing request. */
1129
1300
  method: string;
1130
1301
  /** The request parameter the error concerns, if any. */
1131
1302
  param?: string;
1303
+ /** The `req_`-prefixed identifier of the failing request, minted server-side and returned both on the `X-Request-Id` header and inside every error envelope. It is the only handle that links a caller-side failure to server-side request metadata. */
1132
1304
  request_id: string;
1305
+ /** **Not emitted by any error endpoint.** The per-request metadata the API retains does stamp an ISO timestamp at response time, but it lives in a bounded in-memory LRU cache, not a queryable error log. */
1133
1306
  timestamp: string;
1134
1307
  /** Stripe-style error taxonomy (matches the SDK's ApiErrorType). */
1135
1308
  type: "invalid_request_error" | "authentication_error" | "permission_error" | "rate_limit_error" | "idempotency_error" | "api_error";
@@ -1169,13 +1342,19 @@ type ErrorType = "api_error" | "authentication_error" | "idempotency_error" | "i
1169
1342
  type ExtendedIdentity = {
1170
1343
  /** OpenRTB person-based identifier type. */
1171
1344
  atype: 3;
1345
+ /** When the token stops being usable — for a UID2 token this is the refresh deadline, and it is stored verbatim regardless of `status` (unlike the advertising-identity envelope, which discards `expires_at` on an `available` state). `null` means no declared expiry, not "already expired". */
1172
1346
  expires_at?: string | null;
1347
+ /** The identity value itself, up to 4096 characters and containing no whitespace. Omit it (or send `null`) with `unavailable` to CLEAR a previously stored identity — that is the only way to retract one; sending the envelope with `available` and an empty id is rejected instead. */
1173
1348
  id?: string | null;
1349
+ /** `hem_sha256` must be exactly 64 hexadecimal characters and is lowercased ONCE server-side and never re-hashed — send the digest, not the email. `uid2_token` is an opaque case-sensitive token forwarded byte-for-byte with only a whitespace check, so do not trim, lowercase or re-encode it. Together with `source` this pair is the row key: one identity per `(id_type, source)` per device. */
1174
1350
  id_type: "hem_sha256" | "uid2_token";
1351
+ /** When the device observed this identity — and it is the CONFLICT-RESOLUTION CLOCK, not just provenance: the upsert applies only when this timestamp is greater than or equal to the one already stored, so a replayed or out-of-order envelope is silently discarded with no error. An unparseable value rejects the whole array. */
1175
1352
  observed_at: string;
1176
1353
  /** Canonical top-level domain for this identity system. */
1177
1354
  source: string;
1355
+ /** Version of the SDK or integration that produced this identity, up to 128 characters, kept for provenance only — no server logic branches on it. It is trimmed, and a whitespace-only string is stored as `null` rather than as an empty string. */
1178
1356
  source_version?: string | null;
1357
+ /** `available` requires `id`; `unavailable` requires `id` to be ABSENT — sending both is a validation failure, and because normalisation is all-or-nothing across the array that single bad element discards every other identity in the same request while the heartbeat still returns 200. */
1179
1358
  status: "available" | "unavailable";
1180
1359
  };
1181
1360
  /**
@@ -1184,27 +1363,36 @@ type ExtendedIdentity = {
1184
1363
  *
1185
1364
  */
1186
1365
  type FleetCommand = {
1366
+ /** The device command that was fanned out — one of `reload`, `update_settings`, `restart`, `screenshot`, `clear_cache`, `update_content`, `refresh_ads`, `update_config`, `get_state`, `show_ad`, `play_ad`. Validated in the application against `ALLOWED_COMMANDS`, with no database constraint, so the allow-list can grow without a schema migration. */
1187
1367
  command_type?: string;
1368
+ /** Set on BOTH terminal states — `completed` and `failed` — so it is not a success marker. `null` only while the job has not reached a terminal state. */
1188
1369
  completed_at?: string | null;
1370
+ /** When the job row was inserted, i.e. when the batch was accepted. Because execution is synchronous, `created_at` and `completed_at` bracket the entire fan-out. */
1189
1371
  created_at?: string;
1372
+ /** The raw thrown message, set only when the JOB itself failed; `null` otherwise. Per-screen dispatch failures do NOT populate it — those live in `results[].error`, and a job whose targets all failed still reports `status: "completed"` with `failed` > 0. */
1190
1373
  error?: string | null;
1191
1374
  /** Screens that could not be enqueued */
1192
1375
  failed?: number;
1376
+ /** The `sg_<uuid>` screen group this batch fanned out to. It is a plain TEXT reference, not a foreign key: groups are only ever soft-deleted, so the job outlives its group and keeps the id as an audit record. */
1193
1377
  group_id?: string | null;
1194
- /** Job id (fc_ prefix). */
1378
+ /** Job id: the literal prefix `fc_` followed by a v4 UUID (`pgRepo/fleetCommands.js` mints `fc_${crypto.randomUUID()}`). Opaque — do not parse the UUID half. */
1195
1379
  id?: string;
1380
+ /** Constant string `fleet_command` — the Stripe-style type discriminator, present both here and inside the `data` of a `fleet_command.*` webhook. */
1196
1381
  object?: string;
1197
1382
  /** Screens the command was enqueued to */
1198
1383
  queued?: number;
1384
+ /** One entry per screen the dispatch touched, persisted only when the fan-out actually produced rows. `results[].status` is `queued` or `failed` — the per-screen dispatch outcome, a different vocabulary from the job's own `status` — and `device_command_id` is the queued device-command's Mongo ObjectId, or `null` when nothing was queued (e.g. `error: "No device linked"`). On a `fleet_command.failed` webhook this is always `[]`, because the dispatch never completed. */
1199
1385
  results?: Array<{
1200
1386
  device_command_id?: string | null;
1201
1387
  error?: string | null;
1202
1388
  screen_id?: string;
1203
1389
  status?: "queued" | "failed";
1204
1390
  }>;
1391
+ /** Execution is synchronous inside the create request — the job walks `queued` to `running` to `completed` (or `failed`) before the POST returns — so a poll will in practice only ever observe a terminal value. `queued` and `running` exist for a future async worker that claims `queued` rows. */
1205
1392
  status?: "queued" | "running" | "completed" | "failed";
1206
1393
  /** Member screens resolved for this command */
1207
1394
  targeted?: number;
1395
+ /** Bumped on each job state transition (`queued` to `running` to terminal), so on a completed job it is effectively the moment the fan-out finished. */
1208
1396
  updated_at?: string;
1209
1397
  };
1210
1398
  /**
@@ -1218,8 +1406,11 @@ type FleetCommand = {
1218
1406
  type FleetCommandEvent = {
1219
1407
  /** The fleet_command job object. */
1220
1408
  data?: Record<string, unknown>;
1409
+ /** The terminal state that fired, also sent in the `X-Trillboards-Event` header and mirrored to the SSE stream. Note `fleet_command.completed` fires even when individual screens failed — check `data.failed` and `data.results[]`. Delivery is gated by the `SCREEN_GROUP_EVENTS_ENABLED` runtime flag, off by default. */
1221
1410
  event?: "fleet_command.completed" | "fleet_command.failed";
1411
+ /** `evt_<base36 epoch ms>_<12 hex>`, minted once per occurrence and reused across retries of that delivery — so it is the idempotency key. The same id also goes to every endpoint subscribed to the event, so dedupe per (endpoint, id). It is not the delivery audit id and is not queryable. */
1222
1412
  id?: string;
1413
+ /** Stamped when the server enqueued the delivery, not when it was sent; retries keep the original value. It is the emit time of the terminal-state event, which is not the same as the job's `completed_at`. */
1223
1414
  timestamp?: string;
1224
1415
  };
1225
1416
  /**
@@ -1227,6 +1418,7 @@ type FleetCommandEvent = {
1227
1418
  *
1228
1419
  */
1229
1420
  type HeaderBiddingSettings = {
1421
+ /** `null` unless the screen falls in the ad-buffering rollout bucket. `min_buffer`, `max_buffer` and `warmup_count` are COUNTS OF PREFETCHED ADS (defaults 3 / 5 / 5), not seconds or bytes; `max_cache_age_seconds` (default 300) is how long a prefetched VAST may be held before it must be discarded. */
1230
1422
  ad_buffering?: {
1231
1423
  enabled?: boolean;
1232
1424
  max_buffer?: number;
@@ -1234,18 +1426,31 @@ type HeaderBiddingSettings = {
1234
1426
  min_buffer?: number;
1235
1427
  warmup_count?: number;
1236
1428
  } | null;
1429
+ /** `null` unless the screen falls in the ad-pod experiment bucket. When present it carries `enabled`, `pod_size` (how many ads to request in a single pod) and `variant_name` (the pod-size arm this screen hashed into) — the arm is assigned server-side, not chosen by the client. */
1237
1430
  ad_pod?: Record<string, unknown> | null;
1431
+ /** Share of ad slots the client should give to programmatic, as a decimal FRACTION 0-1 (`0.20` = 20%), defaulting to `1.0`. Same underlying value the CMS exposes as `programmatic_allocation_percent` (0-100, i.e. this times 100). Only honoured when `request_mode` is `allocation`; under `always` it is ignored. */
1238
1432
  allocation?: number;
1433
+ /** One id per waterfall BUILD (one `/ads` response), shared by every entry in `vast_waterfall.sources[]`; the narrower per-source key is `request_id`. This is the value that lands in `vast_requests.auction_request_id`, so it is what groups all the source attempts belonging to a single ad opportunity. Echo it, never mint your own. */
1239
1434
  auction_request_id?: string;
1435
+ /** Constant `true` whenever this object is present — the whole `header_bidding_settings` object is `null` for a screen that is not programmatically eligible. This is header-bidding *configuration*, not the serving master switch: that is `programmatic_enabled` (surfaced as `ProgrammaticSettingsResponse.enabled`) plus any active ads-disabled reason, and the legacy `enabled` shadow was deliberately removed from the serving decision. */
1240
1436
  enabled?: boolean;
1437
+ /** `+`-joined tags for the yield experiments this screen is bucketed into (e.g. `yield_buffer_treatment+ad_pod_pod_3`), covering ad buffering, fast loop and ad pods; `null` when none is active. Assignment is a consistent hash of the screen id, so it is stable for a screen across requests. Report it back for analytics — on its own it changes no client behaviour. */
1241
1438
  experiment_variant?: string | null;
1439
+ /** `null` unless the screen falls in the fast-loop experiment bucket. When present it carries `enabled` and `ad_to_ad_gap_ms` — the gap to leave between consecutive ads, in MILLISECONDS (default 5000), which is the experiment's shortened alternative to the normal ~30s cadence. */
1242
1440
  fast_loop?: Record<string, unknown> | null;
1441
+ /** No live producer — `null` on every response. The only assignment is guarded on a partner-level `fetchMode` setting, and no demand partner in the registry defines one, so this key is never populated at either the top level or per source. Do not branch on it. */
1243
1442
  fetch_mode?: string | null;
1443
+ /** Legacy predecessor of `request_mode`, retained only as the fallback that selects `always` (true) versus `allocation` (false) when no explicit `request_mode` is stored. An explicit `request_mode` always wins — do not read the two independently or you will disagree with the server on screens that set both. */
1244
1444
  force_request?: boolean;
1445
+ /** `null` except on screens in a revenue-recovery experiment arm, where it carries `name` and `reason` plus the IMA overrides to apply for that arm — some subset of `num_redirects`, `vast_load_timeout_ms`, `load_video_timeout_ms`, `source_timeout_ms`, `mime_types`, `enable_preloading`, `sdk_version_override` and `client_hints_override`. The key set is deliberately open: apply the keys you recognise and ignore the rest. */
1245
1446
  ima_profile?: Record<string, unknown> | null;
1447
+ /** Minimum gap the client should leave between VAST requests, in SECONDS (not milliseconds), default `30`, accepted range 0-300. Advisory: no server-side check enforces it — the `/ads` rate limiter is a separate mechanism that does not read this value. */
1246
1448
  min_interval_seconds?: number;
1449
+ /** Always `null`. Every stored value for this key is JSON `null`, it has no typed home on the screen record (dropped from the typed read path), and the builder emits `hbs.prebid || null` — there is no live producer. Prebid Server demand, when it participates, is spliced into `vast_waterfall.sources[]`, not surfaced here. */
1247
1450
  prebid?: Record<string, unknown> | null;
1451
+ /** Opaque public alias of the first waterfall source — the same string as `variant_name` and `source_name`, not the `floodgates`/`prebid`/`other` bidding-provider value the CMS accepts on `ProgrammaticSettingsInput.provider`. The key is absent entirely when the waterfall resolved no sources. */
1248
1452
  provider?: string;
1453
+ /** Player tuning selected purely from the screen's `country`; today only `IN` differs from the default — `timeout_ms` 25000 vs 12000, `fallback_timeout_ms` 35000 vs 18000, `vquality` 1 vs 2, `enable_network_adaptation` true vs false, and `ad_buffer_percentage` as a 0-1 fraction of network capacity (0.25 vs 1.0). Note the wire shapes: `preferred_resolution` is an object `{width, height}` and `vquality` an integer, not the strings declared here. */
1249
1454
  regional_config?: {
1250
1455
  ad_buffer_percentage?: number;
1251
1456
  enable_network_adaptation?: boolean;
@@ -1256,21 +1461,30 @@ type HeaderBiddingSettings = {
1256
1461
  } | null;
1257
1462
  /** Per-request waterfall identifier; echo it back on programmatic-event reports. */
1258
1463
  request_id?: string;
1464
+ /** `allocation` = request only on the `allocation` share of slots; `always` = request on every slot and ignore `allocation`. These are the only two accepted values. Defaults to `always` on the static-tag path; on the waterfall path it defaults from the legacy `force_request` boolean (`always` when true, `allocation` when false). */
1259
1465
  request_mode?: string;
1466
+ /** Opaque alias of the source these top-level fields describe — the first waterfall source, identical to `provider`/`variant_name`. Absent when the waterfall resolved no sources. */
1260
1467
  source_name?: string | null;
1468
+ /** The first source's `priority` sort key, copied up to the top level. Same semantics as `VastWaterfallSource.priority`: an opaque ascending key that may start at 0 and may be non-contiguous — it is not an index, a count, or a position you can do arithmetic on. */
1261
1469
  source_priority?: number | null;
1470
+ /** Opaque public alias of the first waterfall source, byte-identical to `provider` and `source_name` on the same response. Format is `src_<6 hex>` (SHA-256 of the partner key) or `var_<6 hex>` (of the variant name), plus the fixed `src_test` for the sandbox test source and `inbound_dsp` for a collapsed DSP-seat source. Never a real partner name, and it may be rotated per tenant — echo it back on `programmatic-event` reports rather than persisting it as an identity. */
1262
1471
  variant_name?: string;
1472
+ /** Client-side VAST/creative caching directive, `null` unless the fleet-level VAST-cache switch is on (shipped default: off). `ttl_seconds` defaults to 21600 (6 hours). `priority` is a fixed rank of `1.5` — between paid ads (1) and self-promo (2) — and is emitted as a NUMBER despite the string type declared here. */
1263
1473
  vast_cache?: {
1264
1474
  enabled?: boolean;
1265
1475
  priority?: string;
1266
1476
  ttl_seconds?: number;
1267
1477
  } | null;
1478
+ /** Convenience copy of `vast_waterfall.sources[0].vast_url` — the server overwrites it with the first source on every build and drops the key entirely when the waterfall resolved no sources. It is not an extra tag, so fetching only this URL is exactly the `sources[0]`-and-stop defect the `VastWaterfall` contract warns about. */
1268
1479
  vast_tag_url?: string;
1269
1480
  vast_waterfall?: VastWaterfall;
1270
1481
  } | null;
1271
1482
  type HttpProbeResult = {
1483
+ /** IP address that was probed. The target list is PARASITIC on mDNS — the hosts discovered in the same cycle plus the Wi-Fi gateway, de-duplicated and capped at 10. There is no subnet scan, so a device that advertises nothing on mDNS is never probed. */
1272
1484
  host: string;
1485
+ /** Always `80` — the probe only ever tries plain HTTP on port 80, and the value is a literal at the call site rather than something the scanner chose. Printer ports 515 and 9100-9102 are refused before any socket opens, because an HTTP request to a JetDirect/LPD port makes the printer physically spool the bytes. */
1273
1486
  port?: number | null;
1487
+ /** The HTTP `Server:` response header from a `HEAD /` request (HTTP/1.0, 1.5 s timeout). Never null in an emitted row — a probe that gets no usable header produces no row, and the value is rejected outright if it is over 256 characters or contains an `@`, angle brackets, control characters, or the words `password`/`passwd`/`secret`/`phpinfo`. Failures are counted separately in `skipReasonCounts`, so absence from this array is not evidence the host is down. */
1274
1488
  server?: string | null;
1275
1489
  [key: string]: unknown;
1276
1490
  };
@@ -1298,13 +1512,17 @@ type Impression = {
1298
1512
  advertiser_name?: string;
1299
1513
  /** Effective CPM for the impression, in the partner's currency. */
1300
1514
  cpm?: number;
1515
+ /** Sent on the wire as `did`. The server accepts the device's Mongo id, its fingerprint, or the `partner_external_id` you registered for it. An unrecognised value is logged and the call still returns 200 — only a SIGNED impression 404s on an unknown device. */
1301
1516
  device_id: string;
1302
1517
  /** Play duration in seconds. */
1303
1518
  duration_seconds?: number;
1304
1519
  /** Earner revenue accrued for the impression. */
1305
1520
  earnings?: number;
1521
+ /** The per-serve id minted by the server on `data.ads[].impression_id` (`imp_<epoch-ms>_<8 hex>`) and echoed back as the `impid` parameter on the impression call — it is the dedup key, so never generate your own. Note that no endpoint currently returns this object: it is the `@trillboards/api-client` type describing what you SEND. */
1306
1522
  impression_id: string;
1523
+ /** The batch endpoint reads `timestamp`, NOT `played_at` — a `played_at` you send is ignored and the impression is stamped with server receive time instead. That matters for offline sync: send `timestamp` if the real play time has to survive the upload delay. */
1307
1524
  played_at: string;
1525
+ /** Your earner-screen id (24-character hex Mongo ObjectId), sent on the wire as `sid`. It is only a FALLBACK for resolving the screen — the server resolves from the device (`did`) first and reads `sid` solely when no device matched. */
1308
1526
  screen_id: string;
1309
1527
  };
1310
1528
  /**
@@ -1325,12 +1543,19 @@ type LegacyError = {
1325
1543
  success?: boolean;
1326
1544
  };
1327
1545
  type MdnsNetworkDevice = {
1546
+ /** The responder's IP address as a string — the first IPv4 address if it has one, otherwise any address. A record that resolves to no address is dropped rather than emitted with a null host, so in practice this is always populated. It is also what seeds the HTTP-probe target list for the same discovery cycle. */
1328
1547
  host?: string | null;
1548
+ /** The service instance name verbatim from the resolve event. A PII filter runs first and DROPS THE WHOLE ROW when the name looks like a personal name (an apostrophe-s possessive, an email, a MAC), so devices named after people disappear from the list entirely rather than appearing anonymised. */
1329
1549
  instanceName: string;
1550
+ /** Model string lifted from the TXT record using a per-service-type key table — `md` for Chromecast and HAP, `model`/`am` for AirPlay, `ty`/`product` for IPP, `dt` for Matter (a device-TYPE code used as a model surrogate, not a model name), with a broader default list for other types. `null` when no key in that list is present or all their values are blank. */
1330
1551
  mdnsModel?: string | null;
1552
+ /** Version string from the TXT record. Frequently this is `txtvers` — the TXT RECORD FORMAT version, not the device firmware version — because that key is in the fallback list for most service types. `null` when no candidate key is present. */
1331
1553
  mdnsSoftwareVersion?: string | null;
1554
+ /** Vendor string from the TXT record, by the same per-type key table. Two traps: Chromecast has an EMPTY vendor key list, so it is always `null` there; and for HAP this carries the HomeKit `ci` CATEGORY id (a numeric device class) while for Matter it carries the raw `VP` `vendorid+productid` pair — neither is a vendor name. */
1332
1555
  mdnsVendor?: string | null;
1556
+ /** TCP/UDP port advertised in the SRV record. A port of 0 is coerced to `null` rather than forwarded, so `null` means "not advertised", never "port zero". */
1333
1557
  port?: number | null;
1558
+ /** The mDNS service type the record resolved under, e.g. `_googlecast._tcp.`. Discovery is not open-ended — the scanner browses a FIXED list of 19 types (Roku, AirPlay, RAOP, Chromecast, Amazon WPlay, Android TV Remote, Spotify Connect, device-info, companion-link, IPP/IPPS/printer/PDL, HAP, Matter, Alexa, HTTP and the Trillboards CSI type), so a device advertising anything else is invisible here. */
1334
1559
  serviceType: string;
1335
1560
  [key: string]: unknown;
1336
1561
  };
@@ -1341,22 +1566,29 @@ type MdnsNetworkDevice = {
1341
1566
  *
1342
1567
  */
1343
1568
  type MeasurementMethodology = {
1569
+ /** Prose description of the happy-path pipeline: edge capture, buffered ingest, `signal_observations`, a 30-minute device-resolution pass into `resolved_device_observations`, then a 30-minute transition pass. Names the stores only — never infrastructure or source locations. */
1344
1570
  canonical_path?: string;
1345
1571
  /** What each screen observes and what is dropped at the edge. */
1346
1572
  capture_stack?: Record<string, unknown>;
1347
1573
  /** Cross-day identity, including measured coverage (1.6% of identifiable BLE rows). An earlier revision said ~31%; that graded the presence of stable_payload_hash and ignored the sanitizer the identity expression applies, and it is withdrawn. */
1348
1574
  cross_day_matching?: Record<string, unknown>;
1575
+ /** The in-range contour: BLE RSSI at or above -75 dBm, empirically about 5 m line-of-sight for Class-2 radios at typical mounting. Calibration is static per screen-class — there is no per-screen ranging today — and devices inside the contour classified as stationary are excluded from the audience funnel by default. */
1349
1576
  display_exposure_zone?: Record<string, unknown>;
1577
+ /** What promotes a raw in-range observation to a qualifying event, one entry per qualification (`ble_engagement`, `face_attention`, `returning_visitor`), each carrying its definition, its rationale, and the ClickHouse field a reviewer re-derives it from. */
1350
1578
  dwell_qualification_thresholds?: Record<string, unknown>;
1579
+ /** Static disclosure of how observation confidence decays: `algorithm` (Bayesian posterior with temporal decay), `decay_window_days` (90) and `max_confidence` (0.95). These are published constants, not per-request measurements. */
1351
1580
  edge_confidence_model?: Record<string, unknown>;
1352
1581
  /** Evidence grades, which are decisionable, and person-grain resolution. */
1353
1582
  evidence_grade_ladder: Record<string, unknown>;
1583
+ /** Prose description of degraded ingest: observations buffer on a durable queue and replay on recovery, and after repeated failures land in a dead-letter store rather than being dropped. */
1354
1584
  fallback_path?: string;
1355
1585
  /** How identifiers are keyed, and what erasure means. */
1356
1586
  hashing?: Record<string, unknown>;
1357
1587
  /** The counting UNIT. Reach and unique-device counts are counts of BLE identifiers, not devices and not people; one device contributes several identifiers per window and the number grows with the window. Carries the measured identifiers-per-device per fleet type and the controlled comparison it was derived from. */
1358
1588
  identifier_inflation?: Record<string, unknown>;
1589
+ /** Alignment against MRC OOH Phase 2. `mrc_status_accredited` is the machine-readable answer and is **false until an MRC grant reference exists** — read that boolean rather than parsing the English `status` line. Also carries the monotonic qualification chain (`in_range` through `cross_screen`) and the interval method (Wilson score for proportions, 200-sample bootstrap for medians). */
1359
1590
  mrc_ooh_phase2_alignment: Record<string, unknown>;
1591
+ /** Version markers for the edge parsers that produced the disclosed fields — `ble_advertisement_parser`, `mdns_txt_extractor`, and the integer `heartbeat_payload_schema`. Hand-maintained constants, bumped when a parser changes. */
1360
1592
  parser_versions?: Record<string, unknown>;
1361
1593
  /** k-anonymity floor and differential-privacy budget. */
1362
1594
  privacy_gates: Record<string, unknown>;
@@ -1392,25 +1624,39 @@ type Pagination = {
1392
1624
  total?: number;
1393
1625
  };
1394
1626
  type Partner = {
1627
+ /** Defaults to the literal `lite_sdk` when unset, so it is never null, and is `lite_sdk` for effectively the whole fleet. It is load-bearing at serve time: `lite_sdk`/`full_sdk` mark the partner a video-only SDK consumer, so an image creative is swapped for the ad's partner video URL unless `sdk_config.allowed_ad_types` includes `image`. */
1395
1628
  integration_tier?: string;
1629
+ /** Registered company name. The same string is the buyer-facing media-owner label for this partner's inventory, so it is what advertisers see in the media-owner picker. */
1396
1630
  name?: string;
1631
+ /** The 24-character hex Mongo-era identifier (`partners.mongo_partner_id`), not the table's integer `partner_id`. This is the value the API key's owner id and every `partner_mongo_id` foreign key carry. */
1397
1632
  partner_id?: string;
1398
1633
  /** Legacy classification retained for existing integrations. New integrations should not branch on this field. */
1399
1634
  partner_type?: string;
1635
+ /** The partner's share as a percentage 0-100 (the stored rate is 0-1 and is multiplied by 100 here). Resolved from the active, currently-effective `publisher_payout_rates` rule; with no such rule it falls through to the system default and reports `60` — which is also why the `POST /register` response echoes `60` even when the caller asked for 70, since the partner row is read back before the rate row is written. */
1400
1636
  revenue_share_percent?: number;
1401
1637
  sdk_config?: SDKConfig;
1638
+ /** Lower-cased at registration and unique across all partners (a collision is rejected `409`). It appears in device embed URLs and portal links, so it is effectively permanent once devices are provisioned. */
1402
1639
  slug?: string;
1403
1640
  stats?: PartnerStats;
1641
+ /** In practice **always `active` here**: the API-key resolver refuses any partner whose status is not `active`, so a `pending`/`suspended`/`terminated` partner gets `401 invalid_api_key` and never reaches this response. Registration also always creates `active`. */
1404
1642
  status?: "pending" | "active" | "suspended" | "terminated";
1643
+ /** Defaults to the literal `pending` when unset. `POST /earnings/payout` refuses any value other than `active`. */
1405
1644
  stripe_account_status?: string;
1406
1645
  };
1407
1646
  type PartnerAdDeliveryProfile = {
1647
+ /** Android SDK API level as reported by the device; `null` when not reported, and then no Android rule fires at all. Below 23 forces `mode: vast_fallback` and turns `auto_recovery_enabled` off. 23-25 with desugaring reported off only appends a warning to `notes` and leaves the mode alone. */
1408
1648
  android_api_level?: number | null;
1649
+ /** Whether the SERVER may auto-enqueue recovery device commands for this device — the `refresh_ads` and `restart` it issues when a screen heartbeats online without recent VAST requests, or with stalled playback. It is not a client-side setting, and it goes false alongside every `vast_fallback` verdict so we do not restart a device whose player we already know is the wrong one. */
1409
1650
  auto_recovery_enabled?: boolean;
1651
+ /** Echoed back from the device's own heartbeat telemetry, or `unknown` when the device never reported one. It selects which compatibility rules run: `android_native` enables the Android API-level gate, `html5_webview` the Chromium-version checks; `disabled` and `unknown` skip both, so no mode change can result from those. */
1410
1652
  ima_integration?: "android_native" | "html5_webview" | "disabled" | "unknown";
1653
+ /** The server's per-heartbeat verdict on how this device should play ads: `ima_sdk` is the normal IMA path, `vast_fallback` means bypass IMA entirely and play the VAST in your own fallback player. It is recomputed from the telemetry on every heartbeat, so treat it as a live instruction rather than one-time provisioning. */
1411
1654
  mode?: "ima_sdk" | "vast_fallback";
1655
+ /** Human-readable diagnostics accompanying the decision above; an empty array on a healthy device. They are free prose for logs and support tickets, not a stable enum — nothing in the contract depends on matching their text, and their presence alone does not imply degraded delivery. */
1412
1656
  notes?: Array<string>;
1657
+ /** Machine token for why `mode` was chosen: `default` (nothing disqualified the device), `sdk_reported_ima_unsupported` (your own telemetry reported `imaSupported: false`), or `android_api_below_23_for_current_ima_android_sdk`. `default` is always what accompanies `ima_sdk`. */
1413
1658
  reason?: string;
1659
+ /** Major Chromium version of the device's WebView, `null` when not reported. Advisory only — it never changes `mode`: below 87 appends a content-restoration note and below 80 an additional autoplay/media note. */
1414
1660
  webview_chromium_major?: number | null;
1415
1661
  };
1416
1662
  /**
@@ -1418,38 +1664,67 @@ type PartnerAdDeliveryProfile = {
1418
1664
  */
1419
1665
  type PartnerCreativeWarnings = Array<string>;
1420
1666
  type PartnerDefaultStream = {
1667
+ /** The stream's 24-hex id, minted server-side with `newObjectId()` at create and stored as `default_streams.mongo_default_stream_id`. It is the value every other default-stream route takes as `{streamId}`; the shape is Mongo's but PostgreSQL is the store of record. */
1421
1668
  _id?: string;
1669
+ /** Accepted with no required-field check, same as `google_slides_url`. The derived `canva_design_id` is returned alongside it although this schema does not declare it. */
1422
1670
  canva_url?: string;
1671
+ /** Mapped from `default_streams.created_at`. It is the list endpoint's secondary sort key, so two streams sharing a `playback_order` play oldest-first. */
1423
1672
  createdAt?: string;
1673
+ /** Falls back to `name`, then to `Background content`; the upload and image routes use `Uploaded video content` / `Image carousel content` instead. */
1424
1674
  description?: string;
1675
+ /** Seconds one pass of this stream occupies. It is computed as `images.length x image_duration` when you send images without it; for every other type it is only ever what you sent — nothing probes a YouTube or uploaded video to fill it in on this path. */
1425
1676
  duration_seconds?: number;
1677
+ /** The URL the player actually loads into the iframe. It defaults to `generic_iframe_url` when you do not send it, so on an iframe stream the two are usually equal. */
1426
1678
  embed_url?: string;
1679
+ /** The iframe source for the `generic_iframe` type. It also seeds `embed_url` when that is omitted, so sending only this one populates both fields. */
1427
1680
  generic_iframe_url?: string;
1681
+ /** Unlike `youtube`/`twitch`/`uploaded`/`images`, the `google_slides` type has NO required-field check — a create naming that type with no URL is accepted, stores null, and the screen then renders an empty frame. */
1428
1682
  google_slides_url?: string;
1683
+ /** Only five keys survive: `auto_advance`, `slide_delay_ms`, `refresh_interval_seconds`, `sandbox_flags`, `allow_flags` — anything else is silently discarded and a non-object is discarded whole. On update it is MERGED over the stored config rather than replacing it, so a key cannot be cleared by omitting it. */
1429
1684
  iframe_config?: Record<string, unknown>;
1685
+ /** Seconds each image is held before advancing. The multipart image route defaults it to 10 when omitted and uses it to compute `duration_seconds`; the JSON create leaves it unset unless you send it. */
1430
1686
  image_duration?: number;
1687
+ /** Ordered image URLs, each held for `image_duration` seconds. Required and non-empty when `stream_type` is `images` (400 otherwise), null for every other type; the create accepts a real array, a JSON-encoded array string, or a comma-separated string. */
1431
1688
  images?: Array<string>;
1689
+ /** Soft-deactivation flag. The list filters `is_active = true`, so it is always `true` on anything this API returns; the partner DELETE removes the row outright rather than clearing this flag, which is instead used by the screen-wide deactivate paths. */
1432
1690
  is_active?: boolean;
1691
+ /** At most one stream per screen carries this — promoting or reordering clears it on every sibling first. A create sets it on the new stream, `reorder` gives it to `order[0]`, and deleting the current stream hands it to whatever the ordering resolves next. */
1433
1692
  is_currently_playing?: boolean;
1693
+ /** Falls back to `description`, then to the literal `Default Stream`, so it is never empty. The multipart upload route instead falls back to the uploaded file's original name, and the multi-image route to `Image Carousel (N images)`. */
1434
1694
  name?: string;
1695
+ /** Present ONLY on the 201 from the three create routes, which echo the in-memory document back. `default_streams` has no partner column, so list, update and read responses omit this key entirely — tenancy is enforced at the screen, not on the stream row. */
1435
1696
  partner_id?: string;
1697
+ /** 1-based position in the screen's rotation; the list sorts on it and breaks ties with `createdAt`. A newly created stream is promoted to 1 and every sibling shifts down, and `POST .../reorder` rewrites it to each id's index in the `order` array you send. */
1436
1698
  playback_order?: number;
1699
+ /** The owning screen, mapped from `default_streams.screen_mongo_id` — the same 24-hex id you passed as `{screenId}` in the path. Note the camelCase spelling inside an otherwise snake_case object. */
1437
1700
  screenId?: string;
1701
+ /** Accepted with no required-field check. The derived `social_wall_provider` and `social_wall_widget_id` are returned alongside it although this schema does not declare them. */
1438
1702
  social_wall_url?: string;
1703
+ /** Which renderer the screen uses. When omitted at create it is DERIVED from whichever content field you sent, in this fixed 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`) — and the create is rejected 400 if none resolves. Only `youtube`, `uploaded`, `images` and `twitch` are ever content-analyzed; the four embed types deliberately keep `gemini_analyzed_at` NULL. */
1439
1704
  stream_type?: string;
1705
+ /** Required when `stream_type` is `twitch` (400 otherwise). The response also carries the undeclared `twitch_embed_url`, `twitch_content_type` and `twitch_id` companions that the PG reshape returns. */
1440
1706
  twitch_url?: string;
1707
+ /** Mapped from `default_streams.updated_at`. Playback bookkeeping writes it — marking a stream current, clearing a stale current flag, or stamping `last_played_at` — so it moves without anyone editing the stream content. */
1441
1708
  updatedAt?: string;
1709
+ /** Required when `stream_type` is `uploaded`. The `/default-streams/upload` route sets it for you to the stored object URL of the file you posted (`req.file.location`), so you never send it on that path. */
1442
1710
  video_url?: string;
1711
+ /** Required when `stream_type` is `youtube` — the create is rejected 400 (`youtube_url is required for youtube streams`) without it. Null on every other stream type. */
1443
1712
  youtube_url?: string;
1444
1713
  };
1445
1714
  type PartnerDefaultStreamCreate = {
1715
+ /** Accepted with no required-field check, same as `google_slides_url` — a `canva` create with this omitted succeeds and renders nothing. */
1446
1716
  canva_url?: string;
1717
+ /** Optional. Falls back to `name` and then to `Background content`. */
1447
1718
  description?: string;
1448
1719
  /** Optional explicit duration for iframe-based streams */
1449
1720
  duration_seconds?: number;
1721
+ /** 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. */
1450
1722
  embed_url?: string;
1723
+ /** 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. */
1451
1724
  generic_iframe_url?: string;
1725
+ /** 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. */
1452
1726
  google_slides_url?: string;
1727
+ /** 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. */
1453
1728
  iframe_config?: {
1454
1729
  allow_flags?: string;
1455
1730
  auto_advance?: boolean;
@@ -1459,12 +1734,19 @@ type PartnerDefaultStreamCreate = {
1459
1734
  };
1460
1735
  /** Seconds per image */
1461
1736
  image_duration?: number;
1737
+ /** 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. */
1462
1738
  images?: Array<string>;
1739
+ /** Optional. Falls back to `description` and then to the literal `Default Stream`, so the created stream always has one. */
1463
1740
  name?: string;
1741
+ /** Accepted with no required-field check — a `social_wall` create with this omitted succeeds and renders nothing. */
1464
1742
  social_wall_url?: string;
1743
+ /** 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`. */
1465
1744
  stream_type?: "local" | "youtube" | "uploaded" | "images" | "twitch" | "google_slides" | "canva" | "social_wall" | "generic_iframe";
1745
+ /** Required when the resolved `stream_type` is `twitch`; the create is rejected 400 without it. */
1466
1746
  twitch_url?: string;
1747
+ /** 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. */
1467
1748
  video_url?: string;
1749
+ /** Required when the resolved `stream_type` is `youtube`; the create is rejected 400 without it. Sending it alone is enough — the type is derived. */
1468
1750
  youtube_url?: string;
1469
1751
  };
1470
1752
  type PartnerDefaultStreamUpdate = PartnerDefaultStreamCreate;
@@ -1478,6 +1760,7 @@ type PartnerDefaultStreamUpdate = PartnerDefaultStreamCreate;
1478
1760
  type PartnerDeviceBatchRegistrationInput = {
1479
1761
  /** Fleet-wide defaults applied to each device except its identifier fields. */
1480
1762
  defaults?: PartnerDeviceRegistrationInput;
1763
+ /** 1 to 100 entries; more than 100 rejects the whole request with `Maximum 100 devices per batch request`. The batch is NOT transactional — entries are registered sequentially and independently, so the call can return 2xx with some entries reported under `errors[]` while the rest committed. An entry whose `device_id` already exists is an update, counted under `summary.updated` rather than `summary.created`. */
1481
1764
  devices: Array<PartnerDeviceRegistrationInput>;
1482
1765
  };
1483
1766
  /**
@@ -1489,32 +1772,50 @@ type PartnerDeviceBatchRegistrationInput = {
1489
1772
  *
1490
1773
  */
1491
1774
  type PartnerDeviceHeartbeatBody = {
1775
+ /** Legacy flat advertising identifier, superseded by the `advertising_identity` envelope. It is only honoured when no canonical envelope is present AND it passes a plausibility check; a value that fails the check is dropped without an error, and it never reaches the device record through the generic device snapshot — identity has one dedicated persistence rail. */
1492
1776
  advertisingId?: string | null;
1777
+ /** Type of the legacy flat `advertisingId`. It is retained only when `advertisingId` itself survives the plausibility check — otherwise it is nulled with it — and the canonical `advertising_identity.id_type` enum is wider (`gaid`, `aaid`, `afai`, `rida`, `tifa`, `idfa`) than the two values allowed here. */
1493
1778
  advertisingIdType?: "gaid" | "idfa" | null | null;
1494
1779
  advertising_identity?: AdvertisingIdentity;
1780
+ /** Number of distinct BLE devices seen in the scan window, BEFORE the 50-entry cap on `nearbyBleDevices` — so it can legitimately exceed the array length (the on-device scanner tracks up to 100 groups). Omit it and the server substitutes the array length. Both this and the snake_case `ble_device_count` are accepted. */
1495
1781
  bleDeviceCount?: number;
1496
1782
  capabilities?: DeviceCapabilityPayload;
1783
+ /** mDNS/Bonjour service instances found on the LAN, capped at 50. Accepted by the schema but NOT forwarded by the partner heartbeat handler — that controller contains no reference to wifi/mdns/ssdp/http-probe fields at all and passes only the BLE block to the ingest pipeline. The shipped Android agent sends these to the FIRST-PARTY heartbeat instead, in snake_case (`discovered_network_devices`). */
1497
1784
  discoveredNetworkDevices?: Array<MdnsNetworkDevice> | null;
1785
+ /** Up to 16 audience identities (hashed email / UID2) keyed by `(id_type, source)`. Rejection is all-or-nothing — one bad element or a 17th entry drops the whole array — and the row-level upsert additionally refuses any envelope whose `observed_at` is older than the stored one, so a replayed batch is a silent no-op. */
1498
1786
  extended_identities?: Array<ExtendedIdentity>;
1787
+ /** The agent's stable device fingerprint. On this endpoint it is inert: the path parameter `deviceId` is the sole binding boundary, and the partner heartbeat controller never reads `fingerprint` from the body — it uses the fingerprint of the device the path already resolved. */
1499
1788
  fingerprint?: string;
1789
+ /** Hash of the LAN default-gateway IP — the venue-network join key, and the seed the HTTP probe adds to its mDNS-derived target list. Accepted by the schema but NOT forwarded by the partner heartbeat handler — that controller contains no reference to wifi/mdns/ssdp/http-probe fields at all and passes only the BLE block to the ingest pipeline. The shipped Android agent sends these to the FIRST-PARTY heartbeat instead, in snake_case (`gateway_ip_hash`). */
1500
1790
  gatewayIpHash?: string | null;
1791
+ /** Results of HTTP HEAD probes against at most 10 hosts — the mDNS hosts found in the same cycle plus the Wi-Fi gateway, port 80 only. Accepted by the schema but NOT forwarded by the partner heartbeat handler — that controller contains no reference to wifi/mdns/ssdp/http-probe fields at all and passes only the BLE block to the ingest pipeline. The shipped Android agent sends these to the FIRST-PARTY heartbeat instead, in snake_case (`http_probes`). */
1501
1792
  httpProbes?: Array<HttpProbeResult> | null;
1793
+ /** Legacy flat LAT flag. `true` alone is enough to clear a previously stored identifier — the server synthesises a `limited` identity envelope from it even with no `advertisingId` present — so do not send `true` speculatively. */
1502
1794
  limitAdTracking?: boolean | null;
1795
+ /** Alternate namespace for the same telemetry keys as `telemetry`, merged UNDER it (`{...metadata, ...telemetry}`), so `telemetry` wins on a collision. This is where the Android agents actually put their telemetry — `uptimeSeconds`, `agentType`, `agentVersion`, screen geometry, `sensingMode` — and where the per-heartbeat CPU/memory/thermal gauges are merged in. */
1503
1796
  metadata?: Record<string, unknown>;
1504
1797
  /** Capped at 50 devices (RSSI-sorted, strongest first). */
1505
1798
  nearbyBleDevices?: Array<BleScanResultData> | null;
1799
+ /** Wi-Fi scan results, capped at 50 with the joined AP pinned first. Accepted by the schema but NOT forwarded by the partner heartbeat handler — that controller contains no reference to wifi/mdns/ssdp/http-probe fields at all and passes only the BLE block to the ingest pipeline. The shipped Android agent sends these to the FIRST-PARTY heartbeat instead, in snake_case (`nearby_wifi_networks`, whose inner keys are `raw_bssid`/`signal_dbm`/`frequency_mhz` — note `signal_dbm`, not a snake_case form of `signalStrengthDbm`). */
1506
1800
  nearbyWifiNetworks?: Array<WifiScanResult> | null;
1801
+ /** Count of mDNS instances discovered, before the 50-entry cap (the Rust scanner itself keeps at most 30). Accepted by the schema but NOT forwarded by the partner heartbeat handler — that controller contains no reference to wifi/mdns/ssdp/http-probe fields at all and passes only the BLE block to the ingest pipeline. The shipped Android agent sends these to the FIRST-PARTY heartbeat instead, in snake_case (`network_device_count`). */
1507
1802
  networkDeviceCount?: number;
1508
1803
  /** Heartbeat schema version (1=baseline, 2=nativeSensorSnapshot) */
1509
1804
  schemaVersion?: number;
1805
+ /** The screen the agent believes it is bound to. Also inert on this endpoint — the server resolves the screen from the device record, never from this value; correct a wrong binding through registration or the socket heartbeat, which returns an authoritative screen id. */
1510
1806
  screenId?: string | null;
1511
1807
  /** Per-source skip-reason counter map. Shape `{source: {reason: count}}`. */
1512
1808
  skipReasonCounts?: Record<string, Record<string, number>>;
1809
+ /** UPnP/SSDP responders found by one `M-SEARCH` with `ST: ssdp:all`, deduped by LOCATION and capped at 50. Accepted by the schema but NOT forwarded by the partner heartbeat handler — that controller contains no reference to wifi/mdns/ssdp/http-probe fields at all and passes only the BLE block to the ingest pipeline. The shipped Android agent sends these to the FIRST-PARTY heartbeat instead, in snake_case (`ssdp_devices`). */
1513
1810
  ssdpDevices?: Array<SsdpDeviceInfo> | null;
1811
+ /** Agent lifecycle word — the Android agents hardcode `"active"` on every heartbeat, so it carries no information. The partner heartbeat controller does not read it; online/standby state is derived from `telemetry.powerState`, not from this field. */
1514
1812
  status?: string;
1813
+ /** Hash of the joined access point's BSSID. This field is accepted by the schema but NOT forwarded by the partner heartbeat handler — that controller contains no reference to wifi/mdns/ssdp/http-probe fields at all and passes only the BLE block to the ingest pipeline. The shipped Android agent sends these to the FIRST-PARTY heartbeat instead, in snake_case (`wifi_bssid_hash`). */
1515
1814
  wifiBssidHash?: string | null;
1516
1815
  wifiEnvironment?: WifiEnvironmentSnapshot;
1816
+ /** Count of distinct access points seen, before the 50-entry cap on `nearbyWifiNetworks`. Accepted by the schema but NOT forwarded by the partner heartbeat handler — that controller contains no reference to wifi/mdns/ssdp/http-probe fields at all and passes only the BLE block to the ingest pipeline. The shipped Android agent sends these to the FIRST-PARTY heartbeat instead, in snake_case (`wifi_network_count`). */
1517
1817
  wifiNetworkCount?: number;
1818
+ /** Hash of the joined network's SSID. Same as `wifiBssidHash`: accepted by the schema but NOT forwarded by the partner heartbeat handler — that controller contains no reference to wifi/mdns/ssdp/http-probe fields at all and passes only the BLE block to the ingest pipeline. The shipped Android agent sends these to the FIRST-PARTY heartbeat instead, in snake_case (`wifi_ssid_hash`). */
1518
1819
  wifiSsidHash?: string | null;
1519
1820
  [key: string]: unknown;
1520
1821
  };
@@ -1529,6 +1830,7 @@ type PartnerDeviceRegistrationInput = {
1529
1830
  device_id?: string;
1530
1831
  /** Legacy SDK alias for the device identifier, retained for backwards compatibility. */
1531
1832
  device_name?: string;
1833
+ /** One of `mobile`, `tablet`, `tv`, `connected_tv`, `digital_signage`, `kiosk`, `vending_machine`, `display`, `other`. It is stored as the OpenRTB `devicetype` integer, so the mapping is many-to-one and an unrecognised value is silently ACCEPTED as `digital_signage` rather than rejected — read `device_type` back off the response to see what it collapsed to. */
1532
1834
  device_type?: string;
1533
1835
  /** Display specification (pixel resolution and/or physical size). */
1534
1836
  display?: Record<string, unknown>;
@@ -1546,34 +1848,48 @@ type PartnerDeviceRegistrationInput = {
1546
1848
  type PartnerHeartbeatCommand = {
1547
1849
  /** Command normalized for partner SDK compatibility. */
1548
1850
  command?: string;
1851
+ /** Opaque command identifier — echo it verbatim to `POST /device/{deviceId}/command/{commandId}/ack`. The ack is ownership-checked against the device that received it, so acknowledging another device's command is refused with 403. */
1549
1852
  id?: string;
1853
+ /** Command arguments, shape determined by `command` (for example a brightness level or a target URL). Always an object — commands that take no arguments carry `{}`, never null. */
1550
1854
  payload?: Record<string, unknown>;
1855
+ /** When the command was enqueued, not when it was dispatched to you — the gap is up to one heartbeat interval, and a large gap means the device was silent, not that the server was slow. */
1551
1856
  queued_at?: string;
1552
1857
  };
1553
1858
  type PartnerHeartbeatData = {
1554
1859
  ad_delivery_profile?: PartnerAdDeliveryProfile;
1860
+ /** Pending remote commands, at most 5 per heartbeat, drained from the PG `device_commands` store. Each is marked `sent` as it is handed to you, so it appears in exactly ONE heartbeat response and is not re-delivered — acknowledge every entry via the ack endpoint, and dedupe on `id` because delivery is at-most-once, not exactly-once. */
1555
1861
  commands?: Array<PartnerHeartbeatCommand>;
1862
+ /** Server-clock timestamp stamped when this heartbeat was processed — the same instant used for the liveness write, not a device-supplied time and not the time of a previous heartbeat. */
1556
1863
  last_seen_at?: string;
1864
+ /** Liveness verdict for the screen. In practice it is `online`, or `standby` when the heartbeat reported `power_state` of `off`/`standby` — `standby` is NOT in the declared enum, and `offline` can never be returned here because receiving this response is itself proof the device reached us. It falls back to `online` when the device is not yet linked to a screen. */
1557
1865
  status?: "online" | "offline";
1558
1866
  };
1559
1867
  type PartnerHeartbeatMeasurementMetadata = {
1868
+ /** Whole seconds since `receivedAt`, computed when the response was built and floored at 0 — the same number as the sibling `stalenessSeconds`. It measures the age of the HEARTBEAT, not of the cache entry; use `cachedAt` for cache age. */
1560
1869
  ageSeconds?: number | null;
1870
+ /** When the Redis device twin that served this response was written. It is non-null ONLY when `source` is `redis_twin`, and even then only if the twin carried a `cachedAt`/`observedAt` stamp; the `pg_fallback` and `unknown` paths always report `null`. */
1561
1871
  cachedAt?: string | null;
1872
+ /** ISO-8601 server-clock time the underlying heartbeat was received — byte-for-byte the same value as the sibling `lastHeartbeatAt`, not an independent measurement. `null` when no heartbeat has ever been recorded. */
1562
1873
  receivedAt?: string | null;
1874
+ /** Which store answered: `redis_twin` (the hot pre-computed device twin — the normal path), `pg_fallback` (the twin was cold or missing, so the persisted last-heartbeat record was used), or `unknown` (no heartbeat has ever been recorded). This is the field that determines which keys `telemetry` can contain, and it is the only signal that the twin was a miss. */
1563
1875
  source?: "redis_twin" | "pg_fallback" | "unknown";
1564
1876
  [key: string]: unknown;
1565
1877
  };
1566
1878
  type PartnerHeartbeatQueryData = {
1567
1879
  adDeliveryProfile?: PartnerAdDeliveryProfile;
1880
+ /** The device's canonical identifier, which need NOT equal the value you put in the path: the server echoes the device fingerprint, falling back to your registered `external_id` and then to the internal id. Match responses on your own request, not on this field. */
1568
1881
  deviceId: string;
1882
+ /** Server-clock time the most recent heartbeat was RECEIVED — never a device-supplied timestamp. `null` means this device has never sent a heartbeat that the server retained; note the value can come from the Redis twin, which expires, so a cold cache falls back to the persisted record rather than reporting null. */
1569
1883
  lastHeartbeatAt?: string | null;
1570
1884
  measurementMetadata?: PartnerHeartbeatMeasurementMetadata;
1885
+ /** Whole seconds between `lastHeartbeatAt` and the moment this response was built, floored at 0 and rounded — it is computed at read time, so two calls a minute apart return different values for the same heartbeat. `null` when `lastHeartbeatAt` is null. It duplicates `measurementMetadata.ageSeconds` exactly. */
1571
1886
  stalenessSeconds?: number | null;
1572
1887
  telemetry?: PartnerSafeHeartbeatTelemetry;
1573
1888
  [key: string]: unknown;
1574
1889
  };
1575
1890
  type PartnerHeartbeatQueryResponse = {
1576
1891
  data?: PartnerHeartbeatQueryData;
1892
+ /** Always `true` on this 200 response. The 400 (missing `deviceId`), 404 (unknown device), 403 (device belongs to another partner) and 500 paths return `success: false` with a `message` and no `data`. */
1577
1893
  success?: boolean;
1578
1894
  };
1579
1895
  /**
@@ -1584,17 +1900,24 @@ type PartnerHeartbeatQueryResponse = {
1584
1900
  *
1585
1901
  */
1586
1902
  type PartnerHeartbeatRequest = {
1903
+ /** Highest-precedence source for `last_vast_request_at`, `last_vast_response_at` and `proof_of_play_enabled`. All three are dead on both sides — no agent or SDK in the repo emits this block, and nothing reads the values once stored. */
1587
1904
  ad?: {
1588
1905
  last_vast_request_at?: string;
1589
1906
  last_vast_response_at?: string;
1590
1907
  proof_of_play_enabled?: boolean;
1591
1908
  };
1592
1909
  advertising_identity?: AdvertisingIdentity;
1910
+ /** Flat alias of `telemetry.agentStatus`. No producer uses this spelling; live values arrive camelCase and come from two disjoint vocabularies — `sensing`/`camera_failed`/`starting`/`idle` from the Android sensing SDK, `foreground`/`background` from the browser SDK. */
1593
1911
  agent_status?: string;
1912
+ /** Second-precedence source (after `telemetry.agentVersion`) for the Trillboards agent/SDK version — this is the key the Android sensing SDK actually sends on the partner heartbeat. It is kept ONLY if the heartbeat self-identifies as a Trillboards SDK; otherwise the server force-nulls it so a BYO player's app version is never recorded as ours. */
1594
1913
  agent_version?: string;
1914
+ /** Android SDK/API level; below 23 forces `vast_fallback` for a native-Android IMA host. The shipped browser SDK derives it from the user-agent token `Android <n>`, which is the platform RELEASE number (Android 9 gives 9), not the API level (28) — so a value under ~23 is more likely a release number than a genuinely old device. */
1595
1915
  android_api_level?: number;
1916
+ /** Flat alias of `telemetry.brightness`, on the platform's own 0-100 scale. Only the Tizen and webOS agents emit a brightness at all, and they put it in `metadata`, not here. */
1596
1917
  brightness?: number;
1918
+ /** Java 8+ library desugaring in a native-Android IMA host. Nothing in the repo emits it in any casing, so it is absent fleet-wide; it is read only when `ima_integration` is `android_native` and `android_api_level` is 25 or below, where `false` adds a crash-risk note to `ad_delivery_profile.notes`. */
1597
1919
  desugaring_enabled?: boolean;
1920
+ /** Hardware self-description. `os`/`osv`/`make`/`model` here rank below `telemetry`/`metadata` and above the flat body keys, while `android_api_level` and `webview_chromium_major` are read from here in snake_case. It also carries the undocumented `native_bridge` object — the Android WebView bridge's ground-truth make/model/OS/advertising identity — and the DOOH taxonomy keys `dth`/`msid`/`venuetype`/`rdid`/`idtype`/`is_lat`. */
1598
1921
  device?: {
1599
1922
  android_api_level?: number;
1600
1923
  make?: string;
@@ -1603,15 +1926,25 @@ type PartnerHeartbeatRequest = {
1603
1926
  osv?: string;
1604
1927
  webview_chromium_major?: number;
1605
1928
  };
1929
+ /** Up to 16 DSP-matchable audience identities (hashed email / UID2). Normalisation is ALL-OR-NOTHING across the array: one malformed element, a duplicate `(id_type, source)` pair beyond the first, or more than 16 entries causes the ENTIRE array to be dropped and the heartbeat still answers 200. Send `[]`-worth of care — a partially valid batch stores nothing. */
1606
1930
  extended_identities?: Array<ExtendedIdentity>;
1931
+ /** Flat alias, outranked by `ima.integration`. `html5_webview` marks the heartbeat as coming from a Trillboards SDK (which is what permits `agent_version` to be stored); `android_native` with an API level below 23 forces `vast_fallback`. Unrecognised or absent values become `unknown`, never null. */
1607
1932
  ima_integration?: "android_native" | "html5_webview" | "disabled" | "unknown";
1933
+ /** Flat alias with no producer — the browser SDK sends `ima.sdk_version` (from `window.google.ima.VERSION`), which outranks this key and is `null` when IMA failed to load. */
1608
1934
  ima_sdk_version?: string;
1935
+ /** Flat alias with no producer — the browser SDK sends `ima.support_reason`, the literal `ok` on success or its IMA load-failure reason. Stored only; the ad-delivery decision never reads it. */
1609
1936
  ima_support_reason?: string;
1937
+ /** Flat alias, outranked by `ima.supported` and by `telemetry`/`metadata`. An explicit `false` pins `ad_delivery_profile.mode` to `vast_fallback` and turns `auto_recovery_enabled` off, so the server stops attempting to move the device back onto IMA. */
1610
1938
  ima_supported?: boolean;
1939
+ /** Flat alias of `telemetry.inputSource`. Dead on both sides — no producer emits either casing, and the column has no ClickHouse mirror. */
1611
1940
  input_source?: string;
1941
+ /** Numeric IMA `AdError` code from the last IMA failure. The browser SDK sends it inside `ima`, which outranks this flat key; it is persisted on the device record and read by nothing. */
1612
1942
  last_ima_error_code?: number;
1943
+ /** Numeric VAST error code from the last tag failure (e.g. 303 for an empty wrapper chain), sent inside `ima` by the browser SDK. Persisted only, with no server-side consumer. */
1613
1944
  last_vast_error_code?: number;
1945
+ /** Timestamp of the last outbound VAST request. No producer exists anywhere in the repo and nothing reads the stored value, so this field is dead on both ends. */
1614
1946
  last_vast_request_at?: string;
1947
+ /** Timestamp of the last VAST response received. Like `last_vast_request_at`, no agent or SDK emits it and nothing consumes it. */
1615
1948
  last_vast_response_at?: string;
1616
1949
  /** Current GPS fix for a moving screen. Processed only for screens whose `mobility_type` is `mobile` or `semi_mobile` (declare it at registration or via PATCH /device/{deviceId}), OR for any screen still missing coordinates (a one-time self-heal that also kicks off venue classification). Static screens with valid coordinates ignore it. Updates live geo-targeting, geofencing and analytics. Fully optional — omit it on stationary screens. */
1617
1950
  location?: {
@@ -1641,7 +1974,9 @@ type PartnerHeartbeatRequest = {
1641
1974
  };
1642
1975
  /** Alternate namespace for telemetry keys (legacy support). */
1643
1976
  metadata?: Record<string, unknown>;
1977
+ /** Flat alias of `telemetry.muted`. The TV agents fall back to `false` here when power state is unreadable while falling back to `null` for `brightness`/`volume`, so a `false` cannot be read as evidence the read succeeded. */
1644
1978
  muted?: boolean;
1979
+ /** Connection metrics. The server reads ONLY the camelCase keys shown here; the shipped `trillboards-lite` browser SDK sends this block snake_case (`connection_type`, `effective_type`, `bandwidth_mbps`, `latency_rtt`), so not one key matches and its entire network block is silently discarded on every heartbeat. `bandwidthMbps`/`downlinkMbps` share one slot and `latencyRtt`/`rtt` share another. */
1645
1980
  network?: {
1646
1981
  bandwidthMbps?: number;
1647
1982
  connectionType?: string;
@@ -1651,9 +1986,13 @@ type PartnerHeartbeatRequest = {
1651
1986
  rtt?: number;
1652
1987
  saveData?: boolean;
1653
1988
  };
1989
+ /** Flat alias of `telemetry.networkStrengthDbm`, in dBm (negative). No agent or SDK populates either spelling, so it is absent fleet-wide. */
1654
1990
  network_strength_dbm?: number;
1991
+ /** Flat alias of `telemetry.powerState`, used only when the nested value is absent. It coerces to `unknown` rather than failing, and `off`/`standby` downgrade the screen to `standby` so it stops receiving ads. No shipped producer uses this snake_case spelling — every emitter sends the camelCase form. */
1655
1992
  power_state?: "on" | "standby" | "off" | "unknown";
1993
+ /** Declares that the player emits proof-of-play. No producer sends it and no server logic reads it — proof-of-play eligibility is decided server-side, not from this flag. */
1656
1994
  proof_of_play_enabled?: boolean;
1995
+ /** Lowest-precedence source for the IMA/build fields: `ima_supported`, `ima_integration`, `ima_sdk_version`, `android_api_level` and `webview_chromium_major` are read from here only after `ima.*`, `telemetry`/`metadata` and the flat body keys have all missed. Its `version` sub-key is NOT read at all — the SDK version the server stores comes from body `agent_version`/`sdk_version`. */
1657
1996
  sdk?: {
1658
1997
  android_api_level?: number;
1659
1998
  desugaring_enabled?: boolean;
@@ -1663,74 +2002,132 @@ type PartnerHeartbeatRequest = {
1663
2002
  version?: string;
1664
2003
  webview_chromium_major?: number;
1665
2004
  };
2005
+ /** Third-precedence alias of `agent_version` — same destination and the same BYO-nulling rule. This is what the shipped `trillboards-lite` browser SDK sends, alongside `sdk_type: "lite"`, which is itself one of the signals that classify the heartbeat as ours. */
1666
2006
  sdk_version?: string;
1667
2007
  telemetry?: PartnerHeartbeatTelemetry;
2008
+ /** Flat alias of `telemetry.temperatureC`, in degrees Celsius from the Android battery thermal sensor. No producer uses this snake_case spelling, and battery-less CTV hardware omits the reading altogether rather than sending 0. */
1668
2009
  temperature_c?: number;
2010
+ /** Flat alias of `telemetry.uptimeSeconds`. It measures seconds since DEVICE boot (`SystemClock.elapsedRealtime()/1000` on Android — it survives an agent restart and counts through deep sleep), not agent process uptime. Producers send the camelCase form inside `telemetry`/`metadata`. */
1669
2011
  uptime_seconds?: number;
2012
+ /** Flat alias of `telemetry.volume`, on the platform's own scale (0-100 on Tizen/webOS), not a percentage of any documented maximum. Emitted by the Tizen and webOS agents through `metadata` only. */
1670
2013
  volume?: number;
2014
+ /** Chromium major version behind the WebView, parsed from the `Chrome/<n>` user-agent token. Below 87 and below 80 each append an advisory note to `ad_delivery_profile.notes`; neither changes the delivery mode. */
1671
2015
  webview_chromium_major?: number;
1672
2016
  };
1673
2017
  type PartnerHeartbeatResponse = {
1674
2018
  data?: PartnerHeartbeatData;
2019
+ /** Always `true` on this 200 response. The failure paths — 404 for an unresolvable `deviceId`, 400 for an invalid BLE payload, 500 on an internal error — return `success: false` with a `message` and no `data` key at all. */
1675
2020
  success?: boolean;
1676
2021
  };
1677
2022
  type PartnerHeartbeatTelemetry = {
2023
+ /** A status word whose vocabulary depends on which SDK reports it: the Android sensing SDK derives it from camera health and sends exactly `sensing` (camera open), `camera_failed`, `starting` or `idle`, while the browser SDK sends `foreground`/`background` from `document.hidden`. Those four Android values are also an in-band signal that the heartbeat came from a Trillboards SDK, which is what allows `agentVersion` to be stored. */
1678
2024
  agentStatus?: string;
2025
+ /** Version of the TRILLBOARDS agent/SDK only. If the heartbeat does not self-identify as one of ours (`ima.integration=html5_webview`, `sdk_type=lite`, a `capabilities.audienceSensing.cameraHealth` block, or one of the four sensing `agentStatus` values), the server discards what you send and force-nulls both casings, so a BYO player's own app version is never stamped as ours. Resolution order is `telemetry.agentVersion`, then body `agent_version`, then body `sdk_version`. */
1679
2026
  agentVersion?: string;
2027
+ /** Android SDK/API level (23 = Marshmallow, 28 = Pie); below 23 it forces `vast_fallback` when `ima_integration` is `android_native`. Beware the unit — the shipped browser SDK derives it from the user-agent token `Android <n>`, which is the platform RELEASE number (Android 9 gives 9), not the API level (28). */
1680
2028
  android_api_level?: number;
2029
+ /** Downlink bandwidth in MEGABITS PER SECOND from `navigator.connection.downlink`. It shares one destination with `downlinkMbps` (`bandwidthMbps` wins), then the same two keys inside `network`, so only one value ever survives. */
1681
2030
  bandwidthMbps?: number;
2031
+ /** Panel backlight level on the platform's own scale — on Samsung Tizen and LG webOS the 0-100 picture-brightness value, `null` when that picture-control service is unavailable (VIDAA hardcodes `null`, the API does not exist there). Neither partner SDK sends it; the `brightness` seen in the Android agents is a command-ACK detail, not heartbeat telemetry. */
1682
2032
  brightness?: number;
2033
+ /** Free-text connection label (`wifi`, `ethernet`, `cellular`…) that becomes `networkMetrics.connectionType`; `networkConnectionType` outranks it and `network.connectionType` is the fallback. It does NOT set the OpenRTB `device.connectiontype` sent in bid requests — that is the separate all-lowercase `connectiontype` key, which is an INTEGER enum (0 unknown, 1 ethernet, 2 wifi, 3 cellular, 4 2G, 5 3G, 6 4G, 7 5G), is not declared in this schema, and only passes because the object allows additional properties. */
1683
2034
  connectionType?: string;
2035
+ /** Whether Java 8+ library desugaring is enabled in a native-Android IMA host. No agent or SDK anywhere in the repo emits it, so it is absent fleet-wide; it is consulted only when `ima_integration` is `android_native` and `android_api_level` is 25 or below, where `false` adds a known-crash-risk note to `ad_delivery_profile.notes`. */
1684
2036
  desugaring_enabled?: boolean;
2037
+ /** Second-precedence spelling of `bandwidthMbps` (Mbps) filling the same `networkMetrics.bandwidthMbps` slot; ignored whenever `bandwidthMbps` is also present. The npm SDK sends both from the same `connection.downlink` reading. */
1685
2038
  downlinkMbps?: number;
2039
+ /** How the player reaches Google IMA. `html5_webview` is hardcoded by both Trillboards browser SDKs and is itself the signal marking the heartbeat as ours (which is what allows `agentVersion` to be stored); `android_native` with `android_api_level` below 23 forces `ad_delivery_profile.mode` to `vast_fallback`. Absent or unrecognised values normalise to `unknown`, never null, and the undocumented nested `ima.integration` key — what the shipped browser SDK actually sends — outranks this flat one. */
1686
2040
  ima_integration?: "android_native" | "html5_webview" | "disabled" | "unknown";
2041
+ /** The IMA SDK's own version string. Nothing produces this flat spelling — the shipped browser SDK sends it as `ima.sdk_version`, read from `window.google.ima.VERSION`, which takes precedence — and it is `null` whenever IMA failed to load, so absence usually pairs with `ima_supported: false`. */
1687
2042
  ima_sdk_version?: string;
2043
+ /** Free-text explanation for the `ima_supported` verdict — the browser SDK sends the literal `ok` when IMA loaded and its load-failure reason otherwise, under `ima.support_reason`, which outranks this flat spelling (nothing produces the flat one). Stored only: the ad-delivery decision never reads it. */
1688
2044
  ima_support_reason?: string;
2045
+ /** Whether the IMA SDK is usable on this player. An explicit `false` is a hard switch, not a hint: it pins `ad_delivery_profile.mode` to `vast_fallback` AND sets `auto_recovery_enabled: false`, so the server stops trying to move the device back onto IMA. The browser SDKs compute it as `Boolean(window.google.ima)` / an IMA load-failure flag. */
1689
2046
  ima_supported?: boolean;
2047
+ /** Free-form active-input label (e.g. an HDMI port name), stored verbatim on the device record. It has NO producer: no agent or SDK in the repo emits either casing, the only other references are Flutter UI localisation strings, and the schema sidecar marks the column PG-only with no ClickHouse mirror. Treat it as permanently absent. */
1690
2048
  inputSource?: string;
2049
+ /** Numeric IMA `AdError` code from the most recent IMA failure, cached client-side between heartbeats. The shipped browser SDK sends it inside `ima`, which outranks this flat key. Persisted on the device record only — no server logic reads it. */
1691
2050
  last_ima_error_code?: number;
2051
+ /** Numeric VAST error code from the most recent tag failure (e.g. 303 for a wrapper chain that resolved to nothing), cached client-side and sent inside `ima` by the browser SDK. Persisted only, with no server-side consumer. */
1692
2052
  last_vast_error_code?: number;
2053
+ /** Timestamp of the player's last outbound VAST request. It has NO producer: a repo-wide grep finds only the server-side reader and the generated API client, so it is absent on every live device, and nothing downstream consumes the stored value. */
1693
2054
  last_vast_request_at?: string;
2055
+ /** Timestamp of the last VAST response the player received. Like `last_vast_request_at` it has no producer in any agent or SDK, and the persisted value has no consumer. */
1694
2056
  last_vast_response_at?: string;
2057
+ /** Round-trip time in MILLISECONDS from `navigator.connection.rtt`. Shares one destination with `rtt` (`latencyRtt` wins); the read-side schema bounds the stored value to 0-60000. */
1695
2058
  latencyRtt?: number;
2059
+ /** Device manufacturer. Written to the device column but not authoritative — every heartbeat runs a device-identity fusion pass that can replace `make`/`model`/`os` from the user agent and 51Degrees specs, and the corrected value is what is persisted and read back. */
1696
2060
  make?: string;
2061
+ /** Device model, subject to the same identity-fusion overwrite as `make`. The browser SDK has no real model string and sends the full user agent in this field instead. */
1697
2062
  model?: string;
2063
+ /** Audio mute state. Note the asymmetry with `brightness`/`volume`: when the Tizen/webOS agent cannot read power state it still sends `false` here while sending `null` for the other two, so `false` does not distinguish "not muted" from "could not be read". The `muted` the browser SDK sends elsewhere belongs to the ad request, not the heartbeat. */
1698
2064
  muted?: boolean;
2065
+ /** Highest-precedence spelling of the free-text connection label that becomes `networkMetrics.connectionType`. No agent or SDK emits it, so in practice the value comes from `connectionType` or `network.connectionType`. Sending any of the six network keys makes the server stamp `networkMetrics.measuredAt` and increment `networkMetrics.measurementCount`. */
1699
2066
  networkConnectionType?: string;
2067
+ /** Highest-precedence spelling of the Network Information API `effectiveType` bucket (`slow-2g`/`2g`/`3g`/`4g`) — a round-trip-time and downlink classification, not the physical bearer. No producer emits this spelling; live values arrive as `effectiveType` or `network.effectiveType`. */
1700
2068
  networkEffectiveType?: string;
2069
+ /** Wi-Fi/cellular RSSI in dBm (negative; the read-side schema bounds it to -120..0). No agent or SDK populates it — the field exists on the Kotlin model and the only value in the tree is a hardcoded test literal — so it is absent fleet-wide. Per-AP signal is instead carried by `nearbyWifiNetworks[].signalStrengthDbm` and `wifiEnvironment.connectedSignalDbm`. */
1701
2070
  networkStrengthDbm?: number;
2071
+ /** Operating-system family, written straight to the device column `os` — the Android agents send the full `"Android <release>"` string, not a bare family name. It is an input to the identity-fusion pass that runs on every heartbeat, so what the read endpoint later returns may be a corrected value rather than what you sent. */
1702
2072
  os?: string;
2073
+ /** Lower-precedence alias of `osv`: both resolve to the same normalized value and the same `osv` device column, with `osv` winning when both are present. */
1703
2074
  osVersion?: string;
2075
+ /** OS version string. `osv` and `osVersion` are two spellings of ONE value — the normalizer takes `osv` first and falls back to `osVersion`, and both land in the single device column `osv`, so sending different values in the two keys silently keeps only `osv`. */
1704
2076
  osv?: string;
2077
+ /** Display power as the device reports it. Never null and never rejected: any unrecognised or absent value is coerced to `unknown`, and `off`/`standby` make the server record the screen `standby` rather than `online`, which stops ad delivery. Producers use this camelCase spelling only — the browser SDK derives it from `document.hidden` (`standby` when hidden, else `on`), the Fire TV / Android TV agents send their `PowerController` state; the flat `power_state` alias has no producer at all. */
1705
2078
  powerState?: "on" | "standby" | "off" | "unknown";
2079
+ /** Whether the player is configured to emit proof-of-play. No agent or SDK sends it, so it is absent fleet-wide, and nothing reads the stored value — actual proof-of-play eligibility is decided server-side, never from this flag. */
1706
2080
  proof_of_play_enabled?: boolean;
2081
+ /** Second-precedence spelling of `latencyRtt` (milliseconds), ignored when `latencyRtt` is also present. */
1707
2082
  rtt?: number;
2083
+ /** The browser's Data Saver preference (`navigator.connection.saveData`). Accepted as a boolean or the strings `"true"`/`"false"`; anything else becomes `null` rather than `false`. */
1708
2084
  saveData?: boolean;
2085
+ /** Degrees Celsius from the BATTERY thermal sensor — the only real °C reading a non-system Android app can obtain. Battery-less CTV hardware (Fire TV sticks, TV boxes) omits the key entirely rather than sending 0, so absence means "no battery sensor", not "cool"; the derived ClickHouse `cpu_temp_c` was measured above zero on ZERO rows. */
1709
2086
  temperatureC?: number;
2087
+ /** Seconds since the DEVICE booted, not since the agent process started: the Android producers send `SystemClock.elapsedRealtime() / 1000`, which keeps counting through deep sleep and resets only on reboot, so an agent crash-loop leaves this climbing. It is also the sole camelCase survivor in the ClickHouse heartbeat blob — every other gauge there is snake_case. */
1710
2088
  uptimeSeconds?: number;
2089
+ /** Speaker volume on the platform's own scale — 0-100 on Tizen (`tizen.tvaudiocontrol.getVolume()`) and webOS, `null` when the audio-control service is unavailable or (on VIDAA) does not exist. It is not normalised to a percentage of any maximum, and neither partner SDK sends it. */
1711
2090
  volume?: number;
2091
+ /** Major version of the Chromium engine backing the WebView, parsed from the `Chrome/<n>` user-agent token by both browser SDKs. Below 87 adds a content-restoration warning to `ad_delivery_profile.notes` and below 80 an autoplay/media warning; neither changes the delivery mode. */
1712
2092
  webview_chromium_major?: number;
1713
2093
  [key: string]: unknown;
1714
2094
  };
1715
2095
  type PartnerLBarContent = {
2096
+ /** The venue name rendered in the L-bar; empty string when unset. One of only three scalar keys (`enabled`, `business_name`, `current_promotion`) the PUT copies straight across — every other key here is an object that gets shallow-merged. */
1716
2097
  business_name?: string;
2098
+ /** Holds `phone`, `email`, `website`, `address` and `wifi_password`, all free text and all empty-string when unset, shallow-merged on PUT. `wifi_password` is rendered on the screen, so treat it as public. */
1717
2099
  contact_info?: Record<string, unknown>;
2100
+ /** The promo line shown in the L-bar. It is also writable through the much lighter `PUT .../l-bar/live-updates` endpoint as its `promotion` field, which is the path to use for frequent changes. */
1718
2101
  current_promotion?: string;
2102
+ /** Free-text lines rotated in the L-bar. Unlike every nested object above, this is REPLACED wholesale whenever it is present in the body, so send the complete list each time. */
1719
2103
  custom_messages?: Array<string>;
2104
+ /** The L-bar's own cosmetics — NOT the screen display-settings resource. Holds `background_color` (`#000000`), `text_color` (`#FFFFFF`), `accent_color` (`#4A90E2`), the widget toggles `show_weather`/`show_time`/`show_date`/`show_store_hours` (default true) and `show_contact_info`/`show_social_media`/`show_sports_scores`/`show_stock_market` (default false), plus `selected_sports_leagues`. Shallow-merged on PUT. */
1720
2105
  display_settings?: Record<string, unknown>;
2106
+ /** Whether the L-bar content region is drawn; defaults `false`. It is COUPLED to `display_preferences.display_mode` on the PUT — sending `display_mode: "fullscreen"` forces this to `false`, and `"l-bar"` forces it to `true` unless you send an explicit boolean in the same request. */
1721
2107
  enabled?: boolean;
2108
+ /** Exactly three sub-objects are merged — `queue_management` (`enabled`, `current_number`, `queue_message`, default `Now Serving`), `wait_time` (`enabled`, `current_wait_minutes`, `wait_message`, default `Current Wait Time`) and `custom_status` (`enabled`, `status_message`); any other key nested here is silently dropped. The live-updates endpoint sets `enabled` for you as a side effect of writing a value, and clears `custom_status.enabled` when you send an empty message. */
1722
2109
  live_updates?: Record<string, unknown>;
2110
+ /** Holds `facebook`, `instagram`, `twitter` and `google_reviews`, free text and empty-string when unset, shallow-merged on PUT. */
1723
2111
  social_media?: Record<string, unknown>;
2112
+ /** Free-text opening hours: one string per lowercase day name (`monday`..`sunday`) plus a `timezone` defaulting to `America/New_York`. The strings are displayed verbatim — nothing parses them into open/close times — and the PUT shallow-merges them, so you can send a single day. */
1724
2113
  store_hours?: Record<string, unknown>;
1725
2114
  };
1726
2115
  type PartnerLBarDisplayPreferences = {
2116
+ /** Which surfaces ads may occupy; `null` by default, in which case it is derived from `display_mode` + `l_bar_ads_enabled`. Setting `lbar` also flips `display_mode` to `l-bar` when the same request does not set it, and narrows the fullscreen surface to `self_promo` only — paid and programmatic demand stops running full screen. */
1727
2117
  ad_surface_mode?: "fullscreen" | "lbar" | "pip" | "all";
2118
+ /** Default `fullscreen`. Writing it has side effects: `fullscreen` also forces `l_bar_ads_enabled` and `l_bar_content.enabled` to `false`, and `l-bar` forces both to `true` unless the same request sets them explicitly. A change also emits a `refresh` control event so the kiosk reloads and swaps surfaces. */
1728
2119
  display_mode?: "fullscreen" | "l-bar";
2120
+ /** Seconds one L-bar ad is held; default 8, accepted range 3-30. It must be strictly LESS than `l_bar_ad_interval`, and the check falls back to the stored interval when this request does not send one. */
1729
2121
  l_bar_ad_duration?: number;
2122
+ /** Seconds between L-bar ad plays; default 25, accepted range 10-120. It must be strictly GREATER than `l_bar_ad_duration`, and the pair is cross-checked against the STORED values, so lowering this alone can fail on a duration set by an earlier request. */
1730
2123
  l_bar_ad_interval?: number;
2124
+ /** Whether ads may fill the L-bar slots. When it has never been set it is DERIVED as `display_mode === "l-bar"`, so it reads `true` on an L-bar screen and `false` on a fullscreen one. Accepts a boolean or the strings `"true"`/`"false"`; anything else is 400. */
1731
2125
  l_bar_ads_enabled?: boolean;
2126
+ /** Which edge the L-bar occupies; default `bottom`. It decides ad-slot geometry — `bottom` lays slots out horizontally and `right` vertically — and the slot COUNT is derived from it, so switching sides changes how many `lbar_slot_*` surfaces exist. This endpoint does not validate the value against any enum (the display-settings resource's `l_bar.position` does, and accepts `top`/`bottom`/`left`/`right`). */
1732
2127
  l_bar_position?: "bottom" | "right";
2128
+ /** Share of the screen the L-bar occupies, as a PERCENT; default 20. This endpoint accepts it with no range check at all, while the equivalent `l_bar.size_percent` on the display-settings resource clamps to 10-50. It is also not what sizes ad slots — `buildAdSurfaces` uses a fixed 25%. */
1733
2129
  l_bar_size?: number;
2130
+ /** Picture-in-picture ad window: `enabled` (default false), `position` (default `bottom-right`), `size_ratio` as a FRACTION of the screen 0.15-0.45 (default 0.3), `aspect_ratio` as width/height 0.5-3 (default 16/9), and `margin` in PIXELS 8-64 (default 24). A PiP surface is only produced when `ad_surface_mode` is `pip` or `all` AND `enabled` is true. */
1734
2131
  pip_settings?: {
1735
2132
  aspect_ratio?: number;
1736
2133
  enabled?: boolean;
@@ -1740,9 +2137,11 @@ type PartnerLBarDisplayPreferences = {
1740
2137
  };
1741
2138
  };
1742
2139
  type PartnerLBarSettings = {
2140
+ /** The resolved renderable ad surfaces. Two caveats: it is present ONLY on the PUT response (the GET does not compute it), and despite the declared `object` type it is an ARRAY — one entry per surface (`fullscreen`, `lbar_slot_1..N`, `pip`) carrying `id`, `surface_type`, `enabled`, `display_area_ratio`, `dimensions` and `allowed_ad_types`. */
1743
2141
  ad_surfaces?: Record<string, unknown>;
1744
2142
  display_preferences?: PartnerLBarDisplayPreferences;
1745
2143
  l_bar_content?: PartnerLBarContent;
2144
+ /** The screen's resolved pacing contract, present only on the PUT response. `policy_version` is `v1` and every interval is in MILLISECONDS (`paid_interval_ms`, `self_promo_interval_ms`, `image_ad_total_ms`, `image_ad_min_ms`); it also carries `max_paid_share` (null unless a loop share cap is enabled), `surface_policy`, `schedule_policy`, `pip_settings` and `lbar_settings`. */
1746
2145
  playback_policy?: Record<string, unknown>;
1747
2146
  };
1748
2147
  /**
@@ -1760,6 +2159,7 @@ type PartnerMonetizationRequirements = {
1760
2159
  eventually_due?: Array<PartnerRequirement>;
1761
2160
  };
1762
2161
  type PartnerNetworkConfig = {
2162
+ /** Partner-level ad pacing and identity controls applied to every screen you own; on the `/ads` path they OUTRANK the screen's own `ad_interval_seconds` and the partner's `sdk_config.max_ads_per_hour`. A PATCH merges key-by-key and rejects unknown keys with a 400, and sending `null` for `identity_mode` or `self_promo_priority_mode` DELETES the key rather than storing null, so those two come back absent rather than as `null`. */
1763
2163
  ad_delivery?: {
1764
2164
  /** `/ads` response cache lifetime. Zero requires a fresh response for every request. */
1765
2165
  cache_max_age_seconds?: number;
@@ -1791,6 +2191,7 @@ type PartnerNetworkConfig = {
1791
2191
  };
1792
2192
  /** Default content preferences inherited by newly provisioned screens. */
1793
2193
  default_content_preferences?: Record<string, unknown>;
2194
+ /** Partner-level (fleet-wide) demand-source ordering; there is no per-screen override for these keys. A PATCH merges key-by-key into what is stored, and any key other than `source_order`/`total_timeout_ms` is rejected with a 400 naming it. The GET omits this object entirely when nothing has been stored, so absence means "never configured" — it is not a rendering of the effective defaults. */
1794
2195
  default_waterfall?: {
1795
2196
  /** Preferred opaque source aliases from the `/ads` response. Sources not listed here remain enabled and are appended. Internal demand names and `sort_by: cpm` are rejected. */
1796
2197
  source_order?: Array<string>;
@@ -1883,6 +2284,7 @@ type PartnerRegistrationDisplaySettings = {
1883
2284
  type PartnerRequirement = {
1884
2285
  /** Stable machine code, e.g. location_unresolvable, location_awaiting_device_gps, enrichment_failed, display_size_missing. */
1885
2286
  code?: string;
2287
+ /** Built mechanically as `https://api.trillboards.com/docs/monetization-requirements#<code>`, so the anchor always matches `code`. A bare `docs.` subdomain is deliberately NOT used — it resolves to an unrelated portal SPA rather than documentation — and a CI guard blocks reintroducing it. */
1886
2288
  doc_url?: string;
1887
2289
  /** The request field this concerns (location, venue, display). */
1888
2290
  field?: string;
@@ -1899,52 +2301,90 @@ type PartnerRequirement = {
1899
2301
  *
1900
2302
  */
1901
2303
  type PartnerSafeHeartbeatTelemetry = {
2304
+ /** Agent lifecycle word, with two disjoint vocabularies depending on the reporting SDK (`sensing`/`camera_failed`/`starting`/`idle` from the Android sensing SDK, `foreground`/`background` from the browser SDK). Absent on the primary `redis_twin` path — the device twin has no such field, so it surfaces only on the `pg_fallback` path. */
1902
2305
  agentStatus?: string | null;
2306
+ /** Trillboards agent/SDK version. Never present here on either path: the heartbeat writes it to the device record root, not into the last-heartbeat telemetry blob, and the device twin does not carry it either. It is also nulled at write time for any heartbeat that does not self-identify as a Trillboards SDK. */
1903
2307
  agentVersion?: string | null;
2308
+ /** Downlink bandwidth in Mbps. Never present on either path: the write path stores it under `networkMetrics.bandwidthMbps`, a sibling of the last-heartbeat blob this endpoint reads, and the device twin has no such field. */
1904
2309
  bandwidthMbps?: number | null;
2310
+ /** Panel brightness on the platform's own scale (0-100 on Tizen/webOS, `null` where no picture-control API exists). Absent on the primary `redis_twin` path — the device twin has no such field, so it can only appear on the `pg_fallback` path and only for the TV agents that emit it. */
1905
2311
  brightness?: number | null;
2312
+ /** Free-text connection label. Not present on either path — the write path stores it as `networkMetrics.connectionType`, and while the device twin does carry a `connectiontype`, that is the all-lowercase OpenRTB INTEGER enum, a different quantity under a different key. */
1906
2313
  connectionType?: string | null;
2314
+ /** Running count of back-to-back ad failures the player has seen, from the heartbeat's `playback.consecutive_failures`; it is what drives the server's stalled-playback recovery. Absent on the primary `redis_twin` path — the device twin has no such field, so it appears only on the `pg_fallback` path. */
1907
2315
  consecutiveAdFailures?: number | null;
2316
+ /** Network Information API `effectiveType` bucket (`slow-2g`/`2g`/`3g`/`4g`) — a latency/throughput classification, not the physical bearer. Stored under `networkMetrics.effectiveType` and carried by neither source object, so it never appears here. */
1908
2317
  effectiveType?: string | null;
2318
+ /** Round-trip time in MILLISECONDS (bounded 0-60000 by the read-side schema). Like `bandwidthMbps` it is stored under `networkMetrics.*` and is on neither of this endpoint's two source objects. */
1909
2319
  latencyRtt?: number | null;
2320
+ /** Device manufacturer, after the identity-fusion correction pass — so it can differ from what the device reported. Present only on the `redis_twin` path: it is a device-record field, and the `pg_fallback` path returns just the last-heartbeat telemetry blob. */
1910
2321
  make?: string | null;
2322
+ /** Device model, after identity-fusion correction. Like `make`, it is a twin-only field and is absent whenever `measurementMetadata.source` is `pg_fallback`. */
1911
2323
  model?: string | null;
2324
+ /** Mute state. Absent on the primary `redis_twin` path — the device twin has no such field. On the agents that do report it, `false` is also the fallback used when the power state could not be read at all, so it does not prove a successful read. */
1912
2325
  muted?: boolean | null;
2326
+ /** RSSI in dBm, bounded -120..0. Doubly absent: no shipped agent produces it in the first place, and absent on the primary `redis_twin` path — the device twin has no such field. */
1913
2327
  networkStrengthDbm?: number | null;
2328
+ /** Declared but NEVER POPULATED on either source path: nothing writes an `onlineStatus` into the last-heartbeat telemetry blob and the device twin has no such field. The equivalent state lives on the screen row (`earner_screens.online_status`), which this endpoint does not read — use `stalenessSeconds` to judge liveness instead. */
1914
2329
  onlineStatus?: "online" | "offline" | "standby" | "unknown" | null;
2330
+ /** Operating-system family after correction — the Android agents report the full `"Android <release>"` string. Twin-only, so absent on the `pg_fallback` path. */
1915
2331
  os?: string | null;
2332
+ /** OS version string after correction; the heartbeat collapses the `osv` and `osVersion` request spellings into this one value. Twin-only, so absent on the `pg_fallback` path. */
1916
2333
  osv?: string | null;
2334
+ /** Last reported display power (`on`/`standby`/`off`/`unknown`). Absent on the primary `redis_twin` path — the device twin has no such field, so it appears only when `measurementMetadata.source` is `pg_fallback`, which reads the persisted last-heartbeat telemetry blob. */
1917
2335
  powerState?: string | null;
2336
+ /** Degrees Celsius from the Android BATTERY thermal sensor (bounded -50..150 by the read schema). Absent on the primary `redis_twin` path — the device twin has no such field, and battery-less CTV hardware never reports it at all, so absence carries no thermal information. */
1918
2337
  temperatureC?: number | null;
2338
+ /** User agent, resolved with a three-tier fallback — the corrected device UA, then the stored device UA, then the `User-Agent` header of the heartbeat request that built the twin — so it can reflect the HTTP client rather than the player. Twin-only. */
1919
2339
  ua?: string | null;
2340
+ /** Seconds since the DEVICE booted, not since the agent started — it survives an agent restart and counts through deep sleep. Absent on the primary `redis_twin` path — the device twin has no such field, so it surfaces only on the `pg_fallback` path. */
1920
2341
  uptimeSeconds?: number | null;
2342
+ /** The browser `document.visibilityState` of the player page (`visible`/`hidden`), sourced from the heartbeat's `content.visibility_state`. One of only two telemetry-derived fields that survive onto the Redis device twin, so it is the one health signal available on BOTH source paths. */
1921
2343
  visibilityState?: string | null;
2344
+ /** Speaker volume on the platform's own scale, not a normalised percentage. Absent on the primary `redis_twin` path — the device twin has no such field; only the Tizen and webOS agents ever supply it. */
1922
2345
  volume?: number | null;
1923
2346
  [key: string]: unknown;
1924
2347
  };
1925
2348
  type PartnerSelfPromoAd = {
2349
+ /** The 24-hex advertisement id, minted server-side with `newObjectId()`, and the `ad_id` the schedule and disable endpoints take. Partner self-promo rows carry no `user_mongo_id`, so this id is only resolvable through the partner's own `partner-self-promo` placements — an owner-keyed lookup structurally cannot find it. */
1926
2350
  _id?: string;
2351
+ /** Click-through / QR destination; empty string, not null, when it was never set — the PG read coalesces it the same way. */
1927
2352
  campaign_link?: string;
2353
+ /** When the advertisement row was written. It is the rotation order for a screen's self-promo pool: the placements read sorts `instant_ad_qr` first, then oldest `created_at` first. */
1928
2354
  createdAt?: string;
2355
+ /** Defaults to `name` at create, never null. Note the placements listing trims the embedded advertisement to `_id`, `name`, `video`, `photos`, `status` — this field is absent there. */
1929
2356
  description?: string;
2357
+ /** Required at create. It is also copied onto each placement as `advertisement_name`. */
1930
2358
  name?: string;
2359
+ /** Image creatives. A JSON create puts `image_url` here as the single element; the multipart route puts the uploaded file here when its MIME type is `image/*`. Empty array for a video-only promo. */
1931
2360
  photos?: Array<string>;
2361
+ /** Minted as `approved` — self-promo skips review entirely. The disable endpoint sets it to `deactivated`, and that is the ONLY thing that stops delivery: placements stay `booked` so the disable is idempotent, and every serve-path read has to gate on this field. The listings additionally hide `deleted` and `rejected`. */
1932
2362
  status?: string;
2363
+ /** URL of the video creative, or `null` for an image-only promo. This is the field the `trillboards_direct` VAST bridge serves from, which is why the advertisement row is written and awaited BEFORE any placement is created. */
1933
2364
  video?: string;
1934
2365
  /** Measured length of the `video` creative in whole seconds, probed when the ad is created. This is the real media length and is what the served VAST declares as `<Duration>`. `null` means the media could not be measured, in which case the VAST declares the 30 second ad-slot default instead. */
1935
2366
  video_duration_seconds?: number | null;
1936
2367
  };
1937
2368
  type PartnerSelfPromoCreate = {
2369
+ /** Click-through / QR destination. Stored as an empty string, not null, when omitted. */
1938
2370
  campaign_link?: string;
2371
+ /** Optional; defaults to `name` rather than staying null. */
1939
2372
  description?: string;
2373
+ /** 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. */
1940
2374
  end_date?: string;
2375
+ /** 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. */
1941
2376
  image_url?: string;
2377
+ /** 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. */
1942
2378
  name: string;
2379
+ /** 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. */
1943
2380
  screen_ids?: Array<string>;
2381
+ /** 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. */
1944
2382
  start_date?: string;
2383
+ /** 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. */
1945
2384
  video_url?: string;
1946
2385
  };
1947
2386
  type PartnerSelfPromoPlacement = {
2387
+ /** One entry per `partner-self-promo` placement on that screen, each carrying `allocation_id`, `status`, `start_date`, `end_date` and a TRIMMED `advertisement` (`_id`, `name`, `video`, `photos`, `status` only — no `description`, `campaign_link` or `createdAt`). The underlying read filters out `deactivated`/`deleted`/`rejected` advertisements, so a disabled promo disappears from here even though its placement row still exists. */
1948
2388
  ads?: Array<{
1949
2389
  advertisement?: PartnerSelfPromoAd;
1950
2390
  allocation_id?: string;
@@ -1952,17 +2392,24 @@ type PartnerSelfPromoPlacement = {
1952
2392
  start_date?: string;
1953
2393
  status?: string;
1954
2394
  }>;
2395
+ /** Trimmed to `_id` and `name` only. EVERY non-deactivated screen the partner owns appears in this listing, including ones with an empty `ads` array — the response is a full screen roster, not just the placed subset. */
1955
2396
  screen?: {
1956
2397
  _id?: string;
1957
2398
  name?: string;
1958
2399
  };
1959
2400
  };
1960
2401
  type PartnerStats = {
2402
+ /** Recomputed by the daily stats cron as the count of the partner's screens in status `approved`/`active` — an APPROVAL count, not a liveness count. The identically-named field on `GET /v1/partner/analytics` is instead derived live from `online_status`, so the two measure different things. */
1961
2403
  active_devices?: number;
2404
+ /** ISO timestamp stamped by the partner impression-record endpoints on each recorded impression. Only those endpoints write it, so it does not move for revenue booked through other rails. */
1962
2405
  last_impression_at?: string;
2406
+ /** Integer US cents already reserved by an in-flight payout request. Sourced from the typed `partners.pending_payout_cents` column (the JSONB key was dropped) and subtracted from the available balance so repeat payout requests cannot double-spend. */
1963
2407
  pending_payout_cents?: number;
2408
+ /** Read from the `partners.stats` JSONB cache, maintained by signed-delta counters on device register/delete. `GET /v1/partner/analytics` publishes the same field name from a LIVE device count instead, so the two can disagree. */
1964
2409
  total_devices?: number;
2410
+ /** Incremented per impression by the partner impression-record path, then OVERWRITTEN by the daily reconcile with `SUM(impressions_count)` over `programmatic_earnings_daily` — two different denominators writing one key, so its meaning depends on when in the cron cycle you read it. */
1965
2411
  total_impressions?: number;
2412
+ /** Integer US cents. Recomputed daily as `SUM(earner_revenue) * 100` across ALL of the partner's non-demo `programmatic_earnings_daily` rows, with no payable-source filter and no Net-60 maturity gate — so it can exceed `EarningsResponse.total_lifetime_cents`, which applies the payable filter. */
1966
2413
  total_revenue_cents?: number;
1967
2414
  };
1968
2415
  /**
@@ -2029,10 +2476,15 @@ type PartnerZoneCpmDaily = {
2029
2476
  zone_label?: string | null;
2030
2477
  };
2031
2478
  type PayoutResponse = {
2479
+ /** Integer US cents actually reserved: `min(requested, available)`, where available is CONFIRMED earnings minus anything already pending. It must clear the partner's `payout_threshold_cents` (default 2500, i.e. $25.00) or the request is rejected `400`. */
2032
2480
  amount_cents?: number;
2481
+ /** Today plus 5 CALENDAR days, computed in the handler with no reference to banking days, the Stripe payout schedule, or whether any transfer exists. It is a display placeholder. */
2033
2482
  estimated_arrival?: string;
2483
+ /** Generated in the handler as `po_<epoch_ms>_<random>` and **not persisted anywhere** — no row, no Stripe object. It cannot be used to look the payout up afterwards. */
2034
2484
  payout_id?: string;
2485
+ /** Always the literal `processing` on a successful request — the other enum values are never returned by this endpoint. The only side effect is a bump to the partner's `pending_payout_cents` counter; no Stripe transfer is created here. */
2035
2486
  status?: "pending" | "processing" | "completed" | "failed";
2487
+ /** **Always `null`.** The handler returns a hardcoded null because it creates no transfer; a real transfer id appears only later on the corresponding `payout` row from `GET /earnings/transactions`. */
2036
2488
  stripe_transfer_id?: string | null;
2037
2489
  };
2038
2490
  type PlaygroundResult = {
@@ -2072,11 +2524,13 @@ type ProgrammaticSettingsInput = {
2072
2524
  enabled?: boolean;
2073
2525
  /** Minimum CPM in dollars */
2074
2526
  floor_price_cpm?: number;
2527
+ /** 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. */
2075
2528
  google_ima?: {
2076
2529
  ad_tag_url?: string;
2077
2530
  enabled?: boolean;
2078
2531
  max_duration?: number;
2079
2532
  };
2533
+ /** 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. */
2080
2534
  prebid?: {
2081
2535
  bidders?: Array<{
2082
2536
  name?: string;
@@ -2088,26 +2542,36 @@ type ProgrammaticSettingsInput = {
2088
2542
  priority?: "direct_first" | "programmatic_first" | "highest_bid";
2089
2543
  };
2090
2544
  type ProgrammaticSettingsResponse = {
2545
+ /** The master serving gate `earner_screens.programmatic_enabled`, which is `NOT NULL DEFAULT true` — so it reads `true` for a screen nobody has ever configured — and NOT the legacy `header_bidding_settings.enabled` shadow. Setting it false stops programmatic serving for the screen; the write invalidates the serving caches, and a missed invalidation still drains within the 120s hot-cache TTL. */
2091
2546
  enabled?: boolean;
2547
+ /** Minimum clearing price in DOLLARS per thousand impressions (not cents), default `0.50`, accepted range 0.01-100, denominated in the screen's `currency`. It is a floor only: the auction clears at the `max()` of this, any floor rule, the dynamic floor and any matched deal floor, so a low value here does not guarantee cheap fill and can never lower a rule-derived floor. */
2092
2548
  floor_price_cpm?: number;
2549
+ /** Never emitted. The response formatter builds the entire body and has no `google_ima` key, and the string appears nowhere on this API's request or response surface — there is no producer. Configure the IMA tag through the screen's `vast_tag_url` / VAST waterfall instead. */
2093
2550
  google_ima?: {
2094
2551
  ad_tag_url?: string;
2095
2552
  enabled?: boolean;
2096
2553
  max_duration?: number;
2097
2554
  };
2555
+ /** Constant discriminator `programmatic_settings` (Stripe-style envelope). It is present on the GET, the PUT and the PATCH alike, so it identifies the resource type and tells you nothing about which verb produced the response. */
2098
2556
  object?: string;
2557
+ /** Never emitted. The response formatter has no `prebid` key, and the underlying `header_bidding_settings.prebid` is JSON `null` on every stored row with no typed home — nothing populates it on either side. */
2099
2558
  prebid?: {
2100
2559
  bidders?: Array<Record<string, unknown>>;
2101
2560
  enabled?: boolean;
2102
2561
  };
2562
+ /** Never emitted, and not settable. The response formatter has no `priority` key and the field is absent from the write whitelist, so `direct_first` / `programmatic_first` / `highest_bid` can neither be stored through this endpoint nor read back from it. */
2103
2563
  priority?: string;
2564
+ /** Server-minted per HTTP request in the form `req_<32 hex>`, and also returned in the `X-Request-Id` response header. A client-supplied `X-Request-Id` is echoed back separately as `X-Client-Request-Id` and never becomes this value. Unrelated to `HeaderBiddingSettings.request_id`, which identifies a VAST waterfall attempt. */
2104
2565
  request_id?: string;
2566
+ /** Echoed straight back from the path parameter — it is not re-read from the stored row. Must be a 24-character hex Mongo ObjectId; anything else is rejected before any lookup happens. */
2105
2567
  screen_id?: string;
2568
+ /** Never emitted — the response formatter has no `stats` key, so `impressions_today`, `fill_rate_percent` and `avg_cpm` have no producer here; use the partner analytics endpoints for delivery numbers. When you do, note that partner-rail impression counts are delivery accounting, not billing: only `completed_impressions` settles revenue. */
2106
2569
  stats?: {
2107
2570
  avg_cpm?: number;
2108
2571
  fill_rate_percent?: number;
2109
2572
  impressions_today?: number;
2110
2573
  };
2574
+ /** The SCREEN row's `updated_at`, so any change to the screen moves it — not just a programmatic-settings change. When the row carries no timestamp the server substitutes the current time, so this can never be relied on as 'when these settings last changed' or used as a change-detection cursor. */
2111
2575
  updated_at?: string;
2112
2576
  };
2113
2577
  type RequestMetadataResponse = {
@@ -2133,12 +2597,15 @@ type RequestMetadataResponse = {
2133
2597
  type ResolvedContentPolicy = {
2134
2598
  /** All content_preferences keys + blocked_iab_categories. */
2135
2599
  effective?: Record<string, unknown>;
2600
+ /** Always `resolved_content_policy`. This resource is read-only introspection — a "why is this screen serving what it serves" preview — and unlike the content-preferences response it IS wrapped in `{ success, data }`. */
2136
2601
  object?: string;
2602
+ /** Which single layer supplied the whole `effective` object. `source` is `screen` when the screen's OWN policy is explicit — specifically `content_filtering_enabled === true`, or any of the six content lists non-empty, or a non-empty `programmatic_settings.blocked_iab_categories`; the `allow_*` toggles and the two scalars NEVER make it explicit because they always carry a default. Otherwise it is `group` (lowest `group_id` wins, and `group_id` is present only then), then `network` (the partner's `network_config.default_content_preferences`, used only when it has at least one populated key and merged onto the system baseline), then `default`. */
2137
2603
  provenance?: {
2138
2604
  /** Present only when source=group. */
2139
2605
  group_id?: string;
2140
2606
  source?: "screen" | "group" | "network" | "default";
2141
2607
  };
2608
+ /** The screen whose effective policy this is, echoed from the path parameter as a string. Ownership is checked first, so a screen you do not own returns 404 rather than an empty policy. */
2142
2609
  screen_id?: string;
2143
2610
  };
2144
2611
  /**
@@ -2147,6 +2614,7 @@ type ResolvedContentPolicy = {
2147
2614
  *
2148
2615
  */
2149
2616
  type ResolvedSensingConfig = {
2617
+ /** The profile scalars actually in force after resolution. When the winning layer is a group, the base profile is hydrated first and only the group's NON-null overrides are laid over it, so this is always a complete view rather than a sparse patch. */
2150
2618
  effective?: {
2151
2619
  capture_interval_ms?: number | null;
2152
2620
  estimated_fps_impact?: number | null;
@@ -2154,12 +2622,15 @@ type ResolvedSensingConfig = {
2154
2622
  profile_name?: string | null;
2155
2623
  report_interval_ms?: number | null;
2156
2624
  };
2625
+ /** Stripe-style type discriminator; always the literal `resolved_sensing_config`, so it is safe to switch on. */
2157
2626
  object?: string;
2627
+ /** Which layer won: `screen` (the screen's own active deployment), `group_default` (a group sensing config, with `group_id` naming it), or `default` (nothing bound). Resolution is whole-layer — `effective` never mixes values from two sources. */
2158
2628
  provenance?: {
2159
2629
  /** Present only when source=group_default. */
2160
2630
  group_id?: string;
2161
2631
  source?: "screen" | "group_default" | "default";
2162
2632
  };
2633
+ /** The screen's Mongo id — the same identifier the route surfaces call `screenMongoId`. Echoes the path parameter. */
2163
2634
  screen_id?: string;
2164
2635
  };
2165
2636
  /**
@@ -2194,12 +2665,17 @@ type RolePermissions = {
2194
2665
  type SDKConfig = {
2195
2666
  /** Seconds between ads */
2196
2667
  ad_interval?: number;
2668
+ /** The only `sdk_config` key that changes what is SERVED: if it contains `image`, the server stops substituting a transcoded MP4 for an image creative on `lite_sdk`/`full_sdk` tiers, because you are declaring the client can render images itself. Frozen at the quick-start default `["video", "image"]` — there is no partner-facing endpoint that writes `sdk_config`. */
2197
2669
  allowed_ad_types?: Array<string>;
2670
+ /** Maximum number of ads the `trillboards-connect` client retains for offline playback (`cacheAdsOffline`). Client-side hint only — the server never reads it — and it is frozen at the quick-start default of 10 because no partner-facing endpoint writes `sdk_config`. */
2198
2671
  cache_size?: number;
2199
2672
  /** Seconds to display image ads */
2200
2673
  image_duration?: number;
2674
+ /** Fleet-wide hourly ad cap echoed as `settings.max_ads_per_hour` in the `/ads` response, but only as the FALLBACK behind `network_config.ad_delivery.max_ads_per_hour` — that is the writable control, and this one is frozen at the quick-start default of 20 because no partner-facing endpoint writes `sdk_config`. */
2201
2675
  max_ads_per_hour?: number;
2676
+ /** Label the lite SDK paints on the tap-to-continue overlay; when absent the SDK falls back to the literal `TAP TO CONTINUE`, which is also the quick-start default stored for every partner. Frozen — there is no partner-facing endpoint that writes `sdk_config`. */
2202
2677
  overlay_button_text?: string;
2678
+ /** Has no reader anywhere in the server or the shipped SDKs — the `settings.sound_enabled` a player actually obeys is resolved per screen from `earner_screens.sound_enabled` / display preferences, not from this partner-level value. Frozen at the quick-start default `false`. */
2203
2679
  sound_enabled?: boolean;
2204
2680
  };
2205
2681
  /**
@@ -2209,8 +2685,11 @@ type SDKConfig = {
2209
2685
  *
2210
2686
  */
2211
2687
  type Screen = {
2688
+ /** City resolved at registration by reverse/forward geocoding or taken from your own `location.city`, with placeholder strings ("City", "Vending Machine Location") coalesced to null rather than stored. The screens resource reads the partner device endpoints, whose payload carries this as `location.city`. */
2212
2689
  city?: string;
2690
+ /** Country of the screen, taken from the enriched screen row with a fallback to the device row. The device row DEFAULTS this to `"US"` when registration could not resolve a country, so it is effectively never null and a `US` value can mean "unresolved". The screens resource reads the partner device endpoints, whose payload carries this as `location.country`. */
2213
2691
  country?: string;
2692
+ /** Creation time of the DEVICE row backing the screen, not of the screen record itself — the screens resource is a projection of the partner device endpoints, and the screen can be created later than its device. */
2214
2693
  created_at: string;
2215
2694
  /** The paired device identifier. */
2216
2695
  device_id?: string;
@@ -2224,23 +2703,28 @@ type Screen = {
2224
2703
  screen_id: string;
2225
2704
  /** Screen lifecycle status (e.g. pending, active, offline, suspended). */
2226
2705
  status: string;
2706
+ /** IANA zone, and it is ONLY ever what you supplied as `location.timezone` at registration — nothing derives it from the resolved coordinates, so a large share of screens carry null. The screens resource reads the partner device endpoints, whose payload carries this as `location.timezone`, and it is the one geo field read device-first rather than screen-first. */
2227
2707
  timezone?: string;
2708
+ /** `earner_screen_devices.updated_at` of the backing device, which the device-mirror write buffer stamps `NOW()` on every flush including heartbeats. It moves continuously on a live screen, so it cannot be used to detect a configuration change. */
2228
2709
  updated_at: string;
2229
2710
  /** Granular OpenOOH venue type of the screen's location. */
2230
2711
  venue_type?: string;
2231
2712
  };
2232
2713
  type ScreenAnalyticsResponse = {
2714
+ /** `total` is deduped, GIVT-passed `completed_impressions` over the period. `daily_average` divides by the number of days that RETURNED A ROW, not by the period length, so zero-impression days do not dilute it. `trend_percent` compares the second half of the returned rows against the first half and is `0` whenever the earlier half is zero. */
2233
2715
  impressions?: {
2234
2716
  daily_average?: number;
2235
2717
  total?: number;
2236
2718
  /** Percentage change compared to previous period */
2237
2719
  trend_percent?: number;
2238
2720
  };
2721
+ /** `days` is 7, 30 or 90; any other `?period=` value silently becomes 7. `start` is midnight (API-server local time) that many days back and `end` is the moment the request was served, so the window is not a whole number of days. */
2239
2722
  period?: {
2240
2723
  days?: number;
2241
2724
  end?: string;
2242
2725
  start?: string;
2243
2726
  };
2727
+ /** `location` is a `City, State` string or `null` when either part is missing. `online_status` comes from the live heartbeat mirror and is `online`/`offline`/`unknown`, where `unknown` means the screen has never heartbeated — it is not a synonym for offline. `status` is the separate approval state. */
2244
2728
  screen?: {
2245
2729
  id?: string;
2246
2730
  location?: string;
@@ -2248,6 +2732,7 @@ type ScreenAnalyticsResponse = {
2248
2732
  online_status?: string;
2249
2733
  status?: string;
2250
2734
  };
2735
+ /** One entry per day that had impressions — days with none are absent, not zero-filled, which is why `daily_average` and this array can disagree with the period length. **`avg_duration_ms` is always `0`**: it is a hardcoded literal in both the ClickHouse query and the Postgres fallback. */
2251
2736
  timeline?: Array<{
2252
2737
  avg_duration_ms?: number;
2253
2738
  date?: string;
@@ -2263,13 +2748,17 @@ type ScreenAnalyticsResponse = {
2263
2748
  */
2264
2749
  type ScreenGroup = {
2265
2750
  created_at?: string;
2751
+ /** Free-text note. Returned as an explicit `null` rather than omitted when unset, and an empty string supplied at create is stored as `null`. A PATCH writes only the keys you actually send, so omitting it preserves the current value. */
2266
2752
  description?: string | null;
2267
- /** Screen group id (sg_ prefix). */
2753
+ /** Screen group id: the literal prefix `sg_` followed by a v4 UUID (`pgRepo/screenGroups.js` mints `sg_${crypto.randomUUID()}`). Treat it as an opaque string; do not parse the UUID half. */
2268
2754
  id?: string;
2755
+ /** Required, non-empty label. It is NOT unique and has no length constraint in the database, so two of your groups may share a name; `id` (`sg_<uuid>`) is the only stable handle. */
2269
2756
  name?: string;
2757
+ /** Constant string `screen_group` — the Stripe-style type discriminator. It is present on every group payload, including inside a webhook's `data`, so a single handler can switch on it. */
2270
2758
  object?: string;
2271
2759
  /** Distinct member screens (COUNT and member list share one junction — they cannot diverge). */
2272
2760
  screen_count?: number;
2761
+ /** Bumped by membership changes as well as metadata edits — `addMembers`, `removeMembers` and the soft delete all set `updated_at = NOW()` on the group row — so it is a change marker for the group INCLUDING its member set, not just its name and description. */
2273
2762
  updated_at?: string;
2274
2763
  };
2275
2764
  /**
@@ -2282,27 +2771,49 @@ type ScreenGroup = {
2282
2771
  *
2283
2772
  */
2284
2773
  type ScreenGroupContentPolicy = {
2774
+ /** Rejects a direct ad only when `false` AND the ad's classification carries `contains_adult_content`. A `false` also adds `IAB25` plus `IAB25-2`..`IAB25-5` to the `bcat` and `sexually_suggestive`/`sexual_health`/`sensationalism` to the GAM `excl_cat`; unset falls back to the platform default of `false`. */
2285
2775
  allow_adult_content?: boolean;
2776
+ /** Rejects a direct ad only when set to `false` AND the ad's classification carries `contains_alcohol`. A `false` also adds `IAB8-5`/`IAB8-18` to the outgoing OpenRTB `bcat` and `alcohol` to the GAM `excl_cat`; when the key is unset that list-building step uses the platform default, which for alcohol is `true` (not blocked). */
2286
2777
  allow_alcohol?: boolean;
2778
+ /** Rejects a direct ad only when `false` AND the ad's classification carries `contains_gambling`. A `false` also adds `IAB9-7` to the `bcat` and `gambling`/`online_gambling` to the GAM `excl_cat`; unset falls back to the platform default of `false`. */
2287
2779
  allow_gambling?: boolean;
2780
+ /** Rejects a direct ad only when `false` AND the ad's classification carries `contains_political`. A `false` also adds `IAB11-4` to the `bcat` and `politics` to the GAM `excl_cat`; unset falls back to the platform default of `true` (allowed). */
2288
2781
  allow_political?: boolean;
2782
+ /** Rejects a direct ad only when `false` AND the ad's classification carries `contains_profanity`. A `false` also adds `IAB25-2` to the `bcat` and `sensationalism` to the GAM `excl_cat`; unset falls back to the platform default of `false`. */
2289
2783
  allow_profanity?: boolean;
2784
+ /** Rejects a direct ad only when `false` AND the ad's classification carries `contains_religious`. A `false` also adds `IAB23` to the `bcat` and `religion` to the GAM `excl_cat`; unset falls back to the platform default of `true` (allowed). */
2290
2785
  allow_religious?: boolean;
2786
+ /** Rejects a direct ad only when `false` AND the ad's classification carries `contains_tobacco`. A `false` also adds `IAB25-6` to the `bcat` and `drugs_tobacco` to the GAM `excl_cat`; unset falls back to the platform default of `false`, so tobacco is blocked on the programmatic side even when you never configure it. */
2291
2787
  allow_tobacco?: boolean;
2788
+ /** Rejects a direct ad only when `false` AND the ad's classification carries `contains_violence`. A `false` also adds `IAB25-3`/`IAB25-4` to the `bcat` and `violence`/`military_conflict` to the GAM `excl_cat`; unset falls back to the platform default of `false`. */
2292
2789
  allow_violence?: boolean;
2790
+ /** Consulted only when non-empty, and matched against the ad's typed `iab_categories` with `categoriesMatch`, so IAB codes and human-readable names both work (categories are inferred from subjects/keywords when the ad carries none). Under `lenient` and `permissive` a non-match is allowed through anyway, which makes the list inert at those levels; it also adds +10 per match to the content match score. */
2293
2791
  allowed_categories?: Array<string>;
2792
+ /** A SOFT preference, not a filter: it never rejects anything. Matching keywords add +3 each to the content match score used for prioritisation, and the approval path explicitly skips this list. */
2294
2793
  allowed_keywords?: Array<string>;
2794
+ /** Matched by case-insensitive SUBSTRING containment against the ad's `moderation.analysis.subjects`, not equality. Consulted only when non-empty and inert under `lenient`/`permissive`, like `allowed_categories`; it also adds +5 per match to the content match score. */
2295
2795
  allowed_subjects?: Array<string>;
2796
+ /** When `true` and the ad's classification says `is_known_brand`, approval returns IMMEDIATELY — the `blocked_categories`, `blocked_subjects`, `blocked_keywords` and every `allowed_*` check below it never run. The `allow_*` toggles and the age-rating check sit above it and still apply. */
2296
2797
  auto_approve_known_brands?: boolean;
2798
+ /** Same matching as `allowed_categories` (IAB code or human-readable name, via `categoriesMatch`), but a single hit rejects the ad at every `preference_level`. The rejection reason reports the human-readable name rather than the code you stored. */
2297
2799
  blocked_categories?: Array<string>;
2800
+ /** The auction `bcat` sent on OpenRTB bid requests for member screens. It is NOT part of `content_preferences`: on an individual screen the same list lives on `programmatic_settings.blocked_iab_categories`, and a non-empty one there is one of the three conditions that makes the screen layer explicit and bypasses this group policy entirely. */
2298
2801
  blocked_iab_categories?: Array<string>;
2802
+ /** The keyword list that DOES block, unlike `allowed_keywords`. Case-insensitive substring containment against the ad's `moderation.analysis.keywords`, so a short entry matches inside longer words. */
2299
2803
  blocked_keywords?: Array<string>;
2804
+ /** Case-insensitive substring containment against the ad's `moderation.analysis.subjects` — `bar` therefore also blocks `barbecue`, so use whole distinctive words. A hit is a hard reject. */
2300
2805
  blocked_subjects?: Array<string>;
2806
+ /** When `false`, direct-ad moderation short-circuits and every ad is auto-approved for member screens. It does NOT disable brand safety on the programmatic side — the `allow_*` toggles are still translated into the OpenRTB `bcat` and GAM `excl_cat` regardless of this flag. */
2301
2807
  content_filtering_enabled?: boolean;
2808
+ /** The group this row belongs to. It is also the tiebreak: a screen that is a member of more than one policy-bearing group inherits from the LOWEST `group_id`, so a screen should be in at most one. */
2302
2809
  group_id?: string;
2810
+ /** Highest ad classification a member screen accepts. `G` is deliberately NOT the strictest setting — the matcher silently rewrites it to `PG-13` for backwards compatibility — and `preference_level` then adds its buffer on top, so `lenient` with `PG-13` actually admits `R`. */
2303
2811
  max_age_rating?: "G" | "PG" | "PG-13" | "R" | "NC-17";
2812
+ /** Always the literal `screen_group_content_policy`. The policy is returned as `data.content_policy`, and a group carrying no policy of its own returns a bare `null` there — so on that path there is no `object` to branch on at all. */
2304
2813
  object?: string;
2814
+ /** Selects a strictness profile, not a label. It sets the age-rating buffer (`strict`/`moderate` 0, `lenient` +1, `permissive` +2 notches above `max_age_rating`), whether an ad with no AI classification is auto-approved (`lenient`/`permissive` yes; `strict`/`moderate` reject and queue re-moderation), and how the `allowed_*` lists behave on a non-match — `strict` rejects, `moderate` lets common business categories through, `lenient`/`permissive` make them inert. */
2305
2815
  preference_level?: "strict" | "moderate" | "lenient" | "permissive";
2816
+ /** ISO-8601 timestamp of the last write to this policy row, or `null` when the row has none. Member screens read the resolved policy through a screen-keyed Redis cache that a policy, membership or group-delete write busts, so an edit here reaches serving immediately rather than after the 60-second TTL. */
2306
2817
  updated_at?: string;
2307
2818
  } | null;
2308
2819
  /**
@@ -2311,24 +2822,43 @@ type ScreenGroupContentPolicy = {
2311
2822
  *
2312
2823
  */
2313
2824
  type ScreenGroupContentPolicyInput = {
2825
+ /** 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. */
2314
2826
  allow_adult_content?: boolean;
2827
+ /** 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`. */
2315
2828
  allow_alcohol?: boolean;
2829
+ /** 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. */
2316
2830
  allow_gambling?: boolean;
2831
+ /** 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. */
2317
2832
  allow_political?: boolean;
2833
+ /** 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. */
2318
2834
  allow_profanity?: boolean;
2835
+ /** 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. */
2319
2836
  allow_religious?: boolean;
2837
+ /** 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. */
2320
2838
  allow_tobacco?: boolean;
2839
+ /** 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. */
2321
2840
  allow_violence?: boolean;
2841
+ /** 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. */
2322
2842
  allowed_categories?: Array<string>;
2843
+ /** 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. */
2323
2844
  allowed_keywords?: Array<string>;
2845
+ /** 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. */
2324
2846
  allowed_subjects?: Array<string>;
2847
+ /** 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. */
2325
2848
  auto_approve_known_brands?: boolean;
2849
+ /** 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. */
2326
2850
  blocked_categories?: Array<string>;
2851
+ /** 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. */
2327
2852
  blocked_iab_categories?: Array<string>;
2853
+ /** 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. */
2328
2854
  blocked_keywords?: Array<string>;
2855
+ /** 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. */
2329
2856
  blocked_subjects?: Array<string>;
2857
+ /** 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". */
2330
2858
  content_filtering_enabled?: boolean;
2859
+ /** 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`. */
2331
2860
  max_age_rating?: "G" | "PG" | "PG-13" | "R" | "NC-17";
2861
+ /** 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. */
2332
2862
  preference_level?: "strict" | "moderate" | "lenient" | "permissive";
2333
2863
  };
2334
2864
  /**
@@ -2344,8 +2874,11 @@ type ScreenGroupContentPolicyInput = {
2344
2874
  type ScreenGroupEvent = {
2345
2875
  /** The screen_group / content-policy / sensing-config object. */
2346
2876
  data?: Record<string, unknown>;
2877
+ /** The event type, also sent in the `X-Trillboards-Event` header and mirrored to the SSE stream at `GET /v1/partner/events/stream`. This whole event family is gated by the `SCREEN_GROUP_EVENTS_ENABLED` runtime flag, which is OFF by default — when off, nothing is enqueued at all. */
2347
2878
  event?: "screen_group.created" | "screen_group.updated" | "screen_group.deleted" | "screen_group.member.added" | "screen_group.member.removed" | "screen_group.content_policy.updated" | "screen_group.sensing_config.updated";
2879
+ /** `evt_<base36 epoch ms>_<12 hex>`, minted once per occurrence. Use it as the idempotency key: a retried delivery of the same occurrence reuses it, but the same id is also sent to EVERY endpoint you have subscribed to that event, so dedupe per (endpoint, id). It is not the server-side delivery audit id and cannot be looked up. */
2348
2880
  id?: string;
2881
+ /** Stamped when the server ENQUEUED the delivery, not when it was sent, so a retried delivery still carries the original value and can trail wall-clock by the retry backoff. It is not the resource's own `updated_at`. */
2349
2882
  timestamp?: string;
2350
2883
  };
2351
2884
  /**
@@ -2363,14 +2896,23 @@ type ScreenGroupSensingConfig = {
2363
2896
  periodicIntervalMs?: number | null;
2364
2897
  vehicleTriggeredIntervalMs?: number | null;
2365
2898
  } | null;
2899
+ /** Group override of the profile's declared frame-capture cadence in milliseconds; `null` inherits the base profile's value. Treat it as advisory - this legacy scalar does not reach the device capture loop (the edge `FrameCaptureManager` reads the nested `capture` block), and observed cadence was measured at ~12x the declared value against a fleet floor of one analysis per 30 s. */
2366
2900
  capture_interval_ms?: number | null;
2901
+ /** When `false` the whole group step is a no-op and member screens fall straight through to the next layer of the resolver — the row is retained but contributes nothing, which is how you disable group sensing without losing the overrides. */
2367
2902
  enabled?: boolean;
2903
+ /** Advisory workload cost of the profile, normally derived server-side as the sum of the selected models' registry `fps_cost` (built-ins run 5.5-12.0). It is display metadata only — no device or pipeline code reads it, so overriding it throttles nothing. */
2368
2904
  estimated_fps_impact?: number | null;
2905
+ /** The `sg_`-prefixed Screen Group this config belongs to; echoes the `groupId` path parameter. */
2369
2906
  group_id?: string;
2907
+ /** Detection-confidence floor in 0..1, overlaid onto the resolved profile's `thresholds.min_confidence`; built-in profiles ship 0.35-0.45. `null` inherits the base profile's value. */
2370
2908
  min_confidence?: number | null;
2909
+ /** Stripe-style type discriminator; always the literal `screen_group_sensing_config`, so it is safe to switch on. */
2371
2910
  object?: string;
2911
+ /** The sensing profile members inherit, bound by NAME (a builtin or an existing custom profile) rather than by `profile_id`, because `setDeployment` clones a fresh `profile_id` per screen on every redeploy. `null` means the group binds no profile. */
2372
2912
  profile_name?: string | null;
2913
+ /** Group override of how often the device batches observations back to the server, in milliseconds; `null` inherits the base profile's value. Built-in profiles ship 15000-30000. */
2373
2914
  report_interval_ms?: number | null;
2915
+ /** When this group's sensing config row was last written. The write also busts the resolver caches for the members as they stood at that instant and emits a `screen_group.sensing_config.updated` webhook, so a screen added to the group afterwards picks the config up on its own next cache miss instead. */
2374
2916
  updated_at?: string;
2375
2917
  /** Per-screen cloud-vision budget for member screens. screen_hourly_pace wins when both are set; otherwise derived from screen_daily_calls. null = no group budget (fleet keeps platform-default caps). */
2376
2918
  vision_budget?: {
@@ -2392,11 +2934,17 @@ type ScreenGroupSensingConfigInput = {
2392
2934
  periodicIntervalMs?: number | null;
2393
2935
  vehicleTriggeredIntervalMs?: number | null;
2394
2936
  } | null;
2937
+ /** 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. */
2395
2938
  capture_interval_ms?: number | null;
2939
+ /** 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. */
2396
2940
  enabled?: boolean;
2941
+ /** 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. */
2397
2942
  estimated_fps_impact?: number | null;
2943
+ /** 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). */
2398
2944
  min_confidence?: number | null;
2945
+ /** 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. */
2399
2946
  profile_name?: string | null;
2947
+ /** Observation-reporting cadence override in milliseconds (100..3600000); `null` means inherit from the base profile. */
2400
2948
  report_interval_ms?: number | null;
2401
2949
  /** Per-screen cloud-vision budget block; null clears it. Unknown keys are rejected (400). */
2402
2950
  vision_budget?: {
@@ -2412,30 +2960,47 @@ type SensingPerformanceMode = "auto" | "presence_only" | "demographics" | "advan
2412
2960
  * A Sense Anything Route — a (condition → channel actions) alert/automation rule.
2413
2961
  */
2414
2962
  type SensingRoute = {
2963
+ /** Every channel action fired when the route matches; 1 to 10 per route, each with its own `enabled` flag so one can be muted without deleting the route. `webhook` is the only channel with no external side effect — `whatsapp`, `sms`, `email` and `slack` deliver a real message to a real person, so point a test route at `webhook`. */
2415
2964
  actions?: Array<SensingRouteAction>;
2416
2965
  /** One bit per weekday (127 = always). */
2417
2966
  activeDaysMask?: number;
2967
+ /** Inclusive end of the daily active window, in minutes past local midnight in `activeTz` (0..1439). When it is LESS than `activeStartMinute` the window is read as overnight — 1200 to 360 means 20:00 through 06:00 — rather than as an empty range. */
2418
2968
  activeEndMinute?: number | null;
2969
+ /** Inclusive start of the daily active window, in minutes past local midnight in `activeTz` (0..1439; 1200 = 20:00). Ignored unless `activeTz` is set, and a `null` on either end makes the window whole-day. */
2419
2970
  activeStartMinute?: number | null;
2971
+ /** IANA zone name (e.g. `America/New_York`) the active window is evaluated in, trimmed to 64 characters. `null` disables the ENTIRE active-window check — `activeStartMinute`, `activeEndMinute` and `activeDaysMask` do nothing without it — and a zone the runtime cannot parse fails OPEN (always active) rather than silently dropping alerts. */
2420
2972
  activeTz?: string | null;
2973
+ /** `null` on a `count`-metric digest route (there is nothing to aggregate over). On an `avg`/`max` digest it carries only `field`, with `op` and `value` absent — only a `threshold`-mode route has a complete condition. */
2421
2974
  condition?: SensingRouteCondition | null;
2422
2975
  createdAt?: string;
2976
+ /** Minimum gap in seconds between alerts for the same (route, screen) episode; clamped to 0..86400 and defaulting to 300. It also caps the falling-edge clear window (60 s maximum), so a shorter debounce re-arms the route sooner as well as spacing alerts more tightly. */
2423
2977
  debounceSeconds?: number;
2978
+ /** Scheduled-aggregate configuration; `null` on `threshold` routes. `nextRunAt` is a server-held cursor the digest cron advances after every send, so it moves on its own without any edit from you. */
2424
2979
  digest?: {
2425
2980
  intervalMinutes?: number;
2426
2981
  metric?: "count" | "avg" | "max";
2427
2982
  nextRunAt?: string;
2428
2983
  windowMinutes?: number;
2429
2984
  } | null;
2985
+ /** Whether the evaluator considers this route at all. A disabled route keeps its condition, actions and screen set and is simply skipped by every hot-path read; deleting a route also sets this `false` (rows are soft-deleted, never removed). */
2430
2986
  enabled?: boolean;
2987
+ /** `threshold` evaluates `condition` against each incoming observation. `digest` ignores the condition and instead sends a scheduled aggregate driven by the `digest.nextRunAt` cursor; focus regions are rejected on digest routes. */
2431
2988
  mode?: "threshold" | "digest";
2989
+ /** Operator-supplied label, trimmed and capped at 200 characters. It is delivered verbatim as `route_name` on every fire but is not the alert text — the human-readable message is composed separately from the screen's sensing intent. */
2432
2990
  name?: string;
2991
+ /** The owning principal's id, compared against your credential on every read and write — a mismatch answers 404 (existence-hidden), never 403. Equal to your partner id for routes created here. */
2433
2992
  ownerId?: string;
2993
+ /** Which principal created the route: `partner` for anything created through this API, `earner_user` for a route the screen's own operator created in the earner portal. The Partner API only ever lists or edits `partner` routes, but an `earner_user` route on your screen still delivers webhooks to you. */
2434
2994
  ownerKind?: string;
2995
+ /** The partner the route was created under. Equal to `ownerId` for partner-created routes and `null` for a route an earner created in the portal, so it is not a reliable join key for the screen's partner. */
2435
2996
  partnerId?: string;
2997
+ /** Binds the route to a sensing profile BY NAME, never by `profile_id` — redeploying a profile mints a fresh `profile_id` per screen, which would orphan the binding. `null` means unbound; when set, the route stays dormant on any screen whose active profile has a different name. */
2436
2998
  profileName?: string | null;
2999
+ /** Server-minted immutable id: the literal prefix `rt_` followed by a UUID. Webhook consumers should key on this rather than `route_name`, which the owner can rename at any time. */
2437
3000
  routeId?: string;
3001
+ /** Per-camera areas, at most one entry per screen, and NOT geometrically enforced. The server turns each into a single line of prompt text capped at 120 characters; measured 2026-08-25 on `gemini-3.1-flash-lite`, both the rectangle and the lasso form leaked 100% at every anchor, so a focused alert still reacts to the whole frame. */
2438
3002
  screenFocusRegions?: Array<SensingRouteScreenFocusRegion>;
3003
+ /** The screens this route watches — deduped and capped at 250. For a group-targeted route these are the group's LIVE members resolved at read time, not a stored set, so the list can change between two GETs without any edit. */
2439
3004
  screenMongoIds?: Array<string>;
2440
3005
  /** 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. */
2441
3006
  sustainMinSamples?: number;
@@ -2445,6 +3010,7 @@ type SensingRoute = {
2445
3010
  sustainWindowSeconds?: number;
2446
3011
  /** The sg_ Screen Group this route targets. When set, the route fires on the group's LIVE members (screenMongoIds reflects the resolved members at read time) and no explicit screens are stored. */
2447
3012
  targetGroupId?: string | null;
3013
+ /** Last write to the route row — NOT a configuration-change stamp. The digest cron bumps it every time it advances a digest route's next-run cursor, and a soft delete bumps it too, so it moves on rows nobody edited. */
2448
3014
  updatedAt?: string;
2449
3015
  };
2450
3016
  /**
@@ -2453,6 +3019,7 @@ type SensingRoute = {
2453
3019
  type SensingRouteAction = {
2454
3020
  /** Attach the evidence frame/clip (people-facing channels). */
2455
3021
  attachClip?: boolean;
3022
+ /** Selects a strict per-channel shape: `webhook` requires `webhookId`, `whatsapp`/`sms` require `recipientE164`, `email` requires `recipientEmail`, `slack` requires `slackChannelId`, and `clip` needs no destination. Any other destination field on the action is rejected, and `webhook` is the only channel that does not deliver to a person — use it for test routes. */
2456
3023
  channel: "webhook" | "whatsapp" | "sms" | "slack" | "email" | "clip";
2457
3024
  /** E.164 phone number (channel=whatsapp|sms). */
2458
3025
  recipientE164?: string;
@@ -2471,6 +3038,7 @@ type SensingRouteAction = {
2471
3038
  type SensingRouteCondition = {
2472
3039
  /** The sensed field key (snake_case) to evaluate. */
2473
3040
  field: string;
3041
+ /** How the observed value is compared with `value`. `gt`/`gte`/`lt`/`lte` match ONLY when both sides parse as finite numbers; `eq`/`neq` fall back to strict equality for booleans and non-numeric strings. A missing or null observation never matches — including `neq`, which does not fire on absence. */
2474
3042
  op: "gt" | "gte" | "lt" | "lte" | "eq" | "neq";
2475
3043
  /** The threshold value (numeric, string, or boolean depending on the field). */
2476
3044
  value?: number | string | boolean;
@@ -2479,18 +3047,24 @@ type SensingRouteCondition = {
2479
3047
  * A normalized camera area used only by this alert. The rectangle is the compatibility bounding box; optional ordered points define the exact lasso perimeter. Omitted cameras use the whole view.
2480
3048
  */
2481
3049
  type SensingRouteScreenFocusRegion = {
3050
+ /** Rectangle height as a fraction of frame height, at least 0.05. When `points` is supplied this is DERIVED from the lasso's bounding box. Note the region is only ever sent to the model as prompt text and is not geometrically enforced — measured 2026-08-25, both shapes leaked 100% at every anchor, so a focused alert reacts to the whole frame. */
2482
3051
  height: number;
2483
3052
  /** Ordered non-self-intersecting lasso vertices, normalized to a 0.01 coordinate grid so the saved perimeter is represented exactly in model instructions. The perimeter closes from the last point to the first. */
2484
3053
  points?: Array<{
2485
3054
  x: number;
2486
3055
  y: number;
2487
3056
  }>;
3057
+ /** Which of the route's cameras this area applies to. It must appear in the route's explicit `screenMongoIds` and may appear at most once across the array, or the write is a 400; cameras with no entry are watched whole-view. */
2488
3058
  screenMongoId: string;
3059
+ /** Rectangle width as a fraction of frame width, at least 0.05. When `points` is supplied this is DERIVED from the lasso's bounding box rather than read from the request, so older readers keep a safe approximation of the polygon. */
2489
3060
  width: number;
3061
+ /** Left edge of the bounding rectangle as a fraction of frame width, origin top-left. Stored rounded to 4 decimal places, and capped at 0.95 because `width` has a 0.05 minimum and `x + width` may not exceed 1. */
2490
3062
  x: number;
3063
+ /** Top edge of the bounding rectangle as a fraction of frame height, origin top-left. Stored rounded to 4 decimal places, and capped at 0.95 because `height` has a 0.05 minimum and `y + height` may not exceed 1. */
2491
3064
  y: number;
2492
3065
  };
2493
3066
  type SensingSettingsResponse = {
3067
+ /** Summary of the profile row currently deployed and active on this screen (id, name, cadences and derived capabilities). `null` when the screen has never had a profile deployed, in which case the response's mode fields describe the default rather than anything running. */
2494
3068
  active_profile?: {
2495
3069
  capabilities?: Array<string>;
2496
3070
  capture_interval_ms?: number | null;
@@ -2511,11 +3085,14 @@ type SensingSettingsResponse = {
2511
3085
  telemetry_fresh?: boolean;
2512
3086
  };
2513
3087
  effective_mode?: SensingPerformanceMode;
3088
+ /** Despite the name this is not an apply OUTCOME and carries no device acknowledgement: it is `null` when the screen has no active profile and otherwise always `{ status: "active_profile_loaded", profile_id, updated_at }` describing the profile row that is loaded now. */
2514
3089
  last_apply_status?: Record<string, unknown> | null;
3090
+ /** The full selectable set with human labels (`auto` = "Auto recommended", `presence_only` = "Viewer count only", and so on). It is a static list, NOT filtered by this screen's hardware — a mode listed here can still be refused; read `safety.blocks` before offering it. */
2515
3091
  modes?: Array<{
2516
3092
  label?: string;
2517
3093
  value?: SensingPerformanceMode;
2518
3094
  }>;
3095
+ /** Hardware pre-flight for `effective_mode`. `allowed` is simply `blocks.length === 0`; `warnings` never block (stale telemetry, recent device failures), while a `block` — missing fresh telemetry for a load-increasing mode, or FLOOR-tier/CPU-only hardware for `advanced` — makes a PUT of that mode answer 409 with this object under `error.details.safety`. */
2519
3096
  safety?: {
2520
3097
  allowed?: boolean;
2521
3098
  blocks?: Array<{
@@ -2527,6 +3104,7 @@ type SensingSettingsResponse = {
2527
3104
  message?: string;
2528
3105
  }>;
2529
3106
  };
3107
+ /** The screen's Mongo id — the same identifier the route surfaces call `screenMongoId`. Echoes the path parameter. */
2530
3108
  screen_id?: string;
2531
3109
  };
2532
3110
  /**
@@ -2543,13 +3121,17 @@ type SensingSettingsResponse = {
2543
3121
  type SensingThresholdCrossedEvent = {
2544
3122
  /** The sensed field key evaluated (null for count-only digests). */
2545
3123
  field?: string | null;
3124
+ /** Server clock when the evaluator fired — not when the camera saw it. On the cloud path it was measured 8.2 s median and 15.3 s p95 behind the frame's shutter, so use the payload's `captured_at` (the device's own shutter instant) whenever the fire carried a frame. */
2546
3125
  fired_at?: string;
2547
3126
  /** The observed value that crossed the threshold. */
2548
3127
  observed?: unknown;
3128
+ /** The comparison the evaluator applied. `null` for a scheduled digest, which aggregates rather than comparing. Numeric operators only ever match when both the observed value and the threshold parse as finite numbers. */
2549
3129
  op?: "gt" | "gte" | "lt" | "lte" | "eq" | "neq" | null;
3130
+ /** Who owns the route that fired. Webhooks are dispatched to the SCREEN's partner regardless of who created the route, so a partner endpoint legitimately receives `earner_user` fires for routes the screen's own operator configured and which are not visible through the Partner API. */
2550
3131
  owner_kind?: "earner_user" | "partner";
2551
3132
  /** The Route that fired. */
2552
3133
  route_id?: string;
3134
+ /** The route's operator-supplied label at the time of the fire. It is mutable and not unique — match on `route_id` for idempotency and routing. */
2553
3135
  route_name?: string;
2554
3136
  /** The screen (mongo id) whose observation crossed the threshold. */
2555
3137
  screen_id?: string;
@@ -2557,46 +3139,70 @@ type SensingThresholdCrossedEvent = {
2557
3139
  threshold?: unknown;
2558
3140
  };
2559
3141
  type SsdpDeviceInfo = {
3142
+ /** The `<friendlyName>` element from the fetched description XML. If it looks like a personal name (possessive apostrophe-s, an email, a MAC) the ENTIRE row is discarded rather than the field being blanked, so consumer devices named after their owner never appear at all. */
2560
3143
  friendlyName?: string | null;
3144
+ /** The `LOCATION:` header from the M-SEARCH response — the URL of the device description XML, and the de-duplication key for the whole list (one row per distinct LOCATION, last response wins). A response without it produces no row at all. */
2561
3145
  location: string;
3146
+ /** The `<manufacturer>` element from the description XML. `null` whenever the description fetch was skipped, timed out or failed — which is common, since fetching is bounded to half the listen window with a 1.5 s per-host timeout — so absence says nothing about the device. */
2562
3147
  manufacturer?: string | null;
3148
+ /** The `<modelName>` element from the description XML, field-stripped (not row-dropped) if it contains an email or a MAC. Absent on every row whose description fetch did not complete. */
2563
3149
  modelName?: string | null;
3150
+ /** The raw `SERVER:` response header, an RFC 2616 product token such as `Linux UPnP/1.0 Sonos/72.6-31040`. It comes from the M-SEARCH response itself, so unlike the XML-derived fields below it is present even when the description fetch fails — and it is deliberately neither PII-filtered nor hashed server-side, being treated as a low-cardinality vendor hint. */
2564
3151
  server?: string | null;
3152
+ /** The `ST:` search-target header of the response, e.g. `urn:schemas-upnp-org:device:ZonePlayer:1`. The probe searches with `ST: ssdp:all`, so this reflects what the device chose to answer as, not what we asked for. It arrives with the M-SEARCH response, so it survives a failed description fetch; an empty string means the responder omitted the header. */
2565
3153
  st: string;
3154
+ /** The `<UDN>` element — the device's self-declared Unique Device Name, typically a `uuid:` URN and the most stable per-device key SSDP offers. It is hashed server-side, and it is absent whenever the description fetch did not complete. */
2566
3155
  udn?: string | null;
2567
3156
  [key: string]: unknown;
2568
3157
  };
2569
3158
  type TeamInviteResponse = {
3159
+ /** The invited address, lower-cased and trimmed. Once the invitation is accepted the listing reports the accepting user account's email instead, which may differ from this one. */
2570
3160
  email?: string;
2571
3161
  /** When the invitation expires (7 days from creation) */
2572
3162
  expires_at?: string;
3163
+ /** A freshly minted 24-character hex ObjectId held as the membership's legacy id — NOT the `memberships` UUID primary key. It is the value the team routes take as the member path parameter. */
2573
3164
  membership_id?: string;
3165
+ /** Only `admin` and `viewer` can be invited — `owner` is the membership created with the partner account and cannot be granted through this endpoint. */
2574
3166
  role?: "admin" | "viewer";
3167
+ /** `active` when the invited email already had a Trillboards account: the membership is auto-accepted at invite time, `accepted_at` is set immediately, no token is issued and `expires_at` is `null`. `pending` otherwise, with a 7-day invite token. Either way the team is capped at 50 active-or-pending members. */
2575
3168
  status?: "pending" | "active";
2576
3169
  };
2577
3170
  type TeamMember = {
3171
+ /** `null` while `status` is `pending`. Set at INVITE time rather than click time when the invitee already had a Trillboards account and the membership was auto-activated. */
2578
3172
  accepted_at?: string | null;
3173
+ /** The linked user account's email once the invitation is accepted; the address the invitation was SENT to while `status` is `pending`. The two differ if the invitee accepts with a different account. */
2579
3174
  email?: string;
3175
+ /** When the invitation row was created. Re-inviting the same address rotates the token and moves this timestamp forward. */
2580
3176
  invited_at?: string;
3177
+ /** Name and email of the inviting user, resolved by join. `null` for the owner membership created with the partner account, and for any invitation whose inviter row no longer resolves. */
2581
3178
  invited_by?: {
2582
3179
  email?: string;
2583
3180
  name?: string;
2584
3181
  } | null;
3182
+ /** **Always `null`.** The column has no counterpart in `memberships` after the tenant-membership cutover, so the canonical reader projects a literal `NULL::timestamptz` and nothing writes it. */
2585
3183
  last_portal_access?: string | null;
2586
3184
  /** Unique membership record ID */
2587
3185
  membership_id?: string;
3186
+ /** The linked user account's display name. Undefined — and so omitted from the JSON — while `status` is `pending`, because no user row is attached until the invitation is accepted. */
2588
3187
  name?: string;
2589
3188
  permissions?: RolePermissions;
3189
+ /** Profile photo URL from the linked user account, and null/absent while the invitation is pending for the same reason as `name`. */
2590
3190
  photo?: string | null;
3191
+ /** `owner` is the membership created with the partner account and can never be invited or granted — only `admin` and `viewer` are grantable. Role alone determines `permissions`. */
2591
3192
  role?: "owner" | "admin" | "viewer";
3193
+ /** The listing returns `active` + `pending` only; `revoked` rows appear only when `?status=revoked` is passed explicitly. An invitation to an email that already has a Trillboards account is auto-accepted and lands `active` immediately. */
2592
3194
  status?: "active" | "pending" | "revoked";
2593
3195
  /** User ID (null if pending invitation) */
2594
3196
  user_id?: string;
2595
3197
  };
2596
3198
  type Transaction = {
3199
+ /** Integer US cents. Earnings rows are allocated cumulatively so the list sums EXACTLY to the lifetime total — an individual row can therefore differ by one cent from rounding that day's dollars on its own. */
2597
3200
  amount_cents?: number;
3201
+ /** For a `payout`, the real creation timestamp. For an `earning` it is `new Date(period_date)` — midnight UTC of the accounting day, a value derived from the date key rather than the time anything happened. */
2598
3202
  created_at?: string;
3203
+ /** For an `earning` row, the `YYYY-MM-DD` accounting period the aggregate covers. For a `payout` row it is the payout record's creation timestamp, so it carries a time component despite the declared `date` format. */
2599
3204
  date?: string;
3205
+ /** Synthetic and namespaced by origin, never a ledger primary key: earnings rows are `daily_<YYYY-MM-DD>` (one per accounting day, not per impression); payouts are `admin_batch:<ledger_id>`, `earner_withdrawal:<withdrawal_id>` or `legacy_transfer:<id>`. */
2600
3206
  id?: string;
2601
3207
  /** Number of impressions (for earning transactions) */
2602
3208
  impressions?: number;
@@ -2604,6 +3210,7 @@ type Transaction = {
2604
3210
  source?: string;
2605
3211
  /** Stripe transfer ID (for payout transactions) */
2606
3212
  stripe_transfer_id?: string;
3213
+ /** `earning` rows are daily revenue aggregates from `programmatic_earnings_daily`; `payout` rows are money leaving, unioned from three sources and deduplicated by provider transfer id. `?type=all` (the default) merges both and sorts newest-first. */
2607
3214
  type?: "earning" | "payout";
2608
3215
  };
2609
3216
  /**
@@ -2641,24 +3248,35 @@ type TrillboardsError = {
2641
3248
  * Partial, non-clobbering update — include only the fields to change.
2642
3249
  */
2643
3250
  type UpdatePartnerSensingRouteRequest = {
3251
+ /** 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. */
2644
3252
  actions?: Array<SensingRouteAction>;
3253
+ /** 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. */
2645
3254
  activeDaysMask?: number;
3255
+ /** 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. */
2646
3256
  activeEndMinute?: number;
3257
+ /** Inclusive start of the daily active window, minutes past local midnight in `activeTz` (0..1439). Requires `activeTz` to have any effect. */
2647
3258
  activeStartMinute?: number;
3259
+ /** 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. */
2648
3260
  activeTz?: string;
2649
3261
  condition?: SensingRouteCondition;
3262
+ /** 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. */
2650
3263
  debounceSeconds?: number;
3264
+ /** 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. */
2651
3265
  digest?: {
2652
3266
  field?: string;
2653
3267
  intervalMinutes?: number;
2654
3268
  metric?: "count" | "avg" | "max";
2655
3269
  windowMinutes?: number;
2656
3270
  };
3271
+ /** 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. */
2657
3272
  enabled?: boolean;
3273
+ /** 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. */
2658
3274
  name?: string;
3275
+ /** 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. */
2659
3276
  profileName?: string | null;
2660
3277
  /** 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. */
2661
3278
  screenFocusRegions?: Array<SensingRouteScreenFocusRegion>;
3279
+ /** 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). */
2662
3280
  screenMongoIds?: Array<string>;
2663
3281
  /** 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. */
2664
3282
  sustainMinSamples?: number;
@@ -2674,6 +3292,7 @@ type UpdatePartnerSensingRouteRequest = {
2674
3292
  *
2675
3293
  */
2676
3294
  type VastWaterfall = {
3295
+ /** Derived, not configured: it is simply `sources.length > 0`. `false` means no demand source resolved for this screen on this request, so fall back to your own content. It is not a partner-settable toggle and it does not mean programmatic serving is switched off for the screen. */
2677
3296
  enabled?: boolean;
2678
3297
  /** Every source must be attempted, by `priority` ascending, until one actually plays. Not a preference list. */
2679
3298
  sources?: Array<VastWaterfallSource>;
@@ -2684,7 +3303,9 @@ type VastWaterfall = {
2684
3303
  * One demand source in the VAST waterfall. Fetch `vast_url` (already a Trillboards vast-wrap URL — use as-is) and pass the response to your IMA SDK. A failure here means "advance to the next source", not "the request had no demand".
2685
3304
  */
2686
3305
  type VastWaterfallSource = {
3306
+ /** Identical across every source in this response — one id per waterfall build, so it groups all of a single ad opportunity's source attempts, while `request_id` identifies this source alone. Echo it back; never mint your own. */
2687
3307
  auction_request_id?: string;
3308
+ /** The source's effective FLOOR CPM in dollars per thousand impressions (never cents) — the configured tag floor (`NUMERIC(8,2)`) or the partner's default, after any demand price action. It is an expectation used to order the waterfall, not a cleared price and not what the screen earns; `null` when the partner has no floor configured. */
2688
3309
  cpm?: number | null;
2689
3310
  /** High recent error rate measured server-side. Degraded sources are already pushed to the tail of the array for you — do NOT skip a source because of this flag. */
2690
3311
  degraded?: boolean;
@@ -2692,7 +3313,9 @@ type VastWaterfallSource = {
2692
3313
  duration_seconds?: number;
2693
3314
  /** When false, skip this source entirely. Present-but-disabled sources may appear in the array. */
2694
3315
  enabled?: boolean;
3316
+ /** Recent non-response rate for this demand source as a PERCENT 0-100 (not a 0-1 fraction). It is fleet-wide rather than per-screen, derived from an hourly rollup that can be ~15 minutes stale, and it is present only on sources that also carry `degraded: true`. Advisory: it never means skip the source. */
2695
3317
  error_rate_pct?: number;
3318
+ /** No live producer — `null` on every source. The only assignment is guarded on a partner-level `fetchMode` setting that no demand partner in the registry defines. Do not branch on it. */
2696
3319
  fetch_mode?: string | null;
2697
3320
  /** Opaque public alias for the demand source. Echo it back as `variant_name` on `programmatic-event` reports. */
2698
3321
  name?: string;
@@ -2702,6 +3325,7 @@ type VastWaterfallSource = {
2702
3325
  request_id?: string;
2703
3326
  /** Per-source budget in ms for fetching and loading THIS source. Distinct from the waterfall-wide `total_timeout_ms`. */
2704
3327
  timeout_ms?: number;
3328
+ /** Fetch verbatim. The server has already appended this source's `request_id` and rewritten every partner-identifying path segment and query parameter to an opaque alias, so editing, re-ordering or re-signing the query breaks impression attribution. A `data:` URL can appear here for an inbound-DSP source that carries its VAST inline. */
2705
3329
  vast_url?: string;
2706
3330
  };
2707
3331
  type Webhook = {
@@ -2758,24 +3382,40 @@ type WebhookEvent = {
2758
3382
  timestamp?: string;
2759
3383
  };
2760
3384
  type WifiEnvironmentSnapshot = {
3385
+ /** Dimensionless 0.0-1.0: visible APs whose centre frequency is within ±10 MHz of the joined AP, divided by ALL visible APs in the capped list. Because the joined AP is itself in the numerator it is never 0 while connected — a literal `0.0` means not connected (or an empty scan), not an empty channel. Above ~0.5 indicates a dense venue, below ~0.2 an isolated one. */
2761
3386
  channelCongestionRatio?: number | null;
3387
+ /** Channel width of the joined AP in MHz — but it is APPROXIMATE by construction: the producer returns the width of the FIRST entry in the scan list, which is only usually the joined AP, rather than matching on BSSID. `null` when not connected, on Android below API 23, or for an 80+80 MHz AP, which the width mapping does not cover. */
2762
3388
  connectedChannelWidthMhz?: number | null;
3389
+ /** Centre frequency of the joined AP's primary 20 MHz channel, in MHz. `0` means not connected / unavailable, and that zero also zeroes `channelCongestionRatio` and forces `frequencyBand` to `unknown`. */
2763
3390
  connectedFrequencyMhz?: number | null;
3391
+ /** Negotiated PHY link speed of the joined AP in Mbps (`WifiInfo.getLinkSpeed()`) — a link-rate ceiling, not measured throughput. `null` rather than 0 when not connected or when the platform reports a non-positive value. */
2764
3392
  connectedLinkSpeedMbps?: number | null;
3393
+ /** RSSI of the JOINED access point in dBm. `-100` is a SENTINEL, not a measurement: it is the literal default when `WifiInfo` is unavailable or the device is not on Wi-Fi, so a genuinely weak link and no link at all are indistinguishable here. */
2765
3394
  connectedSignalDbm?: number | null;
3395
+ /** Band of the JOINED AP: `2.4ghz`, `5ghz`, `6ghz` or `unknown`. It is derived from `connectedFrequencyMhz`, so a device that is not on Wi-Fi reports `unknown` rather than null — and the 5900-5924 MHz range falls into `unknown` too, because the classifier's 5 GHz and 6 GHz windows do not meet. */
2766
3396
  frequencyBand?: string | null;
3397
+ /** Median RSSI in dBm across ALL visible APs (not just the joined one). It is the UPPER median for an even-sized list — the two middle values are not averaged — and falls back to the `-100` sentinel on an empty list. */
2767
3398
  medianSignalDbm?: number | null;
3399
+ /** Distinct BSSIDs in the scan, after the 50-entry cap. It is literally the SAME value as `uniqueBssidCount` — both are assigned from one field — so the two never disagree and neither is a count of the uncapped scan. */
2768
3400
  networkCount?: number | null;
3401
+ /** Population VARIANCE of the joined AP's RSSI in dBm SQUARED — not a standard deviation, and the divisor is N, not N-1. The window is the last 5 HEARTBEAT cycles held in a process-lifetime buffer, not the current scan, so it reads `0.0` on the first cycle after an agent restart; the `-100` not-connected sentinel is admitted into that history and skews it. Intended as a coarse motion indicator, not occupancy. */
2769
3402
  rssiVariance?: number | null;
3403
+ /** UNIX EPOCH milliseconds (`System.currentTimeMillis()`) stamped when the scan COMPLETED — wall clock, not boot-relative, and not the scan start. It is a different clock from Android's own per-result `ScanResult.timestamp`, which is microseconds since boot and never reaches the wire. */
2770
3404
  scanTimestampMs?: number | null;
3405
+ /** Strongest minus weakest visible RSSI, in dB — a positive difference, not an absolute level. It is `0` when fewer than two APs are visible, which is indistinguishable from a genuinely uniform RF environment. Used as a venue-openness hint. */
2771
3406
  signalSpreadDbm?: number | null;
3407
+ /** Distinct BSSIDs after de-duplication and the 50-entry cap — the identical value to `networkCount` above, assigned from the same field, so it adds no independent information. */
2772
3408
  uniqueBssidCount?: number | null;
2773
3409
  [key: string]: unknown;
2774
3410
  } | null;
2775
3411
  type WifiScanResult = {
3412
+ /** Bonded channel width in MHz, mapped from Android's width enum to a literal 20, 40, 80 or 160. `null` on Android below API 23, on any read failure, AND for an 80+80 MHz AP — that variant has no mapping, so `null` does not mean narrow. */
2776
3413
  channelWidthMhz?: number | null;
3414
+ /** Centre frequency of the AP's PRIMARY 20 MHz channel in MHz (2400-7200 accepted, covering 2.4/5/6 GHz) — not the centre of a wider bonded channel. Convert to a channel number yourself; the wire carries only the frequency. */
2777
3415
  frequencyMhz: number;
3416
+ /** The AP's BSSID exactly as Android reports it, unhashed on the device (hashing happens server-side). Rows are de-duplicated by BSSID keeping the strongest reading, and the literal `02:00:00:00:00:00` — Android's redaction placeholder for a caller without location permission — is dropped rather than forwarded, so a permission-less device produces an empty list, not a list of placeholders. */
2778
3417
  rawBssid: string;
3418
+ /** AP signal level in dBm (negative, bounded -120..0), from `ScanResult.level`. Note the wire key the Android agent actually sends is `signal_dbm`, not a snake_case form of this name — a mismatch of exactly this kind left the ClickHouse `rssi` column NULL on 100% of Wi-Fi rows until 2026-08-08. */
2779
3419
  signalStrengthDbm: number;
2780
3420
  [key: string]: unknown;
2781
3421
  };