Five Minute Config

Change app settings and run experiments without shipping a build.

Product
Version

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_complete or coins_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() and reportMetricEvent() (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.
  • Your client sends the messages below through a Five Minutes client library, never through its own implementation of the protocol.
  • JavaScript: the npm packages 2.0 give fetchRemoteConfig() and reportMetricEvent(name, value?) (see npm packages 2.0).
  • Unity: the asset has no remote config or metric events. For an app built on it, read and report for users through your server.

Server routes and auth

  • Base URL: https://signalr.fiveminutes.cloud/api/v1
  • Bodies: JSON, with Content-Type: application/json.
  • Application: no route takes an application ID; the token decides the application.
  • Auth: POST /api/v1/api-clients/authenticate with {"applicationId":"<application id>","clientSecret":"<API key secret>"} returns {"token","expiresInSeconds":600,"scopes"}. Send Authorization: Bearer <token>.
Routes Scope
GET /remote-config Application.RemoteConfig.Read
PUT /remote-config
DELETE /remote-config
Application.RemoteConfig.Write
GET /experiments
GET /experiments/assignment
GET /experiments/{key}
GET /experiments/{key}/results
GET /metric-events
Application.Experiments.Read
PUT /experiments
DELETE /experiments/{key}
POST /experiments/{key}/start
POST /experiments/{key}/stop
POST /experiments/{key}/promote
PUT /metric-events
DELETE /metric-events
POST /metric-events/archive
POST /metric-events/restore
Application.Experiments.Write
POST /events Application.Events.Write

Write does not imply Read.

Client messages

  • Protocol: 2.0.
  • Messages: ClientRemoteConfigRequest -> ServerRemoteConfigResponse, and ClientReportMetricEventRequest -> ServerReportMetricEventResponse.
  • Rules: send only after ServerWelcome; use a new MessageId per message; match replies on ResponseToMessageId.
  • Fetch: a fetch before login closes the connection. Send ETag "" or your cached ETag.
  • NotModified true: Entries AND Assignments are empty, so keep both from your cache.
  • Reported events: the user and the application come from the connection.

Identifiers

What Rule
Entry key ^[A-Za-z0-9_.-]{1,128}$
Experiment key, 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

Key limits

What Limit
Value size At most 64 KB (65,536 UTF-8 bytes), always sent as a JSON string
Variants 2-5, with whole weights 0-100 that total 100
Experiments per entry One Draft or Running
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

  • Server calls: every call except POST /events is safe to repeat (a repeated start or stop answers 409, a repeated delete 404).
  • POST /events: safe only with an idempotency key (body idempotencyKey or Idempotency-Key header, 24 h, per region).
  • Unanswered metric event on the wire: resend with the SAME MessageId, at most 3 times within 5 minutes. If its ServerAck came but no typed response, the server has the event: wait 5 minutes after the last send and resend once more to get the outcome.
  • Success=false with a temporary reason: send the event with a NEW MessageId.
  • Every other wire message: retry 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

Documentation set

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.5E3 and True are delivered as +8, 1.5E3 and True. Write canonical forms (8, 1500, lower-case true and false) and parse on the client with the same rules: invariant culture, case-insensitive for Bool.
  • 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 409 This 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

  1. 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).
  2. The client sends ClientRemoteConfigRequest with ETag set to "" if it has no cached copy, or to the ETag it stored from its last full answer.
  3. 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.
  4. If the composed configuration is exactly what the presented ETag describes, the answer is NotModified = true with the same ETag, an empty Entries and an empty Assignments. Keep your cached entries AND your cached assignments.
  5. Otherwise the answer is a full answer: NotModified = false, a new ETag, the complete set of public entries in Entries and the user's assignments in Assignments. Replace your whole cache (entries, assignments and ETag) with it. A full answer never contains partial changes.

