Remote Config

Change your app's behaviour and content without a release, like Firebase Remote Config. You publish a template of typed parameters on the dashboard's Remote Config page (or through the Admin API); each app fetches the values that apply to it.

Fetching values

GET /config returns every parameter's value for this caller:

{
  "data": {
    "version": 12,
    "min_fetch_interval": 3600,
    "parameters": { "welcome_text": "Welcome back, VIP", "max_offers": 5, "new_home": true, "theme": "{\"primary\": \"#8a6d3b\"}" }
  }
}
  • Send what conditions look at: platform (ios, android, web…), app_version, language (else the Accept-Language header), and before sign-in an instance_id (a random id kept by the install, 8–128 characters) so percentage rollouts stay stable.
  • Send the access token when signed in (optional): it unlocks conditions on the user's tier, custom claims and percentage buckets. An invalid token answers 401.
  • Keep the answer and its ETag. Fetch again at most every min_fetch_interval seconds (for example at app start), with If-None-Match: <ETag>: 304 means nothing changed.
  • Values of type json are strings holding JSON: parse them in the app. Always have in-app defaults for a first start without network.
// Flutter
Future<Map<String, dynamic>?> fetchConfig({String? etag, String? accessToken}) async {
  final uri = Uri.parse('$baseUrl/config').replace(queryParameters: {
    'platform': Platform.isIOS ? 'ios' : 'android',
    'app_version': appVersion,
    'instance_id': installId,
  });
  final response = await http.get(uri, headers: {
    'X-App-Key': appKey,
    'Accept': 'application/json',
    if (etag != null) 'If-None-Match': etag,
    if (accessToken != null) 'Authorization': 'Bearer $accessToken',
  });
  if (response.statusCode == 304) return null; // keep the cached values
  // store response.headers['etag'] with the values
  return jsonDecode(response.body)['data']['parameters'];
}
// React Native
import { Platform } from 'react-native';

export async function fetchConfig(etag?: string, accessToken?: string) {
  const query = new URLSearchParams({ platform: Platform.OS, app_version: APP_VERSION, instance_id: installId });
  const response = await fetch(`${BASE_URL}/config?${query}`, {
    headers: {
      'X-App-Key': APP_KEY,
      Accept: 'application/json',
      ...(etag ? { 'If-None-Match': etag } : {}),
      ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
    },
  });
  if (response.status === 304) return null; // keep the cached values
  return { etag: response.headers.get('ETag'), parameters: (await response.json()).data.parameters };
}
// iOS
func fetchConfig(etag: String?, accessToken: String?) async throws -> (etag: String?, parameters: [String: Any])? {
    var components = URLComponents(url: baseURL.appendingPathComponent("config"), resolvingAgainstBaseURL: false)!
    components.queryItems = [
        URLQueryItem(name: "platform", value: "ios"),
        URLQueryItem(name: "app_version", value: Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String),
        URLQueryItem(name: "instance_id", value: installId),
    ]
    var request = URLRequest(url: components.url!)
    request.setValue(appKey, forHTTPHeaderField: "X-App-Key")
    request.setValue("application/json", forHTTPHeaderField: "Accept")
    if let etag { request.setValue(etag, forHTTPHeaderField: "If-None-Match") }
    if let accessToken { request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") }

    let (data, response) = try await URLSession.shared.data(for: request)
    let http = response as! HTTPURLResponse
    if http.statusCode == 304 { return nil } // keep the cached values
    let json = try JSONSerialization.jsonObject(with: data) as! [String: Any]
    let parameters = (json["data"] as! [String: Any])["parameters"] as! [String: Any]
    return (http.value(forHTTPHeaderField: "ETag"), parameters)
}
// Android: OkHttp
fun fetchConfig(etag: String?, accessToken: String?): Pair<String?, JSONObject>? {
    val url = "$BASE_URL/config".toHttpUrl().newBuilder()
        .addQueryParameter("platform", "android")
        .addQueryParameter("app_version", BuildConfig.VERSION_NAME)
        .addQueryParameter("instance_id", installId)
        .build()
    val request = Request.Builder().url(url)
        .header("X-App-Key", APP_KEY).header("Accept", "application/json")
        .apply { etag?.let { header("If-None-Match", it) } }
        .apply { accessToken?.let { header("Authorization", "Bearer $it") } }
        .build()
    http.newCall(request).execute().use { response ->
        if (response.code == 304) return null // keep the cached values
        val parameters = JSONObject(response.body!!.string()).getJSONObject("data").getJSONObject("parameters")
        return response.header("ETag") to parameters
    }
}
// Web
export async function fetchConfig(etag?: string, accessToken?: string) {
  const query = new URLSearchParams({ platform: 'web', app_version: APP_VERSION, instance_id: installId });
  const response = await fetch(`${BASE_URL}/config?${query}`, {
    headers: {
      'X-App-Key': APP_KEY,
      Accept: 'application/json',
      ...(etag ? { 'If-None-Match': etag } : {}),
      ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
    },
  });
  if (response.status === 304) return null; // keep the cached values
  return { etag: response.headers.get('ETag'), parameters: (await response.json()).data.parameters };
}

Templates

A template has conditions, in priority order, and parameters with a type, a default and values per condition:

{
  "conditions": [
    {"name": "ios_new", "rules": [
      {"field": "platform", "values": ["ios"]},
      {"field": "app_version", "op": ">=", "value": "2.1"}
    ]},
    {"name": "vip", "rules": [{"field": "claim", "name": "role", "values": ["vip"]}]},
    {"name": "new_home_20", "rules": [{"field": "percent", "seed": "new_home", "from": 0, "to": 20}]}
  ],
  "parameters": {
    "welcome_text": {"type": "string", "default": "Welcome",
                     "conditional_values": {"vip": "Welcome back, VIP", "ios_new": "Welcome to 2.1"}},
    "max_offers": {"type": "number", "default": 5},
    "new_home": {"type": "boolean", "default": false, "conditional_values": {"new_home_20": true}},
    "theme": {"type": "json", "default": "{\"primary\": \"#8a6d3b\"}"}
  }
}
  • A condition matches when all its rules do. A parameter takes the value of the first matching condition it has a value for, else its default.
  • Rules: platform (values), app_version (op == != < <= > >= and value; 2.1 equals 2.1.0, a build suffix such as +45 is ignored), language (en matches en-US), tier (silver, gold, platinum), claim (a custom claim with a value in values), percent (users whose bucket for seed is from from up to to; the same user always lands in the same bucket for a seed).
  • tier and claim never match someone who isn't signed in. percent buckets by the uid after sign-in and by instance_id before; with neither it doesn't match. A user moves to their uid bucket on sign-in, so use percent for experiments on signed-in users where possible.

Every publish is a new version; the dashboard and the Admin API can roll back to any of the last 300.