# Trillboards Partner Webhooks

Trillboards POSTs these events to any active webhook subscription whose
`events` list includes the event type. **22** event types are
subscribable, plus the `test` delivery you can trigger yourself.

Subscriptions are managed through the Partner API — `POST /v1/partner/webhooks`,
`PATCH /v1/partner/webhooks/{webhookId}`, `POST /v1/partner/webhooks/{webhookId}/test`
and `GET /v1/partner/webhooks/{webhookId}/deliveries`. This document describes
what we send you; that one describes how to subscribe.

## ⚠️ Delivery guarantees differ per event. Read this before you rely on one.

There are two delivery paths, and they do not offer the same thing. The
difference is not configurable and you cannot detect it at runtime — it is a
property of the event type, listed below.

| | Queued (16 events) | Synchronous (7 events) |
|---|---|---|
| Retries | **3 attempts**, exponential backoff from **60 seconds** | **None — at most once** |
| In `GET /webhooks/{id}/deliveries` | Yes | **No — no audit row is written** |
| Signature header | `t=,v0=,v1=` (compound) | `sha256=<hex>` (legacy) |
| Envelope `id` | Present | Absent |

**Queued:** `creative.rewards_ready`, `audience.spike`, `audience.demographics_update`, `audience.purchase_intent`, `audience.venue_busy`, `venue.traffic_summary`, `sensing.threshold_crossed`, `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`, `fleet_command.completed`, `fleet_command.failed`.

**Every other event on this page is synchronous.** A non-2xx response or a
timeout on a synchronous event means the notification is **permanently lost** —
nothing retries it and nothing records that it failed. If you need durability
for one of those events, return `2xx` immediately, persist the body yourself,
and process it after; and reconcile from the REST API rather than treating the
webhook as a ledger.

> Separately, 8 further event types exist in the server's internal
> catalogue but are **rejected** by `POST`/`PATCH /v1/partner/webhooks` with a 400,
> so they cannot be subscribed to and are deliberately not documented as webhooks
> here: `screen.content_preferences_changed`, `screen.settings_updated`, `screen.display_settings_changed`, `screen.programmatic_settings_changed`, `programmatic.ad_started`, `programmatic.ad_ended`, `programmatic.no_fill`, `programmatic.error`.

## Delivery headers

```
Content-Type:               application/json
User-Agent:                 Trillboards-Webhook/1.0
X-Trillboards-Event:        <event type>
X-Trillboards-Timestamp:    <unix seconds>
X-Trillboards-Signature:    <see below>
X-Trillboards-Webhook-Id:   <your subscription id>
```

## Signature verification

Every delivery is signed with HMAC-SHA256 using your subscription secret.

> **Two signature header formats are live. A verifier must accept both.**
> Which one you receive follows the delivery path in the table above.

**Compound (Stripe-style)** — the queued path:

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

- `v1 = HMAC-SHA256(secret, "<timestamp>.<raw body>")` — canonical, replay-protected. Prefer it.
- `v0 = HMAC-SHA256(secret, "<raw body>")` — legacy body-only digest, emitted during the migration window.

**Legacy single-value** — the synchronous path:

```
X-Trillboards-Signature: sha256=<hex>
```

where `<hex> = HMAC-SHA256(secret, "<timestamp>.<raw body>")` — the same
computation as `v1`, using `X-Trillboards-Timestamp` for the timestamp.

To verify:

1. Read the raw request body **before any JSON parsing**. Sign the exact bytes received.
2. If the header contains `t=`, parse `t`/`v0`/`v1`; otherwise take the value after `sha256=`
   and read the timestamp from `X-Trillboards-Timestamp`.
3. Reject if `|now - timestamp| > 300` seconds (the default tolerance; configurable per partner).
4. Compare using a **constant-time** comparison. Prefer `v1`, fall back to `v0`.

A worked, copy-pasteable implementation lives at
`https://api.trillboards.com/docs/integrations/webhook-signature-verification.md`,
and the official client exposes `Webhooks.constructEvent(body, headers, secret)`.

## Retries (queued events only)

A delivery succeeds on any 2xx. For a **queued** event anything else — including
a timeout — is retried by the queue with **3 total attempts** and exponential
backoff starting at **60 seconds**, and each outcome is readable via
`GET /v1/partner/webhooks/{webhookId}/deliveries`.

> The audit row's `next_retry_at` is computed on a different curve
> (`5^(n-1)` minutes: 1 min, 5 min, 25 min) from the queue's own backoff. The
> queue schedule is what actually governs redelivery; treat `next_retry_at` as
> an indication, not a promise.

