The platform ships the SSE infrastructure (
api.sse) and wiring. No feature endpoint streams over SSE out of the box — this guide shows how to build one on top of the shared base.When to use SSE
SSE is the right tool when the client only consumes: scan progress, long-running job checkpoints, streamed LLM tokens, cross-client resource-sync notifications. It rides on plain HTTP, reconnects automatically in the browser via the native
EventSource API, and needs no extra protocol.
How it works
SSE is wired throughdjango-eventstream and a small platform layer in api/src/backend/api/sse/:
Transport: the server runs on ASGI
SSE connections are long-lived. Holding one open per synchronous worker would exhaust the worker pool, so the API runs under Gunicorn’s nativeasgi worker (config.asgi:application). Streams are parked on the event loop while ordinary CRUD endpoints keep their synchronous execution (Django runs sync views in a thread-sensitive executor under ASGI). This is configured in config/guniconf.py and used by both the dev and production entrypoints — no separate server process is needed.
The data flow
send_event(channel, event_type, payload). django-eventstream fans it out over Valkey Pub/Sub to every connection subscribed to that channel.
Adding an SSE endpoint to your feature
The example below streams progress for a long-running scan. Adapt the resource, prefix, and event names to your feature.1
Pick a channel prefix
Channels follow the format The tenant id is baked into every channel name. That is what lets the platform enforce cross-tenant isolation without knowing anything about your feature.
<prefix>:<tenant_id>:<resource_id>, built only through make_channel_name. The prefix is owned by your feature and may contain hyphens but never colons (the parser splits on :).2
Subclass BaseSSEViewSet
Create the viewset for the SSE sub-resource. The only required method is
get_channels; it runs inside the tenant transaction set up by the base class, so any database lookup inside it is automatically RLS-scoped.3
Wire the URL as a sub-resource
Mount the endpoint as an
event-stream sub-resource. Keep it outside the DRF router, which would force the URL into a list/detail convention. Route the get method to the viewset’s list action.4
Define your event vocabulary
A feature owns its event types in There is no platform-side enum, registry, or dispatch table — the naming convention is the contract (see below).
<app>/<domain>/events.py: one publish_<event> function per event type, each body a single send_event call so the wire-level string lives in exactly one place.5
Publish from the producer
Wherever the work happens — usually a Celery task — build the channel the same way and publish:
Event naming convention
Every event uses an event type of the form<resource>.<verb> (lowercased, dot-separated). The verb comes from this platform-wide vocabulary — if you need a verb that is not listed, document the addition in this guide so the catalog stays discoverable.
Payloads are flat JSON. The wire-level
event: field already names the event type, so do not wrap the payload in {"type": ..., "data": ...}. Include the canonical resource UUID on terminal events so reconnecting clients can reconcile via REST.Authentication
SSE endpoints use the same authentication stack as the rest of the API. Non-browser clients (CLI, programmatic) send the standardAuthorization header — JWT or API key.
Browser EventSource is the only widely available SSE client API and it cannot set custom headers. For that case only, the endpoint accepts a JWT via the ?access_token=<jwt> query parameter. The header always wins when present — a header is intentional, while a query parameter can leak into referers and logs, so it is consulted only as a fallback.
Tenant isolation & security model
Authorization is enforced at two layers:- At connect,
get_channelsruns under the regular DRF stack inside the tenant transaction (rls_transaction). Resource lookups are RLS-scoped, so a user cannot even resolve a channel for a resource they cannot see. Narrow the queryset further (e.g.created_by=request.user) when a resource is per-user within a tenant. - After connect,
SSEChannelManager.can_read_channelre-verifies tenant membership by parsing the tenant id embedded in the channel name. Cross-tenant subscription is rejected even if a URL-level check ever has a bug. A malformed channel name is treated as “not authorized”.
Reconnect & state recovery
The platform deliberately ships without server-side replay (is_channel_reliable returns False). When a client reconnects, it does not receive missed events. Instead:
- Terminal events (
*.end) carry the canonical resource UUID. - On reconnect, the client refetches the authoritative state from the normal REST endpoint using that id.
Local development
- The dev and production entrypoints both launch Gunicorn with the
asgiworker (config.asgi:application). In dev,DJANGO_DEBUG=Trueenables hot reload;preload_appis automatically disabled under debug so edited code is picked up. - SSE uses a dedicated Valkey database (
EVENTSTREAM_VALKEY_DB, default2) kept separate from the Celery broker so a noisy broker cannot crowd out streaming traffic. It reuses the sameVALKEY_*connection settings as the rest of the platform.
Test the stream end to end with
curl -N (disable buffering) and an auth header:
Testing
The platform basis is covered byapi/tests/test_sse.py (channel parsing, the tenant gate, and auth precedence). For a feature endpoint, test:
get_channelsreturns the expected channel for an authorized resource and raisesNotFound/PermissionDeniedotherwise.- Each
publish_<event>helper emits the correct event type and flat payload (mocksend_event). - The producer builds the channel with
make_channel_nameusing the resource’s owntenant_id.

