Why use a realtime connection?
Polling asks whether something changed on a schedule. SignalR keeps one lightweight connection open and pushes a notification immediately after an approved flag change is committed. Applications can react within a few milliseconds of receiving the event instead of waiting for the next polling interval.
Changes travel over an existing connection, removing repeated request setup and polling delays.
One connection replaces frequent “anything new?” requests from every running client.
The official client automatically reconnects after transient network interruptions.
Choose the connection scope
| Credential | Receives | Best for |
|---|---|---|
| Streaming key | One flag and environment | Deployed services with least-privilege access |
Create and rotate streaming keys from the Streaming workspace. The secret is displayed once, so move it directly into your secret manager or deployment environment.
.NET quick start
Install Microsoft’s ASP.NET Core SignalR client package in a .NET console app or worker service.
Install from NuGet
dotnet add package Microsoft.AspNetCore.SignalR.ClientConfigure the app
The API base URL is public, so it's hardcoded below — but keep your streaming token outside source control. This example reads the Streaming key from an environment variable. The key already limits the connection to one flag and environment.
using Microsoft.AspNetCore.SignalR.Client;
using System.Text.Json;
var baseUrl = "https://maxlona.com";
var streamingKey = Environment.GetEnvironmentVariable("MAXLONA_STREAMING_KEY")
?? throw new InvalidOperationException("MAXLONA_STREAMING_KEY is required.");
var connection = new HubConnectionBuilder()
.WithUrl($"{baseUrl.TrimEnd('/')}/hubs/flags", options =>
options.AccessTokenProvider = () => Task.FromResult<string?>(streamingKey))
.WithAutomaticReconnect()
.Build();
connection.On<JsonElement>("flagChanged", payload =>
Console.WriteLine(JsonSerializer.Serialize(payload)));
await connection.StartAsync();
await Task.Delay(Timeout.InfiniteTimeSpan);Download the .NET sample project
Realtime SignalR consumer
A ready-to-run .NET console project with NuGet setup, environment-variable configuration, reconnect logging, a Streaming key scoped to one flag and environment, and a README.
Connect without an SDK (raw WebSocket)
The hub speaks the standard ASP.NET Core SignalR wire protocol — plain JSON frames over a native WebSocket, each terminated with the 0x1E record separator. That's documented and stable, so any language with a WebSocket library can subscribe without pulling in the @microsoft/signalr package or an equivalent — useful for a lightweight service, a script, or a language we don't ship a client for yet.
- Negotiate over plain HTTP.
POST /hubs/flags/negotiate?negotiateVersion=1withAuthorization: Bearer <key>. This is a normal HTTP request, so the header works here even though it can't on the socket itself. - Follow a redirect once, if present. If the response has
urlandaccessTokeninstead of aconnectionToken, the connection is served by Azure SignalR Service, as maxlona.com is today — negotiate again against thaturlwith the new token before continuing. That second response carries aconnectionIdrather than aconnectionToken; use whichever is present as the socket'sid. A client that skips this step can't connect. - Open the socket with the token in the query string. WebSockets can't send custom headers, so the same credential goes on the URL as
?access_token=...— the same fallback the official clients use. - Send the JSON handshake first.
{"protocol":"json","version":1}followed by the record separator. The server replies with an empty{}frame on success. - Parse every frame after that as a push message. A frame with
type: 1andtarget: "flagChanged"carries the same event payload described above inarguments[0]. A frame withtype: 6is a keep-alive ping — echo it back so an idle proxy doesn't close the connection.
Microsoft.AspNetCore.SignalR.Client, @microsoft/signalr) handle automatic reconnect with backoff for you. A raw client has to redo negotiate → handshake from scratch after any close event.Vanilla JavaScript — no dependency
A Streaming key is the right credential here: it's scoped to one flag and environment, so there's nothing further to subscribe to once connected. The domain is fixed, so it's hardcoded below — only the key needs to come from your own configuration.
const baseUrl = 'https://maxlona.com';
const streamingKey = '<YOUR_STREAMING_KEY>'; // from the Streaming workspace — scoped to one flag + environment
const RECORD_SEPARATOR = '\u001e';
async function connectRaw() {
let hubUrl = `${baseUrl}/hubs/flags`;
let token = streamingKey;
// 1. Negotiate over plain HTTP — the token goes in a header here.
let negotiation = await negotiate(hubUrl, token);
// Azure SignalR Service redirect: maxlona.com currently answers negotiate
// with { url, accessToken } pointing at the Azure endpoint instead of a
// connectionToken. Negotiate again there before opening the socket — "url"
// already carries its own query string (?hub=...), so build on it with the
// helpers below rather than assuming it looks like our own /hubs/flags path.
if (negotiation.url && negotiation.accessToken) {
hubUrl = negotiation.url;
token = negotiation.accessToken;
negotiation = await negotiate(hubUrl, token);
}
// The Azure endpoint answers with negotiate version 0, which returns a
// connectionId and no connectionToken; accept either.
const connectionId = negotiation.connectionToken ?? negotiation.connectionId;
const wsUrl = appendParam(
appendParam(hubUrl, 'id', connectionId),
'access_token',
token,
).replace(/^http/, 'ws');
const socket = new WebSocket(wsUrl);
let handshakeDone = false;
socket.addEventListener('open', () => {
socket.send(JSON.stringify({ protocol: 'json', version: 1 }) + RECORD_SEPARATOR);
});
socket.addEventListener('message', (event) => {
for (const frame of event.data.split(RECORD_SEPARATOR)) {
if (!frame) continue;
const message = JSON.parse(frame);
if (!handshakeDone) {
// An empty {} means the handshake succeeded; otherwise it has an error.
if (message.error) {
console.error('handshake rejected:', message.error);
socket.close();
return;
}
handshakeDone = true;
continue;
}
if (message.type === 1 && message.target === 'flagChanged') {
console.log('flag change:', message.arguments[0]);
} else if (message.type === 6) {
// Server ping — echo one back so idle proxies don't drop the socket.
socket.send(JSON.stringify({ type: 6 }) + RECORD_SEPARATOR);
} else if (message.type === 7) {
// The server is closing the connection, for example when the key is
// disabled or the subscription is inactive.
console.warn('server closed the stream:', message.error ?? 'no reason given');
}
}
});
socket.addEventListener('close', (event) => {
console.warn('stream closed', event.code, event.reason);
// No built-in reconnect here — the official SignalR clients add automatic
// reconnect with backoff; a raw client has to implement that itself.
});
}
async function negotiate(hubUrl, bearerToken) {
const negotiateUrl = appendParam(withPathSuffix(hubUrl, 'negotiate'), 'negotiateVersion', '1');
const response = await fetch(negotiateUrl, {
method: 'POST',
headers: { Authorization: `Bearer ${bearerToken}` },
});
if (!response.ok) throw new Error(`negotiate failed: ${response.status}`);
const negotiation = await response.json();
if (negotiation.error) throw new Error(`negotiate failed: ${negotiation.error}`);
return negotiation;
}
// Inserts a path segment before an existing query string, instead of after it.
function withPathSuffix(url, segment) {
const [path, query] = url.split('?');
const joinedPath = path.endsWith('/') ? path + segment : `${path}/${segment}`;
return query ? `${joinedPath}?${query}` : joinedPath;
}
// Appends a query parameter, using '&' when the URL already has one.
function appendParam(url, key, value) {
return `${url}${url.includes('?') ? '&' : '?'}${key}=${encodeURIComponent(value)}`;
}
connectRaw(); This runs unmodified in Node 22 or later, which provides fetch and WebSocket globally. Run it server-side: a web page on your own domain can't call the hub from the browser, and it would expose the key. The same five steps — negotiate, optional redirect, socket with token on the query string, JSON handshake, frame parsing — are all a client in any other language needs to implement to subscribe without an SDK.
Event handling and resilience
- Listen for
flagChanged. The payload identifies the flag, action, environment, rollout percentage, enabled state, and timestamp. - Enable automatic reconnect. Register reconnecting, reconnected, and closed handlers so operators can see connection health.
- Make handlers idempotent. Treat an event as a prompt to refresh state; do not assume it is the only copy your process will ever observe.
- Respect approval workflows. Protected edits emit their flag-change event only after approval applies the change.
- Dispose cleanly. Stop and dispose the connection when a worker or application shuts down.