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 theAccept-Languageheader), and before sign-in aninstance_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 everymin_fetch_intervalseconds (for example at app start), withIf-None-Match: <ETag>:304means nothing changed. - Values of type
jsonare 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== != < <= > >=andvalue;2.1equals2.1.0, a build suffix such as+45is ignored),language(enmatchesen-US),tier(silver,gold,platinum),claim(a custom claim with a value invalues),percent(users whose bucket forseedis fromfromup toto; the same user always lands in the same bucket for a seed). tierandclaimnever match someone who isn't signed in.percentbuckets by theuidafter sign-in and byinstance_idbefore; with neither it doesn't match. A user moves to theiruidbucket on sign-in, so usepercentfor 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.