Rules:

  • Check Success first. A failed answer has Success = false, an empty ETag, NotModified = false and empty lists; it is not an empty configuration. Keep your cache.
  • Entries holds only public entries, ordered by key. Server-only entries never reach the client.
  • Assignments lists one ExperimentAssignment for every live experiment on a public entry that the user is in, ordered by experiment key. Each assignment always comes with the substituted value in Entries. Experiments on server-only entries never appear here. Assignments is empty when no experiment is live.
  • While experiments are live, the ETag is 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 NotModified answer 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 a MessageId seen 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 NotModified when 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 ServerRemoteConfigResponse carries 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 Binary event counts a user once per run however often it is reported; a Number or Decimal event 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 with HasValue true and a non-finite Value: it throws an ArgumentException before anything is sent. The npm packages refuse it too: the call rejects with a MessageValidationError and nothing is sent. The server refuses such a report as well, without recording it: the response and its ServerAck carry Success false 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 ServerAck came either, the report may not have reached the server: resend it at once.
    • When its ServerAck came, the server has the report: the ServerAck comes 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 another ServerAck (for the first three resends, when IsAckRequested is 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 = true when 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 MessageId within 5 minutes; continued repeats can end the connection with RequestDuplicationViolation.
    • The resend is recognised only in the region that accepted the original. Through another region, a Number or Decimal event counts twice; a Binary event still counts once per user.
  • Success = false with a temporary reason: send the report again with a NEW MessageId. A resend with the same MessageId within 5 minutes would be dropped as a duplicate. In the rare case where the original was recorded after all (a timeout inside Five Minutes), a Number or Decimal event then counts twice.
  • Success = false with 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 RequestTimeoutError from reportMetricEvent, whose acknowledged says whether the server acknowledged the report; resend with { MessageId: error.messageId }, or with the MessageId you passed. When acknowledged is 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. A Success = false answer rejects with a RequestFailedError.

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.Write gets 403 on GET /api/v1/remote-config, and a key with only Application.RemoteConfig.Read gets 403 on PUT and DELETE. The same holds for the experiment scopes.
  • Application.Experiments.Write does not include Application.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 is null.
  • Send every body with Content-Type: application/json; a body with another content type gets 415 with an empty body. A body that is not valid JSON, or a property of the wrong JSON type (for example a remote config value sent as a number instead of a string), gets 400 with 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 PUT replaces every field, a misspelt isPublic makes an entry server-only. Check spelling against this document.
  • A refusal with a reason is 400 or 409 with the sentence as a JSON string body. An unknown entry, experiment or metric event is 404 with 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 with Z; a timestamp without a zone is taken as UTC.
  • updatedBy is 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 GUID 00000000-0000-0000-0000-000000000000), the signed-in user's email address for dashboard writes, and experiments for 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 assignments lists 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 valueType and isPublic when you change only the value.
  • Re-typing an existing entry is allowed: send the new valueType with 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 has createdAt without a zone designator and updatedAt ending in Z. 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 404 once 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 archivedAt set.
  • experimentCount is the number of experiments, in any status, that measure the event; activeExperimentCount counts 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 on POST /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_usd or match-8812:user-42:level_complete. The body's idempotencyKey wins over the Idempotency-Key header. For 24 hours, a retry with the same key and the same body gets the stored 200 with the header Idempotency-Replayed: true and is not counted again. A retry while the first is still running gets 409 with Retry-After: 1. The same key with a different body gets 422. Retry exactly the same request: moving the key between body and header changes the body.
  • Event id. eventId identifies the report. With an idempotency key it is derived from the key, so a retry returns the same eventId; without a key every call gets a new one.
  • Without a key, a retry counts again: a Number or Decimal event is added twice. A Binary event still counts once per user and run.
  • Per region. Idempotency is kept per region. A retry through another region is a new request: a Number or Decimal event counts twice, a Binary event still once.
  • Accepted is not counted. 200 means 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.

  1. key: Experiment key must be 1-64 characters of lower-case letters, digits or '_'.
  2. name: Name must be set. or Name must not exceed 128 characters.
  3. description: Description must not exceed 1024 characters.
  4. entryKey format: Entry key: Key must be 1-128 characters of letters, digits, '_', '.' or '-'.
  5. metricEventName format: Metric event name must be 1-64 characters of lower-case letters, digits or '_'.
  6. The window: The end time must come after the start time. (when both are set and endsAt is not later than startsAt).
  7. 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%.
  8. metricGoal: Unknown metric goal '<n>'. (a number other than 0 or 1).
  9. The entry exists: Remote config entry '<entryKey>' does not exist in this application.
  10. Every variant value parses as the entry's type: Variant '<key>': followed by the value sentence, for example Variant 'sixteen': Value must be an integer in invariant-culture notation. (the sentences are those of Create or update an entry).
  11. The metric event exists: Metric event '<name>' does not exist in this application.
  12. 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, startsAt and endsAt become null, autoPromote becomes false. Re-send the complete definition every time, as you last read it, changing only what you mean to change. Leaving out endsAt on a Running experiment removes its end; leaving out autoPromote on a Stopped experiment with auto-promote on is refused with 409.
  • Variants are matched by key: a kept key keeps its id, a new key gets a new id, 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.
  • startsAt and endsAt in the past are accepted. A start with endsAt already 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 Running experiment counts against the plan allowance from its start until it is stopped, including while it is scheduled (before startsAt) and while it waits for the automatic stop after endsAt (see Plan allowances).
  • With startsAt in the future the experiment is scheduled: it assigns nobody until startsAt.
  • 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 resultVariantId in the results and on the run.
  • With autoPromote on, the stop also promotes the result in the same step: winnerVariantId and promotedAt are 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, with updatedBy experiments.

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 with PUT /api/v1/remote-config. The results and the run stay as they are.
  • promotedBy records who promoted (the API key id, the dashboard user, or the user who stopped the experiment for an automatic promotion; experiments when the window ended it) and autoPromoted whether 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

