Centrifugo
Realtime broker for the in-app inbox. Workers publish; the browser only listens on a JWT-scoped private channel. Used by the FinamX inbox widget on extensions-host.
The browser must never hold the producer API key or call
/v1/centrifugo/token directly. Mint the connection JWT on a host BFF, then inject an already-connected Centrifuge client. Client-side subscribe() is disabled (allow_subscribe_for_client: false).1. How it works
Server-side subscription: the JWT
channels claim is the ACL.Publish (server)
Producer
POST /v1/trigger
Temporal
inapp ladder step
worker-inapp
inappSendActivity
HTTP publish
POST /api/publish · X-API-Key
Centrifugo
Private channel
subscriber:<subscriberId>:inapp
Server-side subscription via JWT channels claim. Client subscribe() is denied.
ACL
JWT 15mHS256 · HMAC secretno client subscribeallow_subscribe_for_client: falseConnect (browser · never the producer key)
Inbox widget
extensions-host
Host BFF
POST /api/bff/centrifugo-token
ingestion-api
POST /v1/centrifugo/token
WebSocket
Centrifuge({ token }).connect()
Client
InboxWidget / subscribeInbox
publication listener only · no newSubscription
Text outline
Producer / campaign / FinamX notify
→ POST /v1/trigger (Bearer producer key)
→ Temporal NotificationWorkflow
→ temporal-worker-inapp
→ POST CENTRIFUGO_API_URL/publish (X-API-Key)
channel = subscriber:{subscriberId}:inapp
Browser (host BFF, never the producer key)
→ POST /api/bff/centrifugo-token { subscriberId }
→ ingestion POST /v1/centrifugo/token (producer Bearer)
→ JWT 15m, channels: [subscriber:<subscriberId>:inapp]
→ new Centrifuge(WS_URL, { token }).connect()
→ InboxWidget / subscribeInbox listens on publication
(does not open a client subscription)2. Channel & payload
Channel name is derived only from the internal subscriber UUID, never from the client.
channel: subscriber:<subscriberId>:inapp
publish data:
{
"workflowId": "<workflow id>",
"idempotencyKey": "<idempotency key>",
"payload": { ... } // raw trigger payload
}
providerMessageId on audit: centrifugo-<idempotencyKey>3. How to connect (host)
Same-origin BFF on extensions-host proxies token mint. WS URL is public (not a secret).
WS wss://<centrifugo-host>/connection/websocket
local: ws://localhost:8100/connection/websocket
Health GET https://<centrifugo-host>/healthconst res = await fetch("/api/bff/centrifugo-token", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ subscriberId }),
});
const { token } = await res.json();
const centrifuge = new Centrifuge(wsUrl, { token });
centrifuge.connect();
<InboxWidget centrifuge={centrifuge} subscriberId={subscriberId} />Live widget: /extensions/inapp on extensions-host (Inbox in the sidebar). SDK: @finamx/outreach-notification-client.
4. Token mint (server only)
Producer-authenticated.
subscriberId must be an existing UUID in subscribers.id — 404 otherwise. HMAC secret must match Centrifugo client.token.hmac_secret_key.POST /v1/centrifugo/token
Authorization: Bearer <OUTREACH_PRODUCER_API_KEY>
Content-Type: application/json
{
"subscriberId": "<subscribers.id UUID>"
}
→ { "token": "<HS256 JWT, 15m>" }5. Env
API key is server-to-server publish. HMAC is JWT signing. WS URL is what the browser opens.
# workers + ingestion (secrets)
CENTRIFUGO_API_URL=http://centrifugo:8000/api # local host: http://localhost:8100/api
CENTRIFUGO_API_KEY=… # must match centrifugo http_api.key
CENTRIFUGO_HMAC_SECRET=… # must match client.token.hmac_secret_key
# browser / extensions-host (not secrets)
CENTRIFUGO_WS_URL=wss://<host>/connection/websocket
NEXT_PUBLIC_CENTRIFUGO_WS_URL=wss://<host>/connection/websocketIn-app mark-read still goes through Outreach API, then PostHog notification_read. See PostHog events.