Skip to main content

Plugin Events

Plugins react to things happening in Owncast by defining a handler for each event they care about. Only define the handlers you want: a missing handler means no subscription, and the SDK derives the manifest's subscription list from which handlers are present, so there's nothing else to keep in sync.

Code below is shown for both SDKs. Pick your language with the tabs, and your choice follows you across the docs. New to this? See the JavaScript or Python setup pages first.

const { definePlugin, owncast, filter } = require('@owncast/plugin-sdk');

module.exports = definePlugin({
onChatMessage(msg) {
/* react to a chat message */
},
onStreamStarted(info) {
/* react to the stream going live */
},
});

Handlers are methods on the object you pass to definePlugin, named in camelCase (onChatMessage, onStreamStarted, …). Payload fields are camelCase too (msg.user.displayName, msg.clientId).

Payloads are shown as their wire shape. Each SDK exposes the fields idiomatically: the JavaScript SDK as-is, the Python SDK as snake_case attributes over the same JSON (with the raw dict available too).

Chat events

Building a chat-focused plugin? Chat plugins is a friendlier starting point.

Chat message: chat.message.received

Fires once per chat message after filters have run and the message is being broadcast to viewers.

interface ChatMessage {
id: string;
user?: User; // full sender identity (see User below); absent for the rare message with no account
clientId?: number; // originating connection; pass to the chat send-to / reply-to APIs for private replies
body: string; // raw text, not HTML-rendered markup
timestamp: string; // RFC3339Nano / ISO-8601, e.g. "2026-05-28T14:00:00.123456789Z"
}
module.exports = definePlugin({
onChatMessage(msg) {
if (msg.user?.scopes?.includes('MODERATOR')) {
owncast.chat.send(`hi mod ${msg.user.displayName}`);
}
},
});

user carries the full sender identity, so key per-user state on the stable user.id and gate moderator-only behavior on user.scopes (e.g. "MODERATOR") rather than matching the display name. To reply privately to the sender, use the chat reply-to API (see Owncast APIs).

timestamp is the host's wall-clock time for the message. The sandbox clock works, but timestamp is deterministic and the right choice when comparing elapsed time across events or asserting in tests.

No permission required to subscribe.

Older hosts delivered user as a plain display-name string rather than the identity object. If you support hosts that predate the identity payload, read it defensively. See your SDK page for the idiom.

Chat user joined / parted: chat.user.joined, chat.user.parted

Fires when a chat user connects or disconnects.

interface User {
id: string;
displayName: string;
displayColor: number; // index into the instance's user-color palette, not a literal color
previousNames?: string[];
createdAt?: string; // ISO-8601
disabledAt?: string; // ISO-8601 if banned, omitted otherwise
isBot?: boolean;
isAuthenticated?: boolean;
scopes?: string[];
}
module.exports = definePlugin({
onChatUserJoined(user) {
owncast.chat.send(`welcome ${user.displayName}`);
},
onChatUserParted(user) {
/* … */
},
});

No permission required.

Chat user renamed: chat.user.renamed

Fires when a chat user changes their display name.

interface { user: User; previousName: string }

No permission required.

Message moderated: chat.message.moderated

Fires when a moderator hides or unhides a chat message.

interface { messageId: string; visible: boolean; moderator?: User }

No permission required.

Stream lifecycle

Stream started: stream.started

Fires when a broadcast begins.

interface { startedAt?: string; title?: string; summary?: string }
module.exports = definePlugin({
onStreamStarted(info) {
owncast.chat.send(`live now: ${info.title}`);
},
onStreamStopped(info) {
/* … */
},
onStreamTitleChanged(change) {
/* change.to */
},
});

No permission required.

Stream stopped: stream.stopped

Fires when a broadcast ends.

interface { stoppedAt?: string }

No permission required.

Stream title changed: stream.title.changed

Fires when the streamer updates the title mid-stream.

interface { from: string; to: string }

from is currently always empty: Owncast's title-changed event carries only the new title.

No permission required.

Fediverse events

Owncast exposes internal plugin event subscriptions for inbound Fediverse activity. These are plugin events, not external HTTP webhooks. Every subscription in this section requires the fediverse.inbound permission.