Deleting an experiment destroys its results permanently, including a Running experiment's run. There is no undo. Stop the experiment and keep it if you may want its results later.

  • 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. currentRun is set only while the experiment is Running; it has the shape shown under Start an experiment, with live variantStats totals.
  • variants[].ordinal is 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

  • runs is empty for a Draft and has one run otherwise (ordinal 1).
  • A run's variants is the frozen copy taken at the start: the values and weights that were served. variantStats holds the run's totals: exposures, converted (users, for a Binary metric), valueSum and valueCount (sum and number of reports, for a Number or Decimal metric). valueSum is a number with up to 6 decimals and may be written with trailing zeros, for example 0.000000 or 1234.500000; parse it as a number.
  • salt is the run's salt, an opaque string.
  • isArchived and archivedAt say 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/assignment always 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

  • startsAt and endsAt are UTC instants. The window includes startsAt and excludes endsAt: a user is assigned when startsAt <= now < endsAt. A bound that is null does not limit the window.
  • The window is checked on every fetch. At startsAt the next fetch of each user returns the variant value, and at endsAt the 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 Running until it is stopped. Within about 1 minute after endsAt, Five Minutes stops it exactly as a stop call would: the run closes, the result is recorded, auto-promote runs if it is on, and updatedBy becomes experiments.
  • While Running, endsAt can be moved later or removed, but not moved earlier, not added to an experiment that had none, and startsAt cannot 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 NotModified answer, 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 Binary report counts the user once per run: the first report after the exposure converts the user, later ones change nothing.
  • A Number or Decimal report counts every time: its value is added to the variant's valueSum and valueCount goes up by one.
  • Reports keep counting for 30 days after the stop (attributionEndsAt in 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 Number and Decimal): 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. null for 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 for Decrease, among the variants with at least one exposure. On a tie the control wins, then the variant listed first. null while 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 resultVariantId with resultRecordedAt. 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 sampleRatioMismatch to true, and sampleRatioDetail names 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. winnerVariantId is set to the result, promotedAt to the time, promotedBy to whoever ended the run and autoPromoted to true, 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 Draft or Running by sending the whole definition again with PUT /api/v1/experiments and the new autoPromote. It does not disturb a running run. On a Stopped experiment it can no longer change (409).
  • Manual promote (Promote a variant): only on a Stopped experiment. 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 answers 409 with This 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 500 with detail The 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/events idempotency key and the wire resend rule (same MessageId) protect a retry only in the region that handled the original. Through another region, a Number or Decimal event counts twice; a Binary event 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).

  1. Your server exchanges the API key for a server token: POST /api/v1/api-clients/authenticate with {"applicationId":"<application id>","clientSecret":"<API key secret>"}. Success: 200 with token. Send Authorization: Bearer <server token> on every following call.

  2. Your server creates the entry: PUT /api/v1/remote-config with {"key":"max_players","valueType":"Int","value":"8","isPublic":true}. Success: 200 with the entry.

  3. Your server creates the metric event: PUT /api/v1/metric-events with {"name":"level_complete","type":"Binary"}. Success: 200, "type": "Binary".

  4. Your server defines the experiment: PUT /api/v1/experiments with the body shown in Create or update an experiment. Success: 200, "status": "Draft".

  5. Your server starts it: POST /api/v1/experiments/max_players_test/start. Success: 200, "status": "Running" and a currentRun with its salt. A 403 means the plan's running experiments are used up.

  6. Client connects, logs in and receives ServerWelcome with the user's unique user id (with the npm packages: await client.connect()).

  7. Client fetches: ClientRemoteConfigRequest with ETag "" (with the npm packages: client.fetchRemoteConfig()). Success: ServerRemoteConfigResponse with Success true, NotModified false, Entries containing max_players = 16 and Assignments containing (max_players_test, sixteen). The client stores the ETag, entries and assignments and plays with 16 players. Five Minutes records the user's exposure.

  8. Client fetches again later with the stored ETag. Success: NotModified true with empty Entries and Assignments; the client keeps its cache. (fetchRemoteConfig() of the npm packages returns the answer with the kept entries and assignments filled in.)

  9. Client reports a finished level: ClientReportMetricEventRequest with Name level_complete and HasValue false (with the npm packages: client.reportMetricEvent("level_complete")). Success: ServerReportMetricEventResponse with Success true. The user now counts as converted in sixteen.

  10. Your server can report on the user's behalf as well, for example from a match server: POST /api/v1/events with {"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: 200 with an eventId. For a Binary event this user is already converted, so the totals do not change.

  11. Your server reads the results while the experiment runs: GET /api/v1/experiments/max_players_test/results. Success: 200 with exposures, converted, rate and liftVsControl per variant and a leadingVariantId. Numbers arrive within seconds of the fetches and reports.

  12. 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 show resultVariantId. Users receive the base value 8 from their next fetch.

  13. Your server promotes the winner: POST /api/v1/experiments/max_players_test/promote with {"variantKey":"sixteen"}. Success: 200 with experiment.winnerVariantId set and entry.value 16.

  14. Client fetches with its stored ETag. Success: a full answer with a new ETag, max_players = 16 and empty Assignments.

  15. Your server cleans up, in this order, because an entry or metric event that an experiment uses cannot be deleted:

    1. DELETE /api/v1/experiments/max_players_test: 204. This also deletes the results.
    2. DELETE /api/v1/metric-events with {"name":"level_complete"}: 204.
    3. DELETE /api/v1/remote-config with {"key":"max_players"}: 204.

    Any other order answers 409 (This event is used by finished experiments; archive it instead. or This 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, Bool and Json for 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/events call, built from your own ids.
  • Send whole definitions. PUT replaces 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