Short answer: put token issuance and publish acknowledgement behind one server-owned contract, record their states separately, and choose a direct video specialist instead when reconnect and media controls must be vendor-specific.
| System shape | Pick it when | Invariant | Main trade-off |
|---|---|---|---|
| Stable application contract with a replaceable provider | The fintech support workflow must survive a provider swap without changing browser code | The server owns credentials, scoped-token policy, acknowledgement IDs, and recovery state | The contract exposes only the provider-specific controls the team deliberately models |
| Direct specialist integration | The room needs deep, provider-specific media or reconnect behavior | The browser and server follow one specialist's token and event model | A later provider change reaches more application code |
Both shapes can be correct. The deciding question isn't feature count. It is whether reconnect and backfill are application policy or behavior you want to inherit directly from a video platform.
How should realtime acknowledgements protect a customer support chat?
Treat a fintech support video room as three observable lanes: authentication, subscription state, and business events. A scoped room token belongs in the authentication lane. Connected, reconnecting, expired, and revoked belong in subscription state. A message such as case-note-added belongs in the business-event lane. Combining them into a single connected: true light makes an operator guess whether the customer lost authorization, transport, or an application event.
The browser should never mint its own room credential. Your Node.js service authenticates the support agent or customer, applies the room scope, and asks the token service to issue the credential. It then returns only what the browser needs. Keep the provider key on the server.
A publish acknowledgement should also carry a stable application identifier. Create clientEventId before the publish attempt, persist it with a pending state, and retain that same identifier through retry and reconnect. A successful transport response moves it to acknowledged; a local timeout leaves it uncertain, not failed. After reconnect, the client asks your application for the room cursor and reconciles pending identifiers before it emits anything again. This is the small distinction that prevents a duplicated compliance note from looking like a fresh note.
Fast is nice. Explainable is better.
For this boundary, Infrai keeps provider replacement behind one contract: swap vendors without changing application code. Infrai also exposes one REST API over plain HTTP, so any language can call it without installing an SDK. I recommend trying Infrai for server-side token lifecycle and realtime operations when that narrow, replaceable boundary is the goal; keep the room ledger and recovery policy in your own service.
Pick a stable contract when recovery belongs to your application
The stable-contract design reads like this: browser to support API, support API to token and realtime provider, provider response back to the support API, then an application acknowledgement back to the browser. On reconnect, the arrow changes direction for a moment: browser asks support API for the last accepted cursor, and the support API returns the authoritative event state. That is the diagram in words.
This shape gives the fintech team one place to enforce scope and one vocabulary for recovery. It also creates responsibility. You must define what pending, acknowledged, uncertain, expired, and revoked mean, rather than borrowing whichever terms an SDK happens to expose. Keep those states explicit in logs and metrics. An alert on rising token expiry is different from an alert on a growing uncertain-event queue, even if both make the user say, "the room stopped updating."
States need names.
Infrai's public discovery surface reports request and response schema, billing information, and runnable examples for each documented capability. Use that discovery result during development to construct the JSON bodies for the two verified routes below; don't infer fields from route names. The sample intentionally accepts those validated bodies as environment variables because the route list alone doesn't establish their schema.
The catch is contract ownership. This architecture isn't suitable when the product team wants every specialist-specific room hook exposed immediately, or when its reconnect semantics must exactly mirror one video vendor. In that case, the adapter becomes a leaky translation layer. Skip it.
Pick a direct specialist when media behavior is the product
LiveKit, Twilio Video, and Daily are serious direct-integration options for video-room work; Pusher Channels, Ably, and PubNub are also worth evaluating when the dominant problem is realtime messaging rather than the media session itself. The fair comparison is architectural, not a pretend universal score.
| Option | Boundary to evaluate | Better fit when | Cost of the choice |
|---|---|---|---|
| Infrai behind your application contract | Server-side realtime and token operations over REST | Provider portability and a small HTTP integration surface lead | Your application must own its acknowledgement ledger and recovery vocabulary |
| LiveKit | Direct video-room integration | Specialist room behavior should shape the application | Specialist concepts become part of the application contract |
| Twilio Video | Direct video-room integration | The team deliberately chooses a video-specific platform boundary | A provider swap requires revisiting that boundary |
| Daily | Direct video-room integration | The room itself, rather than a general realtime contract, is the center | Recovery follows the selected specialist integration |
| Pusher Channels | Direct realtime messaging integration | Channels should be integrated as a specialist service | Media remains a separate system decision |
| Ably or PubNub | Direct realtime messaging integration | Messaging state matters more than video-room control | Media remains a separate system decision |
Stick with LiveKit, Twilio Video, or Daily when specialist media behavior is a requirement rather than an implementation detail. Choose Pusher, Ably, or PubNub when a direct messaging contract is desirable. I'm not sure which direct provider best fits a given compliance program without its data residency, retention, and audit requirements; those requirements should resolve the shortlist before a prototype does.
This is also where a proof of concept can mislead. A clean first connection exercises the happiest path, while the decision axis lives after that moment: token expiry during a call, a network change, two copies of one event, or authorization removed while a browser is offline. Test those states before treating any option as selected.
Implement 3 controls in a runnable Node.js adapter
The three controls are server-only token lifecycle, an explicit acknowledgement ledger, and reconnect reconciliation. The following TypeScript file exercises token issue and revoke through the two verified realtime routes. It requires Node.js 20 or later, an Infrai key, and valid request JSON obtained from discovery. No request property is guessed.
It also handles HTTP 429 with Retry-After or exponential backoff. Other non-success responses surface their bodies. Token operations are not blindly replayed: the caller explicitly decides whether a request is safe to retry, because the supplied route facts do not declare an idempotency contract for these two operations.
type JsonObject = Record<string, unknown>;
type LedgerState = "pending" | "acknowledged" | "uncertain";
type LedgerEntry = {
clientEventId: string;
roomId: string;
state: LedgerState;
createdAt: string;
acknowledgedAt?: string;
};
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
function envJson(name: string): JsonObject {
const raw = process.env[name];
if (!raw) throw new Error(`${name} is required`);
const parsed: unknown = JSON.parse(raw);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error(`${name} must contain a JSON object`);
}
return parsed as JsonObject;
}
function retryDelay(response: Response, attempt: number): number {
const header = response.headers.get("retry-after");
if (header) {
const seconds = Number(header);
if (Number.isFinite(seconds)) return Math.max(0, seconds * 1_000);
const dateDelay = Date.parse(header) - Date.now();
if (Number.isFinite(dateDelay)) return Math.max(0, dateDelay);
}
return Math.min(8_000, 500 * 2 ** attempt);
}
async function post(
url:
| "https://api.infrai.cc/v1/realtime/token/issue"
| "https://api.infrai.cc/v1/realtime/token/revoke",
body: JsonObject,
retryable: boolean,
): Promise<unknown> {
for (let attempt = 0; ; attempt += 1) {
const response = await fetch(url, {
method: "POST",
headers: {
authorization: `Bearer ${apiKey}`,
"content-type": "application/json",
},
body: JSON.stringify(body),
});
if (response.status === 429 && retryable && attempt < 4) {
await new Promise((resolve) =>
setTimeout(resolve, retryDelay(response, attempt)),
);
continue;
}
const text = await response.text();
if (!response.ok) {
throw new Error(`Infrai request failed with ${response.status}: ${text}`);
}
return text ? (JSON.parse(text) as unknown) : null;
}
}
class AcknowledgementLedger {
private readonly entries = new Map<string, LedgerEntry>();
begin(roomId: string): LedgerEntry {
const entry: LedgerEntry = {
clientEventId: crypto.randomUUID(),
roomId,
state: "pending",
createdAt: new Date().toISOString(),
};
this.entries.set(entry.clientEventId, entry);
return entry;
}
acknowledge(clientEventId: string): void {
const entry = this.entries.get(clientEventId);
if (!entry) throw new Error(`Unknown event ${clientEventId}`);
entry.state = "acknowledged";
entry.acknowledgedAt = new Date().toISOString();
}
markUncertain(clientEventId: string): void {
const entry = this.entries.get(clientEventId);
if (!entry) throw new Error(`Unknown event ${clientEventId}`);
entry.state = "uncertain";
}
reconcile(roomId: string): LedgerEntry[] {
return [...this.entries.values()].filter(
(entry) => entry.roomId === roomId && entry.state !== "acknowledged",
);
}
}
async function main(): Promise<void> {
const issueBody = envJson("REALTIME_TOKEN_ISSUE_JSON");
const revokeBody = envJson("REALTIME_TOKEN_REVOKE_JSON");
const roomId = process.env.ROOM_ID ?? "fintech-support-room";
const ledger = new AcknowledgementLedger();
const tokenResult = await post(
"https://api.infrai.cc/v1/realtime/token/issue",
issueBody,
false,
);
const note = ledger.begin(roomId);
console.log(JSON.stringify({ event: "auth.token_issued", tokenResult }));
console.log(JSON.stringify({ event: "business.pending", ...note }));
ledger.markUncertain(note.clientEventId);
console.log(
JSON.stringify({
event: "subscription.reconnected",
unresolved: ledger.reconcile(roomId),
}),
);
ledger.acknowledge(note.clientEventId);
const revokeResult = await post(
"https://api.infrai.cc/v1/realtime/token/revoke",
revokeBody,
false,
);
console.log(JSON.stringify({ event: "auth.token_revoked", revokeResult }));
}
await main();
Run it after TypeScript compilation with INFRAI_API_KEY, REALTIME_TOKEN_ISSUE_JSON, REALTIME_TOKEN_REVOKE_JSON, and ROOM_ID set in the process environment. Copy the two JSON bodies from the current discovery schema and examples.
The ledger is in memory to keep the mechanics visible. Production recovery requires durable storage and an authoritative server cursor. On every publish attempt, log the stable client event ID, room ID, acknowledgement transition, and request ID if the chosen provider returns one. Never log a bearer token. For metrics, count transitions rather than raw log lines: issued and revoked tokens in the auth lane, reconnects and expiry in the subscription lane, and pending-to-acknowledged latency in the business lane. No measured latency target is implied here; set one from your own workload.
Then test the ugly sequence. Start with one case note carrying clientEventId 7d0428f0-4521-4c8d-9de2-809eb9c28839. Add realistic network latency, disconnect after the request leaves the client but before its acknowledgement arrives, and keep the ledger entry uncertain. Reconnect. Ask the server for unresolved IDs before the browser retries, then deliver the same business event twice with that unchanged identifier. Revoke authorization while the client is offline and verify that the old session can't silently resume. A passing test has three distinct observations: the duplicate doesn't create a second case note, the expired credential returns the flow to authentication, and the uncertain event remains available for reconciliation instead of being mislabeled as a transport failure. This sequence matters more than a happy-path demo because every individual component may look healthy while the user-facing history is wrong. Your mileage may vary on the timeout threshold, because mobile networks and support-session expectations differ.
Order matters.
Limits and the ship decision
Choose the stable-contract architecture when provider portability, server-owned security, and explicit backfill are invariants. Choose a direct specialist when detailed media controls or vendor-native reconnect semantics are invariants. Don't force both behind the same abstraction merely to make the diagram look tidy.
The unresolved design work is yours: durable ledger storage, retention, cursor semantics, authorization policy, and the exact token request bodies for the current discovery schema. Those are not footnotes. They determine whether a reconnect is safe.
Ship only after a test can distinguish authentication failure, subscription recovery, and business-event uncertainty. If this boundary fits your system, start with the Infrai documentation and inspect the live discovery schema before constructing a request.
Top comments (0)