Skip to main content

Serving HTTP via Plugins

Plugins can serve their own URLs. Once you declare http.serve in your manifest, the URL space at /plugins/<your-slug>/ is yours: static files from your public/ directory go out verbatim, and anything else falls through to your request handler.

Code is shown for both SDKs. See JavaScript or Python for install and setup.

Routing​

Once http.serve is declared, the host routes every request under /plugins/<your-slug>/ to your plugin:

  1. Static files. Anything in your public/ directory is served verbatim.
  2. Dynamic handler. Anything else falls through to your plugin's request handler.

A request's path is relative to your plugin's namespace: a request to /plugins/my-plugin/api/messages reaches your handler as /api/messages (the query string is excluded). The handler reads query parameters and the request body from the request, and returns a response with a status, optional headers, and an optional body.

There are two routing styles. In JavaScript you write a single onHttpRequest(req) handler and branch on req.method / req.path. In Python you declare per-method routes with decorators. A request whose path matches a route but not its method gets an automatic 405, and an unmatched path falls through to the bare catch-all, else 404.

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

module.exports = definePlugin({
onHttpRequest(req) {
// req: { method, path, headers, query, body, user? }
if (req.method === 'GET' && req.path === '/api/messages') {
return {
status: 200,
headers: { 'Content-Type': 'application/json' },
body: '[]',
};
}
if (req.method === 'POST' && req.path === '/api/messages') {
const data = JSON.parse(req.body || '{}');
return { status: 201 };
}
return { status: 404 };
},
});

The manifest.admin.pages key match at the top is covered in UI: Admin pages. From the perspective of HTTP serving, it is a 401-before-your-handler-runs filter applied to paths matching one of the object's keys.

Static files​

The public/ directory holds files served at /plugins/<your-slug>/<path>. A separate assets/ directory holds files the host reads internally for manifest fields that inline content (styles, scripts, extraPageContent). Those are not reachable through the plugin's URL space.

my-plugin/
└── public/
├── index.html → /plugins/my-plugin/index.html (and /plugins/my-plugin/)
├── style.css → /plugins/my-plugin/style.css
└── img/
└── logo.png → /plugins/my-plugin/img/logo.png

A request to /plugins/my-plugin/ (no trailing path) serves public/index.html automatically.

Request and response limits​

  • Request bodies are capped at 1 MB.
  • Response bodies are capped at 10 MB.
  • Path traversal (..) in URLs is blocked at the host level. You'll never see it in your handler's path.
  • Response headers are filtered through an allowlist. You can set Content-Type, Content-Encoding, Content-Language, Cache-Control, Set-Cookie, Location, ETag, Last-Modified, Vary, Link, and CORS (Access-Control-*) headers. Owncast-owned headers (Server, Content-Security-Policy, Strict-Transport-Security, X-Frame-Options) are blocked.
  • Cookies you set apply to your plugin's URL space by default (/plugins/<your-slug>/). If you want a cookie to be sent on requests outside that path, set Path=... explicitly. Otherwise the browser scopes it to your namespace and won't leak it into other plugins or into Owncast's own paths.
  • Each request is time-capped at 5 seconds before the host returns a 504 and discards your response.

Public vs. authenticated​

Endpoints are public by default. To make something admin-only, either check whether the request is authenticated inside your handler and return 401 when it isn't, or add its path glob as a key in manifest.admin.pages and let the host gate it for you (see UI: Admin pages).

For requests made by a chat user with a valid user-token, the request carries the user's identity (id, display name, and scopes). Useful for per-user dashboards or moderator-only tools:

module.exports = definePlugin({
onHttpRequest(req) {
if (!req.user) return { status: 401 }; // not signed in
if (!req.user.scopes?.includes('MODERATOR')) return { status: 403 };
return { status: 200, body: `hello ${req.user.displayName}` };
},
});

For paths matching a key in manifest.admin.pages, the host returns 401 before your handler runs, so you don't have to check at all.

Realtime updates (Server-Sent Events)​

For pushing live updates to a browser (an overlay that reacts to chat, a dashboard that ticks viewer counts, an alert widget) declare http.sse and use owncast.sse.send.

You do not open or hold the connection yourself. Your request handler can't stream: each call is a single buffered request/response. The host owns the long-lived connection and exposes a ready-made endpoint at /plugins/<your-slug>/_sse/<channel>. Your plugin pushes. The host fans each message out to every connected browser.

Plugin side​

Push from any handler, for example, from your chat handler, by calling owncast.sse.send(channel, event, data):

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

module.exports = definePlugin({
onChatMessage(msg) {
owncast.sse.send('overlay', 'chat', {
from: msg.user?.displayName,
body: msg.body,
});
},
});
  • channel: which stream to push to. Browsers subscribe per channel, so you can run several independent streams ("overlay", "admin-stats") from one plugin. Use "" for a single default channel.
  • event: the event name the browser listens for (addEventListener("chat", ...)). Pass "" for the browser's default message event.
  • data: the payload. Strings are sent as-is. Anything else is JSON-encoded for you.

Sends are fire-and-forget. The call returns immediately and never blocks, even if no one is connected or a client is slow. Slow clients drop frames rather than stall your plugin. There are also SSE-connection lifecycle events (a viewer's stream opening and closing) you can subscribe to: see the handlers reference.

Browser side​

Standard EventSource API on the viewer page. No library. This runs in the browser, so it's always JavaScript whatever language your plugin is written in:

<!-- public/index.html, served at /plugins/my-plugin/ -->
<script>
const events = new EventSource('/plugins/my-plugin/_sse/overlay');
events.addEventListener('chat', e => {
const { from, body } = JSON.parse(e.data);
document.getElementById('feed').textContent = `${from}: ${body}`;
});
</script>

Notes​

  • Up to 64 simultaneous connections per plugin. Over that the endpoint returns 503. EventSource reconnects automatically.
  • If the channel matches a key in admin.pages, it's auth-gated like any admin route. Handy for an admin-only stats stream.
  • The endpoint is host-owned. Your request handler never sees /_sse/... requests, and you can't serve your own route there.

Putting it together: a complete overlay plugin​

The manifest declares the two permissions the overlay needs:

{
"api": "1",
"name": "Chat Overlay",
"slug": "overlay",
"version": "0.1.0",
"permissions": ["http.serve", "http.sse"]
}

The plugin subscribes to chat messages and pushes each one to the overlay SSE channel:

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

module.exports = definePlugin({
onChatMessage(msg) {
owncast.sse.send('overlay', 'chat', {
from: msg.user?.displayName,
body: msg.body,
});
},
});

The viewer page is the same EventSource snippet shown above, pointed at the relative ./_sse/overlay endpoint:

<!-- public/index.html -->
<!doctype html>
<body>
<div id="feed"></div>
<script>
const events = new EventSource('./_sse/overlay');
events.addEventListener('chat', e => {
const { from, body } = JSON.parse(e.data);
document.getElementById('feed').textContent = `${from}: ${body}`;
});
</script>
</body>

Build, package, install. Open /plugins/overlay/ in OBS as a browser source.


Improve this page

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

Contributors to this documentation
Gabe KangasGabe Kangas
G
Gabe Kangas

Related Documents