Introduction
This documentation refers to version 2.0 of the Five Minute npm packages.
The npm packages connect a JavaScript or TypeScript client, such as a web page or a browser game, to Five Minutes. They speak protocol 2.0 of the Five Minute wire protocol over SignalR. The client keeps one connection open, logs the user in, reconnects on its own, and carries every feature of protocol 2.0: user sign-in and sessions, remote config and experiments, metric events, leaderboards, chat and support tickets.
There are three packages. @fiveminutes-io/chat-client and @fiveminutes-io/support-client hold the client: FiveMinutesClient, which has every feature, and ChatClient and SupportClient, which extend it with methods for chat and for support tickets. @fiveminutes-io/contracts holds the TypeScript types of every message and record of protocol 2.0, for the whole protocol or one feature at a time.
Requirements
- An Application ID, and an Application secret (dashboard: "App client secrets") or ephemeral app keys that your server mints (see Ephemeral app keys).
@microsoft/signalr8 and a JavaScript environment with WebSockets: a browser, a bundler or Node. The packages are ES modules; from CommonJS,require()needs Node 22.12 or later.- To sign users in through your own accounts: your own backend, which verifies users and issues their Custom sign-in pair (see Signing in through your backend).
Quick facts for AI agents
- Covers:
@fiveminutes-io/contracts,@fiveminutes-io/chat-clientand@fiveminutes-io/support-client2.0.x. - Client: protocol 2.0 over SignalR. Give the client the host as
serverUrl, for examplehttps://signalr.fiveminutes.cloud; it connects to the path of protocol 2.0 on it itself (hosts: Hosts). - Order: create the client, register the callbacks, then
await connect(). It resolves when the login has completed; from then on every method can be called.onConnectedfires after every login: the first one and each one after a reconnect. - Identity: with
signIn(aDeviceorCustomcredential) the user signs in and the client keeps the session's tokens; without it, the server trusts the unique user id the client presents (uniqueUserId, or a random id per client instance). Store the session fromonSessionChangedand pass it back assession. Unique user ids are private: other users see the display id. - Every feature:
send(type, fields),request(type, fields)andon(type, handler)take every message of the contracts reference by its type name, with typed fields and typed replies. Helpers:fetchRemoteConfig(),getLeaderboard(),getLeaderboardRecord(),submitScore(),reportMetricEvent()andlogout(). - Errors:
connect()rejects withClientConfigError(a value the server would refuse, before connecting),ConnectionClosedError(a refused login, with the server's goodbye; a connection lost during the login; a login attempt that neither a welcome nor a goodbye ends within 35 seconds) andProtocolVersionNotServedError(the host does not serve protocol 2.0; one attempt, no retry). A goodbye that allows a reconnect, such as a server update or the server's own 30-second login deadline, does not failconnect(): the client tries again with the reconnect backoff, andconnect()rejects only after the last attempt. A request rejects withRequestFailedError(the server's FailureReason, also when the server refused the message for breaking a rule of its type) orRequestTimeoutError(no reply within 30 seconds;acknowledgedsays whether the server received the request). Every message is checked before it is sent: one that breaks a rule of its type rejects at once withMessageValidationError(fieldnames the field at fault), and one over 128 KB as sent withFrameTooLargeError; neither is sent. - Retries: after a dropped connection, and after a goodbye that allows it (also during
connect()), the client reconnects on its own after a random, growing delay (up to 1, 2, 4 ... seconds, at most 60, for 10 attempts) and logs in again as the same user. Repeat a failed read as a new request; resend an unanswered score submission or metric event unchanged, with the same MessageId (see Resending a score or a metric event). - Enums: by name, as strings: a goodbye's ReasonType is
"BackendHandover", not a number. - Does not exist: a fallback to another protocol version or to the path
/signalr; a remote config cache that outlives the client instance; a token refresh during a connection; more than the first page of channel history fromfetchChannelHistory(read further pages withrequest). - Related: wire protocol, API Contracts 2.0, Player Authentication, Remote Config, Leaderboards, Server API, npm packages 1.8 for the earlier packages.
Packages
| Package | Version | What it is |
|---|---|---|
@fiveminutes-io/chat-client |
2.0.x | FiveMinutesClient, and ChatClient with methods to join channels, send chat messages, read a channel's history and receive chat messages and whispers. |
@fiveminutes-io/support-client |
2.0.x | FiveMinutesClient, and SupportClient with methods to create and fetch support tickets, send ticket messages and receive ticket messages and whispers. |
@fiveminutes-io/contracts |
2.0.x | Every message and record of protocol 2.0 as TypeScript interfaces, the enums by name, and a few helpers, from the root or from one subpath per feature (see Root and subpaths). It was called @fiveminutes-io/five-minute-chat-contracts before 2.0. The package's major and minor version is the protocol version. |
Install one client with its two peer dependencies, @fiveminutes-io/contracts (~2.0.0: any 2.0.x, not 2.1) and @microsoft/signalr (^8.0.0):
npm install @fiveminutes-io/chat-client @fiveminutes-io/contracts @microsoft/signalr
- Both clients have every feature. Choose
ChatClientorSupportClientfor their extra methods; an app that needs neither usesFiveMinutesClientfrom either package. Every method on this page that is not listed under ChatClient or SupportClient belongs toFiveMinutesClient. - All three are ES modules with type declarations. They load in browsers, bundlers and Node with
import; from CommonJS,require()works on Node 22.12 or later. - Each client package ships its own copy of
FiveMinutesClientand of its error classes, so import the error classes from the package your client comes from.FrameTooLargeError,MessageValidationErrorandProtocolVersionNotServedErrorcome from the contracts package, soinstanceofworks with either import. - Each package carries a
CHANGELOG.mdthat lists its breaking changes; Moving from the 1.8 packages sums them up.
five-minute-chat-client (last version 1.0.13) is deprecated. It speaks protocol 0.8, whose messages the server does not recognise, so it cannot log in. Use the scoped packages on this page.@fiveminutes-io/five-minute-chat-contracts is deprecated. From 2.0 the contracts package is @fiveminutes-io/contracts; the old name only re-exports it, with the same version and the same exports. Install and import @fiveminutes-io/contracts instead.Configuration
import { ChatClient } from "@fiveminutes-io/chat-client";
const client = new ChatClient({
serverUrl: "https://signalr.fiveminutes.cloud",
applicationId: "<application id>",
applicationSecret: "<application secret>",
username: "Ada",
});
| Field | Default | Meaning |
|---|---|---|
serverUrl |
Required | The host, for example https://signalr.fiveminutes.cloud or the global https://signalr.fiveminutes.cloud (see Hosts). The client connects to the path of protocol 2.0 on it. A URL that ends in /signalr, the form the 1.8 packages took, is taken for the host it names. |
applicationId |
Required | Your Application ID, or Demo01 / Demo02 for the public demo applications. |
applicationSecret |
Required | Your Application secret (DemoSecret for the demo applications), or a function that returns the secret for one connection, such as an ephemeral app key (see Ephemeral app keys). |
username |
Empty | The user's name, which other users see: empty, or 3 to 64 letters (of any script), digits or -. A non-empty name renames the user on every login. With an empty one, an existing user keeps their name and a new user is named Anonymous- followed by 7 digits. An e-mail address is not a valid name. |
uniqueUserId |
A random id per client instance | The unique user id to present: 1 to 256 characters, not only spaces, for example the id of your own account system or an e-mail address. Without sign-in the server trusts it; with sign-in it is offered once to keep an existing user (see Who the user is). The unique user id of a session you pass takes its place. Keep it private. |
signIn |
None | A Device or Custom credential, or a function that returns one when the client needs it (see Signing users in). |
session |
None | A session you stored from getSession() or onSessionChanged, to resume it (see Sessions). |
autoJoinChannel |
None | ChatClient only. A channel to join after every login, unless the user is a member already. A join that fails is reported through onError. |
language |
navigator.language |
The device language, for your usage statistics. |
platform |
Web |
The platform, for your usage statistics. |
appVersion |
Empty | Your app's version, for troubleshooting and your usage statistics. |
requestTimeoutMs |
30000 | How long a request waits for its answer, in milliseconds. |
loginTimeoutMs |
35000 | How long one login attempt may take, in milliseconds, counted from its start: the applicationSecret and signIn functions count against it. It ends an attempt that neither the server's welcome nor a goodbye has ended by then. The server itself says goodbye, allowing a reconnect, to a connection that has not logged in within 30 seconds of connecting (see Connecting and reconnecting). |
reconnect |
{ maxAttempts: 10, initialDelayMs: 1000, maxDelayMs: 60000 } |
How the client reconnects on its own (see Reconnects and goodbyes), or false to never reconnect on its own. |
connectionOptions |
None | Options for the SignalR connection of @microsoft/signalr (IHttpConnectionOptions), for example headers, the transport or a logger. |
Checks before connecting
connect() checks the configuration before it opens a connection, and rejects at once with a ClientConfigError whose field names the option at fault, for example username or signIn.deviceId, when a value is one the server would refuse:
serverUrlorapplicationIdis missing, orapplicationSecretis neither a non-empty text nor a function.usernameis not empty and not 3 to 64 letters, digits or-. An e-mail address, or a name with a space, is refused.uniqueUserId, or the unique user id ofsession, is not 1 to 256 characters, or only spaces. An e-mail address is a valid unique user id.signIn.provideris not exactlyDeviceorCustom;signIn.deviceIdorsignIn.customIdis empty or longer than 256 characters;signIn.secretis empty or longer than 1,024 characters;signIn.createis nottrueorfalse; orsignIn.usernamebreaks the name rule.language,platformorappVersionis not a text.- A text option holds half of a character (an unpaired surrogate), which the server cannot read.
requestTimeoutMsorloginTimeoutMsis not a positive number, or a number inreconnectis negative.- The configuration has a
deviceId, the option of the 1.8 packages (see Moving from the 1.8 packages).
What an applicationSecret or signIn function returns is checked each time the client calls it: an invalid value ends that login attempt with a ClientConfigError, and the client stops. Before every login the client also checks the login messages it builds from these values, the session's tokens included, by the server's rules; a value the server would refuse ends the attempt the same way. The error messages never repeat a unique user id, a device id or a secret.
isValidUsername(name) and isValidUniqueUserId(id) apply the same rules, for example to a form field:
import { isValidUniqueUserId, isValidUsername } from "@fiveminutes-io/chat-client";
isValidUsername("Ada-1984"); // true
isValidUsername("ada@example.com"); // false: an e-mail address is not a name
isValidUniqueUserId("ada@example.com"); // true
Every message the client sends after the login is checked as well; see Checks before sending.
Who the user is
Without sign-in
Without signIn and without session tokens, the login presents only a unique user id, and the server trusts it (a legacy login; see How a login decides who the user is). The client presents the unique user id of session, otherwise uniqueUserId, otherwise an id it makes up with createDeviceId() and keeps for the lifetime of the client instance. A new client instance without one of them, such as one after a page reload, is a new user, so store the id and pass it back:
import { ChatClient, createDeviceId } from "@fiveminutes-io/chat-client";
const userId = localStorage.getItem("fm-user") ?? createDeviceId();
localStorage.setItem("fm-user", userId);
const client = new ChatClient({
serverUrl: "https://signalr.fiveminutes.cloud",
applicationId: "<application id>",
applicationSecret: "<application secret>",
uniqueUserId: userId, // the same user on every visit
});
await client.connect();
- Anyone who knows a unique user id can log in as that user this way. Keep it private: never show it to other users or send it to them; they see and address the user by display id.
- An application that requires authenticated sessions refuses such a login:
connect()rejects with aConnectionClosedErrorwhosegoodbye.reasonTypeisUserAuthenticationFailed(see Requiring authenticated sessions). Sign users in instead.
Signing users in
With signIn, the user proves who they are, and the server decides the unique user id. This example signs in with a device id and keeps the session across visits:
import { ChatClient, createDeviceId, type UserSession } from "@fiveminutes-io/chat-client";
// Once per installation: a new device id is a new user
const deviceId = localStorage.getItem("fm-device") ?? createDeviceId();
localStorage.setItem("fm-device", deviceId);
const stored = localStorage.getItem("fm-session");
const client = new ChatClient({
serverUrl: "https://signalr.fiveminutes.cloud",
applicationId: "<application id>",
applicationSecret: "<application secret>",
signIn: { provider: "Device", deviceId },
session: stored ? (JSON.parse(stored) as UserSession) : null,
});
client.onSessionChanged((session) =>
session ? localStorage.setItem("fm-session", JSON.stringify(session)) : localStorage.removeItem("fm-session"));
await client.connect();
{ provider: "Device", deviceId, create?, username? }signs in with a device id alone. Make the device id once per installation withcreateDeviceId()and store it: a new one is a new user. Whoever has the device id is the user, so keep it private.usernamenames a new user and renames the user on every Device sign-in; a non-emptyusernameoption wins over it.{ provider: "Custom", customId, secret, create? }signs in with the pair your backend issued after verifying the user (see Signing in through your backend). Pass a function, such assignIn: async () => fetchPairFromYourBackend(), to fetch the pair only when the client needs a credential.create: falsesigns in existing users only; without it, an unknown credential creates a new user.uniqueUserIdtogether withsignInis offered on the sign-in that creates the user, so that a user who logged in with that id before keeps their data. Pass the id your app used before it signed users in (see Keeping existing users).
On every connection, reconnects included, the client presents one sign-in input, in this order:
- The access token, while it is valid for at least another minute.
- Otherwise the refresh token.
- Otherwise the
signIncredential, calling the function when you passed one. - Without any of them, the unique user id alone.
- After a refused access token the client connects once more with the refresh token, or else the credential; after a refused refresh token, once more with the credential. It drops the refused tokens. Without a next input it stops.
- A sign-in that fails on the server's side, or that the server could not decide yet, is no refusal: its goodbye allows a reconnect, and the client signs in again with the same input and the same unique user id after the reconnect delay (see Reconnects and goodbyes). The same credential signs in the same user, and a
uniqueUserIdoffered for adoption is still offered. - Any other refusal stops the client:
onDisconnectedreports the goodbye, and a pendingconnect()rejects with aConnectionClosedErrorwhosegoodbyeholds the ReasonType and the Reason. When a login is refused lists every Reason. - On every login the client takes the unique user id the server returns, and stores new tokens when the login issued them.
This is the recommended client behaviour of Player Authentication.
Sessions
getSession() returns the user as the server last named them, as a UserSession, or null before the client knows the user:
| Field | Meaning |
|---|---|
uniqueUserId |
The user's unique user id. Private to the user. |
accessToken |
The access token of the session, or empty. |
accessTokenExpiresAt |
When the access token expires, in milliseconds since 1970 (the time of receipt plus its lifetime); 0 without one. |
refreshToken |
The refresh token of the session, or empty. It works once; every refresh replaces it. |
onSessionChanged(session)fires whenever the unique user id or the tokens change: when a login names a user the client did not know yet, after a login that issued new tokens, after the client dropped a refused token, and withnullafterlogout(). Store what it gives you and pass it back assessionto resume.- Store the session per Application ID, in the platform's secure storage where there is one, and never write it to logs. The refresh token signs the user in for up to 30 days, so treat it like a password.
- Keep one session per installation. Two clients that hold the same stored session, such as two tabs of one browser, share one refresh token, which works once: when both need it, one of them is refused and signs in with the credential again, starting a new session, or stops without one.
isSignedIn()tells whether the connection logged in with a token or a credential, so that it holds a session. Linking and unlinking sign-in methods need one (see Linking and unlinking sign-in methods).- Tokens are refreshed only when the client logs in. A connection that stays open past the access token's expiry keeps working, but linking and unlinking then answer
NotAuthenticated: calldisconnect()andconnect(), and the client logs in with the refresh token.
Logging out
logout() signs the user out: it ends the session on the server, in every region (see Revocation), forgets it (onSessionChanged gets null) and disconnects (onDisconnected reports it with requested true). The user's sessions on other devices are not affected.
try {
await client.logout();
} catch (error) {
// The session could not be ended now and is kept: offer to try again
}
- It needs a connection: with a stored refresh token and no login, it rejects with a
NotConnectedError. - A session that the server no longer knows, for example one ended from another device, counts as ended.
- When the server is temporarily unable to end the session, or no answer comes,
logout()rejects and keeps the session: call it again. - Any other refusal still forgets the session and disconnects, then rejects with the
RequestFailedError. - Without a refresh token, as after a login without sign-in, it only forgets the user and disconnects.
- After
logout(), the nextconnect()of the same client presentsuniqueUserIdor a new random id, and signs in withsignInwhen you set it.
Ephemeral app keys
An ephemeral app key (it starts with ecs_) keeps your Application secret out of the client: your server mints a key for one connection (see Ephemeral app keys). Pass a function as applicationSecret: the client calls it before every connection, reconnects included.
import { ChatClient } from "@fiveminutes-io/chat-client";
const client = new ChatClient({
serverUrl: "https://signalr.fiveminutes.cloud",
applicationId: "<application id>",
applicationSecret: async () => (await fetch("/api/fiveminutes-key")).text(),
});
- After a refused key the client calls the function once more and connects again with the new key; a second refusal stops it.
- The function's time counts against
loginTimeoutMs: an attempt whose function has not returned withinloginTimeoutMs(35 seconds by default) fails like a login that took too long. - A function that throws fails that attempt:
connect()rejects with its error, and during reconnects the client goes on with its next attempt.
Connecting and reconnecting
| Method | What it does |
|---|---|
connect(): Promise<void> |
Connects and logs in; resolves when the server has accepted the login. See the outcomes below. |
disconnect(): Promise<void> |
Closes the connection and stops reconnecting. onDisconnected reports it with requested true. A later connect() starts over. |
destroy() |
Disconnects and drops every callback and handler. The client cannot connect again. |
getConnectionState() |
Disconnected, Connecting (during connect()), Connected (logged in) or Reconnecting. |
isConnectedToServer() |
Whether the client is logged in, so that it can send. |
getReconnectionAttempts() |
The number of reconnect attempts since the last login. |
getSession(), isSignedIn(), logout() |
See Sessions and Logging out. |
connect() resolves when the server has welcomed the user. It rejects:
- with a
ClientConfigErrorbefore connecting, when a value breaks a rule (see Checks before connecting); - with a
ProtocolVersionNotServedErrorwhen the host does not serve protocol 2.0, after that one attempt (see Errors); - with a
ConnectionClosedErrorwhen the server refuses the login (error.goodbyeholds the goodbye), when the connection is lost during the login, when neither the server's welcome nor a goodbye has come withinloginTimeoutMs("The server did not complete the login in time"), when anapplicationSecretorsignInfunction does not return in time, or whendisconnect()is called meanwhile; - with the error of the connection attempt when no connection can be opened, for example without a network.
A goodbye that allows a reconnect does not fail connect(). When the server says such a goodbye during the login, for example during a server update or because the login did not complete within the server's 30 seconds, the client tries again the way it reconnects (see Reconnects and goodbyes): onReconnecting fires before each attempt, and getConnectionState() stays Connecting. connect() resolves when a later attempt logs in. It rejects with a ConnectionClosedError after the last attempt (maxAttempts), or at once with reconnect: false, with that goodbye in error.goodbye, and onDisconnected reports the goodbye too. A lost connection or a login timeout during one of these attempts fails connect() at once. With the default reconnect settings these attempts can keep connect() waiting for several minutes; to give up earlier, call disconnect(), and connect() rejects.
When connect() has rejected, the client does not try again on its own: call it again after a delay. While a connect() is under way, or the client is reconnecting, a second call waits for that login; once the client is logged in, a call resolves at once.
Reconnects and goodbyes
- A dropped connection. After a login, when the connection drops, the client opens a new connection and logs in again: after a random delay of up to 1 second, then up to 2, 4, 8 ... seconds (doubling from
initialDelayMs, at mostmaxDelayMs), for up tomaxAttemptsattempts (10).onReconnectingfires before each attempt. After the last failed attempt the client stops andonDisconnectedfires. A connection lost duringconnect()failsconnect()instead. - A goodbye. When the server says goodbye, the client closes the connection at once. When the goodbye allows a reconnect (AllowAutoReconnect true, for example during a server update), it reconnects as above, also during
connect()(see the outcomes ofconnect()above). Otherwise it stops andonDisconnectedreports the goodbye, except for the one more attempt after a refused token or a refused ephemeral app key (see Signing users in). - Every reconnect is a new login. The client calls the
applicationSecretfunction again, presents the session's tokens or the credential, and logs in as the same user. At every login the server puts the connection back into every channel the user has joined, so a reconnect needs nojoinChannel. - Waiting requests. Requests waiting for an answer reject with a
ConnectionClosedErrorwhen the connection drops or a goodbye arrives; the error'sgoodbyeholds the goodbye. While the client reconnects, sending rejects with aNotConnectedError. - A host that stops serving protocol 2.0 during a reconnect stops the client after that one attempt, and
onDisconnectedreports theProtocolVersionNotServedError. - A close that does not allow reconnecting. When the server ends the connection without a goodbye and without allowing a reconnect, the client stops as well.
- With
reconnect: falsethe client does not reconnect after a dropped connection or a goodbye that allows it;onDisconnectedfires instead. The one more attempt after a refused token or key still happens.
Callbacks
Each registration returns a function that removes the callback.
| Callback | When it fires |
|---|---|
onConnected((info) => ...) |
After every login: the first one and each one after a reconnect. info has displayId, uniqueUserId, signedIn (the login presented a token or a credential) and reconnected (the login followed a lost connection or a goodbye, not connect()). |
onDisconnected((info) => ...) |
Once, when the connection has ended and the client will not reconnect on its own. info has message (a text for logs), requested (true after disconnect(), destroy() or logout()), goodbye (reasonType by name, reason and allowAutoReconnect) when a goodbye ended the connection or started the reconnects that failed, and error, the cause, unless disconnect() ended it. It does not fire when connect() could not open a connection at all; connect() rejects instead. |
onReconnecting((info) => ...) |
Before each new connection the client opens on its own: attempt, delayMs and reason (what ended the previous connection). |
onSessionChanged((session) => ...) |
When the unique user id or the tokens change; null after logout() (see Sessions). |
onError((text) => ...) |
Texts for your logs about failures no promise reports: an automatic channel join that failed, a message the client could not read, a handler that threw. A connect() that failed before any connection opened is reported here too. |
A goodbye's reasonType is a name such as "BackendHandover"; a newer server can send a name this version does not list. Decide what to do by allowAutoReconnect and requested, and use reasonType and reason for what to show and log (see When the server closes the connection).
Every feature: send, request and on
The client sends every message a client can send, by its type name in the contracts reference, such as ClientSetUsernameRequest, with its fields as the reference names them. TypeScript checks the type name and the fields, and types the reply. The fields you leave out get their defaults, and every message gets a new MessageId and IsAckRequested true unless you pass them.
// Fire and forget: resolves with the message as sent
await client.send("ClientWhisperMessage", { Recipient: "<display id>", Content: "Well played!" });
// A request and its typed reply
const renamed = await client.request("ClientSetUsernameRequest", { Username: "Alice" });
console.log(renamed.DisplayId);
// What the server sends on its own; the returned function removes the handler
const off = client.on("ServerUserEvent", (event) => console.log(event.EventType));
send(type, fields?)resolves with the message as sent, MessageId included, once it is on its way. It does not wait for an answer.request(type, fields?, { timeoutMs }?)sends the message and waits for its answer: the typed reply that answers its MessageId, or, for a message type without a typed reply, the ServerAck. The server acknowledges a message as soon as it has received it, before any reply, so for a request with a typed reply the ServerAck says only that the request arrived, andrequestwaits on for the reply. The outcomes are in the table below.on(type, handler)calls the handler with every message of that type the server sends, except the replies that answer arequest();off(type, handler)removes it. Messages of a type this version of the package does not know are ignored.sendandrequestcheck the message before sending it, and reject at once with aMessageValidationErrorwhen it breaks a rule of its type and with aFrameTooLargeErrorwhen it is over 128 KB as sent; neither is sent (see Checks before sending).sendandrequestreject with aNotConnectedErrorbefore the login and while the client reconnects, and with aTypeErrorfor a type name that is not a client message, and for ClientCredentialsResponse and ClientUserInfoResponse, which the client sends itself at every login.- A request for a message type without a typed reply needs IsAckRequested true, the default; with false,
requestrejects with aTypeError: usesend. - One request per MessageId can wait at a time. ServerDeleteChannelResponse does not name its request, so the client lets one ClientDeleteChannelRequest wait at a time, with IsAckRequested true (the default): its ServerAck pairs the reply with it.
| What comes back | request() |
|---|---|
| The typed reply with Success true | Resolves with the reply. |
| The typed reply with Success false | Rejects with a RequestFailedError: its message is the FailureReason, failureReason holds it too, and response the whole reply, for fields such as ErrorCode. |
| The ServerAck of a message type without a typed reply | Resolves with the ServerAck. |
| A ServerAck with Success false | Rejects with a RequestFailedError: the server refused the message for breaking a rule of its type, and the FailureReason names the rule. The message was not processed. |
| The ServerAck of a request with a typed reply | Nothing yet: the request arrived, and request waits on for the reply. |
No typed reply within requestTimeoutMs (30 seconds) |
Rejects with a RequestTimeoutError (type, messageId, timeoutMs, acknowledged). The outcome is unknown. acknowledged is true when the ServerAck came: the server received the request, but its reply was lost or late, or the server dropped a repeat of a MessageId it saw in the last 5 minutes, which gets only the ServerAck. |
The connection drops, a goodbye arrives, or disconnect() is called first |
Rejects with a ConnectionClosedError. The outcome is unknown. |
After a RequestTimeoutError, repeat a read as a new request, which gets a new MessageId. Repeat a write only when it is safe to repeat (a chat message sent twice is posted twice), and handle a score submission or a metric event as below. Replies and acknowledgements explains what the server sends.
Helpers
The helpers are requests, with the checks and the outcomes above.
| Method | Resolves with |
|---|---|
fetchRemoteConfig() |
ServerRemoteConfigResponse: the user's public remote config entries and experiment assignments. The client keeps the last full answer of the user for its lifetime and presents its ETag; when nothing changed, the reply has NotModified true and the client fills in the kept Entries and Assignments. See Fetch remote config. |
reportMetricEvent(name, value?, { MessageId }?) |
ServerReportMetricEventResponse. Pass a value for a Number or Decimal event and none for a Binary event. See Report a metric event. |
getLeaderboard(key, { Window, Offset, Limit, CycleId, GroupMoniker }?) |
ServerGetLeaderboardResponse: by default the 50 records around the user in the current cycle; { Window: "Top" } reads from the top. See Read a leaderboard view. |
getLeaderboardRecord(key, cycleId?) |
ServerGetLeaderboardRecordResponse: the user's own record, with Found false when there is none. |
submitScore(key, score, { Subscore, Metadata, MessageId }?) |
ServerSubmitScoreResponse. See Submit a score. |
logout() |
Nothing: the session is ended and forgotten, and the client disconnected (see Logging out). |
const config = await client.fetchRemoteConfig();
const maxPlayers = Number(config.Entries.find((e) => e.Key === "max_players")?.Value ?? "8");
const result = await client.submitScore("weekly_arena", 18250);
const page = await client.getLeaderboard("weekly_arena", { Limit: 10 });
await client.reportMetricEvent("level_complete");
Resending a score or a metric event
The server applies a score submission or a metric event once, recognising it by its MessageId for 24 hours. When submitScore or reportMetricEvent rejects with a RequestTimeoutError, the outcome is unknown:
error.acknowledgedfalse: it may not have arrived. Send it again unchanged, with the same MessageId (error.messageId), at most 3 times within 5 minutes.error.acknowledgedtrue: the server has it. A resend within 5 minutes of the previous send gets only another acknowledgement and times out again, so wait until 5 minutes have passed since the last send, then send it once more, unchanged and with the same MessageId, to learn the outcome.
import { RequestTimeoutError } from "@fiveminutes-io/chat-client";
import { newMessageId } from "@fiveminutes-io/contracts";
const messageId = newMessageId(); // keep it until the outcome is known
try {
const result = await client.submitScore("weekly_arena", 18250, { MessageId: messageId });
showResult(result.Accepted, result.Score, result.Rank);
} catch (error) {
if (error instanceof RequestTimeoutError) {
// Unknown outcome: resend unchanged with { MessageId: messageId }. Not acknowledged: at most
// 3 times within 5 minutes. Acknowledged: once more, 5 minutes after the last send
}
}
- A
ConnectionClosedErrorcarries no MessageId. To resend after a lost connection, make the MessageId yourself withnewMessageId()from the contracts package, as above, and pass it with every send of that score or event. - Resend only an unchanged message: a score submission with the same MessageId and another board, score, subscore or metadata is refused with
This message was already used for a different submission. - A metric event is recognised only in the region that accepted it. A client that may resend one after a reconnect connects to a regional host, such as
https://signalr.fiveminutes.cloud, rather than to the global host. - Details: After a lost response for scores and Resending a metric event for metric events.
ChatClient
| Method | What it does |
|---|---|
sendChatMessage(content, channel?): Promise<void> |
Sends a chat message of 1 to 16,384 characters to a channel the user has joined; without channel it sends to Global. Resolves when the message is sent; an empty or longer text rejects with a MessageValidationError and is not sent. sendMessage is the same method. |
joinChannel(channel): Promise<void> |
Joins a channel. Resolves when the server has confirmed the join, and rejects with a RequestFailedError when it refuses it, for example with You are banned from this channel. |
fetchChannelHistory(channel): Promise<ChatMessageEvent[]> |
Resolves with the first page of the channel's history. For a channel that does not exist it rejects with a RequestFailedError whose FailureReason is Channel not found.. Read further pages with request("ClientChannelHistoryPagedRequest", { ChannelName, PageKey }), with PageKey set to the NextPageKey of the previous page. |
onMessageReceived((message) => ...) |
For every chat message in a channel the user has joined, the user's own included. |
onWhisperReceived((message) => ...) |
For every whisper to or from the user, including notices from the server. |
A ChatMessageEvent has messageId, content, sender (the sender's name, or display id), timestamp, channel and metadata. A WhisperMessageEvent has messageId, content, sender, receiver, timestamp and metadata. A refused chat message arrives as a whisper from a system user; see Fire-and-forget: sending a chat message. Send a whisper with send("ClientWhisperMessage", { Recipient, Content }), addressing the recipient by display id. A failure of sendChatMessage other than a NotConnectedError is also reported through onError.
import { ChatClient, ConnectionClosedError, ProtocolVersionNotServedError } from "@fiveminutes-io/chat-client";
const chat = new ChatClient({
serverUrl: "https://signalr.fiveminutes.cloud",
applicationId: "<application id>",
applicationSecret: "<application secret>",
username: "Ada",
uniqueUserId: storedUserId, // the same user on every visit
autoJoinChannel: "general", // joined after every login
});
chat.onMessageReceived((m) => showLine(m.channel, m.sender, m.content));
chat.onWhisperReceived((w) => showWhisper(w.sender, w.content));
chat.onDisconnected((info) => showOffline(info.message));
try {
await chat.connect();
} catch (error) {
if (error instanceof ProtocolVersionNotServedError) {
// The host does not serve protocol 2.0: tell the user, do not retry in a loop
} else if (error instanceof ConnectionClosedError && error.goodbye) {
// The login was refused: error.goodbye.reasonType and error.goodbye.reason say why
}
throw error;
}
await chat.sendMessage("Hello!", "general");
SupportClient
| Method | What it does |
|---|---|
createSupportTicket(topic, message): Promise<string> |
Creates a ticket and resolves with its id; onTicketCreated fires with it too. Rejects with a MessageValidationError, without sending, when the topic has more than 512 characters, the message more than 32,768, or both are empty, and with a RequestFailedError when the server refuses the ticket. |
getSupportTicket(ticketId): Promise<SupportTicket> |
Asks for one of the user's tickets. Resolves with { id, messages: [] }; the ticket's messages then arrive one by one through onSupportMessageReceived. Rejects with a RequestFailedError, for example with Support ticket not found. |
sendSupportMessage(ticketId, message): Promise<void> |
Adds a message to the ticket and resolves when it is sent; an empty message rejects with a MessageValidationError and is not sent. The server answers with the message itself, through onSupportMessageReceived. |
onSupportMessageReceived((message) => ...) |
For every message of the user's tickets, from the user and from your support staff. |
onTicketCreated((ticketId) => ...) |
After createSupportTicket has created a ticket. |
onWhisperReceived((message) => ...) |
For every whisper to or from the user. |
A SupportMessageEvent has messageId, ticketId, content, sender and timestamp. The timestamp is the send time only in the server's answer to the user's own message; fetched messages and replies from your support staff carry the minimum date (year 1). Show ticket messages in the order they arrive and drop duplicates by messageId; see Support ticket fetching for how the server delivers them. A WhisperMessageEvent has messageId, content, sender, receiver and timestamp.
A ticket belongs to the unique user id that created it, so log in as the same user whenever the user comes back to a ticket: with the same uniqueUserId, or signed in. As another user, getSupportTicket fails with Support ticket not found. A ticket's topic can have at most 512 characters and its first message at most 32,768; a later message has no limit of its own beyond the 128 KB of a message as sent. The client checks these rules before sending (see Checks before sending).
import { SupportClient } from "@fiveminutes-io/support-client";
const support = new SupportClient({
serverUrl: "https://signalr.fiveminutes.cloud",
applicationId: "<application id>",
applicationSecret: "<application secret>",
username: "Ada",
uniqueUserId: "ada@example.com", // an e-mail address can be the unique user id, not the name
});
support.onSupportMessageReceived((m) => showTicketLine(m.ticketId, m.sender, m.content));
await support.connect();
const ticketId = await support.createSupportTicket("Billing", "How do I upgrade my plan?");
await support.sendSupportMessage(ticketId, "I pay by card.");
Errors
All are exported by both client packages.
| Error | When | What to do |
|---|---|---|
ClientConfigError |
connect() found a value the server would refuse, before connecting; or an applicationSecret or signIn function returned one. field names the option, such as username. |
Fix the value (see Checks before connecting). |
ProtocolVersionNotServedError |
The host refused the connection to the path of protocol 2.0, before any login: it does not serve protocol 2.0. connect() rejects with it after that one attempt; on a reconnect the client stops, and onDisconnected reports it. It carries protocolVersion ("2.0") and url (the URL it tried). When connectionOptions sets skipNegotiation, the client cannot recognise the refusal and reports an ordinary connection error instead. |
Do not retry in a loop: the answer does not change soon. Check that serverUrl names a Five Minutes host (see Hosts). If it does, the server has not been updated to protocol 2.0 yet: tell the user that the service is unavailable and try again much later, after minutes rather than seconds. With skipNegotiation, treat a connection error that comes back on every attempt the same way. |
ConnectionClosedError |
The connection ended before the login completed or before an answer came: a refused login or a goodbye (goodbye holds reasonType, reason and allowAutoReconnect), a login that did not complete in time, a lost connection, or disconnect(). |
For a refused login, act as When the server closes the connection and When a login is refused say, and do not retry it in a loop. For a waiting request, the outcome is unknown. |
NotConnectedError |
A message was to be sent before connect() resolved, while the client reconnects, or after the connection ended. |
Send after await connect(), or from onConnected. |
MessageValidationError |
A message breaks a rule the server checks it by, found before sending. type is the message type, field the field at fault, such as Limit or Metadata[1].Key (empty when the message as a whole is at fault), and the message says what is wrong. Nothing is sent, and the connection stays open. |
Fix the value: the same message fails the same way every time. See Checks before sending. |
RequestFailedError |
The server answered a request with Success false. Its message is the FailureReason; response is the whole reply. |
The feature's document explains each FailureReason. |
RequestTimeoutError |
No reply within requestTimeoutMs. type, messageId and timeoutMs describe it; acknowledged says whether the server acknowledged the request, that is, received it. |
The outcome is unknown: repeat a read as a new request; resend a score or a metric event as above. |
FrameTooLargeError |
A message is larger than the server accepts: 128 KB (131,072 bytes) per message as sent, counted over the whole message, not only its text. The message is not sent and the connection stays open. It carries type, bytes (the message's size as sent) and limit. |
Shorten or split the text. A chat message within its own limit of 16,384 characters fits, unless it carries large metadata. |
A connect() on a client after destroy() rejects with an Error.
Checks before sending
The client checks every message before it sends it, by the rules the server checks it by: in send, request, the helpers and the methods of ChatClient and SupportClient. A message that breaks a rule rejects at once with a MessageValidationError and is not sent, and the connection stays open. Without the check the server would refuse such a message: the request would reject with a RequestFailedError that names the rule, and more than 10 refused messages within 10 seconds would close the connection (see Replies and acknowledgements).
- The rules of each message type, which the contracts reference and the feature documents give: for example a chat message of 1 to 16,384 characters, a whisper of 1 to 1,024 characters, a leaderboard key of 1 to 128 characters, a
Limitof 1 to 100, a metric event name of 1 to 64 lower-case letters, digits or_, metadata keys of 1 to 128 characters, and event types of one kind (users or channels) in one subscription. - The value of every field, as the contracts package types it: texts without half a character (an unpaired surrogate), GUIDs, ISO 8601 date-times, whole numbers within the field's range, finite numbers,
trueorfalse, enums by their exact names ("Top", not"top"or a number), flags as names joined by", "(see Enums by name), andnullonly where a field is typedT | null. TypeScript keeps your code to most of these; the checks also catch values from plain JavaScript, stored data or user input.
error.type is the message type, error.field the field at fault, such as Limit, Metadata[1].Key or OtherUserIds[0] (empty when the message as a whole is at fault), and the error's message says which rule it broke. The checks look at the message only: whether a board, channel or ticket exists, and what the user may do, is the server's answer, for example a RequestFailedError with Unknown leaderboard '<key>'.
validateClientMessage(type, message) checks a message the same way without sending it, and returns it or throws the MessageValidationError. It takes a complete message, as send sends it: build one from your fields with createClientMessage(type, fields), which fills in the defaults and a new MessageId as send does. Both come from the contracts package, and both client packages export them.
import { MessageValidationError, createClientMessage, validateClientMessage } from "@fiveminutes-io/chat-client";
try {
await client.getLeaderboard("weekly_arena", { Limit: 500 });
} catch (error) {
if (error instanceof MessageValidationError) {
// error.type "ClientGetLeaderboardRequest", error.field "Limit",
// error.message "Parameter 'Limit' must be 1-100"; nothing was sent
}
}
// Check a text before offering to send it, for example from a form
const draft = createClientMessage("ClientChatMessage", { ChannelName: "general", Content: text });
try {
validateClientMessage("ClientChatMessage", draft);
} catch (error) {
if (error instanceof MessageValidationError) showError(error.message); // empty or over 16,384 characters
}
Device ids
createDeviceId() returns a new random id, a version 4 UUID from the platform's secure random source: crypto.randomUUID(), or crypto.getRandomValues() where the former is missing, as on pages served over http. It throws when neither exists.
- Make a device id once per installation and store it, for example in
localStorage: a new one is a new user. - A device id signs the user in with Device. Never show it, log it or send it anywhere else.
- The client uses the same source for the random unique user id it makes up without
uniqueUserId. Where there is no secure random source,connect()rejects with aClientConfigErrorforuniqueUserId: pass one.
Enums by name
The contracts package writes every enum as a frozen object of its names, with a string union type of the same name. A value is the member's name, as the server sends it:
import { EventTypeFlags, Language, ServerGoodbyeReasonTypes } from "@fiveminutes-io/contracts";
client.onDisconnected((info) => {
if (info.goodbye?.reasonType === ServerGoodbyeReasonTypes.ServiceCapReached) {
// The same as info.goodbye.reasonType === "ServiceCapReached"
}
});
const translateTo = Language.English; // "English"
const events = EventTypeFlags.of("UserConnected", "UserDisconnected"); // "UserDisconnected, UserConnected"
EventTypeFlags.has(events, "UserConnected"); // true
EventTypeis a flags enum: a field typedEventTypeFlagsholds one name or several joined by", ", in the order the server writes them. Build one withEventTypeFlags.of(...), read it withEventTypeFlags.names(value)and test it withEventTypeFlags.has(value, name).- A newer server can send a name this package does not list, for example a new ReasonType. Handle an unknown name like one you have no special handling for, and decide on reconnecting by AllowAutoReconnect.
- The contracts reference lists every enum with its members. It also gives their numbers, which the npm packages do not use.
The contracts package
@fiveminutes-io/contracts gives the clients, and your code, the types:
| Export | What it is for |
|---|---|
| The interfaces | One per message and record, named as in the contracts reference, with every field as the server names it and its description as a doc comment. Ids and date-times are strings (GUIDs, and ISO 8601 date-times in UTC); 64-bit numbers are number, exact up to 2^53. A field the server may leave empty is typed T | null. |
ClientWireTypes, ServerWireTypes, ClientMessage, ServerMessage |
The interface of every message by its type name, and unions that narrow on the type name. isClientWireType(type) and isServerWireType(type) tell whether a type name is one of them. |
| The enums | By name; see Enums by name. AuthProviderNames holds the sign-in provider names (Device, Custom). |
newMessageId() |
A new MessageId, for a score submission or a metric event that you may have to resend (see Resending a score or a metric event). |
validateClientMessage, createClientMessage, MessageValidationError |
The checks the clients make before sending, for checking a message without sending it, and the complete message send builds from your fields. Both client packages export them too (see Checks before sending). |
MAX_FRAME_BYTES, FrameTooLargeError, ProtocolVersionNotServedError |
The size limit of a message as sent (131,072 bytes) and the two errors the clients take from this package. |
Root and subpaths
The root, @fiveminutes-io/contracts, exports everything. Each subpath exports one feature: its interfaces, its enums and its constants. A subpath loads only its feature and /common, so a bundler leaves the other features out. A name is the same whichever way it is imported, so root and subpath imports mix freely.
| Import from | What it holds |
|---|---|
@fiveminutes-io/contracts |
Everything below, plus the helpers of the whole protocol: ClientWireTypes, ServerWireTypes, ClientMessage, ServerMessage, createClientMessage, isClientWireType, isServerWireType, validateClientMessage, MessageValidationError. |
@fiveminutes-io/contracts/common |
Login, user info, metadata, acknowledgements and goodbyes (ServerWelcome, UserInfo, ServerGoodbyeReasonTypes, AuthProviderNames, ...), and newMessageId, MAX_FRAME_BYTES, ProtocolVersionNotServedError, FrameTooLargeError. |
@fiveminutes-io/contracts/chat |
Channels, chat messages and whispers, and Language. |
@fiveminutes-io/contracts/social |
Event subscriptions and personal groups, and EventType, EventTypeFlags. |
@fiveminutes-io/contracts/leaderboards |
Scores and boards. |
@fiveminutes-io/contracts/remote-configuration |
Remote config, experiments and metric events. |
@fiveminutes-io/contracts/support |
Support tickets. |
@fiveminutes-io/contracts/voice |
Peer-to-peer stream negotiation. |
import { newMessageId, ServerGoodbyeReasonTypes } from "@fiveminutes-io/contracts/common";
import type { ServerGetLeaderboardResponse } from "@fiveminutes-io/contracts/leaderboards";
import type { ServerRemoteConfigResponse } from "@fiveminutes-io/contracts/remote-configuration";
TypeScript resolves the subpaths with moduleResolution node16, nodenext or bundler. The older node setting sees only the root.
Behaviour and limits
| Topic | Behaviour |
|---|---|
| Protocol | Protocol 2.0, on the path of protocol 2.0 of the host (/signalr/v2.0), which sets the protocol version of the connection. The packages speak this version only; there is no fallback to another version or to the path /signalr. |
| Login | The client runs the login on every connection itself. connect() resolves and onConnected fires when the server has welcomed the user. The server says goodbye to a connection that has not logged in within 30 seconds of connecting, allowing a reconnect, and the client then tries again with the reconnect backoff, also during connect(). A login attempt that neither a welcome nor a goodbye ends within 35 seconds (loginTimeoutMs) fails. |
| Calls before the login | Sending rejects with a NotConnectedError until the login has completed and while the client reconnects, so nothing reaches the server before its welcome. |
| Reconnects | After a dropped connection, and after a goodbye that allows it: a new connection and a new login, after a random delay of up to 1, 2, 4 ... seconds, at most 60, for up to 10 attempts (reconnect). Every reconnect presents the session's tokens or the credential, calls the applicationSecret function again and keeps the same user. |
| Goodbyes | The client closes the connection at once. It reconnects when the goodbye allows it; otherwise onDisconnected reports the goodbye, with its ReasonType by name. |
| Answers | A request waits up to 30 seconds (requestTimeoutMs) for its answer, and rejects at once when the connection drops or a goodbye arrives. |
| Message fields | Every message is checked before it is sent, by the rules of its type and the type of each field; one that breaks a rule rejects with MessageValidationError and is not sent (see Checks before sending). |
| Message size | 128 KB per message as sent; a larger one rejects with FrameTooLargeError and is not sent. |
| Message pacing | As for every client: 10 times the "Messages Per Second Per User" of your plan per 10 seconds; see Pacing. |
| Connection attempts | Each connection makes HTTP requests that count against the edge limit of 5 requests per second per IP address; see Pacing. The random reconnect delays spread the attempts of many users behind one IP address. |
| Remote config cache | The last full answer of fetchRemoteConfig() is kept in memory, per user, for the lifetime of the client instance. To keep it across page reloads, store the answer yourself (see Fetch remote config). |
Moving from the 1.8 packages
The 2.0 packages replace @fiveminutes-io/five-minute-chat-contracts 1.8.x and the clients 1.0.x. The CHANGELOG.md of each package lists every change; this is what to change in your code.
With the chat or support client
- Install the 2.0 clients with
@fiveminutes-io/contracts2.0.x, and remove@fiveminutes-io/five-minute-chat-contracts; the clients do not work with contracts 1.8. - Set
serverUrlto the host, such ashttps://signalr.fiveminutes.cloud. A URL ending in/signalrstill works and is taken for the host it names. - Rename
deviceIdtouniqueUserId:connect()refuses adeviceIdoption with aClientConfigErrorrather than ignore it. For a Device sign-in, the device id goes intosignIn.deviceId.usernameis now optional; an empty one keeps the user's name, and an e-mail address is refused as a name (it can be the unique user id). - Call the other methods after
await connect(), which now resolves when the login has completed. Remove code that waited foronConnectedbefore the first call, or that skipped its extra call before a reconnect's login:onConnectednow fires once per login. - Handle a rejected
connect():ClientConfigErrorbefore connecting,ConnectionClosedErrorfor a refused login (with the server's goodbye), a lost connection or a login not completed, andProtocolVersionNotServedError. A goodbye that allows a reconnect makesconnect()try again with the reconnect backoff before it rejects; calldisconnect()to give up earlier. - Read
onConnected(info)andonDisconnected(info)as objects (ConnectedInfo,DisconnectInfo) instead of texts, and move reconnect reporting from the "Reconnecting..." texts ofonErrortoonReconnecting. - Remove your own reconnect logic: the client reconnects with a random, growing delay for 10 attempts, also after a goodbye that allows it, and
onDisconnectedfires once, when it will not reconnect. Thereconnectoption changes this. - Expect the states
Disconnected,Connecting,ConnectedandReconnectingfromgetConnectionState(). - Catch the new errors where you send:
NotConnectedError,MessageValidationError,RequestFailedError,RequestTimeoutError(30 seconds by default;acknowledgedsays whether the server received the request),ConnectionClosedErrorandFrameTooLargeError.joinChannelnow waits for the server's confirmation and rejects when the server refuses the join. - Keep the function a callback registration returns if you want to remove the callback later. The
ConnectionStateEventtype is gone. - With
autoJoinChannelset, the client now joins that channel after every login; drop ajoinChannelcall that does the same. - Load the packages as ES modules;
require()from CommonJS needs Node 22.12 or later.
The 2.0 clients also have what the 1.0 clients lacked: sign-in and sessions (signIn, session, getSession(), onSessionChanged, isSignedIn(), logout()), an applicationSecret function for ephemeral app keys, every message through send, request and on, and the helpers for remote config, metric events and leaderboards. A web app that used its own server and the Server API for these features can now use the client instead.
With the contracts package on its own
- Replace
@fiveminutes-io/five-minute-chat-contractswith@fiveminutes-io/contractsin your dependencies and imports. Each feature can also be imported on its own (see Root and subpaths). - Send and receive messages through a 2.0 client:
send,requestandontake every message of the package by its type name. There is no message to construct withnew: messages and records are interfaces now. - Drop
$typeandApiVersion, and theMessageBaseandVersionclasses: messages no longer carry them. - Compare enum values by name (
ServerGoodbyeReasonTypes.BackendHandover,"English"), not by number, and build and read EventType values withEventTypeFlags. - Read FailureReason instead of Reason on ServerJoinChannelResponse, ServerLeaveChannelResponse, ServerSetMetadataResponse, ServerSetUsernameResponse, ServerSubscribeEventResponse, ServerUnsubscribeEventResponse, ServerUserInfoResponse and ServerUserInfosResponse. Stop reading UserInfo.Id and ServerChatMessage.ApplicationId, and stop sending UniqueUserId in ClientWhisperHistoryRequest and ClientWhisperHistoryPagedRequest: protocol 2.0 does not carry them.
- Follow the types in your null checks: a field is
T | nullonly where the server may leave it empty. - Load the package as an ES module, as above.
Related documents
- Five Minute wire protocol 2.0: the protocol, hosts, goodbyes, pacing and the chat and support ticket flows.
- Five Minute API Contracts 2.0: every message and record of protocol 2.0, with its fields.
- Five Minute Player Authentication 1.0: sign-in, sessions and tokens, logout, linking sign-in methods and Require authenticated sessions.
- Five Minute Remote Config 1.0: remote config, experiments and metric events.
- Five Minute Leaderboards 1.0: boards, scores, cycles and ranking.
- Five Minute Server API 1.0: your server's HTTPS API, including minting ephemeral app keys.
- npm packages 1.8: the earlier packages, at protocol 1.8.
- Unity Asset 1.20: the other client library.