EventJavaScript handlerPython handlerPayload
fediverse.followonFediverseFollow@plugin.on_fediverse_follow{ actor }
fediverse.likeonFediverseLike@plugin.on_fediverse_like{ actor, target }
fediverse.repostonFediverseRepost@plugin.on_fediverse_repost{ actor, target }
fediverse.quoteonFediverseQuote@plugin.on_fediverse_quoteFediverseQuote
fediverse.mentiononFediverseMention@plugin.on_fediverse_mentionFediverseInboundPost
fediverse.replyonFediverseReply@plugin.on_fediverse_replyFediverseInboundPost
fediverse.activityonFediverse@plugin.on_fediverseRaw ActivityPub JSON object

Follow, like, repost, and quote

interface FediverseActor {
name: string;
handle: string;
url?: string;
image?: string;
}

interface FediverseEngagement {
actor: FediverseActor;
target?: { url: string };
}

interface FediverseQuote extends FediverseEngagement {
target: { url: string }; // locally authored post being quoted
content?: string; // rendered HTML from the source instance
contentText?: string; // plain-text version
url: string; // remote quote post permalink
postedAt?: string; // ISO-8601
inReplyTo?: string;
attachments?: { url: string; mediaType: string; alt?: string }[];
language?: string;
}

A follow contains only actor. Likes and reposts also contain target.

A quote contains target for the locally authored post and url for the remote quote post. Content metadata is included when the requesting server embeds its quote Note in the QuoteRequest. Some servers send only the quote post IRI, so content, contentText, postedAt, inReplyTo, attachments, and language are optional.

module.exports = definePlugin({
onFediverseFollow(event) {
owncast.chat.send(`new follower: ${event.actor.handle}`);
},
onFediverseQuote(event) {
console.log(`${event.actor.handle}: ${event.contentText ?? 'quoted your post'}`);
console.log(`quote: ${event.url}`);
},
});

actor.handle is the fully qualified address, such as @alice@fediverse.example. The follow examples also call owncast.chat.send, which separately requires chat.send:

{ "permissions": ["fediverse.inbound", "chat.send"] }

Mention and reply

Both receive a FediverseInboundPost:

interface FediverseInboundPost {
actor: FediverseActor;
content: string; // rendered HTML from the source instance
contentText: string; // plain-text version, usually what you want
url: string; // permalink on the source instance
postedAt: string; // ISO-8601
inReplyTo?: string; // parent post URL, set when this is a reply
attachments?: { url: string; mediaType: string; alt?: string }[];
language?: string;
}

These specialized hooks accept a verified Create activity containing exactly one Note. The note must be attributed to the activity actor. A mention must address the local Owncast actor. A reply must reference a post stored by the local Owncast instance.

Use contentText for analysis or to echo into chat. Use content only when you need the original formatting, and sanitize it before rendering.

Raw inbound activity

fediverse.activity receives the verified inbound ActivityPub activity as its raw JSON object. Owncast sends it after the HTTP signature passes verification and the activity actor's origin matches the signing key owner's origin.

The catch-all runs in addition to a specialized handler. For example, an accepted quote can invoke both onFediverseQuote and onFediverse.

module.exports = definePlugin({
onFediverse(activity) {
if (typeof activity.type === 'string') {
console.log(`inbound activity: ${activity.type}`);
}
},
});

Signature and actor-origin verification establish where the activity came from. They do not make its fields safe. Treat the raw object as untrusted plugin input. Check field types and required values, sanitize content before rendering it, and validate URLs before fetching them.

Filter chain

Filters see chat messages before they're broadcast, with the ability to rewrite or drop them. They run sequentially in priority order (lowest first), and any one filter can short-circuit the chain: a drop ends it, while a modify passes the new payload to the next filter.

Chat message filter: chat.message.received (filter)

A filter handler receives the same ChatMessage shape as the chat-message event and returns one of three results:

  • pass: let the message through unchanged.
  • modify: replace the message with a new payload, which flows to the next filter.
  • drop: block the message with a reason. The chain stops here.
module.exports = definePlugin({
filterChatMessage(msg) {
if (msg.body.includes('spam')) return filter.drop('spam');
if (msg.body.includes('damn'))
return filter.modify({ ...msg, body: msg.body.replace('damn', '****') });
return filter.pass();
},
});

Requires the chat.filter permission. Reading or rewriting every chat message is a meaningful side-effect, so the admin has to see the permission to grant it. The host rejects the load if a plugin defines the filter handler without declaring the permission.

Filter priority (optional)

