Webhooks
Get a signed event pushed to your endpoint when someone starts or stops watching.
Airwave can push a signed event to any HTTPS endpoint the moment something happens, so an integration reacts instantly instead of polling. Deliveries use the Standard Webhooks scheme, so you can verify them with an off-the-shelf library in most languages.
Add a webhook
In the admin, go to Settings → Webhooks → New webhook:
- Enter the delivery URL (where Airwave POSTs events).
- Tick the events you want, or leave them all unchecked to receive every event.
- Save. The signing secret (
whsec_…) is shown once, right then. Copy it now, you can't see it again (you can rotate it later).
You can also manage webhooks over the API (/api/public/v1/webhooks) with an admin API
key, which is how an integration like n8n registers its own endpoint.
Events
| Event | When |
|---|---|
session.started | A viewer starts watching a channel |
session.ended | A viewer stops watching (also fires when a session goes stale, e.g. the client crashed or dropped off the network) |
channel.tuned | A viewer changes channel without leaving the player |
playback.started | Playback begins, the first play of a session (or content resuming from an idle/loading state) |
playback.paused | The viewer pauses |
playback.resumed | The viewer resumes after a pause |
playback.stopped | Playback ends together with the session |
ping | A test event you trigger from the webhook's Send test button |
The playback.* events fire only for clients that report their real play/pause state (the Airwave TV apps do).
An older or third-party client that only heartbeats a channel still produces session.started / session.ended,
just without the playback.* detail, so nothing breaks if a client doesn't send it.
How a channel change surfaces depends on the client. The apps that keep the player open across channels (the
desktop, mobile, and Roku clients) emit a single channel.tuned. The browser/TV-web client tears the player
down and rebuilds it per channel, so there it arrives as a session.ended followed by a session.started.
The payload
Every delivery is a POST with a JSON envelope. Session and playback events carry the same rich data the
"Now Watching" view has, so you can react straight off the payload without a follow-up call:
{
"id": "evt_2a9f…",
"type": "playback.paused",
"timestamp": "2026-09-28T18:04:11.000Z",
"data": {
"userId": "clx9user01",
"deviceId": "roku-3f9c1a2b7d40",
"user": "James",
"channel": { "id": "clx9ch01", "number": 101, "name": "90s Sitcoms", "callsign": "SITCOM" },
"program": {
"ratingKey": "45210",
"title": "The One with the Blackout",
"showTitle": "Friends",
"season": 1,
"episode": 7,
"year": 1994
},
"playbackState": "paused",
"delaySeconds": 42,
"progress": { "positionSeconds": 610, "durationSeconds": 1380 }
}
}id is unique per event (also sent as the webhook-id header) so you can dedupe. type is the dotted event
name. In data:
deviceId— the specific device this session is on (see Per-device sessions).channel— id, number, name, and callsign.program— the current program, withshowTitle/season/episodefor episodic content (movies leave thosenull).nullif nothing is scheduled.playbackState—playing,paused,buffering, oridle.nullfrom a client that doesn't report it.delaySeconds— how far behind the live edge the viewer is (0 at the live edge, higher when paused or rewound).progress—positionSecondsinto a program ofdurationSeconds.nullwhen there's no resolvable program.
Fields resolve on a best-effort basis: anything Airwave can't determine comes through as null rather than
being omitted, so your parser can rely on the shape.
Per-device sessions
Sessions are keyed per (user, device), so the same account watching two different things on two TVs is two
sessions, each with its own deviceId and its own events. The deviceId is the stable id the client already
uses (the same one the capability system keys on). A client that doesn't send a deviceId collapses to a single
"legacy" session for that user, unchanged from older behavior.
Verify the signature
Each POST carries three headers:
webhook-id: evt_2a9f…
webhook-timestamp: 1790000651
webhook-signature: v1,g0hM9SsE…The signature is v1, plus the base64 HMAC-SHA256 of {id}.{timestamp}.{body}, keyed by the secret's bytes
(the part after whsec_, base64-decoded). Verify with a Standard Webhooks library:
# pip install standardwebhooks
from standardwebhooks import Webhook
wh = Webhook("whsec_your_secret")
payload = wh.verify(request_body_bytes, dict(request.headers)) # raises on a bad signatureReject anything that fails verification, and optionally reject a webhook-timestamp more than a few minutes
old to prevent replay.
Home Assistant
Create an automation with a webhook trigger, which gives you a URL like
https://ha.example.com/api/webhook/airwave-xyz. Add that URL as an Airwave webhook subscribed to
session.started and session.ended, then act on the payload:
# automation: dim the lights when someone starts watching
automation:
- alias: Airwave – lights down on watch
trigger:
- platform: webhook
webhook_id: airwave-xyz
allowed_methods: [POST]
local_only: false
condition:
- "{{ trigger.json.type == 'session.started' }}"
action:
- service: light.turn_off
target: { entity_id: light.living_room }Delivery attempts (and retries) show up per endpoint under Settings → Webhooks, where you can also send a test event or redeliver a past one.
