PushHub REST API Reference
Base URLs:
- Production:
https://api.pushhub.kemegames.com/api/v1 - Local development:
http://localhost:5000/api/v1
The full machine-readable spec is available at docs/openapi.yaml.
Authentication
All endpoints (except health and auth) require one of:
Bearer JWT (dashboard users)
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR...
Obtain a JWT by calling POST /auth/login.
API Key (SDK / server-to-server)
Authorization: ApiKey ph_your_api_key_here
API keys belong to a workspace. Owners and admins create and revoke them in the dashboard under Settings → API credentials, choosing one or more scopes. A key can only call endpoints its scopes allow:
| Scope | Allows |
|---|---|
notifications:send |
POST /notifications/send, delivery and open receipts, delivery logs. Also allows firing events. |
events:write |
POST /event-triggers/fire |
devices:manage |
POST /devices/register, GET /devices, GET /devices/count |
campaigns:manage |
Campaigns, templates, segments, and event-trigger configuration |
analytics:read |
App analytics, campaign lists and results, delivery logs |
remote_config:read |
Remote Config state, versions, preview, schedules list and adoption metrics |
remote_config:write |
Everything remote_config:read allows, plus saving, publishing, scheduling and rolling back Remote Config |
admin |
Every scope, plus the remaining workspace endpoints (apps, usage, audit log) |
Keys created before October 2026 may list legacy names (send:notifications, read:analytics, manage:devices, manage:campaigns); they keep working and are treated as the canonical scope of the same meaning. Endpoints that change workspace membership, provider credentials, or API keys require a signed-in owner or admin and cannot be called with an API key.
A key without the required scope receives 403 with code: "API_KEY_SCOPE_MISSING" and the accepted scopes in requiredScopes. A request that would exceed the workspace plan (apps, team seats, or monthly notifications) receives 402 with the plan-limit-exceeded problem type and a detail naming the limit.
Common Response Formats
Success
{
"id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"name": "My App",
"createdAt": "2024-01-15T12:00:00Z"
}
Validation Error (400)
{
"message": "Validation failed",
"errors": {
"title": ["Title is required"],
"body": ["Body must not exceed 1000 characters"]
}
}
Error (4xx / 5xx)
{
"message": "Resource not found",
"code": "RESOURCE_NOT_FOUND"
}
Pagination
List endpoints return a paged response:
{
"items": [...],
"total": 1250,
"page": 1,
"pageSize": 50,
"totalPages": 25
}
Pass ?page=2&pageSize=50 to paginate.
Endpoints
Health
GET /health
No authentication required. Returns service status.
Response 200:
{
"status": "Healthy",
"version": "1.0.0",
"timestamp": "2024-01-15T12:00:00Z",
"checks": {
"database": "Healthy",
"redis": "Healthy"
}
}
Authentication
POST /auth/register
Create a new account.
Request:
{
"email": "dev@example.com",
"password": "SecurePass123!",
"name": "Alex Johnson"
}
Response 201:
{
"accessToken": "eyJhbGciOiJIUzI1NiIsInR...",
"refreshToken": "dGhpcyBpcyBhIHJlZnJlc2...",
"expiresAt": "2024-01-16T12:00:00Z",
"user": {
"id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"email": "dev@example.com",
"name": "Alex Johnson",
"createdAt": "2024-01-15T12:00:00Z"
}
}
POST /auth/login
Request:
{
"email": "dev@example.com",
"password": "SecurePass123!"
}
Response 200: Same as register response.
POST /auth/refresh
Request:
{ "refreshToken": "dGhpcyBpcyBhIHJlZnJlc2..." }
Response 200: Same as register response.
GET /auth/me
Returns the current authenticated user.
{
"id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"email": "dev@example.com",
"name": "Alex Johnson",
"createdAt": "2024-01-15T12:00:00Z"
}
Apps
GET /apps
List all apps for the authenticated account.
Response 200:
[
{
"id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"name": "My Game App",
"bundleId": "com.example.mygame",
"platform": "Android",
"hasFcmConfig": true,
"hasApnsConfig": false,
"createdAt": "2024-01-15T12:00:00Z",
"updatedAt": "2024-01-15T12:00:00Z"
}
]
POST /apps
Request:
{
"name": "My Game App",
"bundleId": "com.example.mygame",
"platform": "Android"
}
Response 201: Returns the created App object.
GET /apps/{appId}
Returns a single App object.
PUT /apps/{appId}
Request:
{ "name": "My Game App v2" }
DELETE /apps/{appId}
Returns 204 No Content.
PUT /apps/{appId}/fcm-config
Request:
{
"serviceAccountJson": "{\"type\":\"service_account\",\"project_id\":\"my-project\",...}"
}
PUT /apps/{appId}/apns-config
Request:
{
"keyId": "ABCDE12345",
"teamId": "XYZ9876543",
"bundleId": "com.example.mygame",
"p8KeyContent": "-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----",
"useSandbox": false
}
Devices
POST /devices/register
Auth: API Key
Register or update a device token. Call on every app launch.
Request:
{
"appId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"deviceToken": "fMe4G8kT5Q:APA91bH...",
"platform": "Android",
"userId": "user-123",
"deviceMetadata": {
"country": "US",
"language": "en",
"appVersion": "2.1.0"
}
}
Response 200:
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"appId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"deviceToken": "fMe4G8kT5Q:APA91bH...",
"platform": "Android",
"userId": "user-123",
"deviceMetadata": { "country": "US", "language": "en" },
"isActive": true,
"lastSeenAt": "2024-01-15T12:00:00Z",
"createdAt": "2024-01-10T08:00:00Z"
}
GET /devices?appId={appId}&page=1&pageSize=50
Returns paginated PagedDevices.
Optional filters: platform, userId.
GET /devices/{deviceId}
Returns a single Device.
PUT /devices/{deviceId}
Auth: API Key
Request:
{
"userId": "new-user-456",
"deviceMetadata": { "subscriptionTier": "premium" }
}
DELETE /devices/{deviceId}
Returns 204 No Content.
POST /devices/register replaces the device's whole metadata document. Device lists include a context object with the SDK-reported fields described below (null until reported).
Client SDK installations
The PushHub SDKs call these endpoints with an app publishable key and, after installation, the installation credential (Authorization: PushHubInstallation <credential>). You only need them when you build your own client.
POST /sdk/installations and PATCH /sdk/installations/{installationId}
Both accept an optional context object and the metadata fields below alongside the existing installation fields:
{
"userId": "player-1842",
"metadata": { "level": 19, "vip": null },
"replaceMetadata": false,
"context": {
"appVersion": "2.4.1",
"appBuild": "241",
"osVersion": "14",
"locale": "ar-SA",
"country": "SA",
"timezone": "Asia/Riyadh"
}
}
Device context. Every field is optional; on PATCH, an omitted or null field keeps its stored value and an empty string clears it. The server trims each value and allows at most 64 characters.
| Field | Format |
|---|---|
appVersion, appBuild, osVersion |
Free text, e.g. 2.4.1, 241, 14 |
locale |
BCP-47 tag; ar_sa is normalized to ar-SA. The server derives language (ar) from it. |
country |
ISO 3166-1 alpha-2, stored upper case, e.g. SA |
timezone |
IANA zone name, e.g. Asia/Riyadh |
A malformed value returns 400 with a detail naming the field (for example context.country must be an ISO 3166-1 alpha-2 code such as SA.), and nothing in the request is applied.
Metadata merge. metadata merges into the stored traits by top-level key: a key with a value is added or overwritten, a key whose value is null is removed, and keys the request does not name are kept. Nested objects are replaced, not merged. The merged document must stay within 16 KB; a request that would exceed it returns 400. Send "replaceMetadata": true to replace the whole document instead (the behavior before merge semantics).
GET /sdk/installations/{installationId}/config
Returns the installation's Remote Config: the active published version of its environment, resolved for this device. The environment is the one the publishable key that created the installation is bound to; installations without one read production.
GET /api/v1/sdk/installations/5b0e.../config
Authorization: PushHubInstallation ph_inst_...
If-None-Match: "v42-9f86d081884c7d65"
200 OK
ETag: "v43-2c26b46b68ffc68f"
Cache-Control: private, no-cache
{
"version": 43,
"environment": "production",
"parameters": {
"starter_coins": 200,
"maintenance_mode": false,
"store_layout": { "tabs": ["offers", "coins"] }
},
"minimumFetchIntervalSeconds": 900
}
- Values are typed JSON: a number, boolean, string, or object/array, in the order of the template.
- Resolution: for each parameter, the first condition (in the template's priority order) that matches the device and has a value for that parameter supplies it; otherwise the default applies. A condition matches when its rules, its percentage slice and its time window all match. Rules use the segment rule format and read the stored device record:
platform,userId,pushProvider,testDevice, the reported context (appVersion,appBuild,osVersion,locale,language,country,timezone) and any metadata key, including theversion_*operators. PATCH a changedcontextormetadatabefore fetching so the server evaluates current values. - Left-out parameters: a parameter whose resolved value is
null(no server value) is not inparameters, so the SDK uses its in-app default. - Nothing published:
versionis0andparametersis{}. - ETag:
"v{version}-{first 16 hex characters of the SHA-256 of the parameters JSON}". Devices that resolve to the same values share it, and it changes whenever a publish changes this device's values. Send it back inIf-None-Match; when it still matches, the response is304 Not Modifiedwith the sameETagand no body. - Adoption: every fetch, including a
304, is counted for the dashboard's adoption metrics (fetches per day, unique devices per environment and per version). - Fetch interval: do not fetch more often than
minimumFetchIntervalSeconds(default 900;RemoteConfig:MinimumFetchIntervalSecondson self-hosted deployments) unless the player explicitly forces a refresh. - Instant refresh: when a publish asks to notify devices, a device whose installation reports a supported SDK (
android0.1.0-beta.6+,ios0.1.0-beta.2+,javascript2.0.0-beta.3+; any othersdkNameis never sent one) receives a silent push whose data is{"pushhub_action":"config_refresh","pushhub_config_version":"<n>","pushhub_environment":"<slug>"}(an APNs background push, or a data-only FCM, HMS or ADM message). A client that handles it should not show it or report it as received, and should fetch with a forced refresh when its stored version is lower thanpushhub_config_version(or unknown). That fetch is the explicit refresh the interval allows.
| Status | When |
|---|---|
200 |
Config returned |
304 |
If-None-Match equals the current ETag |
401 |
The installation credential is missing, invalid or revoked |
404 |
The installation's device is inactive (consent denied) or deleted |
429 |
The per-installation rate limit was exceeded |
Segments
POST /segments
Request:
{
"appId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"name": "Premium US Users",
"description": "Users on premium tier in the United States",
"filterRules": {
"operator": "and",
"conditions": [
{ "field": "country", "op": "eq", "value": "US" },
{ "field": "subscriptionTier", "op": "eq", "value": "premium" }
]
}
}
operator is and or or. Each condition has a field, an op and a value:
platform(Android,iOS),pushProvider(FCM,APNS,HMS,ADM) anduserIdread the device record. Any other field is a custom attribute your app reports in device metadata.testDeviceistruefor a device designated as a test device (and not revoked) andfalsefor every other device. It takes onlyeqorneqwithtrueorfalse:{ "field": "testDevice", "op": "eq", "value": true }.appVersion,appBuild,osVersion,locale,language,countryandtimezoneread the device context the SDK reports. A device that has not reported a field falls back to the metadata key of the same name, so segments written against metadata keep working. String comparisons ignore case (countryeqsamatchesSA);gt,gte,ltandltecompare numbers, so they suitappBuildand a majorosVersion. For dotted versions like2.4.1, use theversion_*operators.opis one ofeq,neq,contains,gt,gte,lt,lte,in,not_in,version_eq,version_neq,version_gt,version_gte,version_lt,version_lte.inandnot_intake an array;gt,gte,ltandltetake a number; theversion_*operators take a version (see below).- A device without the field never matches the condition.
{}(or no conditions) includes every active device of the app. Rules in any other shape are rejected with400.
Version comparisons. eq compares two strings as text and gt/gte/lt/lte compare numbers, so neither orders app versions correctly ("1.10" is not "1.1", and as a number 1.10 is less than 1.9). Use the version_* operators on any field that holds a version, such as appVersion, appBuild, osVersion or a custom attribute:
{ "field": "appVersion", "op": "version_gte", "value": "2.4" }
| Operator | Matches when the device's version is |
|---|---|
version_eq |
the same version as value |
version_neq |
a different version from value |
version_gt / version_gte |
newer than / at least value |
version_lt / version_lte |
older than / at most value |
- Versions are dot-separated numbers compared segment by segment as numbers:
1.10is newer than1.9, and10.0is newer than9.9.9. - Missing segments count as
0:2.4,2.4.0and2.4.0.0are the same version. - A leading
vorVis ignored:v2.0equals2.0. - A pre-release after
-sorts before its release and after the previous release:2.4.0<2.4.1-beta<2.4.1. Pre-releases compare as SemVer 2.0 defines: identifier by identifier, numbers numerically, numbers before text, text in ASCII order (case-sensitive), and a longer list wins when all shared identifiers are equal, so1.0.0-alpha<1.0.0-alpha.1<1.0.0-beta<1.0.0-beta.2<1.0.0-beta.11<1.0.0-rc.1<1.0.0. - Build metadata after
+is ignored:2.4.0+77equals2.4.0. valuemust be a version, given as a string ("2.4.1-beta") or a JSON number (2). A value that is not a version, such as"latest"or"2.4.x", is rejected with400.- A device whose field is missing or is not a version (for example
"unknown") never matches aversion_*condition, includingversion_neq.
Response 201: Returns Segment object, including estimatedReach (matching active devices at save time). A duplicate name returns 409.
A campaign that targets segments re-evaluates their rules when it is sent, so it reaches devices registered after the segment was saved, and each send refreshes estimatedReach.
PUT /segments/{segmentId}
Same body as POST; name, description and filterRules are optional. New rules are re-evaluated and estimatedReach refreshed.
GET /segments?appId={appId}
Returns array of Segment objects.
GET /segments/{segmentId}/estimate?appId={appId}
Re-evaluates the rules against the app's active devices and stores the new reach.
{ "segmentId": "…", "segmentName": "Premium US Users", "deviceCount": 45231, "evaluatedAt": "2026-10-03T18:00:00Z" }
Campaigns
POST /campaigns
Request:
{
"appId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"name": "Summer Sale Announcement",
"title": "Summer Sale is LIVE!",
"body": "Up to 50% off all in-game items this weekend only.",
"imageUrl": "https://cdn.example.com/summer-sale.png",
"deepLink": "myapp://store/sale",
"customData": { "campaign_type": "promotional" },
"targetType": "Segment",
"segmentId": "seg-uuid-here"
}
Response 201: Returns Campaign object with status: "Draft".
POST /campaigns/{campaignId}/send
Send immediately.
Response 202:
{
"campaignId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"status": "Queued",
"estimatedRecipients": 45231
}
POST /campaigns/{campaignId}/schedule
Request:
{ "scheduledAt": "2024-01-20T09:00:00Z" }
GET /campaigns/{campaignId}/analytics
Response 200:
{
"campaignId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"campaignName": "Summer Sale Announcement",
"totalSent": 45231,
"totalDelivered": 43891,
"totalOpened": 8234,
"totalFailed": 1340,
"deliveryRate": 97.04,
"openRate": 18.2,
"variants": [
{
"variantId": "9b1d…", "variantName": "A", "title": "Summer Sale is LIVE!", "body": "…", "weight": 50,
"sentCount": 22610, "deliveredCount": 21950, "openedCount": 4400, "deliveryRate": 97.08, "openRate": 19.46
}
]
}
How delivery is counted. totalSent is the number of notifications the push
provider accepted (status Sent, Delivered or Opened). Failed notifications are
counted only in totalFailed; notifications suppressed by send policy (for
example quiet hours) and those still queued are in neither. deliveryRate and
openRate are percentages (0–100) of totalSent. Per-variant counts use the
same rules, so for an A/B campaign they add up to the totals.
Campaign objects returned by GET /campaigns?appId=… and GET /campaigns/{campaignId}
carry the same totalSent, totalDelivered, totalOpened, totalFailed,
deliveryRate and openRate, counted live, so receipts that arrive after
delivery finishes are included.
Notifications
POST /notifications/send
Send a direct notification (up to 1000 devices per call).
Request:
{
"appId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"deviceIds": ["device-uuid-1", "device-uuid-2"],
"title": "Your order shipped!",
"body": "Order #ORD-456 is on its way.",
"deepLink": "myapp://orders/ORD-456",
"customData": { "orderId": "ORD-456" }
}
Response 202:
{
"notificationIds": ["notif-uuid-1", "notif-uuid-2"],
"queued": 2,
"rejected": 0
}
POST /notifications/track-delivery
Auth: API Key
{ "notificationId": "notif-uuid-1" }
Response 204
POST /notifications/track-opened
Auth: API Key
{ "notificationId": "notif-uuid-1" }
Response 204
Analytics
GET /analytics/apps/{appId}/overview?from=2024-01-01&to=2024-01-31
from and to default to the last 30 days.
{
"appId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"appName": "Galaxy Raiders",
"totalDevices": 98000,
"activeDevices": 98000,
"totalSent": 2450000,
"totalDelivered": 2377000,
"totalOpened": 448000,
"totalFailed": 31000,
"deliveryRate": 97.02,
"openRate": 18.29,
"timeSeries": [
{ "date": "2024-01-01T00:00:00+00:00", "sent": 81000, "delivered": 78600, "opened": 14800, "failed": 1000 }
],
"from": "2024-01-01T00:00:00+00:00",
"to": "2024-01-31T00:00:00+00:00"
}
How app analytics are counted. The numbers cover the app's campaign and
direct notifications whose send was attempted between from and to, counted
exactly like campaign analytics: totalSent is the notifications the push
provider accepted (status Sent, Delivered or Opened), failed notifications are
counted only in totalFailed, and those suppressed by send policy or still
queued are in neither. deliveryRate and openRate are percentages (0–100) of
totalSent.
timeSeries has one entry per UTC day from from to to, including days with
no activity, and the days add up to the totals. A notification is placed on the
day its send was attempted and counted in its current status, so one sent on
Monday and opened on Tuesday counts as opened on Monday. totalDevices and
activeDevices both count the app's active devices.
API Keys
POST /api-keys
Request:
{
"appId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"name": "Production SDK Key"
}
Response 201:
{
"id": "key-uuid",
"appId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"name": "Production SDK Key",
"prefix": "ph_abc123",
"key": "ph_abc123def456ghi789jkl012mno345pqr678stu901",
"createdAt": "2024-01-15T12:00:00Z",
"lastUsedAt": null
}
The key field is returned only at creation. Store it immediately.
DELETE /api-keys/{keyId}
Returns 204 No Content. The key is immediately revoked.
Event Triggers
POST /event-triggers?appId={appId}
A trigger acts on the device that fired an event, after delaySeconds. What it does depends on actionType:
actionType |
Action | Needs |
|---|---|---|
send_template (the default) |
Sends a notification template to the device. | templateId, a template of the same app (another app's template returns 404) |
set_config_value |
Sets a remote config value for that player's device (a player override). No push is sent, and it doesn't count toward the plan's monthly notifications. | action, and no templateId |
Event names are stored lowercase and trimmed.
Request (send a template):
{
"eventName": "purchase_completed",
"templateId": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"delaySeconds": 300,
"conditions": { "key": "amount", "operator": "gt", "value": "20" }
}
Request (set a remote config value):
{
"eventName": "level_fail",
"actionType": "set_config_value",
"action": { "parameterKey": "difficulty", "value": "easy", "durationSeconds": 86400 },
"conditions": { "key": "attempts", "operator": "gte", "value": "3" }
}
The action fields:
parameterKeyis a remote config parameter key: a letter or_first, then letters, digits,_,.or-(at most 100 characters).valueis any JSON value exceptnull. It should match the parameter's type; a value of the wrong type is ignored when config is served.durationSecondsis optional: 1 second to 90 days, counted from when the value is applied (after the delay). Leave it out or sendnullfor no expiry.
When the event fires again, the value is set again and its duration restarts. The value applies in every environment the device fetches config from.
conditions is optional. Send {} (or omit it) to fire on every event, one condition object, or a list of condition objects that must all match. Each condition compares one event property:
operatorisequals,not_equals,contains,gt,gte,lt,lte,version_eq,version_neq,version_gt,version_gte,version_ltorversion_lte.gt,gte,ltandltecompare numbers and need a numericvalue.- The
version_*operators compare versions the same way segment rules do (see Version comparisons):1.10is newer than1.9,2.4equals2.4.0, and2.4.1-betais older than2.4.1. They need a versionvalue, for example{ "key": "clientVersion", "operator": "version_gte", "value": "2.4" }; an event property that is not a version never matches them. equalsandnot_equalscompare numbers numerically (5equals5.0) and other text without regard to case.containsis a case-insensitive substring check.- Property keys are case-insensitive. An event without the property never matches the condition.
- Malformed conditions are rejected with
400and a message naming the problem.
Validation: an invalid action returns 400 with errors keyed by the field: actionType (unknown type), templateId (missing for send_template, or sent with set_config_value), action (sent with send_template, or missing for set_config_value), action.parameterKey, action.value or action.durationSeconds.
Response 201: Returns the EventTrigger object:
{
"id": "3b1f8c2e-6a0d-4f57-9a8e-2d4c1b7e9f10",
"appId": "4f8a2c1d-9b3e-4d6f-8a7c-1e2b3c4d5e6f",
"eventName": "level_fail",
"actionType": "set_config_value",
"templateId": null,
"action": { "parameterKey": "difficulty", "value": "easy", "durationSeconds": 86400 },
"conditions": { "key": "attempts", "operator": "gte", "value": "3" },
"delaySeconds": 0,
"isActive": true,
"createdAt": "2026-10-05T09:30:00+00:00",
"updatedAt": "2026-10-05T09:30:00+00:00"
}
For send_template, templateId is set and action is null. Triggers created before action types existed are send_template triggers and behave as before. GET /event-triggers?appId={appId} lists triggers in the same shape.
PUT /event-triggers/{triggerId}
Every field is optional; only the fields sent change (eventName, conditions, delaySeconds, isActive, templateId, actionType, action).
- Switching action: send
actionType. Switching toset_config_valueneeds anaction, and the template is removed. Switching tosend_templateneeds atemplateId, and the action is removed. - Changing the value: send a new
action(withoutactionType) to replace the settings of aset_config_valuetrigger. - Errors: the same
400errors as on create. A rejected update changes nothing.
DELETE /event-triggers/{triggerId}
Returns 204 No Content. A value a trigger has already set stays until it expires or is cleared.
POST /event-triggers/fire?appId={appId}
Auth: API Key with events:write, or a workspace session. Send an Idempotency-Key header to make retries safe.
Fire an event from your game server for one registered device. Every active trigger for the event whose conditions match runs once for that device, after the trigger's delay: a send_template trigger sends its notification, and a set_config_value trigger sets its remote config value. Event properties are also available to the template as {{name}} placeholders; templateVariables adds or overrides values without being checked by conditions.
Request:
{
"eventName": "purchase_completed",
"deviceToken": "fcm-or-apns-device-token",
"eventProperties": { "orderId": "ORD-789", "amount": "49.99" },
"templateVariables": { "reward": "500 coins" }
}
Response 202:
{ "triggersMatched": 1, "triggersSkipped": 0 }
triggersSkipped counts active triggers for the event whose conditions did not match. A device token that is not registered and active for the app returns 404.
Games using a PushHub SDK can send the same events from the device with POST /sdk/installations/{installationId}/events; conditions are evaluated the same way.
Player overrides: /remote-config/players/...?appId={appId}
Set a remote config value for one player directly, without an event. Values set by set_config_value triggers are managed with the same routes. The full behaviour is described in docs/REMOTE_CONFIG_PLAYER_OVERRIDES.md.
| Method & path | Scope | Result |
|---|---|---|
GET /remote-config/players/{deviceId}/overrides |
remote_config:read |
The device's unexpired overrides |
PUT /remote-config/players/{deviceId}/overrides/{key} |
remote_config:write |
200 with the override. Body: { "value": …, "durationSeconds": 86400 } (duration optional) |
DELETE /remote-config/players/{deviceId}/overrides/{key} |
remote_config:write |
204 |
PUT /remote-config/players/by-user/{userId}/overrides/{key} |
remote_config:write |
Sets the key on every active device of the user ID |
DELETE /remote-config/players/by-user/{userId}/overrides/{key} |
remote_config:write |
Clears the key on every device of the user ID |
Signed-in members need the Developer role or above to set or clear. An unknown app, device or user ID returns 404. A device holds at most 200 unexpired overrides; adding a new key past that returns 409.
Remote Config preview
POST /remote-config/preview?appId={appId}&environment=production
Shows what one device of the app would receive, resolved exactly as GET /sdk/installations/{installationId}/config resolves it. Any workspace member may call it; API keys need remote_config:read (or remote_config:write). environment defaults to production.
Request: omit template to evaluate the environment's active version, or pass an unsaved template from the editor.
{ "deviceId": "8d2f0c55-3a8e-4f4f-9a51-0f8a4c1e2b77", "template": { "parameters": [], "conditions": [] } }
Response:
{
"parameters": {
"starter_coins": { "value": 200, "source": "condition", "condition": "new_players_sa" },
"maintenance_mode": { "value": false, "source": "default", "condition": null }
},
"matchedConditions": ["new_players_sa"]
}
matchedConditions lists every condition the device matches, in priority order. Parameters without a server value are left out, as they are for the SDK. An unknown app, environment or device (or one from another workspace) returns 404. A supplied template that fails validation returns 400 with errors keyed by path, such as parameters[2].key or conditions[0].rules.
Remote Config publish and rollback: refresh
POST /remote-config/publish ({ revision, note?, notifyDevices? }) and POST /remote-config/versions/{version}/rollback ({ note?, notifyDevices? }) need remote_config:write. notifyDevices is none (default), test (designated test devices only) or all; once the new version is live, PushHub sends the environment's devices a silent refresh so they fetch it now. Only installations on an SDK that handles the refresh silently receive it: Android 0.1.0-beta.6 or later (including the engine and framework SDKs built on it), iOS 0.1.0-beta.2 or later, and JavaScript 2.0.0-beta.3 or later. Older or unknown SDKs would show it as a notification, so they are left out. They pick up the new version on their next normal fetch. The response reports it in refresh:
{ "version": 9, "publishedAt": "2026-10-05T09:00:00+00:00", "sha256": "9f2c…",
"refresh": { "status": "rate_limited", "mode": "all", "retryAfterSeconds": 412 } }
refresh.status |
Meaning |
|---|---|
queued |
Devices are being notified |
rate_limited |
The environment was refreshed recently (all: once per 10 minutes; test: once per minute). retryAfterSeconds says when the next refresh is allowed. The version was published. |
skipped |
No refresh was asked for (mode is none), or it could not be queued |
An unknown notifyDevices returns 400 with errors.notifyDevices. See Instant refresh in docs/REMOTE_CONFIG.md for who receives it and how it is sent.
Remote Config scheduling and adoption
All take ?appId={appId}&environment=production. Signed-in members need the Developer role or above to create or cancel a schedule; any member can read.
| Method & path | Scope | Result |
|---|---|---|
POST /remote-config/schedules |
remote_config:write |
201: publishes a snapshot of the saved draft at publishAt |
GET /remote-config/schedules |
remote_config:read |
Pending schedules plus the last 20 finished, soonest first |
DELETE /remote-config/schedules/{id} |
remote_config:write |
204: the pending schedule is cancelled |
GET /remote-config/adoption?days=14 |
remote_config:read |
Daily fetches and unique devices, and unique devices per version |
Scheduling. Send the draft revision you saved, a publishAt 1 minute to 90 days ahead (ISO-8601 with an offset), an optional note and an optional notifyDevices (none, test or all):
{ "revision": 7, "publishAt": "2026-10-10T06:00:00Z", "note": "Weekend event on", "notifyDevices": "all" }
{ "id": "6f1c…", "publishAt": "2026-10-10T06:00:00+00:00", "status": "pending", "note": "Weekend event on",
"createdBy": "dev@studio.com", "parameterCount": 14, "notifyDevices": "all",
"refresh": { "status": "queued", "mode": "all" } }
refresh.status is queued when devices will be notified after the schedule publishes (the rate limit applies then, and the refresh goes out about a minute after publishing), otherwise skipped. List items include notifyDevices.
The snapshot is frozen: later draft edits are not part of it. At publishAt it becomes a new version with source: "scheduled". List items add publishedVersion, failureReason and completedAt; status is pending, publishing, published, skipped (it was already live), cancelled or failed. A stale revision returns 409 with currentRevision; a draft equal to the live version returns 400 (Nothing to schedule); over the Free plan's 25 parameters returns 402. Cancelling a schedule that is no longer pending returns 409 with its status. A scheduled publish reaches devices within about a minute, since API instances cache the live version for 60 seconds.
Adoption. days is 1–30 (default 14):
{ "environment": "production",
"days": [ { "date": "2026-10-04", "fetches": 1234, "devices": 456 } ],
"versions": [ { "version": 3, "devicesToday": 400, "devicesLast7Days": 420 } ],
"liveVersion": 3, "degraded": false }
days is oldest first. fetches counts every environment of the app; devices counts unique devices of the requested environment. versions lists the 5 newest versions plus any older one devices received in the last 7 days, newest first. Unique counts are HyperLogLog estimates. When the metrics store is unavailable, every count is 0 and degraded is true.
Rate Limits
| Endpoint group | Limit |
|---|---|
POST /auth/* |
20 req/min per IP |
POST /devices/register |
1000 req/min per API key |
POST /event-triggers/fire |
5000 req/min per API key |
POST /notifications/send |
100 req/min per API key |
POST /sdk/challenges, POST /sdk/installations (SDK bootstrap) |
60 req/min per client IP, shared by both routes |
/sdk/installations/{installationId} and everything under it (PATCH, DELETE, /events, /receipts, /config) |
120 req/min per installation, bursts of up to 60 |
| All other endpoints | 300 req/min per token |
SDK calls made with an installation credential are limited per installation, not per IP address, so players who share a carrier-NAT or Wi-Fi address do not use up each other's allowance. Tokens refill every 10 seconds (20 at a time at the default rate). A throttled request returns 429 Too Many Requests with a Retry-After header in seconds and a problem-details body; wait that long before retrying. Self-hosted deployments can change the per-installation limits with ClientSdk:RateLimits:InstallationBurst and ClientSdk:RateLimits:InstallationPerMinute.
Rate limit headers are included in every response:
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 987
X-RateLimit-Reset: 1705312800