Deliveries are **not ordered** and may arrive more than once. Make your handler
idempotent.

## Envelope

The 22 subscribable events share `EventEnvelope`; only queued events carry its
optional `id`. The `test` delivery does **not** use the envelope at all — see
`TestEvent`. There are 23 documented deliveries in total.

- Interactive reference: https://api.trillboards.com/docs#webhooks
- OpenAPI document: https://api.trillboards.com/docs/openapi/partner-webhooks.yaml

## Events (23)

### device.online

Device online

A registered device transitioned to online.

Sent with `X-Trillboards-Event: device.online`.

Delivered **synchronously, at most once**: a failure is NOT retried and does NOT appear in your delivery history. Signed with the legacy `sha256=<hex>` header.

### device.offline

Device offline

A registered device stopped reporting and is now considered offline.

Sent with `X-Trillboards-Event: device.offline`.

Delivered **synchronously, at most once**: a failure is NOT retried and does NOT appear in your delivery history. Signed with the legacy `sha256=<hex>` header.

### device.requirements_updated

Device requirements updated

The playback or hardware requirements for a device changed.

Sent with `X-Trillboards-Event: device.requirements_updated`.

Delivered **synchronously, at most once**: a failure is NOT retried and does NOT appear in your delivery history. Signed with the legacy `sha256=<hex>` header.

### impression.recorded

Impression recorded

An impression was recorded against one of your devices.

Sent with `X-Trillboards-Event: impression.recorded`.

Delivered **synchronously, at most once**: a failure is NOT retried and does NOT appear in your delivery history. Signed with the legacy `sha256=<hex>` header.

### campaign.allocated

Campaign allocated

A campaign was allocated to one of your devices.

Sent with `X-Trillboards-Event: campaign.allocated`.

Delivered **synchronously, at most once**: a failure is NOT retried and does NOT appear in your delivery history. Signed with the legacy `sha256=<hex>` header.

### creative.rewards_ready

Creative rewards ready

Native play refresh has measured states ready for an owned creative experiment. This queued hint contains no reward rows. Follow reward_reader_url with a caller-owned idempotency key; the canonical reward page is separately metered and uses latest-state rewind and revision deduplication.

Sent with `X-Trillboards-Event: creative.rewards_ready`.

Delivered through the **queue**: retried, and recorded in your delivery history. Signed with the compound `t=,v0=,v1=` header.

### payout.processed

Payout processed

A payout was processed for your account.

Sent with `X-Trillboards-Event: payout.processed`.

Delivered **synchronously, at most once**: a failure is NOT retried and does NOT appear in your delivery history. Signed with the legacy `sha256=<hex>` header.

### audience.spike

Audience spike

A Sense Anything Route on a viewer-count signal crossed its threshold.

Sent with `X-Trillboards-Event: audience.spike`.

Delivered through the **queue**: retried, and recorded in your delivery history. Signed with the compound `t=,v0=,v1=` header.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `route_id` |  |  |  |
| `route_name` |  |  |  |
| `owner_kind` |  |  |  |
| `screen_id` |  |  |  |
| `field` |  |  |  |
| `op` |  |  |  |
| `threshold` |  |  |  |
| `observed` |  |  |  |
| `fired_at` |  |  |  |

### audience.demographics_update

Audience demographics update

Updated audience demographics are available for a screen.

Sent with `X-Trillboards-Event: audience.demographics_update`.

Delivered through the **queue**: retried, and recorded in your delivery history. Signed with the compound `t=,v0=,v1=` header.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `route_id` |  |  |  |
| `route_name` |  |  |  |
| `owner_kind` |  |  |  |
| `screen_id` |  |  |  |
| `field` |  |  |  |
| `op` |  |  |  |
| `threshold` |  |  |  |
| `observed` |  |  |  |
| `fired_at` |  |  |  |

### audience.purchase_intent

Audience purchase intent

A purchase-intent signal crossed its threshold.

Sent with `X-Trillboards-Event: audience.purchase_intent`.

Delivered through the **queue**: retried, and recorded in your delivery history. Signed with the compound `t=,v0=,v1=` header.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `route_id` |  |  |  |
| `route_name` |  |  |  |
| `owner_kind` |  |  |  |
| `screen_id` |  |  |  |
| `field` |  |  |  |
| `op` |  |  |  |
| `threshold` |  |  |  |
| `observed` |  |  |  |
| `fired_at` |  |  |  |

### audience.venue_busy

Audience venue busy

A venue-busy signal crossed its threshold.

Sent with `X-Trillboards-Event: audience.venue_busy`.

