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.
- JavaScript
- Python
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).
from owncast_plugin import plugin, owncast, filter
@plugin.on_chat_message
def handle_chat(msg):
# react to a chat message
...
@plugin.on_stream_started
def handle_live(info):
# react to the stream going live
...
Handlers are decorated functions (@plugin.on_chat_message, @plugin.on_stream_started, …). Payload fields are snake_case (msg.user.display_name, msg.client_id). Use msg.raw for the underlying dict.
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"
}
- JavaScript
- Python
module.exports = definePlugin({
onChatMessage(msg) {
if (msg.user?.scopes?.includes('MODERATOR')) {
owncast.chat.send(`hi mod ${msg.user.displayName}`);
}
},
});
@plugin.on_chat_message
def greet_mods(msg):
if msg.user and "MODERATOR" in (msg.user.scopes or []):
owncast.chat.send(f"hi mod {msg.user.display_name}")
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
useras 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[];
}
- JavaScript
- Python
module.exports = definePlugin({
onChatUserJoined(user) {
owncast.chat.send(`welcome ${user.displayName}`);
},
onChatUserParted(user) {
/* … */
},
});
@plugin.on_chat_user_joined
def welcome(user):
owncast.chat.send(f"welcome {user.display_name}")
@plugin.on_chat_user_parted
def farewell(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 }
- JavaScript
- Python
module.exports = definePlugin({
onStreamStarted(info) {
owncast.chat.send(`live now: ${info.title}`);
},
onStreamStopped(info) {
/* … */
},
onStreamTitleChanged(change) {
/* change.to */
},
});
@plugin.on_stream_started
def announce(info):
owncast.chat.send(f"live now: {info.title}")
@plugin.on_stream_stopped
def wrap_up(info):
...
@plugin.on_stream_title_changed
def retitle(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.
| Event | JavaScript handler | Python handler | Payload |
|---|---|---|---|
fediverse.follow | onFediverseFollow | @plugin.on_fediverse_follow | { actor } |
fediverse.like | onFediverseLike | @plugin.on_fediverse_like | { actor, target } |
fediverse.repost | onFediverseRepost | @plugin.on_fediverse_repost | { actor, target } |
fediverse.quote | onFediverseQuote | @plugin.on_fediverse_quote | FediverseQuote |
fediverse.mention | onFediverseMention | @plugin.on_fediverse_mention | FediverseInboundPost |
fediverse.reply | onFediverseReply | @plugin.on_fediverse_reply | FediverseInboundPost |
fediverse.activity | onFediverse | @plugin.on_fediverse | Raw 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.
- JavaScript
- Python
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}`);
},
});
@plugin.on_fediverse_follow
def thank(event):
owncast.chat.send(f"new follower: {event.actor.handle}")
@plugin.on_fediverse_quote
def record_quote(event):
print(f"{event.actor.handle}: {event.content_text or 'quoted your post'}")
print(f"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.
- JavaScript
- Python
module.exports = definePlugin({
onFediverse(activity) {
if (typeof activity.type === 'string') {
console.log(`inbound activity: ${activity.type}`);
}
},
});
@plugin.on_fediverse
def record_activity(activity):
if isinstance(activity.type, str):
print(f"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.
- JavaScript
- Python
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();
},
});
@plugin.filter_chat_message
def clean(msg):
if "spam" in msg.body:
return filter.drop("spam")
if "damn" in msg.body:
return filter.modify({**msg.raw, "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.
- JavaScript
- Python
module.exports = definePlugin({
commands: {
uptime: { description: "How long we've been live", run: ctx => ctx.reply('a while!') },
},
});
plugin.commands({
"uptime": {"description": "How long we've been live",
"run": lambda 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;
}
- JavaScript
- Python
module.exports = definePlugin({
onHttpRequest(req) {
if (req.path === '/status') return { status: 200, body: '{"ok":true}' };
return { status: 404 };
},
});
@plugin.get("/status")
def status(req):
return {"status": 200, "body": '{"ok":true}'}
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
ttlin 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 };
- JavaScript
- Python
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();
},
});
from owncast_plugin import plugin, owncast, auth_check
@plugin.on_auth_check
def check(req):
if owncast.kv.get(f"banned:{req.user.id}"):
return auth_check.deny("access revoked")
return auth_check.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.
- JavaScript
- Python
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>' : '';
},
});
@plugin.on_tab_content("stats")
def stats(ctx):
name = ctx.user.display_name if ctx.user else "viewer"
return f"<h1>Live stats for {name}</h1>"
@plugin.on_page_content("banner")
def banner(ctx):
return "<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
}
- JavaScript
- Python
module.exports = definePlugin({
onSseConnect(e) {
/* e.connectionId, e.channel */
},
onSseDisconnect(e) {
/* same connectionId as the matching connect */
},
});
@plugin.on_sse_connect
def joined(e):
... # e.connection_id, e.channel
@plugin.on_sse_disconnect
def left(e):
...
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
}
- JavaScript
- Python
module.exports = definePlugin({
onTick(e) {
/* e.now */
},
});
@plugin.on_tick
def each_second(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.
- JavaScript
- Python
module.exports = definePlugin({
on: {
'other-plugin.milestone'(payload) {
/* react */
},
},
onStreamStarted() {
owncast.events.emit('my-plugin.went-live', { at: Date.now() });
},
});
@plugin.on("other-plugin.milestone")
def react(payload):
...
@plugin.on_stream_started
def went_live(info):
owncast.events.emit("my-plugin.went-live", {"at": info.started_at})
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.
| Event | Payload | Permission to subscribe |
|---|---|---|
chat.message.received | ChatMessage | none |
chat.user.joined | User | none |
chat.user.parted | User | none |
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.quote | FediverseQuote | fediverse.inbound |
fediverse.mention | FediverseInboundPost | fediverse.inbound |
fediverse.reply | FediverseInboundPost | fediverse.inbound |
fediverse.activity | Raw ActivityPub JSON object | fediverse.inbound |
| chat message filter | ChatMessage | chat.filter |
| HTTP request | IncomingHttpRequest | http.serve |
| auth check | AuthCheckRequest | auth.gate |
sse.connect | SSEConnectionEvent | http.sse |
sse.disconnect | SSEConnectionEvent | http.sse |
tick | { now } | none |
| tab content | ContentRequest | none to subscribe. Whatever APIs the handler calls |
| page content | ContentRequest | none 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.
Related Documents
- Serving HTTP via PluginsServe URLs from your plugin, write dynamic handlers, gate admin endpoints, and push realtime events to browsers.
- Chat plugin commandsDeclare chat commands with aliases, cooldowns, moderator gating, and automatic !help listings.
- Chat pluginsBuild chat bots, moderation tools, and message filters for Owncast plugins using chat handlers and owncast.chat APIs.
- Owncast Plugin APIsEvery owncast.* method your plugin can call from inside a handler, what it returns, and what permission it needs.
- Plugin quickstartScaffold a new Owncast plugin in JavaScript or Python, build it, package it, and install it on your server.
- Plugin Manifest referenceEvery field your plugin's manifest can contain, with examples.
