AviatoAviato
Developer

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

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 --> F

Quick Start

  1. Open Settings → Webhooks as an admin.
  2. Click Add webhook, enter a name and the receiving URL.
  3. (Optional) Set a signing secret. Aviato will sign each payload with HMAC SHA256.
  4. Pick which events to subscribe to. Leave All events on to receive every event Aviato emits, including future additions.
  5. 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.

EndpointDescription
GET /api/webhooksList webhooks (secret redacted to ***)
POST /api/webhooksCreate 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-typesList every event name the webhook system can subscribe to
GET /api/webhooks/{id}/deliveriesRecent delivery attempts (most recent first)
DELETE /api/webhooks/deliveries/purge?days=30Purge delivery rows older than N days
POST /api/webhooks/{id}/testSend 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:

  1. network.externalBaseUrl setting. Explicit admin override.
  2. First entry in network.customAccessUrls. Fallback for users who already advertise multiple URLs.
  3. Derived from TLS configuration: https://{network.certDomain}[:network.httpsPort] when network.tlsTier is anything other than none. Tailscale and Aviato Dynamic DNS provision certDomain automatically; for custom and letsencrypt tiers the admin sets it.
  4. null. No external URL is available. Asset URLs in webhook payloads will be omitted (set to null) 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:

ObjectFieldsPresent on
userid, username, displayNameUser-triggered events
profileid, nameEvents scoped to a user profile
itemid, title, type, libraryId, libraryName, posterAssetId, posterUrlLibrary item / playback events
libraryid, name, typeLibrary scan events
playerdevice, ip, userAgent, decisionmedia.play only
playbackposition, duration (seconds, may be null)playback.* events
sessionid, startPositionmedia.play, transcode.*
webhookid, namewebhook.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:

EventFires when
media.playA playback session begins (transcode or direct play, non-books libraries)
media.readA reader session begins (libraries with mediaType books)
playback.progressPlayer reports a position update (high frequency)
playback.completedItem crossed the 90% watched threshold or was manually marked watched
playback.removedPlayback state was deleted
playback.unwatchedAn item was manually marked unwatched (resume position cleared, history kept)
playback.session.startedA new analytics watch-through session was created (per item watch instance)
playback.session.pausedThe viewer paused playback within an active session
playback.session.resumedThe viewer resumed playback (also fires on the first play of a session)
playback.session.endedAn active session was finalized — fires only on the active→ended transition, not on idempotent retries
transcode.startedA transcode session was created
transcode.progressPeriodic progress update from an active transcode
transcode.stoppedA transcode session ended (explicit stop or stale cleanup)
library.item.ingestingA new bundle entered the pipeline (status pending)
library.item.addedA new item completed the pipeline successfully
library.item.updatedAn item's metadata changed (re-index, edit)
library.item.enrichedAn indexer plugin attached additional metadata to an item
library.item.removedAn item was deleted
library.scan.startedA library scan started
library.scan.progressScanner reports counts during a scan
library.scan.completedA library scan finished
plugin.started / stopped / errorPlugin lifecycle events
webhook.testSynthetic event triggered by the Send test event button

Two events are present in the registry but are not delivered as webhooks:

  • webhook.test is server internal. The test fire endpoint calls the delivery path directly so a synthetic test never fans out to other webhooks subscribed via *.
  • server.shutdown is 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:

HeaderValue
Content-Typeapplication/json
User-AgentAviato-Webhook/0.1
X-Aviato-EventThe event name, e.g. media.play
X-Aviato-DeliveryA unique delivery id (UUID without dashes)
X-Aviato-AttemptAttempt number (1, 2, or 3)
X-Aviato-Signaturesha256=<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.

AttemptBackoff
1Immediate
230 seconds
35 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:

FieldNotes
idDelivery id (matches X-Aviato-Delivery)
webhookIdWebhook this delivery belongs to
eventTypeEvent name
payloadFull JSON payload that was sent
statusCodeHTTP status from the receiver (null on network error)
responseBodyFirst 1024 bytes of the response
durationMsWall time of the request
successtrue when the receiver returned 2xx
attempt1, 2, or 3
createdAtWhen 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/json and 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.

On this page