Each filter can declare a priority. Lower numbers run earlier (default 100). Use this when your plugin's behavior depends on whether other filters have already run (for example, a profanity filter should usually run before a translator). See your SDK page for where to set it.

Filter safety

  • Errors are treated as a pass. A throwing filter never blocks chat. The chain continues with the original message.
  • Filters are time-capped at 50 ms. A slow filter is cancelled and treated as pass.
  • After 5 consecutive failures (errors or timeouts) the plugin is auto-disabled for the rest of the session, with a one-time log line. A successful filter call resets the counter, so transient flakiness doesn't accumulate. Restart the host to re-enable.

Command tables

Declare a command table for aliases, cooldowns, moderator gating, parsed arguments, and automatic !help listings. Gating uses the sender identity (user.scopes, user.id), not a display-name guess.

module.exports = definePlugin({
commands: {
uptime: { description: "How long we've been live", run: ctx => ctx.reply('a while!') },
},
});

See Chat commands for the full command-table reference (aliases, cooldowns, mod-only gating, !help).

HTTP handler

HTTP request

Fires for every request to /plugins/<your-slug>/* that didn't match a static file in public/. Returns a response object.

interface IncomingHttpRequest {
method: string;
path: string; // relative to /plugins/<your-slug>/
query: Record<string, string>;
headers: Record<string, string>;
body: string;
remoteAddr: string;
authenticated: boolean; // came from any authenticated Owncast session, admin or viewer
user?: { id: string; displayName: string; scopes: string[] }; // user-token requests only
}

interface OutgoingHttpResponse {
status?: number; // default 200
headers?: Record<string, string>;
body?: string;
}
module.exports = definePlugin({
onHttpRequest(req) {
if (req.path === '/status') return { status: 200, body: '{"ok":true}' };
return { status: 404 };
},
});

Endpoints are public by default. Gate admin features on req.authenticated. Paths matching a key in admin.pages are auth-gated by the host before your handler runs, so for those routes you don't need to check.

Requires the http.serve permission. The JavaScript SDK exposes a single onHttpRequest catch-all. The Python SDK adds declarative per-path/per-method routes (@plugin.get, @plugin.route, …). See Serving HTTP for the full request model.

Authentication

Auth check hook

Only fires for the enabled auth.gate plugin, and only on a viewer's / page load, never on the hot path (video segments, the API, chat). By the time it runs, the host has already verified the viewer's session cookie and resolved their identity. Your handler decides whether that session should continue. It's optional: omit it and a valid cookie is enough until it expires.

Return one of three verdicts via the authCheck helper:

  • ok: keep the session as-is.
  • refresh: keep it and re-issue the cookie, optionally with a new ttl in seconds (sliding expiry).
  • deny: end the session and bounce the viewer back to the login screen. This is how you revoke access (a user deleted or banned upstream).
interface AuthCheckRequest {
user: {
id: string;
displayName: string;
scopes?: string[];
isAuthenticated?: boolean;
};
}

type AuthCheckResult =
{ action: 'ok' } | { action: 'refresh'; ttl?: number } | { action: 'deny'; reason?: string };
const { definePlugin, owncast, authCheck } = require('@owncast/plugin-sdk');

module.exports = definePlugin({
onAuthCheck(req) {
if (owncast.kv.get(`banned:${req.user.id}`)) {
return authCheck.deny('access revoked');
}
return authCheck.ok();
},
});

Requires auth.gate, and it fails closed: if the handler errors or times out, the host treats that page load as a deny. Because the check runs only on /, a viewer whose access you revoke keeps any open tab working until they reload or the cookie expires. The session ttl is the hard backstop.

Content handlers

These two handlers let a plugin generate tab or extra-page HTML at request time. Use them when content should be personalised per viewer or depend on live stream data. They're the dynamic counterpart to shipping a static HTML file via a tab value's content member or manifest.extraPageContent.content.

Both handlers receive a ContentRequest:

interface ContentRequest {
slug: string; // manifest.tabs object key or manifest.extraPageContent.slug
user?: User; // viewer's chat identity: present when authenticated, absent for anonymous viewers
}

Return the full HTML string for the content block. If you don't recognise the slug, return an empty string.

module.exports = definePlugin({
onTabContent(ctx) {
if (ctx.slug === 'stats') {
return `<h1>Live stats for ${ctx.user?.displayName ?? 'viewer'}</h1>`;
}
return '';
},
onPageContent(ctx) {
return ctx.slug === 'banner' ? '<p>Welcome!</p>' : '';
},
});

Tab content

Called when a value in the manifest.tabs object has no static content file. The host passes that value's object key as slug, so a single plugin can serve multiple tabs. No permission required to subscribe. Whatever Owncast APIs you call from inside the handler require their usual permissions.

Page content

Called when manifest.extraPageContent has no static content file. The host passes the slug from the manifest so the handler knows which content slot is being requested. Same permission rules as tab content.

See Contributing UI for the manifest side.

SSE connection events

When a browser opens or closes one of your plugin's /plugins/<name>/_sse/<channel> streams, Owncast fires sse.connect and sse.disconnect. Use them to track who is connected, for example to keep a live count for an overlay. See Realtime updates for the push side that sends data to those browsers.

Connect / disconnect: sse.connect, sse.disconnect

interface SSEConnectionEvent {
channel: string; // which _sse/<channel> stream the browser opened
connectionId: number; // unique per connection for the life of the host process
user?: User; // present only when the connection carried a chat identity
}
module.exports = definePlugin({
onSseConnect(e) {
/* e.connectionId, e.channel */
},
onSseDisconnect(e) {
/* same connectionId as the matching connect */
},
});