Delivered through the **queue**: retried, and recorded in your delivery history. Signed with the compound `t=,v0=,v1=` header.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `route_id` |  |  |  |
| `route_name` |  |  |  |
| `owner_kind` |  |  |  |
| `screen_id` |  |  |  |
| `field` |  |  |  |
| `op` |  |  |  |
| `threshold` |  |  |  |
| `observed` |  |  |  |
| `fired_at` |  |  |  |

### venue.traffic_summary

Venue traffic summary

A periodic venue traffic summary is available.

Sent with `X-Trillboards-Event: venue.traffic_summary`.

Delivered through the **queue**: retried, and recorded in your delivery history. Signed with the compound `t=,v0=,v1=` header.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `route_id` |  |  |  |
| `route_name` |  |  |  |
| `owner_kind` |  |  |  |
| `screen_id` |  |  |  |
| `field` |  |  |  |
| `op` |  |  |  |
| `threshold` |  |  |  |
| `observed` |  |  |  |
| `fired_at` |  |  |  |

### sensing.threshold_crossed

Sensing threshold crossed

A Sense Anything Route's threshold was crossed on one of your screens (custom sensed field).

Sent with `X-Trillboards-Event: sensing.threshold_crossed`.

Delivered through the **queue**: retried, and recorded in your delivery history. Signed with the compound `t=,v0=,v1=` header.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `route_id` |  |  |  |
| `route_name` |  |  |  |
| `owner_kind` |  |  |  |
| `screen_id` |  |  |  |
| `field` |  |  |  |
| `op` |  |  |  |
| `threshold` |  |  |  |
| `observed` |  |  |  |
| `fired_at` |  |  |  |

### screen_group.created

Screen group created

A screen group was created.

Sent with `X-Trillboards-Event: screen_group.created`.

Delivered through the **queue**: retried, and recorded in your delivery history. Signed with the compound `t=,v0=,v1=` header.

### screen_group.updated

Screen group updated

A screen group was updated.

Sent with `X-Trillboards-Event: screen_group.updated`.

Delivered through the **queue**: retried, and recorded in your delivery history. Signed with the compound `t=,v0=,v1=` header.

### screen_group.deleted

Screen group deleted

A screen group was deleted.

Sent with `X-Trillboards-Event: screen_group.deleted`.

Delivered through the **queue**: retried, and recorded in your delivery history. Signed with the compound `t=,v0=,v1=` header.

### screen_group.member.added

Screen group member added

A screen was added to a screen group.

Sent with `X-Trillboards-Event: screen_group.member.added`.

Delivered through the **queue**: retried, and recorded in your delivery history. Signed with the compound `t=,v0=,v1=` header.

### screen_group.member.removed

Screen group member removed

A screen was removed from a screen group.

Sent with `X-Trillboards-Event: screen_group.member.removed`.

Delivered through the **queue**: retried, and recorded in your delivery history. Signed with the compound `t=,v0=,v1=` header.

### screen_group.content_policy.updated

Screen group content policy updated

A screen group's content policy was updated.

Sent with `X-Trillboards-Event: screen_group.content_policy.updated`.

Delivered through the **queue**: retried, and recorded in your delivery history. Signed with the compound `t=,v0=,v1=` header.

### screen_group.sensing_config.updated

Screen group sensing config updated

A screen group's sensing configuration was updated.

Sent with `X-Trillboards-Event: screen_group.sensing_config.updated`.

Delivered through the **queue**: retried, and recorded in your delivery history. Signed with the compound `t=,v0=,v1=` header.

### fleet_command.completed

Fleet command completed

A group fleet command finished successfully.

Sent with `X-Trillboards-Event: fleet_command.completed`.

Delivered through the **queue**: retried, and recorded in your delivery history. Signed with the compound `t=,v0=,v1=` header.

### fleet_command.failed

Fleet command failed

A group fleet command failed.

Sent with `X-Trillboards-Event: fleet_command.failed`.

Delivered through the **queue**: retried, and recorded in your delivery history. Signed with the compound `t=,v0=,v1=` header.

### test

Test

A synthetic event emitted by `POST /v1/partner/webhooks/{webhookId}/test`. It is delivered through the same signing and retry path as a real event, so it is a valid end-to-end check of a subscriber. It carries no production data.

Sent with `X-Trillboards-Event: test`.

Delivered **synchronously, at most once**: a failure is NOT retried and does NOT appear in your delivery history. Signed with the legacy `sha256=<hex>` header.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `test` | `boolean` | yes |  |
| `message` | `string` | yes |  |
| `partner_id` | `string` | yes |  |
| `webhook_id` | `string` | yes |  |
