Webhooks
Forward server events from Aviato to external HTTP endpoints. Webhooks share the plugin event bus, so any event a plugin can subscribe to can be delivered as a webhook.
Purpose: Forward server events from Aviato to external HTTP endpoints. Webhooks share the plugin event bus, so any event a plugin can subscribe to can be delivered as a webhook.
Table of Contents
- Overview
- Quick Start
- Configuration
- External base URL
- Payload structure
- Event reference
- Headers
- Signature verification
- Retries
- Test fire
- Idempotency
- Delivery log
- Limits
Overview
A webhook is a (URL, event filter) pair owned by an admin. When an event matching the filter fires, Aviato POSTs a JSON payload to the URL. Webhooks are server global. They receive events for every user on this server. No per-user webhooks exist.
The webhook delivery system subscribes to the same event vocabulary as plugins (ALL_SERVER_EVENT_TYPES in packages/common/src/types/auth.ts). Adding a new event to that registry automatically makes it deliverable to webhooks. No separate wiring step is required.
flowchart LR
A[emitEvent] --> B[Event bus]
B -->|onEvent| C[Plugins via event-bridge]
B -->|onEvent| D[Webhook delivery]
D --> E[shapePayload]
E --> F[fetch + HMAC]
F --> G[(Delivery log)]
F --> R[Retry queue]
R --> FQuick Start
- Open Settings → Webhooks as an admin.
- Click Add webhook, enter a name and the receiving URL.
- (Optional) Set a signing secret. Aviato will sign each payload with HMAC SHA256.
- Pick which events to subscribe to. Leave All events on to receive every event Aviato emits, including future additions.
- Save, then click Send test event to verify connectivity.
Configuration
Webhooks are admin only. The configuration lives in the webhooks table and is exposed through the REST API at /api/webhooks. The Settings UI (Settings → Webhooks) is the supported interface; the API is documented for automation.
| Endpoint | Description |
|---|---|
GET /api/webhooks | List webhooks (secret redacted to ***) |
POST /api/webhooks | Create a webhook |
GET /api/webhooks/{id} | Get a webhook (secret redacted) |
PATCH /api/webhooks/{id} | Update; send secret: null to clear the signing secret |
DELETE /api/webhooks/{id} | Delete a webhook (also cascades the delivery log) |
GET /api/webhooks/event-types | List every event name the webhook system can subscribe to |
GET /api/webhooks/{id}/deliveries | Recent delivery attempts (most recent first) |
DELETE /api/webhooks/deliveries/purge?days=30 | Purge delivery rows older than N days |
POST /api/webhooks/{id}/test | Send a synthetic webhook.test event to the URL |
The events field on a webhook is a comma separated list of event names, or * to subscribe to everything. Unknown event names are rejected with HTTP 400.
A webhook can be temporarily disabled by setting enabled: false via PATCH. Disabled webhooks remain in the database but receive no deliveries until re-enabled.
External base URL
For webhook payloads to include absolute URLs to assets (poster images), Aviato needs to know its own public hostname. The unified resolver getExternalBaseUrl() (in packages/server/src/network/external-url.ts) consults, in order:
network.externalBaseUrlsetting. Explicit admin override.- First entry in
network.customAccessUrls. Fallback for users who already advertise multiple URLs. - Derived from TLS configuration:
https://{network.certDomain}[:network.httpsPort]whennetwork.tlsTieris anything other thannone. Tailscale and Aviato Dynamic DNS provisioncertDomainautomatically; forcustomandletsencrypttiers the admin sets it. null. No external URL is available. Asset URLs in webhook payloads will be omitted (set tonull) and webhook receivers must resolve the asset ID themselves if needed.
Configure the override in Settings → Network → External base URL.
Payload structure
Every webhook payload is a JSON object with a stable, nested shape. The structure deliberately uses nested objects (user.id, not userId) so future fields can be added without forcing receivers to re-parse flat keys.
Common envelope fields on every payload:
{
"event": "media.play",
"timestamp": "2026-05-03T12:34:56.789Z",
"server": {
"id": "<server-uuid>",
"name": "Aviato @ home"
}
// ...event-specific fields below
}Reusable sub-objects:
| Object | Fields | Present on |
|---|---|---|
user | id, username, displayName | User-triggered events |
profile | id, name | Events scoped to a user profile |
item | id, title, type, libraryId, libraryName, posterAssetId, posterUrl | Library item / playback events |
library | id, name, type | Library scan events |
player | device, ip, userAgent, decision | media.play only |
playback | position, duration (seconds, may be null) | playback.* events |
session | id, startPosition | media.play, transcode.* |
webhook | id, name | webhook.test |
posterUrl is the absolute URL to the poster (e.g. https://aviato.example.com/api/assets/<id>). It is null when no external base URL is configured. posterAssetId is always populated when a poster exists, so receivers can fetch the image themselves.
When an item has been deleted before the payload is shaped (for example library.item.removed), the shaper falls back to inline fields and may report type: "unknown" and libraryName: null rather than failing the delivery.
Example: media.play
{
"event": "media.play",
"timestamp": "2026-05-03T12:34:56.789Z",
"server": { "id": "1d6e...", "name": "Aviato @ home" },
"user": { "id": "u_123", "username": "richard", "displayName": "Richard Hendricks" },
"profile": { "id": "p_45", "name": "Richard" },
"player": {
"device": "Web",
"ip": "192.168.1.42",
"userAgent": "Mozilla/5.0 ...",
"decision": "direct_play"
},
"item": {
"id": "i_7890",
"title": "Pied Piper Pitch",
"type": "movies",
"libraryId": "lib_movies",
"libraryName": "Movies",
"posterAssetId": "a3f2c1...",
"posterUrl": "https://aviato.example.com/api/assets/a3f2c1..."
},
"session": { "id": "s_abc", "startPosition": 0 }
}Example: playback.progress
{
"event": "playback.progress",
"timestamp": "2026-05-03T12:34:56.789Z",
"server": { "id": "1d6e...", "name": "Aviato @ home" },
"user": { "id": "u_123", "username": "richard", "displayName": "Richard Hendricks" },
"profile": { "id": "p_45", "name": "Richard" },
"item": {
"id": "i_7890",
"title": "Pied Piper Pitch",
"type": "movies",
"libraryId": "lib_movies",
"libraryName": "Movies",
"posterAssetId": "a3f2c1...",
"posterUrl": "https://aviato.example.com/api/assets/a3f2c1..."
},
"playback": { "position": 612.5, "duration": 5400 }
}playback.progress is high frequency. Throttle on your side or subscribe to playback.completed instead if you only care about finished sessions.
Example: library.item.added
{
"event": "library.item.added",
"timestamp": "2026-05-03T12:34:56.789Z",
"server": { "id": "1d6e...", "name": "Aviato @ home" },
"item": {
"id": "i_7890",
"title": "Pied Piper Pitch",
"type": "movies",
"libraryId": "lib_movies",
"libraryName": "Movies",
"posterAssetId": "a3f2c1...",
"posterUrl": "https://aviato.example.com/api/assets/a3f2c1..."
},
"status": "active"
}library.item.added fires once per item, after the pipeline (probe + index) finishes. Items still being processed are signalled with library.item.ingesting instead.
Example: library.scan.completed
{
"event": "library.scan.completed",
"timestamp": "2026-05-03T12:34:56.789Z",
"server": { "id": "1d6e...", "name": "Aviato @ home" },
"library": { "id": "lib_movies", "name": "Movies", "type": "movies" },
"counts": { "files": 1287, "bundles": 412, "jobsCreated": 38, "missing": 2 }
}counts is omitted when the underlying scan event did not report progress totals.
Example: transcode.started
{
"event": "transcode.started",
"timestamp": "2026-05-03T12:34:56.789Z",
"server": { "id": "1d6e...", "name": "Aviato @ home" },
"session": { "id": "tx_9f1c..." },
"item": {
"id": "i_7890",
"title": "Pied Piper Pitch",
"type": "movies",
"libraryId": "lib_movies",
"libraryName": "Movies",
"posterAssetId": "a3f2c1...",
"posterUrl": "https://aviato.example.com/api/assets/a3f2c1..."
}
}item may be null for transcode sessions that are not bound to a library item (e.g. preview transcodes).
Example: playback.session.ended
playback.session.started, playback.session.paused, playback.session.resumed, and playback.session.ended all share the same shape — a session object alongside the standard user/profile/item. Receivers can subscribe to a single event or all four.
{
"event": "playback.session.ended",
"timestamp": "2026-05-03T12:34:56.789Z",
"server": { "id": "1d6e...", "name": "Aviato @ home" },
"user": { "id": "u_123", "username": "richard", "displayName": "Richard Hendricks" },
"profile": { "id": "p_45", "name": "Richard" },
"item": {
"id": "i_7890",
"title": "Pied Piper Pitch",
"type": "movies",
"libraryId": "lib_movies",
"libraryName": "Movies",
"posterAssetId": "a3f2c1...",
"posterUrl": "https://aviato.example.com/api/assets/a3f2c1..."
},
"session": {
"id": "s_abc123",
"status": "ended",
"source": "tracked",
"position": 7140,
"startPosition": 0,
"endPosition": 7140,
"duration": 7200,
"secondsWatched": 7140,
"completed": true
}
}session.status is 'active' | 'ended' | 'abandoned'. session.source is 'tracked' | 'manual' | 'imported' — manual mark-watched produces a synthetic session with source: 'manual' and secondsWatched == duration, so dashboards can filter that source out via the toggle. position always tracks the latest known progress (== startPosition on session.started, == current position on paused/resumed, == endPosition on session.ended).
Example: playback.unwatched
playback.unwatched is shaped identically to media.read (no session payload — the action just clears the resume position):
{
"event": "playback.unwatched",
"timestamp": "2026-05-03T12:34:56.789Z",
"server": { "id": "1d6e...", "name": "Aviato @ home" },
"user": { "id": "u_123", "username": "richard", "displayName": "Richard Hendricks" },
"profile": { "id": "p_45", "name": "Richard" },
"item": { "id": "i_7890", "title": "Pied Piper Pitch", "type": "movies", "libraryId": "lib_movies", "libraryName": "Movies", "posterAssetId": "a3f2c1...", "posterUrl": "https://aviato.example.com/api/assets/a3f2c1..." }
}Default shape
Events Aviato hasn't shaped yet pass through with a generic envelope:
{
"event": "scheduler.started",
"timestamp": "2026-05-03T12:34:56.789Z",
"server": { "id": "1d6e...", "name": "Aviato @ home" },
"data": { "...": "raw event data" }
}This guarantees receivers always get something useful even for events with no bespoke shaper. The fields under data mirror the event's internal shape exactly and are not version stable. Treat them as best effort until a dedicated shaper lands.
Event reference
The full list of subscribable events is returned by GET /api/webhooks/event-types. The most commonly subscribed events:
| Event | Fires when |
|---|---|
media.play | A playback session begins (transcode or direct play, non-books libraries) |
media.read | A reader session begins (libraries with mediaType books) |
playback.progress | Player reports a position update (high frequency) |
playback.completed | Item crossed the 90% watched threshold or was manually marked watched |
playback.removed | Playback state was deleted |
playback.unwatched | An item was manually marked unwatched (resume position cleared, history kept) |
playback.session.started | A new analytics watch-through session was created (per item watch instance) |
playback.session.paused | The viewer paused playback within an active session |
playback.session.resumed | The viewer resumed playback (also fires on the first play of a session) |
playback.session.ended | An active session was finalized — fires only on the active→ended transition, not on idempotent retries |
transcode.started | A transcode session was created |
transcode.progress | Periodic progress update from an active transcode |
transcode.stopped | A transcode session ended (explicit stop or stale cleanup) |
library.item.ingesting | A new bundle entered the pipeline (status pending) |
library.item.added | A new item completed the pipeline successfully |
library.item.updated | An item's metadata changed (re-index, edit) |
library.item.enriched | An indexer plugin attached additional metadata to an item |
library.item.removed | An item was deleted |
library.scan.started | A library scan started |
library.scan.progress | Scanner reports counts during a scan |
library.scan.completed | A library scan finished |
plugin.started / stopped / error | Plugin lifecycle events |
webhook.test | Synthetic event triggered by the Send test event button |
Two events are present in the registry but are not delivered as webhooks:
webhook.testis server internal. The test fire endpoint calls the delivery path directly so a synthetic test never fans out to other webhooks subscribed via*.server.shutdownis excluded so the server is not still running a 5 minute retry loop while the process is tearing down.
The complete vocabulary lives in packages/common/src/types/auth.ts (ALL_SERVER_EVENT_TYPES).
Headers
Aviato sends these headers with every delivery:
| Header | Value |
|---|---|
Content-Type | application/json |
User-Agent | Aviato-Webhook/0.1 |
X-Aviato-Event | The event name, e.g. media.play |
X-Aviato-Delivery | A unique delivery id (UUID without dashes) |
X-Aviato-Attempt | Attempt number (1, 2, or 3) |
X-Aviato-Signature | sha256=<hex>. Only present when a secret is set. |
X-Aviato-Delivery is regenerated for every attempt, so attempts 1, 2, and 3 of the same logical event each have a different delivery id. Use the event name plus a stable identifier from the body (such as item.id and timestamp) if you need to correlate retries.
Signature verification
When you configure a signing secret, every payload includes X-Aviato-Signature: sha256=<hex> where <hex> is HMAC-SHA256(secret, raw_request_body).
Always verify against the raw bytes of the request body. Re-serializing parsed JSON will not produce the same string.
// Node.js
import { createHmac, timingSafeEqual } from 'node:crypto'
function verify (secret, headerSignature, rawBody) {
if (!headerSignature?.startsWith('sha256=')) return false
const provided = Buffer.from(headerSignature.slice('sha256='.length), 'hex')
const expected = createHmac('sha256', secret).update(rawBody).digest()
return provided.length === expected.length && timingSafeEqual(provided, expected)
}# Python
import hashlib
import hmac
def verify(secret: str, header_signature: str, raw_body: bytes) -> bool:
if not header_signature.startswith("sha256="):
return False
provided = bytes.fromhex(header_signature[len("sha256="):])
expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).digest()
return hmac.compare_digest(provided, expected)// Go
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"strings"
)
func verify(secret string, headerSig string, rawBody []byte) bool {
if !strings.HasPrefix(headerSig, "sha256=") {
return false
}
provided, err := hex.DecodeString(strings.TrimPrefix(headerSig, "sha256="))
if err != nil {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(rawBody)
return hmac.Equal(provided, mac.Sum(nil))
}If you sit behind a proxy that rewrites bodies (re-formatting JSON, normalising whitespace, gzipping then decompressing), the signature will fail. Terminate the webhook on a path that preserves bytes verbatim.
Retries
A failed delivery (network error, timeout, or non 2xx response) is retried up to two more times.
| Attempt | Backoff |
|---|---|
| 1 | Immediate |
| 2 | 30 seconds |
| 3 | 5 minutes |
Each attempt writes its own row to the delivery log with a monotonic attempt value, so the log is a complete audit trail. Retry timers live in process. If the server restarts between attempts, pending retries are dropped, but the permanent record of every attempt that did run is preserved.
The HTTP request timeout is 10 seconds. Receivers should respond promptly with 2xx and process work asynchronously if it might take longer.
There is no circuit breaker. A webhook URL that returns 500 forever will continue to be retried (up to 3 attempts per event) for every matching event. If you need to disable a misbehaving receiver, PATCH it with enabled: false until you have fixed the endpoint.
Test fire
POST /api/webhooks/{id}/test (and the Send test event button in the UI) sends a synthetic webhook.test event with the receiving webhook's id and name as the payload's webhook object:
{
"event": "webhook.test",
"timestamp": "2026-05-03T12:34:56.789Z",
"server": { "id": "...", "name": "Aviato @ home" },
"webhook": { "id": "<webhook-id>", "name": "<webhook-name>" }
}It uses the same delivery pipeline as a real event. The delivery log, retries, and signature all behave identically. The first attempt is awaited so the API caller and the UI see a real success or failure result; if attempt 1 fails, attempts 2 and 3 continue in the background. Delivered only to the webhook you fired it from, never to other webhooks subscribed to * or webhook.test.
Idempotency
Aviato does not deduplicate retries. If attempt 1 succeeded but the response was lost in transit, attempt 2 will fire and your receiver will see the event twice. Use the X-Aviato-Delivery header (unique per attempt) and/or the event plus item.id plus timestamp combination to dedupe on your side if exactly once semantics matter.
The webhook.test event reuses the same delivery infrastructure but is generated each time the button is pressed. There is no idempotency promise.
Delivery log
Every delivery attempt, success or failure, is recorded in the webhook_deliveries table and surfaced in the UI. Columns:
| Field | Notes |
|---|---|
id | Delivery id (matches X-Aviato-Delivery) |
webhookId | Webhook this delivery belongs to |
eventType | Event name |
payload | Full JSON payload that was sent |
statusCode | HTTP status from the receiver (null on network error) |
responseBody | First 1024 bytes of the response |
durationMs | Wall time of the request |
success | true when the receiver returned 2xx |
attempt | 1, 2, or 3 |
createdAt | When this attempt was recorded |
Old rows are purged automatically by the webhook-deliveries-cleanup scheduler task once per day (configurable via webhooks.deliveryRetentionDays and webhooks.deliveryCleanupInterval in config.yml, or the AVIATO_WEBHOOKS_DELIVERY_RETENTION_DAYS and AVIATO_WEBHOOKS_DELIVERY_CLEANUP_INTERVAL env vars; defaults to 30 days). Admins can also force a purge via DELETE /api/webhooks/deliveries/purge?days=N.
Limits
- Request timeout: 10 seconds.
- Maximum response body recorded: 1024 bytes (truncated).
- Maximum attempts per event: 3.
- Receivers should accept
Content-Type: application/jsonand respond with 2xx within the timeout. - Webhook delivery is fire and forget from the server's perspective. Slow receivers do not back pressure event emission, but a slow receiver will tie up its own delivery worker for the full timeout on each attempt.
Media Scan Plugins
How a plugin participates in the post-ingestion media scan pipeline, returns chapters or other typed outputs, and uses the shared fingerprint cache to short-circuit expensive re-analysis.
API Overview
Authenticate with an API key, hit the OpenAPI described REST surface, and use the interactive Scalar reference to explore endpoints.