Introduction
This documentation refers to version 1.0 of Five Minute Remote Config.
Five Minute Remote Config keeps your app's tunable settings on the server instead of in the build, lets you test alternative values of a setting on your users, and measures which value does better. It has three parts:
- Entries: named settings (a drop rate, a welcome text, a feature switch, a JSON block of level parameters) that your client fetches over its connection and your server reads and writes over HTTPS.
- Experiments: A/B tests on one entry. Every logged-in user who fetches while the experiment runs is assigned to one variant and receives that variant's value.
- Metric events: named events that your client or your server reports for a user, such as
level_completeorcoins_spent. They are the success metric an experiment is judged by.
Your server defines entries, metric events and experiments, reads results and reports events on a user's behalf. Your client fetches its configuration and reports events for its own user. You can also manage entries, metric events and experiments and read results in the dashboard, on the application's Remote config page.
Requirements
- For the client: protocol 2.0 and a completed login. The messages in this document exist only at protocol 2.0. The npm packages 2.0 have them, as
fetchRemoteConfig()andreportMetricEvent()(see npm packages 2.0). The Unity asset does not: it speaks protocol 1.7 or earlier (asset 1.20 speaks protocol 1.6, asset 1.23 speaks protocol 1.7; see Client libraries). For an app built on it, your server can read what a user receives and report events on the user's behalf (see From your server). - For your server: an API key with the scopes of the routes you call (see Calling the Server API).
Quick facts for AI agents
- Covers: remote config entries, experiments (A/B tests on one entry), metric events and the events endpoint, from the client (wire messages) and from your server (REST).
- Build with: your server calls the routes below over HTTPS; the client sends the messages below through a Five Minutes client library, never through its own implementation of the protocol. In JavaScript, the npm packages 2.0:
fetchRemoteConfig()andreportMetricEvent(name, value?)(see npm packages 2.0). The Unity asset has no remote config or metric events; for an app built on it, read and report for users through your server. - Your server: base URL
https://signalr.fiveminutes.cloud/api/v1. JSON bodies needContent-Type: application/json. Routes:GET,PUT,DELETE /remote-config(Application.RemoteConfig.Readfor GET,Application.RemoteConfig.Writeotherwise);PUT /experiments,GET /experiments,GET /experiments/assignment,GETandDELETE /experiments/{key},GET /experiments/{key}/results,POST /experiments/{key}/start,/stop,/promote,GET,PUT,DELETE /metric-events,POST /metric-events/archive,/restore(Application.Experiments.Readfor GET,Application.Experiments.Writeotherwise);POST /events(Application.Events.Write). Write does not imply Read. No route takes an application ID: the token decides the application. - Server auth:
POST /api/v1/api-clients/authenticatewith{"applicationId":"<application id>","clientSecret":"<API key secret>"}returns{"token","expiresInSeconds":600,"scopes"}; sendAuthorization: Bearer <token>. - Client: protocol 2.0; messages
ClientRemoteConfigRequest->ServerRemoteConfigResponseandClientReportMetricEventRequest->ServerReportMetricEventResponse; send only after ServerWelcome; a new MessageId per message; match replies on ResponseToMessageId.- A fetch before login closes the connection. Send
ETag""or your cached ETag. NotModifiedtrue meansEntriesANDAssignmentsare empty: keep both from your cache.- The user and the application of a reported event come from the connection.
- A fetch before login closes the connection. Send
- Identifiers: entry key
^[A-Za-z0-9_.-]{1,128}$; experiment key and metric event name^[a-z0-9_]{1,64}$; variant key^[a-z0-9_]{1,32}$; unique user id (UniqueUserId/uniqueUserId) 1-256 characters; idempotency key 1-128 characters. - Limits: value at most 64 KB (65,536 UTF-8 bytes), always sent as a JSON string; 2-5 variants with whole weights 0-100 that total 100; one Draft or Running experiment per entry; event values at most 10^15 in magnitude, Decimal kept to 15 significant digits and rounded to 6 decimals; running experiments per application Free 1, Subscription 5 (403 on start).
- Retries: every call except
POST /eventsis safe to repeat (a repeated start or stop answers409, a repeated delete404).POST /eventsis safe only with an idempotency key (bodyidempotencyKeyorIdempotency-Keyheader, 24 h, per region). On the wire, resend an unanswered metric event with the SAME MessageId, at most 3 times within 5 minutes; when itsServerAckcame but no typed response, the server has the event: wait 5 minutes after the last send and resend once more to get the outcome. AfterSuccess=falsewith a temporary reason, send it with a NEW MessageId. Every other wire message is retried with a new MessageId, except a leaderboard score submission (see Five Minute Leaderboards). - Does not exist: server push of config changes; a traffic allocation or holdout share (everyone in the window gets a variant); a GET for a single entry (read the list); a fetch before login; restarting a stopped experiment; renaming a metric event; statistical significance; refusals for event volume (events are metered, never refused); backdated events (no timestamp field); batch endpoints.
- Related: Five Minute Server API, Five Minute wire protocol, Five Minute Player Authentication, Five Minute API Contracts.
Documentation set
- Five Minute Server API 1.0: hosts, API keys, the token exchange, scopes, the error model, rate limits and idempotency for every call your server makes.
- Five Minute wire protocol 2.0: how a client connects, logs in and exchanges messages.
- Five Minute Player Authentication 1.0: where the unique user id comes from.
- Five Minute API Contracts 2.0: the field-level reference of every message type.
- Five Minute Remote Config 1.0 (this document): entries, experiments, metric events and their messages and endpoints.
Concepts
Application ID. The GUID of your application. Your server never sends it to the routes in this document; the server token carries it.
API key. The credential your server exchanges for a server token (dashboard: Secrets & API keys > "API Clients" > "Create API key"). It consists of a key ID (shown as "client ID") and a secret.
Server token. The token returned by the token exchange. Your server sends it as Authorization: Bearer <server token>.
Unique user id. The id of one user of your application: UniqueUserId on the wire and uniqueUserId in REST. The login decides it; see Five Minute Player Authentication. Experiments assign and count users by this id.
Logged-in user. A client that has completed its login and received ServerWelcome, whether it signed in with a token or a credential or logged in the legacy way with a self-declared id. Remote config, experiments and metric events work the same for every logged-in user: a legacy login gets variants, is exposed and reports events under the id it declared.
Entry. One setting of one application: a key, a value type, a value and a visibility.
Base value. The value stored in an entry. Users receive it whenever no running experiment substitutes another value.
Public entry and server-only entry. A public entry (isPublic: true) is delivered to every client that fetches. A server-only entry (isPublic: false) is readable only by your server and in the dashboard.
ETag. An opaque string that identifies exactly what one fetch delivered: the entries with their values and the user's assignments. The client stores it and presents it on its next fetch.
Experiment. A test of alternative values of one entry, judged by one metric event and a goal (Increase or Decrease). It is addressed by its key.
Variant. One arm of an experiment: a key, a name, a value for the entry and a weight.
Control. The first variant of an experiment. Lift is measured against it.
Weight. The whole percentage of users a variant receives, from 0 to 100. The weights of an experiment total 100.
Run. The period from an experiment's start to its stop. An experiment runs at most once, so it has at most one run; the run has a random salt and a frozen copy of the variants.
Salt. A random value created when the run starts. With the unique user id it decides the user's variant. REST reads show it as an opaque string.
Window. The optional startsAt and endsAt of an experiment, in UTC. Outside the window a running experiment assigns nobody.
Assignment. The pair of experiment key and variant key that says which variant a user is in.
Exposure. The first time a user actually receives a variant's value, on a fetch or on your server's per-user read. Only exposed users count in the results.
Metric event. A named event in the application's catalogue. Only configured names are accepted.
Report. One occurrence of a metric event for one user, sent by the client or by your server.
Conversion. For a Binary metric event, a user who reported the event at least once after their exposure.
Attribution. Counting a report against every experiment run that measures the event and in which the user is exposed.
Leading variant. The variant with the best number for the goal so far. It is a label, not a claim of statistical significance.
Result. The leading variant at the moment a run ends, recorded on the run.
Promote. Writing a variant's value into the entry, so it becomes the base value for everyone.
Entries
An entry belongs to one application and is identified by its key. Keys are unique within the application.
Keys. A key is 1-128 characters of letters, digits, _, . and - (^[A-Za-z0-9_.-]{1,128}$), for example max_players or events.halloween.enabled. Do not rely on letter case to tell keys apart: writing Max_Players while max_players exists may update the existing entry instead of creating a second one. Choose one spelling, lower-case is simplest, and use it everywhere, including in your app's code that looks keys up.
Value types. Every entry declares a type, and its value must parse as that type. Values are always carried as text.
| Type | Accepted value | Example |
|---|---|---|
String |
Any text, including the empty string. | Welcome, pilot! |
Int |
A 64-bit integer: an optional + or -, then digits (-9223372036854775808 to 9223372036854775807). No spaces, no thousands separators, no decimal point. |
-42 |
Float |
A finite number in invariant notation: optional sign, digits, optional . and fraction, optional exponent (1.5e3). Surrounding spaces are accepted. The decimal separator is always .; there are no thousands separators. NaN and Infinity are refused. |
0.25 |
Bool |
true or false, in any letter case, surrounding spaces accepted. |
true |
Json |
One complete JSON document of any kind: object, array, string, number, true, false or null. No comments, no trailing commas, at most 64 levels of nesting. Not empty. |
{"cols":3,"rows":4} |
- Clients receive the type as
RemoteConfigValueType, with the same five names; REST writes it by name:"String","Int","Float","Bool","Json". - Every value, of every type, is at most 64 KB: 65,536 bytes of UTF-8.
- Values are stored and delivered exactly as written. The server checks that a value parses but never rewrites it:
+8,1.5E3andTrueare delivered as+8,1.5E3andTrue. Write canonical forms (8,1500, lower-casetrueandfalse) and parse on the client with the same rules: invariant culture, case-insensitive forBool. - The same rules apply to every variant value of an experiment, checked against the type of the experiment's entry.
Visibility. A public entry reaches every client that fetches, so anyone who can read your client's traffic or memory can read it. A server-only entry (the default) never reaches clients: your server reads it over REST and you see it in the dashboard. Keep anything a user must not see, such as an economy table you do not want data-mined, server-only.
Changing an entry. A write replaces the entry's value, type and visibility in place; you can change the type of an existing entry. Two rules apply while a Draft or Running experiment uses the entry:
- Changing the type or the visibility is refused with
409This entry has an active experiment. - Changing the value is allowed, but inside a running window every logged-in user who fetches receives a variant's value, not the base value. A new base value reaches nobody who is logged in until the window ends or the experiment stops.
Changes reach clients on their next fetch, in every region: usually within seconds, at most 5 minutes. Nothing is pushed to connected clients.
Deleting an entry. An entry that any experiment uses, in any status, cannot be deleted. Delete those experiments first.
Experiments
An experiment tests alternative values of one entry. Instead of changing max_players from 8 to 16 for everyone, you run both: some users get 8, some get 16, and a metric event tells you which does better.
Variants. An experiment has 2 to 5 variants: the control first, then 1 to 4 alternatives. Each variant has:
- a key of 1-32 lower-case letters, digits or
_(^[a-z0-9_]{1,32}$), unique within the experiment; - a name of 1-128 characters;
- a value that parses as the entry's type, with the same rules and 64 KB limit as the entry's own value;
- a weight: a whole percentage from 0 to 100. The weights total exactly 100. A variant with weight 0 receives nobody.
Everyone in the window takes part. There is no traffic share and no holdout. Every logged-in user who fetches while the run is inside its window is assigned to exactly one variant and receives that variant's value. Users assigned to the control receive the control's value, even when it differs from the entry's base value. To keep most users on today's value, give the control today's value and a large weight, for example 90 against 10.
Stickiness. A user's variant depends only on the run's salt and the unique user id. The user keeps it on every fetch, every reconnect, every device and in every region for the whole run. See Bucketing and stickiness.
One active experiment per entry. At most one Draft or Running experiment may use an entry. A second one is refused with 409 An experiment is already active on this entry. Stopped experiments do not count, so after a stop you can define the next experiment on the same entry.
The metric and the goal. An experiment names one metric event from the catalogue (metricEventName) and a goal: Increase when a higher number is better, Decrease when a lower one is. The metric event must exist and must not be archived.
Statuses. The API reports three statuses: Draft, Running and Stopped. Derive the other states from the window:
| Status | Derived state | When | What a user receives |
|---|---|---|---|
Draft |
draft | Defined, never started. | The base value. |
Running |
scheduled | Started, and startsAt is later than now. |
The base value. No assignment, no exposure. |
Running |
live | Started, and now is inside the window: at or after startsAt (when set) and before endsAt (when set). |
Their variant's value, with the assignment. |
Running |
ended | Started, and endsAt has passed. Five Minutes stops it within about 1 minute. |
The base value. |
Stopped |
stopped | Stopped by you, or by the end of its window. Final. | The base value, or the promoted value after a promotion. |
A stop is final. A stopped experiment never starts again, and its definition no longer changes: only its name, its description and its variant names do. To test again, create a new experiment with a new key; it gets a new salt, so users are split afresh.
Metric events
A metric event is a name in your application's catalogue. Reports of a name that is not in the catalogue are refused (The event is not configured for this application.); names are never created automatically, so a typo in a build shows up as refusals instead of a new metric.
Names. 1-64 lower-case letters, digits or _ (^[a-z0-9_]{1,64}$). A name never changes, because shipped builds report by name.
Types.
| Type | A report carries | How reports count | The number an experiment is judged on |
|---|---|---|---|
Binary |
No value. | Users: an exposed user counts once per run, however often they report. | Rate: converted users divided by exposed users. |
Number |
A whole number, at most 10^15 in magnitude. | Every report: its value is added to the variant's value sum and the report count goes up by one. | Average: value sum divided by exposed users. |
Decimal |
Any finite number, at most 10^15 in magnitude. It is kept to 15 significant digits and then rounded to 6 decimals with banker's rounding (a half goes to the even digit), so 0.0000125 counts as 0.000012 and a large value keeps fewer decimals: 1234567890.123456 counts as 1234567890.12346. |
As Number. |
Average: value sum divided by exposed users. |
A report is refused only when its name is not in the catalogue or its value does not fit the type; the volume of reports never leads to a refusal. A value with more than 6 decimals on a Decimal event is rounded, not refused.
Unit. An optional display unit of up to 32 characters, such as coins or USD, for Number and Decimal events. Surrounding spaces are removed, a blank unit is stored as null, and a Binary event never has a unit: one you send is dropped.
Lifecycle. The catalogue protects the experiments that measure an event:
| Action | Allowed when | Otherwise |
|---|---|---|
| Change the type or the unit | No experiment, in any status, uses the event, and the event is not archived. | 409 This event is used by experiments; its type and unit can no longer change. or 409 This event is archived; restore it to change it. |
| Write the event again unchanged | Always. | Not applicable: 200 with the event as it is. |
| Archive | No Draft or Running experiment uses the event. Archiving an archived event answers 200. |
409 This event is used by a draft or running experiment. |
| Restore | Always. Restoring an event that is not archived answers 200. |
Not applicable. |
| Delete | No experiment, in any status, uses the event. | 409 This event is used by a draft or running experiment. or 409 This event is used by finished experiments; archive it instead. |
| Choose it as an experiment's metric | The event is not archived. An experiment that already measures an archived event keeps it. | 409 The event '<name>' is archived; restore it to measure an experiment with it. |
An archived event stays on the stopped experiments that measured it, and reports of it are still accepted and still count for those experiments' runs during their attribution period. Archive an event you no longer want offered as a success metric; delete it only when no experiment refers to it.
In the client
The client reads its configuration and reports metric events over the connection it already has with Five Minutes. There are two request and response pairs.
Client prerequisites
These messages need protocol 2.0 and a completed login: send them only after ServerWelcome. Anything sent earlier makes the server close the connection. Give every message a new MessageId. Each request gets a ServerAck as soon as the server has received it, when IsAckRequested is true, then one typed response whose ResponseToMessageId is your MessageId. The ServerAck says only that the request arrived; the typed response says whether it succeeded. Failures arrive in the typed response as Success=false with a FailureReason. The server never sends these responses to a connection below protocol 2.0.
How messages are exchanged: Message conventions in the Five Minute wire protocol. The tables below give each message's fields and what they mean; the client library sends and reads the messages. The field-level reference of every type is in Five Minute API Contracts 2.0.
With the npm packages 2.0, fetchRemoteConfig() sends ClientRemoteConfigRequest and reportMetricEvent() sends ClientReportMetricEventRequest. They resolve with the typed response when Success is true and reject with a RequestFailedError, whose message is the FailureReason, when it is false (see npm packages 2.0). The examples below show the C# message classes, then the npm packages.
Fetch remote config (ClientRemoteConfigRequest)
Direction: client -> server. Send it only after ServerWelcome. Reply: ServerAck when IsAckRequested is true, then ServerRemoteConfigResponse.
| Field | Type | Rules | Meaning |
|---|---|---|---|
ETag |
text | Empty, or exactly the ETag of the last full answer you cached. |
The copy the client already has. Empty asks for the full set. |
MessageId |
GUID | A new GUID for every fetch. | Identifies the request; the reply carries it as ResponseToMessageId. |
IsAckRequested |
true/false | Default true. |
Ask for a ServerAck, which comes as soon as the server has the request, before the reply. |
How the fetch works
- The client logs in and receives ServerWelcome. A fetch sent before that is not answered: the server closes the connection (see When the server closes the connection).
- The client sends
ClientRemoteConfigRequestwithETagset to""if it has no cached copy, or to theETagit stored from its last full answer. - Five Minutes composes the user's configuration: every public entry, with the variant value substituted for each entry under a live experiment the user is assigned to.
- If the composed configuration is exactly what the presented
ETagdescribes, the answer isNotModified=truewith the sameETag, an emptyEntriesand an emptyAssignments. Keep your cached entries AND your cached assignments. - Otherwise the answer is a full answer:
NotModified=false, a newETag, the complete set of public entries inEntriesand the user's assignments inAssignments. Replace your whole cache (entries, assignments and ETag) with it. A full answer never contains partial changes.
Rules:
- Check
Successfirst. A failed answer hasSuccess=false, an emptyETag,NotModified=falseand empty lists; it is not an empty configuration. Keep your cache. Entriesholds only public entries, ordered by key. Server-only entries never reach the client.Assignmentslists oneExperimentAssignmentfor every live experiment on a public entry that the user is in, ordered by experiment key. Each assignment always comes with the substituted value inEntries. Experiments on server-only entries never appear here.Assignmentsis empty when no experiment is live.- While experiments are live, the
ETagis specific to the user: two users in different variants get different ETags for the same entries. Treat the ETag as opaque: store it verbatim and compare it only for equality. Do not parse it, and do not share one ETag between users. - A full answer that carries an assignment counts as the user's exposure to that experiment (see Exposures). A
NotModifiedanswer is not an exposure: it delivered nothing new. - There is no push. A connected client keeps what it fetched until it fetches again. Fetch after every login (including every reconnect), and again at natural moments such as returning to the main menu or before a match. A change made by you or your server is visible to fetches in every region usually within seconds, at most 5 minutes.
- Give every fetch a new
MessageId. A request that repeats aMessageIdseen in the last 5 minutes is treated as a duplicate and gets no typed response. If a fetch gets no reply, simply send a new fetch. - Store the ETag, the entries and the assignments together, for example on disk, under the application ID and the unique user id, so that another user on the same device never presents them. A client that presents a stored ETag in a new session gets a cheap
NotModifiedwhen nothing changed. - Ship built-in defaults for every key. Until the first answer arrives, or when the user is offline, your app runs on them.
- Over TCP, one message is at most 1 MiB, and a
ServerRemoteConfigResponsecarries every public entry in a single message. An answer larger than 1 MiB is not delivered, so the client gets no reply. Keep the total size of your public entries well under 1 MiB: 64 KB values add up quickly.
ServerRemoteConfigResponse fields
Direction: server -> client. Fields:
| Field | Type | Rules | Meaning |
|---|---|---|---|
ETag |
text | Opaque. Empty when Success is false. |
Identifies what this answer describes. Present it on the next fetch. |
NotModified |
true/false | true: the presented ETag is current; Entries and Assignments are empty. |
|
Entries |
list of RemoteConfigEntry |
Ordered by key. Empty when NotModified is true. |
The complete set of public entries as this user receives them. |
Assignments |
list of ExperimentAssignment |
Ordered by experiment key. Empty when NotModified is true or no experiment is live. |
The user's variant in every live experiment on a public entry. |
ResponseToMessageId |
GUID | The MessageId of your ClientRemoteConfigRequest. |
|
Success |
true/false | false when the fetch failed. |
|
FailureReason |
text | Empty when Success is true. |
Why the fetch failed. |
RemoteConfigEntry and ExperimentAssignment are objects inside the response. They have no MessageId or response fields.
RemoteConfigEntry fields:
| Field | Type | Rules | Meaning |
|---|---|---|---|
Key |
text | 1-128 characters of letters, digits, _, . and -. |
The entry's key. |
ValueType |
RemoteConfigValueType |
String, Int, Float, Bool or Json. |
How to parse Value. |
Value |
text | At most 65,536 UTF-8 bytes, exactly as written. | The base value, or the user's variant value. |
ExperimentAssignment fields:
| Field | Type | Rules | Meaning |
|---|---|---|---|
ExperimentKey |
text | 1-64 characters of lower-case letters, digits and _. |
The experiment. |
VariantKey |
text | 1-32 characters of lower-case letters, digits and _. |
The user's variant in it. |
Example: fetching and caching
using System.Globalization;
using System.Linq;
using FiveMinutes.Model.Enums;
using FiveMinutes.Model.Messages.Client;
using FiveMinutes.Model.Messages.Server;
// After ServerWelcome: ask for the configuration, presenting the cached ETag ("" when there is none).
var request = new ClientRemoteConfigRequest { ETag = cache.ETag ?? string.Empty };
// Send request over the connection; the reply has ResponseToMessageId == request.MessageId.
void OnRemoteConfig( ServerRemoteConfigResponse response )
{
if( !response.Success )
return; // keep the cache; fetch again later with a new ClientRemoteConfigRequest
if( response.NotModified )
return; // Entries and Assignments are empty: keep the cached entries AND assignments
cache.ETag = response.ETag;
cache.Entries = response.Entries.ToDictionary( e => e.Key, e => e );
cache.Assignments = response.Assignments.ToArray();
cache.Save();
}
long ReadInt( string key, long fallback )
=> cache.Entries.TryGetValue( key, out var e ) && e.ValueType == RemoteConfigValueType.Int
? long.Parse( e.Value, NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture )
: fallback;
double ReadFloat( string key, double fallback )
=> cache.Entries.TryGetValue( key, out var e ) && e.ValueType == RemoteConfigValueType.Float
? double.Parse( e.Value, NumberStyles.Float, CultureInfo.InvariantCulture )
: fallback;
bool ReadBool( string key, bool fallback )
=> cache.Entries.TryGetValue( key, out var e ) && e.ValueType == RemoteConfigValueType.Bool
? bool.Parse( e.Value ) // case-insensitive
: fallback;
Example: fetching with the npm packages
fetchRemoteConfig() keeps the last full answer it received for the user, for the lifetime of the client, and presents its ETag. On a NotModified answer it returns that answer with the kept ETag, Entries and Assignments filled in, so the result always holds the whole configuration. A failed fetch rejects with a RequestFailedError and leaves the kept answer as it is.
import type { ServerRemoteConfigResponse } from "@fiveminutes-io/contracts";
// After connect(), and again at natural moments
const config = await client.fetchRemoteConfig();
const maxPlayers = readInt(config, "max_players", 8);
function readInt(answer: ServerRemoteConfigResponse, key: string, fallback: number): number {
const entry = answer.Entries.find((e) => e.Key === key);
return entry?.ValueType === "Int" ? Number(entry.Value) : fallback;
}
A new client instance, for example after a page reload, starts without a kept answer, so its first fetch gets a full answer. To present an ETag across reloads, store the full answer yourself, under the application ID and the unique user id, and send the request with request:
const stored = loadCache(); // a full ServerRemoteConfigResponse you saved for this application and user, or null
const answer = await client.request("ClientRemoteConfigRequest", { ETag: stored?.ETag ?? "" });
const current = answer.NotModified && stored ? stored : answer;
if (!answer.NotModified) saveCache(answer);
Fetch failure reasons
| FailureReason | Cause | Client action |
|---|---|---|
Remote config could not be fetched: the service is temporarily unavailable. |
A temporary failure on the server side. | Keep the cache. Fetch again after a few seconds with a new MessageId, with backoff. |
Remote config could not be fetched. |
A temporary failure on the server side. | As above. |
Treat any other FailureReason on this response the same way. A fetch never fails because of the user or the ETag you present: an unknown or stale ETag simply gets a full answer.
Report a metric event (ClientReportMetricEventRequest)
Direction: client -> server. Send it only after ServerWelcome. Reply: ServerAck when IsAckRequested is true, then ServerReportMetricEventResponse.
| Field | Type | Rules | Meaning |
|---|---|---|---|
Name |
text | 1-64 characters of lower-case letters, digits and _; must be in the application's catalogue. |
The metric event. |
Value |
number | With HasValue true: finite, at most 10^15 in magnitude, a whole number for a Number event. Ignored when HasValue is false. |
The number the report carries. |
HasValue |
true/false | false for a Binary event; true for a Number or Decimal event. |
Whether Value is set. |
MessageId |
GUID | A new GUID for every new report; the same GUID when resending an unanswered one. | Identifies the report; also makes a resend count once. |
IsAckRequested |
true/false | Default true. Keep it true: the ServerAck says that the report arrived, and is how a resend learns that the original arrived. |
Ask for a ServerAck, which comes as soon as the server has the report, before the reply. |
Rules:
- The user and the application are the connection's: the unique user id from the login and the application from the handshake. A client can only report for its own user; to report for any user, use your server.
- Report only after the user's first fetch in which they received the experiment's value. A report counts only for experiments the user is already exposed to; a report that is processed before the exposure does not count, even if the user is exposed a moment later (see Attribution). The natural order is: login, fetch, use the value, report.
- Each report is one event. A
Binaryevent counts a user once per run however often it is reported; aNumberorDecimalevent adds every report to the sums, so a report that is sent twice with two different MessageIds counts twice. - Reports are metered against your plan's monthly metric events and never refused for volume (see Plan allowances).
- The contracts library refuses to send a report with an invalid
Name, or withHasValuetrue and a non-finiteValue: it throws anArgumentExceptionbefore anything is sent. The npm packages refuse it too: the call rejects with aMessageValidationErrorand nothing is sent. The server refuses such a report as well, without recording it: the response and its ServerAck carrySuccessfalse and the rule's text (see Replies and acknowledgements).
ServerReportMetricEventResponse fields
Direction: server -> client. Fields:
| Field | Type | Rules | Meaning |
|---|---|---|---|
Name |
text | The Name you reported, echoed. |
|
ResponseToMessageId |
GUID | The MessageId of your report. |
|
Success |
true/false | true: the report was accepted. It counts for every experiment it is attributed to. |
|
FailureReason |
text | Empty when Success is true. |
Why the report was refused. |
Success = true means the report was accepted, not that it counted for an experiment: a report from a user who is not exposed to any experiment measuring the event is accepted and counts nowhere.
Example: reporting events
using FiveMinutes.Model.Messages.Client;
using FiveMinutes.Model.Messages.Server;
// A Binary event: no value.
var completed = new ClientReportMetricEventRequest { Name = "level_complete", HasValue = false };
// A Number event: a whole number.
var spent = new ClientReportMetricEventRequest { Name = "coins_spent", Value = 250, HasValue = true };
// A Decimal event: any finite number, rounded to 6 decimals.
var purchase = new ClientReportMetricEventRequest { Name = "purchase_usd", Value = 4.99, HasValue = true };
// Send each one; keep the message until its reply arrives, and resend an unanswered one
// unchanged (same MessageId), as described under Resending a metric event.
void OnMetricEventReported( ServerReportMetricEventResponse response )
{
if( response.Success )
return; // accepted
// Temporary reasons: send a copy with a new MessageId, with backoff.
// Any other reason: log it and fix the build or the catalogue.
}
Example: reporting with the npm packages
import { RequestFailedError, RequestTimeoutError } from "@fiveminutes-io/chat-client";
import { newMessageId } from "@fiveminutes-io/contracts";
await client.reportMetricEvent("level_complete"); // a Binary event: no value
await client.reportMetricEvent("coins_spent", 250); // a Number event: a whole number
const messageId = newMessageId(); // kept until the outcome is known
try {
await client.reportMetricEvent("purchase_usd", 4.99, { MessageId: messageId }); // a Decimal event
} catch (error) {
if (error instanceof RequestTimeoutError) {
// Unknown outcome: resend unchanged with { MessageId: messageId }, as described below
// (error.acknowledged: the server has the report, so wait 5 minutes after the last send first)
} else if (error instanceof RequestFailedError) {
// error.failureReason: after a temporary reason, report again with a new call (a new MessageId)
}
}
reportMetricEvent(name, value?) sends HasValue true exactly when you pass a value. Every call has a new MessageId unless you pass one; pass your own, made with newMessageId(), when you may have to resend after a lost connection, because the ConnectionClosedError you then get carries none. A report whose Name breaks the format, or whose value is not a finite number, rejects at once with a MessageValidationError and is not sent: fix the name or the value.
Metric event failure reasons
| FailureReason | Cause | Client action |
|---|---|---|
The event is not configured for this application. |
Name is not in the catalogue. |
Do not retry. Add the name to the catalogue, or fix the name in the build. |
The event '<name>' is counted, not measured; report it without a value. |
HasValue is true on a Binary event. |
Do not retry. Send HasValue = false. |
The event '<name>' carries a value; report one. |
HasValue is false on a Number or Decimal event. |
Do not retry. Send a value. |
The event '<name>' takes whole numbers. |
A fractional Value on a Number event. |
Do not retry. Send a whole number, or make the event Decimal. |
The value of '<name>' must be a finite number no larger than 10^15 in magnitude. |
Value is out of range. |
Do not retry. |
Parameter 'Name' must be 1-64 characters of lower-case letters, digits and underscores |
Name has the wrong format. The contracts library and the npm packages refuse to send such a report (the npm packages with a MessageValidationError that carries this text), so an app built on them does not receive this reason. |
Do not retry. |
Parameter 'Value' must be a finite number |
HasValue is true and Value is NaN or infinite. The contracts library and the npm packages refuse to send such a report (the npm packages with a MessageValidationError), so an app built on them does not receive this reason. |
Do not retry. |
The event could not be recorded; try again. |
Temporary. The report was not recorded. | Send it again with a NEW MessageId. |
Metric events are temporarily unavailable. |
Temporary. | Send it again with a NEW MessageId, with backoff. |
The event was not accepted. |
Temporary. | Send it again with a NEW MessageId, with backoff. |
Resending a metric event
- No typed response within 30 seconds (for example because the connection dropped): send the same report again with the SAME
MessageId, after reconnecting and logging in as the same user if needed.- When no
ServerAckcame either, the report may not have reached the server: resend it at once. - When its
ServerAckcame, the server has the report: theServerAckcomes as soon as the server receives a report, before its typed response, and does not tell you whether it was accepted. Within 5 minutes of the previous send, a resend gets no typed response, only anotherServerAck(for the first three resends, whenIsAckRequestedis true). - To learn the outcome, wait until 5 minutes have passed since your last send and send the same report once more with the same
MessageId. It is then processed and answered:Success=truewhen the original was accepted (it does not count again), or the answer a new report would get when the original was refused or never recorded. This works for 24 hours after the original was accepted. - Send at most 3 resends of one
MessageIdwithin 5 minutes; continued repeats can end the connection withRequestDuplicationViolation. - The resend is recognised only in the region that accepted the original. Through another region, a
NumberorDecimalevent counts twice; aBinaryevent still counts once per user.
- When no
Success=falsewith a temporary reason: send the report again with a NEWMessageId. A resend with the sameMessageIdwithin 5 minutes would be dropped as a duplicate. In the rare case where the original was recorded after all (a timeout inside Five Minutes), aNumberorDecimalevent then counts twice.Success=falsewith any other reason: do not resend. Fix the report or the catalogue.- With the npm packages 2.0, a missing typed response shows as a
RequestTimeoutErrorfromreportMetricEvent, whoseacknowledgedsays whether the server acknowledged the report; resend with{ MessageId: error.messageId }, or with theMessageIdyou passed. Whenacknowledgedis false, resend at most 3 times within 5 minutes. When it is true, a resend within 5 minutes of the previous send gets only another acknowledgement and times out again: wait until 5 minutes have passed since the last send; that resend resolves or rejects with the outcome. ASuccess=falseanswer rejects with aRequestFailedError.
From your server
Your server manages entries, metric events and experiments, reads what a user receives, reports metric events on a user's behalf and reads results. Every call goes to the Server API over HTTPS.
Calling the Server API
Your server calls https://signalr.fiveminutes.cloud/api/v1/... with Authorization: Bearer <server token>. Get the token with POST /api/v1/api-clients/authenticate and the body {"applicationId":"<application id>","clientSecret":"<API key secret>"}; it is valid for expiresInSeconds (600) seconds and there is no refresh token. A missing, invalid or expired token gets 401 with an empty body; a token without the needed scope gets 403 with an empty body. Error bodies, rate limits and idempotency rules: Five Minute Server API.
The scopes for the routes in this document:
| Scope | Dashboard label | Routes |
|---|---|---|
Application.RemoteConfig.Read |
Remote config: read | GET /api/v1/remote-config |
Application.RemoteConfig.Write |
Remote config: write | PUT /api/v1/remote-config, DELETE /api/v1/remote-config |
Application.Experiments.Read |
Experiments: read | GET /api/v1/experiments, GET /api/v1/experiments/assignment, GET /api/v1/experiments/{key}, GET /api/v1/experiments/{key}/results, GET /api/v1/metric-events |
Application.Experiments.Write |
Experiments: write | PUT /api/v1/experiments, DELETE /api/v1/experiments/{key}, POST /api/v1/experiments/{key}/start, /stop and /promote, PUT and DELETE /api/v1/metric-events, POST /api/v1/metric-events/archive and /restore |
Application.Events.Write |
Metric events: report | POST /api/v1/events |
Application.Admin.FullAccess |
Full access | Every route above. |
- Write does not imply Read. A key with only
Application.RemoteConfig.Writegets403onGET /api/v1/remote-config, and a key with onlyApplication.RemoteConfig.Readgets403onPUTandDELETE. The same holds for the experiment scopes. Application.Experiments.Writedoes not includeApplication.Events.Write.- A server that only reports events needs only
Application.Events.Write(Metric events: report). See Scopes and authorization.
Conventions for every route in this document:
- No route takes an application ID. The server token decides the application.
- Bodies are JSON with camelCase property names; enums are written by name (
"Int","Binary","Running"). Responses include properties whose value isnull. - Send every body with
Content-Type: application/json; a body with another content type gets415with an empty body. A body that is not valid JSON, or a property of the wrong JSON type (for example a remote configvaluesent as a number instead of a string), gets400with an empty body. - Request property names are matched case-insensitively and unknown properties are ignored. A misspelt property is therefore dropped and takes its default, and because a
PUTreplaces every field, a misspeltisPublicmakes an entry server-only. Check spelling against this document. - A refusal with a reason is
400or409with the sentence as a JSON string body. An unknown entry, experiment or metric event is404with an empty body. - Every timestamp is UTC. Experiment and metric event timestamps end in
Z. Entry timestamps (createdAt,updatedAt) can come without a zone designator, for example"2026-09-28T14:03:11.517"; treat them as UTC. Send timestamps withZ; a timestamp without a zone is taken as UTC. updatedByis the key ID of the API key for writes by your server (an API key created before key scopes were introduced may record the all-zero GUID00000000-0000-0000-0000-000000000000), the signed-in user's email address for dashboard writes, andexperimentsfor the automatic stop at the end of a window.- URL-encode path segments (the experiment
key) and query values (uniqueUserId).
Remote config endpoints
Remote config entries have one route, /api/v1/remote-config, with three methods. There is no route for a single key: read the list and pick the key.
List entries (GET /api/v1/remote-config)
Returns every entry of the application, public and server-only, with its base value.
| Method | Path | Scope | Success | Idempotency-Key |
|---|---|---|---|---|
| GET | /api/v1/remote-config |
Application.RemoteConfig.Read |
200 | No |
Parameters
None. Without uniqueUserId this route returns the base values; with it, see Read the config a user sees.
Example request
GET /api/v1/remote-config HTTP/1.1
Host: signalr.fiveminutes.cloud
Authorization: Bearer <server token>
Example response
{
"entries": [
{
"key": "max_players",
"valueType": "Int",
"value": "8",
"isPublic": true,
"createdAt": "2026-09-28T14:03:11.517",
"updatedAt": "2026-09-29T08:12:40.0431542",
"updatedBy": "<API key id>"
},
{
"key": "shop.price_table",
"valueType": "Json",
"value": "{\"gems_small\":99,\"gems_large\":499}",
"isPublic": false,
"createdAt": "2026-09-20T10:00:02.25",
"updatedAt": "2026-09-20T10:00:02.2503",
"updatedBy": "designer@example.com"
}
],
"assignments": []
}
Responses
| Status | Body | When | What to do |
|---|---|---|---|
| 200 | JSON object | Always, also when the application has no entries ("entries": []). |
Read entries. assignments is always empty here. |
Notes
- Entries are ordered by key (ordinal).
- The values are the base values, not what a user in an experiment receives. Reading this list is not an exposure.
Read the config a user sees (GET /api/v1/remote-config?uniqueUserId=)
Returns every entry, public and server-only, as one user receives it, with the user's assignments. This is how your server reads a variant value of an experiment on a server-only entry.
| Method | Path | Scope | Success | Idempotency-Key |
|---|---|---|---|---|
| GET | /api/v1/remote-config?uniqueUserId=<unique user id> |
Application.RemoteConfig.Read |
200 | No |
Parameters
| Name | In | Type | Required | Default | Rules |
|---|---|---|---|---|---|
uniqueUserId |
query | string | Yes | none | The unique user id, 1-256 characters, not blank. URL-encode it. |
Example request
GET /api/v1/remote-config?uniqueUserId=<unique user id> HTTP/1.1
Host: signalr.fiveminutes.cloud
Authorization: Bearer <server token>
Example response
{
"entries": [
{
"key": "max_players",
"valueType": "Int",
"value": "16",
"isPublic": true,
"createdAt": "2026-09-28T14:03:11.517",
"updatedAt": "2026-09-29T08:12:40.0431542",
"updatedBy": "<API key id>"
},
{
"key": "shop.price_table",
"valueType": "Json",
"value": "{\"gems_small\":99,\"gems_large\":499}",
"isPublic": false,
"createdAt": "2026-09-20T10:00:02.25",
"updatedAt": "2026-09-20T10:00:02.2503",
"updatedBy": "designer@example.com"
}
],
"assignments": [
{
"experimentKey": "max_players_test",
"variantKey": "sixteen"
}
]
}
Responses
| Status | Body | When | What to do |
|---|---|---|---|
| 200 | JSON object | The user's view was composed. | Use entries; assignments lists the user's variant in every live experiment. |
| 400 | JSON string A player id (uniqueUserId) is required. |
uniqueUserId is empty or only spaces. |
Fix the request. |
| 400 | JSON string The player id (uniqueUserId) must not exceed 256 characters. |
uniqueUserId is too long. |
Fix the request. |
Notes
- Every live experiment is applied, on public and server-only entries alike: its entry carries the user's variant value and
assignmentslists it. Entries under no live experiment carry the base value. - A response with an assignment counts as the user's exposure to that experiment, exactly like a client's fetch. Call it only when you actually deliver the value to the user. To see a user's variants without exposing them, use Get a user's assignments.
- The unique user id is not checked against your users: any id is assigned a variant, and exposed if you read with it.
- There is no ETag on this route; every call returns the full view.
Create or update an entry (PUT /api/v1/remote-config)
Creates the entry addressed by key, or replaces its value, type and visibility.
| Method | Path | Scope | Success | Idempotency-Key |
|---|---|---|---|---|
| PUT | /api/v1/remote-config |
Application.RemoteConfig.Write |
200 | No |
Parameters
| Name | In | Type | Required | Default | Rules |
|---|---|---|---|---|---|
key |
body | string | Yes | none | 1-128 characters of letters, digits, _, . and -. |
valueType |
body | string enum | No | "String" |
"String", "Int", "Float", "Bool" or "Json". |
value |
body | string | Yes | none | Parses as valueType (see Entries); at most 65,536 UTF-8 bytes. The empty string is a valid String value. Always a JSON string, also for numbers and JSON documents. |
isPublic |
body | boolean | No | false |
true delivers the entry to clients; false keeps it server-only. |
Example request
PUT /api/v1/remote-config HTTP/1.1
Host: signalr.fiveminutes.cloud
Authorization: Bearer <server token>
Content-Type: application/json
{"key":"max_players","valueType":"Int","value":"8","isPublic":true}
Example response
{
"key": "max_players",
"valueType": "Int",
"value": "8",
"isPublic": true,
"createdAt": "2026-09-29T08:12:40.0431179Z",
"updatedAt": "2026-09-29T08:12:40.0431542Z",
"updatedBy": "<API key id>"
}
Responses
| Status | Body | When | What to do |
|---|---|---|---|
| 200 | JSON object | The entry was created or replaced. | Clients see it on their next fetch. |
| 400 | JSON string Key must be 1-128 characters of letters, digits, '_', '.' or '-'. |
key is missing or has the wrong format. |
Fix the request. |
| 400 | JSON string Unknown value type '<n>'. |
valueType is a number outside 0-4. |
Send one of the five names. |
| 400 | JSON string Value must be set. |
value is missing or null. |
Fix the request. |
| 400 | JSON string Value must not exceed 64 KB (65536 bytes UTF-8). |
value is too large. |
Shorten the value. |
| 400 | JSON string Value must be an integer in invariant-culture notation. |
Int value that does not parse. |
Fix the value. |
| 400 | JSON string Value must be a finite number in invariant-culture notation. |
Float value that does not parse, or is NaN or infinite. |
Fix the value. |
| 400 | JSON string Value must be 'true' or 'false'. |
Bool value that does not parse. |
Fix the value. |
| 400 | JSON string Value must be a valid JSON document. |
Json value that does not parse. |
Fix the value. |
| 400 | empty | The body is not valid JSON, valueType is an unknown name, or a property has the wrong JSON type, for example "value": 8 instead of "value": "8". |
Fix the request. |
| 409 | JSON string This entry has an active experiment. |
The write changes valueType or isPublic while a Draft or Running experiment uses the entry. |
Stop or delete the experiment first, or keep type and visibility unchanged. |
| 409 | JSON string Remote config key '<key>' was written concurrently; retry the request. |
Two writes of a new key raced. | Retry the request. |
Notes
- The write is an upsert by key. Every field is replaced: a field you leave out takes its default, so re-send
valueTypeandisPublicwhen you change only the value. - Re-typing an existing entry is allowed: send the new
valueTypewith a value that parses as it. It is refused only while a Draft or Running experiment uses the entry. - While a live experiment uses the entry, a new base value reaches no logged-in user until the window ends or the experiment stops: every one of them receives a variant value.
- Writing the same value again is harmless: the ETag clients hold does not change, so they keep getting
NotModified. - Clients see a change on their next fetch, usually within seconds, at most 5 minutes, in every region.
- The number of entries is a plan allowance, not a hard limit: a write is never refused for the number of entries (see Plan allowances).
- The answer to a create has both timestamps ending in
Z, as in the example. The answer to an update of an existing entry hascreatedAtwithout a zone designator andupdatedAtending inZ. Both are UTC.
Delete an entry (DELETE /api/v1/remote-config)
Deletes the entry addressed by key. The key goes in a JSON body, not in the path.
| Method | Path | Scope | Success | Idempotency-Key |
|---|---|---|---|---|
| DELETE | /api/v1/remote-config |
Application.RemoteConfig.Write |
204 | No |
Parameters
| Name | In | Type | Required | Default | Rules |
|---|---|---|---|---|---|
key |
body | string | Yes | none | 1-128 characters of letters, digits, _, . and -. |
Example request
DELETE /api/v1/remote-config HTTP/1.1
Host: signalr.fiveminutes.cloud
Authorization: Bearer <server token>
Content-Type: application/json
{"key":"max_players"}
Example response
Status 204 with an empty body.
Responses
| Status | Body | When | What to do |
|---|---|---|---|
| 204 | empty | The entry was deleted. | Clients stop receiving it on their next fetch. |
| 400 | JSON string Key must be 1-128 characters of letters, digits, '_', '.' or '-'. |
key is missing or has the wrong format. |
Fix the request. |
| 400 | empty | No body, or a body that is not valid JSON. | Send {"key":"<key>"} with Content-Type: application/json. |
| 404 | empty | No entry with this key. | Nothing to delete. |
| 409 | JSON string This entry has an active experiment. |
A Draft or Running experiment uses the entry. | Delete the experiment first (a Running one can be deleted directly). |
| 409 | JSON string This entry has experiment history; delete those experiments first. |
A Stopped experiment uses the entry. | Delete those experiments first; their results go with them. |
Notes
- Make sure your HTTP client sends a body with
DELETE. - Repeating a delete answers
404once the entry is gone.
Metric event endpoints
The metric event catalogue has one route, /api/v1/metric-events, plus archive and restore. The event is addressed by name in the JSON body.
List metric events (GET /api/v1/metric-events)
Returns the application's catalogue.
| Method | Path | Scope | Success | Idempotency-Key |
|---|---|---|---|---|
| GET | /api/v1/metric-events |
Application.Experiments.Read |
200 | No |
Parameters
None.
Example request
GET /api/v1/metric-events HTTP/1.1
Host: signalr.fiveminutes.cloud
Authorization: Bearer <server token>
Example response
{
"events": [
{
"name": "coins_spent",
"type": "Number",
"unit": "coins",
"createdAt": "2026-09-29T08:15:02.113Z",
"archivedAt": null,
"experimentCount": 0,
"activeExperimentCount": 0
},
{
"name": "level_complete",
"type": "Binary",
"unit": null,
"createdAt": "2026-09-29T08:14:47.902Z",
"archivedAt": null,
"experimentCount": 1,
"activeExperimentCount": 1
}
]
}
Responses
| Status | Body | When | What to do |
|---|---|---|---|
| 200 | JSON object | Always, also for an empty catalogue. | Read events. |
Notes
- Events are ordered by name. Archived events are included, with
archivedAtset. experimentCountis the number of experiments, in any status, that measure the event;activeExperimentCountcounts the Draft and Running ones. They decide what the lifecycle allows (see Metric events).
Create or update a metric event (PUT /api/v1/metric-events)
Creates the event addressed by name, or changes its type and unit.
| Method | Path | Scope | Success | Idempotency-Key |
|---|---|---|---|---|
| PUT | /api/v1/metric-events |
Application.Experiments.Write |
200 | No |
Parameters
| Name | In | Type | Required | Default | Rules |
|---|---|---|---|---|---|
name |
body | string | Yes | none | 1-64 characters of lower-case letters, digits and _. |
type |
body | string enum | No | "Binary" |
"Binary", "Number" or "Decimal". |
unit |
body | string or null | No | null |
At most 32 characters, surrounding spaces included. Trimmed; blank becomes null; always null for Binary. |
Example request
PUT /api/v1/metric-events HTTP/1.1
Host: signalr.fiveminutes.cloud
Authorization: Bearer <server token>
Content-Type: application/json
{"name":"coins_spent","type":"Number","unit":"coins"}
Example response
{
"name": "coins_spent",
"type": "Number",
"unit": "coins",
"createdAt": "2026-09-29T08:15:02.113Z",
"archivedAt": null,
"experimentCount": 0,
"activeExperimentCount": 0
}
Responses
| Status | Body | When | What to do |
|---|---|---|---|
| 200 | JSON object | Created, changed, or already exactly like this. | Report the event from your builds. |
| 400 | JSON string Metric event name must be 1-64 characters of lower-case letters, digits or '_'. |
name is missing or has the wrong format. |
Fix the request. |
| 400 | JSON string Unknown event type '<n>'. |
type is a number outside 0-2. |
Send one of the three names. |
| 400 | JSON string Unit must not exceed 32 characters. |
unit is longer than 32 characters (checked before a Binary event drops it). |
Shorten the unit. |
| 400 | empty | The body is not valid JSON, or type is an unknown name. |
Fix the request. |
| 409 | JSON string This event is archived; restore it to change it. |
The event is archived and the write changes its type or unit. | Restore it first. |
| 409 | JSON string This event is used by experiments; its type and unit can no longer change. |
An experiment, in any status, measures the event and the write changes its type or unit. | Create an event with a new name instead. |
| 409 | JSON string Metric event '<name>' was written concurrently; retry the request. |
Two writes of a new name raced. | Retry the request. |
Notes
- A new name is accepted by report calls in every region usually within seconds, at most 5 minutes.
- Re-sending the event unchanged always answers
200, even when it is locked or archived.
Delete a metric event (DELETE /api/v1/metric-events)
Deletes the event addressed by name. The name goes in a JSON body.
| Method | Path | Scope | Success | Idempotency-Key |
|---|---|---|---|---|
| DELETE | /api/v1/metric-events |
Application.Experiments.Write |
204 | No |
Parameters
| Name | In | Type | Required | Default | Rules |
|---|---|---|---|---|---|
name |
body | string | Yes | none | 1-64 characters of lower-case letters, digits and _. |
Example request
DELETE /api/v1/metric-events HTTP/1.1
Host: signalr.fiveminutes.cloud
Authorization: Bearer <server token>
Content-Type: application/json
{"name":"coins_spent"}
Example response
Status 204 with an empty body.
Responses
| Status | Body | When | What to do |
|---|---|---|---|
| 204 | empty | The event was deleted. | Reports of the name are refused from now on. |
| 400 | JSON string Metric event name must be 1-64 characters of lower-case letters, digits or '_'. |
name is missing or has the wrong format. |
Fix the request. |
| 400 | empty | No body, or a body that is not valid JSON. | Send {"name":"<name>"}. |
| 404 | empty | No event with this name. | Nothing to delete. |
| 409 | JSON string This event is used by a draft or running experiment. |
A Draft or Running experiment measures the event. | Stop and delete that experiment first. |
| 409 | JSON string This event is used by finished experiments; archive it instead. |
Only Stopped experiments measure the event. | Archive it, or delete those experiments first. |
Notes
- After a delete, every report of the name is refused with
The event is not configured for this application., in the client and onPOST /api/v1/events. Remove the report from your builds first.
Archive a metric event (POST /api/v1/metric-events/archive)
Stops offering the event as a success metric while keeping it on the experiments that measured it.
| Method | Path | Scope | Success | Idempotency-Key |
|---|---|---|---|---|
| POST | /api/v1/metric-events/archive |
Application.Experiments.Write |
200 | No |
Parameters
| Name | In | Type | Required | Default | Rules |
|---|---|---|---|---|---|
name |
body | string | Yes | none | 1-64 characters of lower-case letters, digits and _. |
Example request
POST /api/v1/metric-events/archive HTTP/1.1
Host: signalr.fiveminutes.cloud
Authorization: Bearer <server token>
Content-Type: application/json
{"name":"level_complete"}
Example response
{
"name": "level_complete",
"type": "Binary",
"unit": null,
"createdAt": "2026-09-29T08:14:47.902Z",
"archivedAt": "2026-10-20T09:30:00.5Z",
"experimentCount": 1,
"activeExperimentCount": 0
}
Responses
| Status | Body | When | What to do |
|---|---|---|---|
| 200 | JSON object | Archived now, or already archived. | Nothing. |
| 400 | JSON string Metric event name must be 1-64 characters of lower-case letters, digits or '_'. |
name is missing or has the wrong format. |
Fix the request. |
| 400 | empty | No body, or a body that is not valid JSON. | Send {"name":"<name>"}. |
| 404 | empty | No event with this name. | Check the name. |
| 409 | JSON string This event is used by a draft or running experiment. |
A Draft or Running experiment measures the event. | Stop the experiment first. |
Notes
- Archiving is allowed whenever no Draft or Running experiment measures the event, including when no experiment ever used it.
- Reports of an archived event are still accepted, and still count for runs within their attribution period.
- An archived event cannot be chosen as the metric of an experiment and its type and unit cannot change until it is restored.
Restore a metric event (POST /api/v1/metric-events/restore)
Makes an archived event available as a success metric again.
| Method | Path | Scope | Success | Idempotency-Key |
|---|---|---|---|---|
| POST | /api/v1/metric-events/restore |
Application.Experiments.Write |
200 | No |
Parameters
| Name | In | Type | Required | Default | Rules |
|---|---|---|---|---|---|
name |
body | string | Yes | none | 1-64 characters of lower-case letters, digits and _. |
Example request
POST /api/v1/metric-events/restore HTTP/1.1
Host: signalr.fiveminutes.cloud
Authorization: Bearer <server token>
Content-Type: application/json
{"name":"level_complete"}
Example response
{
"name": "level_complete",
"type": "Binary",
"unit": null,
"createdAt": "2026-09-29T08:14:47.902Z",
"archivedAt": null,
"experimentCount": 1,
"activeExperimentCount": 0
}
Responses
| Status | Body | When | What to do |
|---|---|---|---|
| 200 | JSON object | Restored now, or not archived in the first place. | Nothing. |
| 400 | JSON string Metric event name must be 1-64 characters of lower-case letters, digits or '_'. |
name is missing or has the wrong format. |
Fix the request. |
| 400 | empty | No body, or a body that is not valid JSON. | Send {"name":"<name>"}. |
| 404 | empty | No event with this name. | Check the name. |
Reporting metric events
Your server reports a metric event for any user of your application with POST /api/v1/events, for example a purchase your store backend confirmed or a match result your server decided. Clients report their own user's events on the wire instead (see Report a metric event). Both count the same way.
Report a metric event from your server (POST /api/v1/events)
Reports one occurrence of a metric event for one user.
| Method | Path | Scope | Success | Idempotency-Key |
|---|---|---|---|---|
| POST | /api/v1/events |
Application.Events.Write |
200 | Yes: body idempotencyKey or Idempotency-Key header |
Parameters
| Name | In | Type | Required | Default | Rules |
|---|---|---|---|---|---|
uniqueUserId |
body | string | Yes | none | The unique user id, 1-256 characters, not blank. |
name |
body | string | Yes | none | 1-64 characters of lower-case letters, digits and _; must be in the catalogue. |
value |
body | number or null | For Number and Decimal events |
null |
Omit or null for a Binary event. A whole number for a Number event. Any finite number for a Decimal event (kept to 15 significant digits, then rounded to 6 decimals). At most 10^15 in magnitude. |
idempotencyKey |
body | string or null | No | null |
1-128 characters, not only whitespace. Unique to this event within your application. Wins over the header. |
Idempotency-Key |
header | string | No | none | The same key, as a header. Used when the body has no idempotencyKey. |
Example request
POST /api/v1/events HTTP/1.1
Host: signalr.fiveminutes.cloud
Authorization: Bearer <server token>
Content-Type: application/json
{"uniqueUserId":"<unique user id>","name":"coins_spent","value":250,"idempotencyKey":"order-77120:coins_spent"}
Example response
{
"eventId": "<event id>",
"name": "coins_spent"
}
Responses
| Status | Body | When | What to do |
|---|---|---|---|
| 200 | JSON object | The report was accepted. With Idempotency-Replayed: true, this is the stored answer to an earlier request with the same key; it was not counted again. |
Nothing. |
| 400 | JSON string A player id (uniqueUserId) is required. |
uniqueUserId is missing or blank. |
Fix the request. |
| 400 | JSON string The player id (uniqueUserId) must not exceed 256 characters. |
uniqueUserId is too long. |
Fix the request. |
| 400 | JSON string Metric event name must be 1-64 characters of lower-case letters, digits or '_'. |
name is missing or has the wrong format. |
Fix the request. |
| 400 | JSON string The idempotency key must be 1-128 characters. |
The key is longer than 128 characters or only whitespace. | Fix the key. |
| 400 | JSON string The event is not configured for this application. |
name is not in the catalogue. |
Add it with PUT /api/v1/metric-events, then send the report again (a 400 is not stored, so the same key works). |
| 400 | JSON string The event '<name>' is counted, not measured; report it without a value. |
A value on a Binary event. |
Omit value. |
| 400 | JSON string The event '<name>' carries a value; report one. |
No value on a Number or Decimal event. |
Send a value. |
| 400 | JSON string The event '<name>' takes whole numbers. |
A fractional value on a Number event. |
Send a whole number. |
| 400 | JSON string The value of '<name>' must be a finite number no larger than 10^15 in magnitude. |
value is out of range. |
Fix the value. |
| 400 | empty | The body is not valid JSON, or value is neither a JSON number nor a string holding one. |
Fix the request. |
| 409 | JSON string A request with this idempotency key is still being processed; retry shortly. and Retry-After: 1 |
A request with the same key is still running. | Retry after Retry-After. |
| 422 | JSON string This idempotency key was already used for a different request. |
The same key was used within 24 hours for a request with a different body. | Use a new key for a different event. |
| 502 | application/problem+json, detail The event could not be recorded; try again. |
A temporary failure; the report was not recorded. | Retry with the same key, with backoff. |
| 500 or 503 | See Errors | A temporary failure inside Five Minutes. | Retry with the same key, with backoff. |
Notes
- Idempotency key. Send a key that is unique to this occurrence within your application, built from your own ids, such as
order-77120:purchase_usdormatch-8812:user-42:level_complete. The body'sidempotencyKeywins over theIdempotency-Keyheader. For 24 hours, a retry with the same key and the same body gets the stored200with the headerIdempotency-Replayed: trueand is not counted again. A retry while the first is still running gets409withRetry-After: 1. The same key with a different body gets422. Retry exactly the same request: moving the key between body and header changes the body. - Event id.
eventIdidentifies the report. With an idempotency key it is derived from the key, so a retry returns the sameeventId; without a key every call gets a new one. - Without a key, a retry counts again: a
NumberorDecimalevent is added twice. ABinaryevent still counts once per user and run. - Per region. Idempotency is kept per region. A retry through another region is a new request: a
NumberorDecimalevent counts twice, aBinaryevent still once. - Accepted is not counted.
200means the report was accepted. It counts only for experiments that measure the event and in which the user is already exposed (see Attribution). A report for a user who never fetched counts nowhere. The unique user id is not checked against your users. - No timestamp. A report is counted at the moment it is processed. There is no field for the time the event happened, and events cannot be backdated.
- Metered. Accepted reports count toward your organisation's monthly metric events and are never refused for volume. Replayed and refused reports are not counted.
- All API keys of one application share the key space: the same key sent with any of them is the same report.
Experiment endpoints
Experiments are addressed by their key: PUT /api/v1/experiments carries it in the body, every other route in the path. The lifecycle is: define (PUT, status Draft), start (status Running), stop (status Stopped, final), optionally promote, and eventually delete.
Create or update an experiment (PUT /api/v1/experiments)
Creates the experiment addressed by key as a Draft, or replaces its definition.
| Method | Path | Scope | Success | Idempotency-Key |
|---|---|---|---|---|
| PUT | /api/v1/experiments |
Application.Experiments.Write |
200 | No |
Parameters
| Name | In | Type | Required | Default | Rules |
|---|---|---|---|---|---|
key |
body | string | Yes | none | 1-64 characters of lower-case letters, digits and _. Do not use assignment (see Get a user's assignments). |
name |
body | string | Yes | none | 1-128 characters, not blank. Surrounding spaces are removed. |
description |
body | string or null | No | null |
At most 1024 characters. Blank becomes null. |
entryKey |
body | string | Yes | none | The key of an existing entry of the application. |
metricEventName |
body | string | Yes | none | The name of an existing, not archived metric event. |
metricGoal |
body | string enum | No | "Increase" |
"Increase" (a higher number is better) or "Decrease". |
variants |
body | array | Yes | none | 2-5 variant objects; the first is the control. Never send a null item: the request fails with 500 instead of a 400. |
variants[].key |
body | string | Yes | none | 1-32 characters of lower-case letters, digits and _; unique within the experiment. |
variants[].name |
body | string | Yes | none | 1-128 characters, not blank. |
variants[].value |
body | string | Yes | none | Parses as the entry's value type; at most 65,536 UTF-8 bytes. Always a JSON string. |
variants[].weight |
body | integer | No | 0 |
0-100. The weights total 100. |
startsAt |
body | string (date-time) or null | No | null |
UTC. null: effective from the start. |
endsAt |
body | string (date-time) or null | No | null |
UTC, later than startsAt. null: runs until stopped. |
autoPromote |
body | boolean | No | false |
true: the result recorded when the run ends is promoted into the entry (see Auto-promote and manual promote). |
Example request
PUT /api/v1/experiments HTTP/1.1
Host: signalr.fiveminutes.cloud
Authorization: Bearer <server token>
Content-Type: application/json
{"key":"max_players_test","name":"Max players 8 vs 16","description":"Do 16-player rounds make more players finish a level?","entryKey":"max_players","metricEventName":"level_complete","metricGoal":"Increase","startsAt":null,"endsAt":"2026-10-13T00:00:00Z","autoPromote":false,"variants":[{"key":"control","name":"8 players","value":"8","weight":50},{"key":"sixteen","name":"16 players","value":"16","weight":50}]}
Example response
{
"id": "<experiment id>",
"key": "max_players_test",
"name": "Max players 8 vs 16",
"description": "Do 16-player rounds make more players finish a level?",
"entryKey": "max_players",
"status": "Draft",
"startsAt": null,
"endsAt": "2026-10-13T00:00:00Z",
"metricEventName": "level_complete",
"metricGoal": "Increase",
"winnerVariantId": null,
"variants": [
{
"id": "<control variant id>",
"ordinal": 0,
"key": "control",
"name": "8 players",
"value": "8",
"weight": 50
},
{
"id": "<sixteen variant id>",
"ordinal": 1,
"key": "sixteen",
"name": "16 players",
"value": "16",
"weight": 50
}
],
"currentRun": null,
"createdAt": "2026-09-29T08:20:11.3021Z",
"updatedAt": "2026-09-29T08:20:11.3021Z",
"updatedBy": "<API key id>",
"autoPromote": false,
"promotedAt": null,
"promotedBy": null,
"autoPromoted": false
}
Validation order
The checks run in this order and the first failure answers. Steps 1-11 answer 400, step 12 answers 409; every body is a JSON string.
key:Experiment key must be 1-64 characters of lower-case letters, digits or '_'.name:Name must be set.orName must not exceed 128 characters.description:Description must not exceed 1024 characters.entryKeyformat:Entry key: Key must be 1-128 characters of letters, digits, '_', '.' or '-'.metricEventNameformat:Metric event name must be 1-64 characters of lower-case letters, digits or '_'.- The window:
The end time must come after the start time.(when both are set andendsAtis not later thanstartsAt). - The variants, first the count, then each variant in order:
An experiment needs at least 2 variants (the control and one alternative).An experiment can have at most 5 variants.Variant key must be 1-32 characters of lower-case letters, digits or '_'.Variant key '<key>' is used more than once.Variant '<key>' needs a name.Variant name must not exceed 128 characters.Variant '<key>' has an allocation of <weight>%; allocations are whole percentages from 0 to 100.- and after all variants:
Allocations add up to <total>% — they must total 100%.
metricGoal:Unknown metric goal '<n>'.(a number other than 0 or 1).- The entry exists:
Remote config entry '<entryKey>' does not exist in this application. - Every variant value parses as the entry's type:
Variant '<key>':followed by the value sentence, for exampleVariant 'sixteen': Value must be an integer in invariant-culture notation.(the sentences are those of Create or update an entry). - The metric event exists:
Metric event '<name>' does not exist in this application. - The metric event is not archived, unless the experiment already measures it:
The event '<name>' is archived; restore it to measure an experiment with it.
Then the state rules, all 409:
- A new experiment on an entry that already has a Draft or Running experiment, or an existing experiment moved to such an entry:
An experiment is already active on this entry. - A Running experiment whose write changes more than it may (table below):
The experiment is running; stop it to change variants, weights, the entry or the metric. - A Stopped experiment whose write changes more than it may:
The experiment has ended; create a new experiment to test again. - Two writes of a new key raced:
Experiment '<key>' was written concurrently; retry the request.
What may change
| Field | Draft | Running | Stopped |
|---|---|---|---|
name, description |
Yes | Yes | Yes |
variants[].name |
Yes | Yes | Yes |
autoPromote |
Yes | Yes | No |
endsAt later than the current one, or endsAt removed (null) |
Yes | Yes | Accepted; has no effect |
endsAt earlier, or set on an experiment that had none |
Yes | No | No |
startsAt |
Yes | No | No |
entryKey, metricEventName, metricGoal |
Yes | No | No |
| Variant keys, values, weights, their number or their order | Yes | No | No |
Responses
| Status | Body | When | What to do |
|---|---|---|---|
| 200 | JSON object | Created as Draft, or updated. |
Start it when ready. |
| 400 | JSON string | One of steps 1-11 of the validation order above failed. | Fix the definition. |
| 400 | empty | The body is not valid JSON, a property has the wrong type, or metricGoal is an unknown name. |
Fix the request. |
| 409 | JSON string | Step 12 or a state rule failed. | See the sentence; read the experiment first. |
Notes
- PUT replaces the whole definition. A property you leave out takes its default:
description,startsAtandendsAtbecomenull,autoPromotebecomesfalse. Re-send the complete definition every time, as you last read it, changing only what you mean to change. Leaving outendsAton a Running experiment removes its end; leaving outautoPromoteon a Stopped experiment with auto-promote on is refused with409. - Variants are matched by key: a kept key keeps its
id, a new key gets a newid, a missing key is removed. Their order is the order you send; the first is the control. - Every change to an experiment reaches clients' fetches in every region usually within seconds, at most 5 minutes.
startsAtandendsAtin the past are accepted. A start withendsAtalready past opens a run that is stopped within about 1 minute.
Start an experiment (POST /api/v1/experiments/{key}/start)
Opens the experiment's run: a new salt and a frozen copy of the variants. Status becomes Running.
| Method | Path | Scope | Success | Idempotency-Key |
|---|---|---|---|---|
| POST | /api/v1/experiments/{key}/start |
Application.Experiments.Write |
200 | No |
Parameters
| Name | In | Type | Required | Default | Rules |
|---|---|---|---|---|---|
key |
path | string | Yes | none | The experiment key. |
Example request
POST /api/v1/experiments/max_players_test/start HTTP/1.1
Host: signalr.fiveminutes.cloud
Authorization: Bearer <server token>
Example response
{
"id": "<experiment id>",
"key": "max_players_test",
"name": "Max players 8 vs 16",
"description": "Do 16-player rounds make more players finish a level?",
"entryKey": "max_players",
"status": "Running",
"startsAt": null,
"endsAt": "2026-10-13T00:00:00Z",
"metricEventName": "level_complete",
"metricGoal": "Increase",
"winnerVariantId": null,
"variants": [
{
"id": "<control variant id>",
"ordinal": 0,
"key": "control",
"name": "8 players",
"value": "8",
"weight": 50
},
{
"id": "<sixteen variant id>",
"ordinal": 1,
"key": "sixteen",
"name": "16 players",
"value": "16",
"weight": 50
}
],
"currentRun": {
"id": "<experiment run id>",
"ordinal": 1,
"salt": "<salt>",
"startedAt": "2026-09-29T08:21:00.004Z",
"stoppedAt": null,
"variants": [
{
"id": "<control variant id>",
"ordinal": 0,
"key": "control",
"name": "8 players",
"value": "8",
"weight": 50
},
{
"id": "<sixteen variant id>",
"ordinal": 1,
"key": "sixteen",
"name": "16 players",
"value": "16",
"weight": 50
}
],
"variantStats": [
{
"variantId": "<control variant id>",
"variantKey": "control",
"exposures": 0,
"converted": 0,
"valueSum": 0,
"valueCount": 0
},
{
"variantId": "<sixteen variant id>",
"variantKey": "sixteen",
"exposures": 0,
"converted": 0,
"valueSum": 0,
"valueCount": 0
}
],
"isArchived": false,
"archivedAt": null,
"resultVariantId": null,
"resultRecordedAt": null
},
"createdAt": "2026-09-29T08:20:11.3021Z",
"updatedAt": "2026-09-29T08:21:00.004Z",
"updatedBy": "<API key id>",
"autoPromote": false,
"promotedAt": null,
"promotedBy": null,
"autoPromoted": false
}
Responses
| Status | Body | When | What to do |
|---|---|---|---|
| 200 | JSON object | The run is open. | Clients get their variants on their next fetch (inside the window). |
| 400 | JSON string Experiment key must be 1-64 characters of lower-case letters, digits or '_'. |
The key has the wrong format. | Fix the path. |
| 403 | application/problem+json, detail The application has reached its limit of <n> running experiments. |
The application already runs as many experiments as its plan allows. | Stop another experiment, or change the plan. |
| 403 | application/problem+json, detail Experiments are not included in the application's current plan. |
The plan has no experiments. | Change the plan. |
| 404 | empty | No experiment with this key. | Check the key. |
| 409 | JSON string The experiment is already running. |
It is Running. |
Nothing to do. |
| 409 | JSON string The experiment has ended; create a new experiment to test again. |
It is Stopped. A stop is final. |
Create a new experiment with a new key. |
| 409 | JSON string An experiment is already active on this entry. |
Another Draft or Running experiment uses the same entry. | Stop or delete that one first. |
Notes
- An empty body is fine; the route reads nothing from it.
- Every
Runningexperiment counts against the plan allowance from its start until it is stopped, including while it is scheduled (beforestartsAt) and while it waits for the automatic stop afterendsAt(see Plan allowances). - With
startsAtin the future the experiment is scheduled: it assigns nobody untilstartsAt. - To learn which variant a user is in, call Get a user's assignments (see Bucketing and stickiness).
Stop an experiment (POST /api/v1/experiments/{key}/stop)
Closes the run for good and records its result. Status becomes Stopped.
| Method | Path | Scope | Success | Idempotency-Key |
|---|---|---|---|---|
| POST | /api/v1/experiments/{key}/stop |
Application.Experiments.Write |
200 | No |
Parameters
| Name | In | Type | Required | Default | Rules |
|---|---|---|---|---|---|
key |
path | string | Yes | none | The experiment key. |
Example request
POST /api/v1/experiments/max_players_test/stop HTTP/1.1
Host: signalr.fiveminutes.cloud
Authorization: Bearer <server token>
Example response
{
"id": "<experiment id>",
"key": "max_players_test",
"name": "Max players 8 vs 16",
"description": "Do 16-player rounds make more players finish a level?",
"entryKey": "max_players",
"status": "Stopped",
"startsAt": null,
"endsAt": "2026-10-13T00:00:00Z",
"metricEventName": "level_complete",
"metricGoal": "Increase",
"winnerVariantId": null,
"variants": [
{
"id": "<control variant id>",
"ordinal": 0,
"key": "control",
"name": "8 players",
"value": "8",
"weight": 50
},
{
"id": "<sixteen variant id>",
"ordinal": 1,
"key": "sixteen",
"name": "16 players",
"value": "16",
"weight": 50
}
],
"currentRun": null,
"createdAt": "2026-09-29T08:20:11.3021Z",
"updatedAt": "2026-10-06T16:02:45.771Z",
"updatedBy": "<API key id>",
"autoPromote": false,
"promotedAt": null,
"promotedBy": null,
"autoPromoted": false
}
Responses
| Status | Body | When | What to do |
|---|---|---|---|
| 200 | JSON object | The run is closed and its result recorded. | Read the results; promote a variant if you want. |
| 400 | JSON string Experiment key must be 1-64 characters of lower-case letters, digits or '_'. |
The key has the wrong format. | Fix the path. |
| 404 | empty | No experiment with this key. | Check the key. |
| 409 | JSON string The experiment is not running. |
It is Draft or already Stopped. |
Nothing to stop. Delete a Draft you do not want. |
Notes
- A stop is final: the experiment never runs again. Users receive the entry's base value from their next fetch.
- The run records its result: the leading variant for the goal among the variants with exposures, or none when nobody was exposed. Read it as
resultVariantIdin the results and on the run. - With
autoPromoteon, the stop also promotes the result in the same step:winnerVariantIdandpromotedAtare set, and the result's value is written into the entry unless the result is the control. See Auto-promote and manual promote. - Reports keep counting for the run for 30 days after the stop (
attributionEndsAt), so users exposed just before the stop still get to convert. - The end of the window stops the experiment the same way, within about 1 minute after
endsAt, withupdatedByexperiments.
Promote a variant (POST /api/v1/experiments/{key}/promote)
Writes one variant's value into the experiment's entry and records the variant as the winner. Only after the stop, and only once per experiment.
| Method | Path | Scope | Success | Idempotency-Key |
|---|---|---|---|---|
| POST | /api/v1/experiments/{key}/promote |
Application.Experiments.Write |
200 | No |
Parameters
| Name | In | Type | Required | Default | Rules |
|---|---|---|---|---|---|
key |
path | string | Yes | none | The experiment key. |
variantKey |
body | string | Yes | none | The key of one of the experiment's variants. |
Example request
POST /api/v1/experiments/max_players_test/promote HTTP/1.1
Host: signalr.fiveminutes.cloud
Authorization: Bearer <server token>
Content-Type: application/json
{"variantKey":"sixteen"}
Example response
{
"experiment": {
"id": "<experiment id>",
"key": "max_players_test",
"name": "Max players 8 vs 16",
"description": "Do 16-player rounds make more players finish a level?",
"entryKey": "max_players",
"status": "Stopped",
"startsAt": null,
"endsAt": "2026-10-13T00:00:00Z",
"metricEventName": "level_complete",
"metricGoal": "Increase",
"winnerVariantId": "<sixteen variant id>",
"variants": [
{
"id": "<control variant id>",
"ordinal": 0,
"key": "control",
"name": "8 players",
"value": "8",
"weight": 50
},
{
"id": "<sixteen variant id>",
"ordinal": 1,
"key": "sixteen",
"name": "16 players",
"value": "16",
"weight": 50
}
],
"currentRun": null,
"createdAt": "2026-09-29T08:20:11.3021Z",
"updatedAt": "2026-10-06T16:05:12.09Z",
"updatedBy": "<API key id>",
"autoPromote": false,
"promotedAt": "2026-10-06T16:05:12.09Z",
"promotedBy": "<API key id>",
"autoPromoted": false
},
"entry": {
"key": "max_players",
"valueType": "Int",
"value": "16",
"isPublic": true,
"createdAt": "2026-09-28T14:03:11.517",
"updatedAt": "2026-10-06T16:05:12.09Z",
"updatedBy": "<API key id>"
}
}
Responses
| Status | Body | When | What to do |
|---|---|---|---|
| 200 | JSON object | The value is in the entry and the winner recorded. | Clients receive the value from their next fetch. |
| 400 | JSON string Experiment key must be 1-64 characters of lower-case letters, digits or '_'. |
The key has the wrong format. | Fix the path. |
| 400 | JSON string Variant key must be 1-32 characters of lower-case letters, digits or '_'. |
variantKey is missing or has the wrong format. |
Fix the body. |
| 400 | JSON string The experiment has no variant '<variantKey>'. |
No variant with this key. | Use a key from variants. |
| 400 | empty | No body, or a body that is not valid JSON. | Send {"variantKey":"<key>"} with Content-Type: application/json. |
| 404 | empty | No experiment with this key. | Check the key. |
| 409 | JSON string The experiment has not run yet. |
It is Draft. |
Start and stop it first, or write the entry directly. |
| 409 | JSON string Stop the experiment before promoting a winner. |
It is Running. |
Stop it first. |
| 409 | JSON string This experiment's result was already promoted. To change the value, edit the remote config entry. |
The experiment was already promoted, by hand or automatically at the end of its run. | Write the entry with PUT /api/v1/remote-config. |
| 409 | JSON string The entry's value type changed since the experiment ran; the variant's value no longer fits it. |
The entry was re-typed after the stop and the value does not parse as the new type. | Write the entry directly. |
Notes
- A manual promotion always writes the variant's value into the entry, the control's value included.
- A promotion happens once per experiment, by hand or automatically. A second promote call answers
409; to change the value afterwards, write the entry withPUT /api/v1/remote-config. The results and the run stay as they are. promotedByrecords who promoted (the API key id, the dashboard user, or the user who stopped the experiment for an automatic promotion;experimentswhen the window ended it) andautoPromotedwhether the promotion was automatic.- Promotion is an ordinary entry write: clients see the value on their next fetch, usually within seconds, at most 5 minutes.
Delete an experiment (DELETE /api/v1/experiments/{key})
Deletes the experiment in any status, with its run, its results and its history.
| Method | Path | Scope | Success | Idempotency-Key |
|---|---|---|---|---|
| DELETE | /api/v1/experiments/{key} |
Application.Experiments.Write |
204 | No |
Parameters
| Name | In | Type | Required | Default | Rules |
|---|---|---|---|---|---|
key |
path | string | Yes | none | The experiment key. |
Example request
DELETE /api/v1/experiments/max_players_test HTTP/1.1
Host: signalr.fiveminutes.cloud
Authorization: Bearer <server token>
Example response
Status 204 with an empty body.
Responses
| Status | Body | When | What to do |
|---|---|---|---|
| 204 | empty | The experiment and everything recorded for it are gone. | Nothing. |
| 400 | JSON string Experiment key must be 1-64 characters of lower-case letters, digits or '_'. |
The key has the wrong format. | Fix the path. |
| 404 | empty | No experiment with this key. | Nothing to delete. |
Notes
- A Running experiment can be deleted directly; users receive the base value from their next fetch.
- Deleting an experiment frees its entry and its metric event, so they can then be deleted too.
List experiments (GET /api/v1/experiments)
Returns every experiment of the application, with its variants and, while it runs, its current run.
| Method | Path | Scope | Success | Idempotency-Key |
|---|---|---|---|---|
| GET | /api/v1/experiments |
Application.Experiments.Read |
200 | No |
Parameters
None.
Example request
GET /api/v1/experiments HTTP/1.1
Host: signalr.fiveminutes.cloud
Authorization: Bearer <server token>
Example response
{
"experiments": [
{
"id": "<experiment id>",
"key": "max_players_test",
"name": "Max players 8 vs 16",
"description": "Do 16-player rounds make more players finish a level?",
"entryKey": "max_players",
"status": "Draft",
"startsAt": null,
"endsAt": "2026-10-13T00:00:00Z",
"metricEventName": "level_complete",
"metricGoal": "Increase",
"winnerVariantId": null,
"variants": [
{
"id": "<control variant id>",
"ordinal": 0,
"key": "control",
"name": "8 players",
"value": "8",
"weight": 50
},
{
"id": "<sixteen variant id>",
"ordinal": 1,
"key": "sixteen",
"name": "16 players",
"value": "16",
"weight": 50
}
],
"currentRun": null,
"createdAt": "2026-09-29T08:20:11.3021Z",
"updatedAt": "2026-09-29T08:20:11.3021Z",
"updatedBy": "<API key id>",
"autoPromote": false,
"promotedAt": null,
"promotedBy": null,
"autoPromoted": false
}
]
}
Responses
| Status | Body | When | What to do |
|---|---|---|---|
| 200 | JSON object | Always, also when there are none ("experiments": []). |
Read experiments. |
Notes
- Experiments are ordered by key. Every status is included.
- Each item has the same shape as the answer of Create or update an experiment.
currentRunis set only while the experiment isRunning; it has the shape shown under Start an experiment, with livevariantStatstotals. variants[].ordinalis 0-based: 0 is the control.
Get an experiment and its runs (GET /api/v1/experiments/{key})
Returns one experiment and its run history, including the run of a stopped experiment.
| Method | Path | Scope | Success | Idempotency-Key |
|---|---|---|---|---|
| GET | /api/v1/experiments/{key} |
Application.Experiments.Read |
200 | No |
Parameters
| Name | In | Type | Required | Default | Rules |
|---|---|---|---|---|---|
key |
path | string | Yes | none | The experiment key. |
Example request
GET /api/v1/experiments/max_players_test HTTP/1.1
Host: signalr.fiveminutes.cloud
Authorization: Bearer <server token>
Example response
{
"experiment": {
"id": "<experiment id>",
"key": "max_players_test",
"name": "Max players 8 vs 16",
"description": "Do 16-player rounds make more players finish a level?",
"entryKey": "max_players",
"status": "Stopped",
"startsAt": null,
"endsAt": "2026-10-13T00:00:00Z",
"metricEventName": "level_complete",
"metricGoal": "Increase",
"winnerVariantId": null,
"variants": [
{
"id": "<control variant id>",
"ordinal": 0,
"key": "control",
"name": "8 players",
"value": "8",
"weight": 50
},
{
"id": "<sixteen variant id>",
"ordinal": 1,
"key": "sixteen",
"name": "16 players",
"value": "16",
"weight": 50
}
],
"currentRun": null,
"createdAt": "2026-09-29T08:20:11.3021Z",
"updatedAt": "2026-10-06T16:02:45.771Z",
"updatedBy": "<API key id>",
"autoPromote": false,
"promotedAt": null,
"promotedBy": null,
"autoPromoted": false
},
"runs": [
{
"id": "<experiment run id>",
"ordinal": 1,
"salt": "<salt>",
"startedAt": "2026-09-29T08:21:00.004Z",
"stoppedAt": "2026-10-06T16:02:45.771Z",
"variants": [
{
"id": "<control variant id>",
"ordinal": 0,
"key": "control",
"name": "8 players",
"value": "8",
"weight": 50
},
{
"id": "<sixteen variant id>",
"ordinal": 1,
"key": "sixteen",
"name": "16 players",
"value": "16",
"weight": 50
}
],
"variantStats": [
{
"variantId": "<control variant id>",
"variantKey": "control",
"exposures": 1204,
"converted": 301,
"valueSum": 0,
"valueCount": 0
},
{
"variantId": "<sixteen variant id>",
"variantKey": "sixteen",
"exposures": 1187,
"converted": 356,
"valueSum": 0,
"valueCount": 0
}
],
"isArchived": false,
"archivedAt": null,
"resultVariantId": "<sixteen variant id>",
"resultRecordedAt": "2026-10-06T16:02:45.771Z"
}
]
}
Responses
| Status | Body | When | What to do |
|---|---|---|---|
| 200 | JSON object | Found. | Read experiment and runs. |
| 400 | JSON string Experiment key must be 1-64 characters of lower-case letters, digits or '_'. |
The key has the wrong format. | Fix the path. |
| 404 | empty | No experiment with this key. | Check the key. |
Notes
runsis empty for a Draft and has one run otherwise (ordinal1).- A run's
variantsis the frozen copy taken at the start: the values and weights that were served.variantStatsholds the run's totals:exposures,converted(users, for aBinarymetric),valueSumandvalueCount(sum and number of reports, for aNumberorDecimalmetric).valueSumis a number with up to 6 decimals and may be written with trailing zeros, for example0.000000or1234.500000; parse it as a number. saltis the run's salt, an opaque string.isArchivedandarchivedAtsay whether the run's per-user records have been retired (see Retention and archived results); the totals stay.
Get a user's assignments (GET /api/v1/experiments/assignment)
Returns which variant of every live experiment a user is in, without delivering any value and without counting an exposure.
| Method | Path | Scope | Success | Idempotency-Key |
|---|---|---|---|---|
| GET | /api/v1/experiments/assignment?uniqueUserId=<unique user id> |
Application.Experiments.Read |
200 | No |
Parameters
| Name | In | Type | Required | Default | Rules |
|---|---|---|---|---|---|
uniqueUserId |
query | string | Yes | none | The unique user id, 1-256 characters, not blank. URL-encode it. |
Example request
GET /api/v1/experiments/assignment?uniqueUserId=<unique user id> HTTP/1.1
Host: signalr.fiveminutes.cloud
Authorization: Bearer <server token>
Example response
{
"assignments": [
{
"experimentKey": "max_players_test",
"variantKey": "sixteen"
}
]
}
Responses
| Status | Body | When | What to do |
|---|---|---|---|
| 200 | JSON object | Always for a valid unique user id; assignments is empty when no experiment is live. |
Read assignments. |
| 400 | JSON string A player id (uniqueUserId) is required. |
uniqueUserId is missing, empty or only spaces. |
Fix the request. |
| 400 | JSON string The player id (uniqueUserId) must not exceed 256 characters. |
uniqueUserId is too long. |
Fix the request. |
Notes
- The answer covers every live experiment, on public and server-only entries alike, ordered by experiment key. Scheduled and ended experiments are not live and are not listed.
- It is the same assignment the user's fetch composes, computed from the run's salt and the unique user id alone. Nothing is delivered, so it is not an exposure: use it for support tools, logging and analytics.
- Do not give an experiment the key
assignment:GET /api/v1/experiments/assignmentalways answers this route, so that experiment could not be read by key.
Get experiment results (GET /api/v1/experiments/{key}/results)
Returns the results of the experiment's run: totals and derived numbers per variant, the leading variant, the recorded result, an hourly series and the sample-ratio check.
| Method | Path | Scope | Success | Idempotency-Key |
|---|---|---|---|---|
| GET | /api/v1/experiments/{key}/results |
Application.Experiments.Read |
200 | No |
Parameters
| Name | In | Type | Required | Default | Rules |
|---|---|---|---|---|---|
key |
path | string | Yes | none | The experiment key. |
runId |
query | string (GUID) | No | none | A run id from Get an experiment and its runs. Without it: the open run, or else the latest run. |
Example request
GET /api/v1/experiments/max_players_test/results HTTP/1.1
Host: signalr.fiveminutes.cloud
Authorization: Bearer <server token>
Example response
{
"experimentId": "<experiment id>",
"experimentKey": "max_players_test",
"status": "Running",
"startsAt": null,
"endsAt": "2026-10-13T00:00:00Z",
"runId": "<experiment run id>",
"runOrdinal": 1,
"startedAt": "2026-09-29T08:21:00.004Z",
"stoppedAt": null,
"isCurrentRun": true,
"attributionEndsAt": null,
"metricEventName": "level_complete",
"metricType": "Binary",
"metricUnit": null,
"metricGoal": "Increase",
"controlVariantId": "<control variant id>",
"leadingVariantId": "<sixteen variant id>",
"variants": [
{
"variantId": "<control variant id>",
"variantKey": "control",
"name": "8 players",
"value": "8",
"weight": 50,
"exposures": 1204,
"converted": 301,
"valueSum": 0,
"valueCount": 0,
"rate": 0.25,
"average": 0,
"liftVsControl": null
},
{
"variantId": "<sixteen variant id>",
"variantKey": "sixteen",
"name": "16 players",
"value": "16",
"weight": 50,
"exposures": 1187,
"converted": 356,
"valueSum": 0,
"valueCount": 0,
"rate": 0.2999157540016849,
"average": 0,
"liftVsControl": 19.966301600673965
}
],
"series": [
{
"hourUtc": "2026-09-29T08:00:00Z",
"variantId": "<control variant id>",
"exposures": 41,
"hits": 9,
"valueSum": 0
},
{
"hourUtc": "2026-09-29T08:00:00Z",
"variantId": "<sixteen variant id>",
"exposures": 38,
"hits": 12,
"valueSum": 0
}
],
"isArchived": false,
"archivedAt": null,
"sampleRatioMismatch": false,
"sampleRatioDetail": null,
"resultVariantId": null,
"resultRecordedAt": null,
"autoPromote": false,
"promotedAt": null,
"promotedBy": null,
"autoPromoted": false
}
Result fields
| Field | Meaning |
|---|---|
experimentId, experimentKey |
The experiment. |
status, startsAt, endsAt, autoPromote, promotedAt, promotedBy, autoPromoted |
The experiment as it is now. |
runId, runOrdinal |
The run these numbers belong to. runOrdinal is 1. |
startedAt, stoppedAt |
When the run started and stopped; stoppedAt is null while it is open. |
isCurrentRun |
true while the run is open. |
attributionEndsAt |
stoppedAt plus 30 days: reports count for the run until then. null while the run is open. |
metricEventName, metricType, metricUnit, metricGoal |
The metric the run is judged on. |
controlVariantId |
The first variant. |
leadingVariantId |
The variant with the best headline number for the goal among the variants with exposures; null when nobody is exposed. A label, not significance. |
resultVariantId, resultRecordedAt |
The result recorded when the run ended; both null while the run is open, and resultVariantId also null when nobody was exposed. |
variants[].variantId, variantKey, name, value, weight |
The variant as it ran (the frozen copy). |
variants[].exposures |
Users exposed to the variant in this run. |
variants[].converted |
For a Binary metric: exposed users who reported the event at least once after their exposure. 0 for other types. |
variants[].valueSum, valueCount |
For a Number or Decimal metric: the sum of the reported values and the number of reports counted. 0 for Binary. |
variants[].rate |
converted / exposures; null without exposures. The headline number for Binary. |
variants[].average |
valueSum / exposures, per exposed user (not per report); null without exposures. The headline number for Number and Decimal. |
variants[].liftVsControl |
(headline - control headline) / control headline x 100, in percent. null for the control, when either side has no exposures, or when the control's headline is 0. |
series[] |
One point per UTC hour and variant with activity, covering the whole run, ordered by hour: hourUtc (the start of the hour), exposures (users who received the variant's value in that hour), hits (new conversions for Binary; reports counted for Number and Decimal) and valueSum (sum of values in that hour). Hours without activity have no point. A user who keeps fetching is counted in exposures again about once a day, and once in each region they fetch through, so the series' exposures can add up to more than variants[].exposures: use the series for the shape over time and the per-variant totals for the numbers. |
sampleRatioMismatch, sampleRatioDetail |
true with a sentence when the split looks skewed (see Results). |
isArchived, archivedAt |
Whether the run's per-user records have been retired and these numbers come from the frozen record. |
Responses
| Status | Body | When | What to do |
|---|---|---|---|
| 200 | JSON object | The run's results. | Read them. |
| 400 | JSON string Experiment key must be 1-64 characters of lower-case letters, digits or '_'. |
The key has the wrong format. | Fix the path. |
| 400 | empty | runId is not a GUID. |
Fix the query. |
| 404 | empty | No experiment with this key, the experiment has never started, or runId is not a run of this experiment. |
Check the key; start the experiment first. |
| 500 | application/problem+json, detail The archived results are not available right now; try again later. |
A retired run's frozen record could not be read. | Retry later. |
Notes
- Results are eventually consistent: exposures and reports reach the totals asynchronously, usually within seconds.
- The series is best effort. When it cannot be read, the answer comes with an empty
series; the per-variant totals are the authoritative numbers. - See Results for how to read these numbers.
How experiments behave
Bucketing and stickiness
A user's variant in a run depends only on the run and the unique user id.
- The user gets the same variant on every fetch, every reconnect, every device and in every region, for the whole run.
- Users are spread over the variants by weight: a variant with weight 30 receives about 30% of the users. A variant with weight 0 is never chosen. Because the weights total 100, every user in the window is in exactly one variant.
- Whether the user is assigned at all depends on the window: a scheduled or ended experiment assigns nobody.
- A new experiment gets a new salt, so the same user can land in a different variant of a follow-up experiment.
- To learn which variant a user is in, call Get a user's assignments. It returns the user's variant in every live experiment without counting an exposure.
Windows and automatic stop
startsAtandendsAtare UTC instants. The window includesstartsAtand excludesendsAt: a user is assigned whenstartsAt<= now <endsAt. A bound that isnulldoes not limit the window.- The window is checked on every fetch. At
startsAtthe next fetch of each user returns the variant value, and atendsAtthe base value, without anything else happening. A client that holds the ETag of the old state gets a full answer, because the composed configuration changed. - The experiment stays
Runninguntil it is stopped. Within about 1 minute afterendsAt, Five Minutes stops it exactly as a stop call would: the run closes, the result is recorded, auto-promote runs if it is on, andupdatedBybecomesexperiments. - While
Running,endsAtcan be moved later or removed, but not moved earlier, not added to an experiment that had none, andstartsAtcannot change. To end early, stop the experiment.
Exposures
- A user is exposed to a run the first time a full answer delivers the run's variant value to them: a client's fetch that returns the assignment, or your server's per-user read.
- A user is exposed once per run. Later fetches, reconnects and other devices do not add exposures.
- These are not exposures: a
NotModifiedanswer, a fetch while the experiment is scheduled or ended, Get a user's assignments, and List entries without a user. - An experiment on a server-only entry never reaches clients. Its users are exposed only by your server's per-user read, so read it for a user exactly when you deliver the value.
- Exposures reach the totals asynchronously, usually within seconds. If recording one fails, a later fetch of the user records it, at the latest a fetch about a day later.
Attribution
- An accepted report counts for every run that measures the reported event, is open or stopped less than 30 days ago, and in which the user is exposed.
- The exposure must come first. A report that is processed before the user's exposure to a run does not count for that run, and it is not counted later when the user is exposed.
- A
Binaryreport counts the user once per run: the first report after the exposure converts the user, later ones change nothing. - A
NumberorDecimalreport counts every time: its value is added to the variant'svalueSumandvalueCountgoes up by one. - Reports keep counting for 30 days after the stop (
attributionEndsAtin the results), so users exposed shortly before the stop still get to convert. After that, reports no longer count for the run. - Reports of an archived metric event still count.
- Across regions: an exposure is known at once in the region that delivered the value and reaches the other regions within seconds. A report processed in another region before that does not count, and that region then treats the user as not exposed for up to 5 minutes, so the user's reports processed there in that time do not count either. This affects only the first minutes after a user's first exposure.
Results
Read the results with Get experiment results:
- Rate (the headline for
Binary):converted / exposures. - Average (the headline for
NumberandDecimal):valueSum / exposures, per exposed user, not per report. Users who never reported count as 0. - Lift:
(headline - control's headline) / control's headline x 100, in percent.nullfor the control, when either side has no exposures, or when the control's headline is 0. - Leading: the variant with the highest headline for
Increase, or the lowest forDecrease, among the variants with at least one exposure. On a tie the control wins, then the variant listed first.nullwhile nobody is exposed. It is a label for the best number so far, not a statistical verdict: Five Minutes computes no significance or confidence interval. - Result: the leading variant at the moment the run ended, recorded as
resultVariantIdwithresultRecordedAt. It never changes afterwards, even though late reports during the attribution period still move the totals. - Sample-ratio check: once the run has at least 1000 exposures in total, a variant whose share of the exposures differs from its weight by more than 2 percentage points sets
sampleRatioMismatchtotrue, andsampleRatioDetailnames the variant that deviates most:Variant '<key>' received <x.x>% of exposures against an allocation of <weight>%; the split looks skewed, so the comparison may not be reliable.A skewed split usually means users do not fetch as you think, for example a variant whose value makes users quit before the fetch completes, or exposures read by your server for some users only. - Eventual consistency: exposures and reports reach the totals asynchronously, usually within seconds. Every night the exposure and conversion totals of runs still within their attribution period are recounted from the per-user records, so they can shift slightly, for example when inactive users are deleted. Value sums are not recounted.
Auto-promote and manual promote
- Auto-promote (
autoPromote: true): when the run ends, by a stop call or at the end of its window, the recorded result is promoted in the same step.winnerVariantIdis set to the result,promotedAtto the time,promotedByto whoever ended the run andautoPromotedtotrue, and the result's value is written into the entry, unless the result is the control: then the entry keeps its current value, even when the control's value differs from it. When nobody was exposed there is no result and nothing is promoted. - Switch auto-promote on or off while the experiment is
DraftorRunningby sending the whole definition again withPUT /api/v1/experimentsand the newautoPromote. It does not disturb a running run. On aStoppedexperiment it can no longer change (409). - Manual promote (Promote a variant): only on a
Stoppedexperiment. It always writes the chosen variant's value into the entry, the control's included, and records the variant as the winner. A promotion happens once: after an automatic or a manual promotion, another promote answers409withThis experiment's result was already promoted. To change the value, edit the remote config entry., and an experiment promoted by hand is never promoted automatically. Change the value afterwards by writing the entry. - A promotion is an ordinary change of the entry's base value: every user receives it from their next fetch.
- Auto-promote acts on the best number at the moment the run ends, not on a proven winner. Use it with a window long enough to gather a comfortable number of exposures per variant.
Retention and archived results
| When | What happens |
|---|---|
| While the run is open | Live totals, results and series. |
| At the stop | The result is recorded. Reports keep counting for 30 days (attributionEndsAt). |
| 30 days after the stop | The run is final: reports no longer count for it. |
| 90 days after that (about 120 days after the stop) | The per-user records (who was exposed, who converted) are deleted and the run's totals, results and series are frozen. isArchived becomes true and archivedAt is set on the run and in the results. The numbers do not change. |
| 365 days after the run became final | The run's working records are removed; the frozen record keeps answering the run in Get an experiment and its runs and in the results. |
- The daily pass that performs these steps runs at 03:00 UTC, so each step can happen up to a day after the date above.
- Results stay readable until you delete the experiment. Deleting the experiment deletes everything, frozen records included.
- If a frozen record cannot be read for a moment, the results call answers
500withdetailThe archived results are not available right now; try again later.
Consistency across regions
- One set of data. Entries, experiments and the metric event catalogue belong to the application, not to a region. A change made through the API or the dashboard is served in every region.
- Propagation. Clients in every region see a change on their next fetch, usually within seconds and at most 5 minutes later. The same bound applies to a new or deleted metric event name for reports, and to experiment starts, stops and window changes.
- The same answer everywhere. A user's assignment depends only on the salt and the unique user id, and the ETag only on what the user receives, so a user gets the same variant and the same ETag in every region.
- One automatic stop. The end of a window stops the experiment once, for all regions.
- Idempotency is per region. The
POST /api/v1/eventsidempotency key and the wire resend rule (sameMessageId) protect a retry only in the region that handled the original. Through another region, aNumberorDecimalevent counts twice; aBinaryevent still counts once per user and run. - Attribution just after an exposure. A report processed in another region within the first minutes after the user's exposure may not count (see Attribution).
Plan allowances
| Allowance | Free | Subscription | How you experience it |
|---|---|---|---|
| Running experiments per application | 1 | 5 | Enforced at start: POST /api/v1/experiments/{key}/start answers 403 with an application/problem+json body whose detail is The application has reached its limit of <n> running experiments.. A plan without experiments answers Experiments are not included in the application's current plan.. Every Running experiment counts, scheduled and ended ones included, until it is stopped. |
| Remote config entries per application | 20 | 200 | The number of entries your plan includes. A write is never refused for the number of entries. |
| Metric events per calendar month, across all applications of the organisation | 100,000 | 10,000,000 | Metered: accepted reports are counted against the allowance. A report is never refused for volume. |
Other plans have their own values. Drafts, stopped experiments and metric event definitions do not count against any allowance.
Walkthrough
An end-to-end integration: your server sets up an experiment on max_players, a client receives its variant and reports conversions, your server reads the results, stops the experiment, promotes the winner and cleans up. The host is https://signalr.fiveminutes.cloud and the API key needs Full access (or the five scopes of this document).
Your server exchanges the API key for a server token:
POST /api/v1/api-clients/authenticatewith{"applicationId":"<application id>","clientSecret":"<API key secret>"}. Success:200withtoken. SendAuthorization: Bearer <server token>on every following call.Your server creates the entry:
PUT /api/v1/remote-configwith{"key":"max_players","valueType":"Int","value":"8","isPublic":true}. Success:200with the entry.Your server creates the metric event:
PUT /api/v1/metric-eventswith{"name":"level_complete","type":"Binary"}. Success:200,"type": "Binary".Your server defines the experiment:
PUT /api/v1/experimentswith the body shown in Create or update an experiment. Success:200,"status": "Draft".Your server starts it:
POST /api/v1/experiments/max_players_test/start. Success:200,"status": "Running"and acurrentRunwith itssalt. A403means the plan's running experiments are used up.Client connects, logs in and receives ServerWelcome with the user's unique user id (with the npm packages:
await client.connect()).Client fetches:
ClientRemoteConfigRequestwithETag""(with the npm packages:client.fetchRemoteConfig()). Success:ServerRemoteConfigResponsewithSuccesstrue,NotModifiedfalse,Entriescontainingmax_players=16andAssignmentscontaining (max_players_test,sixteen). The client stores the ETag, entries and assignments and plays with 16 players. Five Minutes records the user's exposure.Client fetches again later with the stored
ETag. Success:NotModifiedtruewith emptyEntriesandAssignments; the client keeps its cache. (fetchRemoteConfig()of the npm packages returns the answer with the kept entries and assignments filled in.)Client reports a finished level:
ClientReportMetricEventRequestwithNamelevel_completeandHasValuefalse(with the npm packages:client.reportMetricEvent("level_complete")). Success:ServerReportMetricEventResponsewithSuccesstrue. The user now counts as converted insixteen.Your server can report on the user's behalf as well, for example from a match server:
POST /api/v1/eventswith{"uniqueUserId":"<unique user id>","name":"level_complete","idempotencyKey":"match-8812:<unique user id>:level_complete"}(the key names the match, the user and the event, so it is unique to this report). Success:200with aneventId. For aBinaryevent this user is already converted, so the totals do not change.Your server reads the results while the experiment runs:
GET /api/v1/experiments/max_players_test/results. Success:200withexposures,converted,rateandliftVsControlper variant and aleadingVariantId. Numbers arrive within seconds of the fetches and reports.Your server stops the experiment once each variant has enough exposures:
POST /api/v1/experiments/max_players_test/stop. Success:200,"status": "Stopped". The results now showresultVariantId. Users receive the base value8from their next fetch.Your server promotes the winner:
POST /api/v1/experiments/max_players_test/promotewith{"variantKey":"sixteen"}. Success:200withexperiment.winnerVariantIdset andentry.value16.Client fetches with its stored ETag. Success: a full answer with a new
ETag,max_players=16and emptyAssignments.Your server cleans up, in this order, because an entry or metric event that an experiment uses cannot be deleted:
DELETE /api/v1/experiments/max_players_test:204. This also deletes the results.DELETE /api/v1/metric-eventswith{"name":"level_complete"}:204.DELETE /api/v1/remote-configwith{"key":"max_players"}:204.
Any other order answers
409(This event is used by finished experiments; archive it instead.orThis entry has experiment history; delete those experiments first.). In production you would normally keep the entry and the stopped experiment and only archive the metric event.
Good practice
- Ship defaults in the build. Treat remote config as an override. Until the first answer arrives, or when the user is offline, your app runs on built-in defaults.
- Keep types honest. Use
Int,Float,BoolandJsonfor what they are, and parse with the rules in Entries. Write canonical values (8,0.25,true). - Group related settings in one Json entry when they must change together; a fetch returns a complete set, but your code reads keys one at a time.
- Keep sensitive values server-only. Anything public can be read from the client.
- Never repurpose a key or a metric event name. Old builds keep reading and reporting them. Add a new one and retire the old one when no supported build uses it.
- Keep the public set small. Every fetch carries every public entry, and over TCP one message is at most 1 MiB.
- Fetch after every login and at natural moments; store the ETag with the entries and the assignments.
- Decide the metric before you start. Create the metric event, ship a build that reports it, and check that reports are accepted before you start the experiment. An experiment measured by an event nobody sends never shows a result.
- Report after the fetch. A report counts only for experiments the user is already exposed to.
- Give the control today's value. Users in the control receive the control's value, not the base value.
- Let it run. Leading is the best number so far, not proof. Differences over a few hundred exposures per variant are mostly noise, and an early lead often disappears. Watch
sampleRatioMismatch. - Use idempotency keys on every
POST /api/v1/eventscall, built from your own ids. - Send whole definitions.
PUTreplaces everything; read the experiment, change what you mean to change, send it all back. - Keep finished experiments. Deleting destroys the results; archive the metric event instead.
Limits
| Limit | Value | What happens when exceeded |
|---|---|---|
| Entry key | 1-128 characters of letters, digits, _, ., - |
400 Key must be 1-128 characters of letters, digits, '_', '.' or '-'. |
| Entry or variant value | 65,536 UTF-8 bytes (64 KB) | 400 Value must not exceed 64 KB (65536 bytes UTF-8). |
Int value |
64-bit signed integer | 400 Value must be an integer in invariant-culture notation. |
Json value nesting |
64 levels | 400 Value must be a valid JSON document. |
| Public entries in one fetch over TCP | 1 MiB per message | The answer is not delivered; the client gets no reply. |
| Experiment key, metric event name | 1-64 characters of lower-case letters, digits, _ |
400 with the key or name sentence. |
| Variant key | 1-32 characters of lower-case letters, digits, _ |
400 Variant key must be 1-32 characters of lower-case letters, digits or '_'. |
| Experiment name, variant name | 1-128 characters | 400 Name must not exceed 128 characters. or Variant name must not exceed 128 characters. |
| Experiment description | 1024 characters | 400 Description must not exceed 1024 characters. |
| Variants per experiment | 2-5 | 400 An experiment needs at least 2 variants (the control and one alternative). or An experiment can have at most 5 variants. |
| Variant weights | Whole numbers 0-100, total exactly 100 | 400 with the allocation sentence. |
| Active experiments per entry | 1 Draft or Running | 409 An experiment is already active on this entry. |
| Runs per experiment | 1 | 409 The experiment has ended; create a new experiment to test again. |
| Running experiments per application | Free 1, Subscription 5 | 403 application/problem+json on start. |
| Metric event unit | 32 characters | 400 Unit must not exceed 32 characters. |
| Metric event value | Magnitude at most 10^15; Number whole; Decimal 15 significant digits and 6 decimals |
Refused (400 or Success=false); extra digits are rounded, not refused. |
| Unique user id | 1-256 characters | 400 on the REST routes. |
| Idempotency key | 1-128 characters, not only whitespace; kept 24 hours, per region | 400 The idempotency key must be 1-128 characters. |
| Wire duplicate window | 5 minutes per MessageId |
A resend within the window gets no typed response. |
| Attribution after the stop | 30 days | Reports no longer count for the run. |
| Remote config entries per application | Free 20, Subscription 200 | Not enforced on write. |
| Metric events per month, per organisation | Free 100,000, Subscription 10,000,000 | Metered; never refused. |
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
The connection closes right after sending ClientRemoteConfigRequest. |
It was sent before ServerWelcome. | Fetch only after the login completed. |
A fetch never gets a ServerRemoteConfigResponse. |
The connection is below protocol 2.0; the MessageId was reused within 5 minutes; or, over TCP, the public entries exceed 1 MiB. |
Connect with protocol 2.0, use a new MessageId per fetch, keep the public set small. |
With the npm packages, reportMetricEvent rejects at once with MessageValidationError for one name. |
The name breaks the format (1-64 lower-case letters, digits or _); nothing was sent. |
Use the name exactly as it is in the catalogue. |
| The cache empties after a fetch. | NotModified answers were treated as a full answer, or Success was not checked. |
On NotModified keep entries and assignments; on Success=false keep everything. |
| A change does not show up in your app. | No push: the client has not fetched since; or less than 5 minutes have passed; or a live experiment on the entry serves variant values. | Fetch again at a natural moment; check Assignments; wait up to 5 minutes. |
| A new base value reaches nobody. | A live experiment gives every logged-in user a variant value. | Stop the experiment, or promote a variant. |
400 with an empty body on PUT /api/v1/remote-config. |
value was sent as a JSON number, boolean or object instead of a string, or the body is not valid JSON. |
Send every value as a JSON string: "value":"8", "value":"true", "value":"{\"cols\":3}". |
| An entry disappears from clients, or changes type, after a write. | The PUT left out or misspelt isPublic or valueType, which then took its default (false, "String"). |
Send key, valueType, value and isPublic on every write. |
415 with an empty body. |
The body was sent without Content-Type: application/json. |
Set the header on every call that has a body. |
409 This entry has an active experiment. on PUT /api/v1/remote-config. |
The write changes the type or visibility while a Draft or Running experiment uses the entry. | Keep type and visibility, or stop or delete the experiment first. |
409 on DELETE of an entry or metric event. |
An experiment still uses it. | Delete the experiment first, or archive the metric event. |
403 with an empty body. |
The API key lacks the scope; Write does not imply Read. | Use a key with the scope, or with Full access. |
403 application/problem+json on start. |
The plan's running experiments are used up. | Stop another experiment or change the plan. |
409 The experiment is running; stop it to change variants, weights, the entry or the metric. on a rename. |
The PUT left out or changed another field, such as startsAt, or added an endsAt. |
Read the experiment and send the complete definition back with only the name changed. |
409 The experiment has ended; create a new experiment to test again. on a rename. |
The PUT left out autoPromote (default false) on an experiment that has it on, or changed the definition. |
Send the complete definition as it is. |
| Clients never get an assignment. | The experiment is Draft, scheduled or ended; the entry is server-only; or the fetch answered NotModified to a client that already had it. |
Check status, startsAt, endsAt and isPublic. |
| Exposures stay at 0. | Nobody fetched since the start, or the experiment is on a server-only entry and your server never read the per-user view. | Fetch after login; for server-only entries use GET /api/v1/remote-config?uniqueUserId=. |
| Reports are accepted but conversions stay at 0. | The users were not exposed before reporting; the report names another event than the experiment's metric; or the report went through another region right after the exposure. | Report after the fetch; check metricEventName. |
The event is not configured for this application. |
The name is not in the catalogue, has a typo, or was deleted. A new name can take up to 5 minutes to be accepted everywhere. | Add it with PUT /api/v1/metric-events. The dashboard's Remote config page lists names that were refused. |
A Number or Decimal total is higher than expected. |
Retries without an idempotency key, retries through another region, or wire resends with a new MessageId after no reply. |
Use idempotency keys; resend unanswered reports with the same MessageId. |
422 on POST /api/v1/events. |
The idempotency key was reused for a different report, or moved between body and header. | Use one key per occurrence and resend exactly the same request. |
sampleRatioMismatch is true. |
Exposures do not follow the weights, for example a variant makes users quit before their fetch completes. | Investigate before trusting the comparison. |
Results 404. |
The experiment is Draft (never started) or the key is wrong. | Start it, or check the key. |
Related documents
- Five Minute Server API 1.0: hosts, API keys, the token exchange (Getting a server token), scopes, errors, rate limits and idempotency.
- Five Minute wire protocol 2.0: connecting, message conventions and the login handshake.
- Five Minute Player Authentication 1.0: how the login decides the unique user id that experiments and metric events use.
- npm packages 2.0: the JavaScript client, with
fetchRemoteConfig()andreportMetricEvent(). - Five Minute API Contracts 2.0: the field-level reference of
ClientRemoteConfigRequest,ServerRemoteConfigResponse,RemoteConfigEntry,ExperimentAssignment,ClientReportMetricEventRequestandServerReportMetricEventResponse. - Five Minute Leaderboards 1.0: boards, scores and cycles, the other user-data feature.
