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.

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: false

Connect (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>/health
const 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/websocket

In-app mark-read still goes through Outreach API, then PostHog notification_read. See PostHog events.