connectionId is stable for the life of a connection, so you can pair a disconnect with its matching connect and count the same viewer across several tabs. Both handlers require the http.sse permission.

Tick

Owncast dispatches a tick event about once a second to any plugin that defines a tick handler. Use it for periodic work such as flushing counters or refreshing cached data. Defining the handler is what opts you in, so plugins that leave it out pay nothing.

Periodic tick: tick

interface TickEvent {
now: number; // host wall-clock time in unix milliseconds when the tick fired
}
module.exports = definePlugin({
onTick(e) {
/* e.now */
},
});

For one-off or custom-interval scheduling, use timers (owncast.timer.setTimeout and setInterval) instead of the tick. No permission required.

Plugin-to-plugin events

Plugins can compose by emitting and subscribing to arbitrary custom events. Subscribing to a custom event requires no permission. To emit, declare events.emit. Event names are arbitrary strings. Namespacing with your plugin name (for example "my-plugin.thing-happened") avoids collisions.

module.exports = definePlugin({
on: {
'other-plugin.milestone'(payload) {
/* react */
},
},
onStreamStarted() {
owncast.events.emit('my-plugin.went-live', { at: Date.now() });
},
});

See Owncast APIs for the emit API.

Complete handler reference

Each row is a runtime event. The handler name follows your SDK's convention: camelCase methods (onChatMessage) in JavaScript, @plugin.* decorators (@plugin.on_chat_message) in Python.

EventPayloadPermission to subscribe
chat.message.receivedChatMessagenone
chat.user.joinedUsernone
chat.user.partedUsernone
chat.user.renamed{ user, previousName }none
chat.message.moderated{ messageId, visible, moderator}none
stream.started{ startedAt, title, summary }none
stream.stopped{ stoppedAt }none
stream.title.changed{ from, to }none
fediverse.follow{ actor }fediverse.inbound
fediverse.like{ actor, target }fediverse.inbound
fediverse.repost{ actor, target }fediverse.inbound
fediverse.quoteFediverseQuotefediverse.inbound
fediverse.mentionFediverseInboundPostfediverse.inbound
fediverse.replyFediverseInboundPostfediverse.inbound
fediverse.activityRaw ActivityPub JSON objectfediverse.inbound
chat message filterChatMessagechat.filter
HTTP requestIncomingHttpRequesthttp.serve
auth checkAuthCheckRequestauth.gate
sse.connectSSEConnectionEventhttp.sse
sse.disconnectSSEConnectionEventhttp.sse
tick{ now }none
tab contentContentRequestnone to subscribe. Whatever APIs the handler calls
page contentContentRequestnone to subscribe. Whatever APIs the handler calls
custom events(per-event)none to subscribe, events.emit to emit

Subscribing to ungated hooks and custom events requires no permission. Gated hooks require the permission listed in the table. Calling Owncast APIs from inside a handler requires the API's permission too. See Owncast APIs for the catalog of methods and what each one grants.


Improve this page

See something missing or incorrect? Edit this page and improve the documentation for everyone.

Contributors to this documentation

Related Documents