PushHub JavaScript and TypeScript SDK
Release: 2.0.0-beta.4 · Preview · tarball on the downloads page. Preview until it has been run against the live API in Node.js and in a browser; its automated tests and a clean install from the tarball pass.
Runtime: Node.js 18+ or a modern fetch implementation
Security: client-safe installation credentials
The Phase Four client replaces embedded ph_live_ server keys with an app-bound publishable key and one rotatable credential per installation.
Install
Install the release tarball from the PushHub downloads page:
npm install https://docs.pushhub.kemegames.com/downloads/pushhub-js-2.0.0-beta.4.tgz
npm records the URL and the tarball's sha512 integrity in package-lock.json, so later installs get the same bytes. You can also download the file, check its SHA-256 against SHA256SUMS.txt, keep it in your repository and npm install ./vendor/pushhub-js-2.0.0-beta.4.tgz.
The package isn't on the npm registry, so npm install pushhub-js fails. PushHub hasn't registered that name there; if a registry package with that name appears, it isn't PushHub's. It ships ESM (import), CommonJS (require) and TypeScript declarations for both.
Build from source
cd sdk/javascript
npm ci
npm test
npm pack
npm pack runs the build (prepack) and writes pushhub-js-<version>.tgz with dist/index.js, dist/index.cjs, dist/index.d.ts and dist/index.d.cts.
Configure
Create a publishable key in Workspace → Apps → your app → Integrations. A publishable key begins with ph_pub_ and can be included in a client bundle. A privileged ph_live_ API key must remain on a trusted server.
Each key belongs to one app environment (Production by default, or Development once it is added). Installations take the environment of the key they register with, so ship a Development key in development builds and a Production key in releases; the SDK has no separate environment option.
import PushHubClient from 'pushhub-js'
const pushhub = new PushHubClient({
appId: 'your-app-uuid',
publishableKey: 'ph_pub_your_publishable_key',
storage: secureStorage,
appVersion: '2.4.1', // optional, reported for targeting
appBuild: '241', // optional
})
storage implements asynchronous getItem, setItem, and removeItem. Mobile and game clients must use Keychain, Keystore-backed, or equivalent encrypted storage. The default in-memory adapter is intended only for server processes and tests.
Device context
Requires 2.0.0-beta.2 or later, which adds
appVersion,appBuild,osVersion,collectDeviceContextandsyncContext().
The client reports device context that segments target as built-in fields: appVersion, appBuild and osVersion from the config, and the locale (navigator.language), country (the locale's region) and timezone (Intl.DateTimeFormat().resolvedOptions().timeZone) it detects. The server derives language from the locale. Pass collectDeviceContext: false to report only the config values. Values the server would reject, such as a numeric region like 419, are left out.
The context is sent with registerInstallation. On later start-ups, call syncContext() after restore(); it sends a PATCH only when the context changed since the last report, for example after an app update:
if (await pushhub.restore()) await pushhub.syncContext()
Use in a browser
The client works in a browser game served from your own domain. The SDK routes (https://api.pushhub.kemegames.com/api/v1/sdk/*) accept cross-origin requests from any origin, so you don't need to register your domain. Requests carry no cookies. The publishable key and the Authorization: PushHubInstallation … header are the only credentials.
- No credentials mode. The API answers with
Access-Control-Allow-Origin: *, which browsers reject for credentialed requests. If you pass your ownfetch, don't setcredentials: 'include'. - Allowed request headers:
Authorization,Content-Type,If-None-MatchandIdempotency-Key. The client sends only these. A customfetchthat adds other headers fails the browser's preflight check. - Readable response headers:
ETag(so remote config fetches can be answered with304 Not Modified),Retry-After(on429) andX-PushHub-Signature(see Signed config). The browser caches the preflight for up to two hours. - Methods:
GET,POST,PATCHandDELETE. - Content Security Policy. If your page sets one, allow
connect-src https://api.pushhub.kemegames.com. - Storage. Browsers have no Keychain or Keystore. Pass an adapter over
localStorageor IndexedDB so the installation survives page reloads; the default in-memory adapter forgets it on every load. Any script on your page can read that storage.
Only the SDK routes are open to other origins. Dashboard and server routes answer PushHub's own sites only, and a ph_live_ key must never ship in browser code.
Register an installation
Ask for notification permission in your own product flow, obtain the current FCM or APNs token, then register it:
const installation = await pushhub.registerInstallation({
deviceToken: providerToken,
platform: 'Android', // or 'iOS'
userId: currentUser?.id,
metadata: { tier: 'gold' }, // custom traits; app version and locale are reported as device context
})
PushHub creates a five-minute, single-use challenge and exchanges it for an installation credential. The SDK stores the returned state through the configured storage adapter. Call registerInstallation again when the provider token rotates; PushHub rotates the installation credential without exposing a server key.
Identity and consent
await pushhub.identify('player-42', { level: 18 })
await pushhub.setConsent('Granted')
// On logout:
await pushhub.unlinkUser()
// When notification consent is withdrawn:
await pushhub.setConsent('Denied')
Denied consent deactivates the device for future targeting. Identification does not grant notification consent.
Metadata merges into the traits already stored for the device: keys you pass are added or overwritten, a null value removes that key, and keys you leave out are kept. The server does the merging, so it applies to every SDK version. To replace the whole document instead, pass replaceMetadata: true (2.0.0-beta.2 or later):
await pushhub.identify('player-42', { level: 19, vip: null }) // update level, drop vip
await pushhub.updateInstallation({ metadata: { tier: 'gold' }, replaceMetadata: true })
Track an event
await pushhub.trackEvent({
name: 'level_up',
properties: { level: '18', world: 'north-realm' },
idempotencyKey: 'level-up-player-42-18',
})
Event properties are string values. Use a stable idempotency key for the same logical action.
Track receipt and open
PushHub notifications sent to an SDK installation contain pushhub_receipt_token. Do not construct or replace it.
await pushhub.trackReceipt(message.data.pushhub_receipt_token, 'received')
await pushhub.trackReceipt(message.data.pushhub_receipt_token, 'opened')
await pushhub.trackReceipt(message.data.pushhub_receipt_token, 'action', 'claim_reward')
The signed token binds the notification to one installation. “Provider accepted,” “received,” and “opened” remain separate outcomes.
Remote config
Requires 2.0.0-beta.2 or later.
Remote config delivers values you change from the dashboard (LiveOps → Remote config) without shipping an update. Values can differ by condition, such as country, app version or a percentage of players. The API is on pushhub.config.
Set defaults and fetch on start-up
const pushhub = new PushHubClient({
appId: 'your-app-uuid',
publishableKey: 'ph_pub_your_publishable_key',
remoteConfig: {
defaults: { starter_coins: 100, maintenance_mode: false, welcome_text: 'Welcome!', store_layout: { tabs: ['offers', 'coins'] } },
minimumFetchIntervalSeconds: 3600, // optional; the server's minimum (900 s) applies when larger
fetchTimeoutMs: 10_000, // optional; default 10 seconds
},
})
if (await pushhub.restore()) {
const changed = await pushhub.config.fetchAndActivate().catch(() => false) // offline: keep the last values
if (changed) renderStore()
}
pushhub.config.setDefaults({...}) replaces the defaults at any time. A fetch needs a registered installation; before that, fetchAndActivate() rejects with code not_initialized. Before fetching, the client calls syncContext(), so conditions see the current app version, locale and country.
Read values
Getters are synchronous:
pushhub.config.getNumber('starter_coins') // 100 until a fetched value is activated
pushhub.config.getBoolean('maintenance_mode')
pushhub.config.getString('welcome_text')
pushhub.config.getJson<{ tabs: string[] }>('store_layout') // parsed value (a copy)
pushhub.config.getAll() // defaults overlaid with remote values
pushhub.config.getValue('starter_coins') // { value: 250, source: 'remote' }
Each getter returns the active remote value, then your default, then the type's zero value ('', 0, false, or undefined for getJson). getValue(key).source is 'remote', 'default' or 'static'.
React to updates
const unsubscribe = pushhub.config.onUpdated(changedKeys => {
if (changedKeys.includes('maintenance_mode')) showMaintenanceBanner()
})
// Later: unsubscribe()
Fetch now, apply later
await pushhub.config.fetch() // download; the getters still return the old values
const changed = await pushhub.config.activate() // apply, then notify the listeners
Storage, offline behaviour and fetch interval
- The active values, their version, the ETag, the exact response they came from and the time of the last fetch are saved under
pushhub:{appId}:remote-config. Trusted signing keys are saved underpushhub:{appId}:remote-config:signing-keys. Storage isremoteConfig.storageif set, then the clientstorage, thenlocalStoragewhen available, then memory (Node). With synchronous storage such aslocalStorage, the last activated config is available as soon as the client is created. With asynchronous storage, or when the saved config must be verified first (see Signed config),await pushhub.config.ready()first. - Each fetch sends the stored ETag as
If-None-Match. When nothing changed, the server answers304 Not Modifiedand the values stay as they are. - A failed fetch rejects with the network error or a
PushHubError; a timeout rejects with codetimeout. The active values stay as they were. A call you do not await never raises an unhandled rejection. - Fetches are throttled to the larger of the server's minimum interval (900 seconds by default) and
minimumFetchIntervalSeconds(default 0; also settable aspushhub.config.minimumFetchIntervalSeconds). Within that intervalfetchAndActivate()makes no request and resolvesfalse. UsefetchAndActivate({ force: true })to fetch anyway, for example from a debug menu. - With nothing published yet, the server returns version 0 with no parameters, and the getters use your defaults.
pushhub.config.versionis the active version.
Instant refresh
Requires 2.0.0-beta.3 or later.
PushHub sends refreshes only to installations that report
sdkNamejavascriptat 2.0.0-beta.3 or later. Older clients would leave the message to your push library, which may show it, so they aren't sent one and pick up the new config on their next normal fetch. If you override thesdkNameorsdkVersionoptions, the installation is judged by what you report, and anysdkNameother thanjavascriptgets no refreshes.
When you publish, roll back or schedule a publish with Notify devices set to Test devices only or All players, PushHub sends the environment's Android and iOS devices a silent data message whose data includes pushhub_action: 'config_refresh' and pushhub_config_version. Browsers and servers don't receive it. In a hybrid app that registers through this client, your push library delivers it; pass its data to the config:
// In your push library's data-message handler:
if (await pushhub.config.handleRefreshMessage(message.data)) return // a PushHub refresh, handled
handleRefreshMessage resolves true for a PushHub refresh and false for any other message. For a refresh, when the announced version is newer than pushhub.config.version, or can't be read, it calls fetchAndActivate({ force: true }), so your onUpdated listeners run. It never rejects. On iOS, a silent notification reaches your code only when the app has the Remote notifications background mode and your push library forwards background notifications.
If that fetch fails, for example while the device is offline or before an installation is registered, the client remembers the announced version in its storage (from 2.0.0-beta.4). Your next fetch, also after a restart, then skips the minimum fetch interval and goes to the server. The hint is cleared once a later fetch reaches the server or the active version catches up. Without a missed refresh, the interval applies as usual.
Signed config
Requires 2.0.0-beta.3 or later.
When an environment has a signing key (App setup → Config signing), PushHub signs every config response with it: ECDSA P-256 with SHA-256 (ES256) over the exact response bytes, sent in the X-PushHub-Signature header. The client checks the signature once you give it the public keys you trust, keyed by key ID:
const pushhub = new PushHubClient({
appId: 'your-app-uuid',
publishableKey: 'ph_pub_your_publishable_key',
remoteConfig: {
signingKeys: { KEY_ID: 'MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE...' }, // the public key (base64 SPKI) from App setup → Config signing
},
})
// Or later: pushhub.config.setSigningKeys({ ... })
await pushhub.config.ready() // the saved config has been checked against the keys
Once keys are set, verification is required everywhere, not only for a fresh fetch (from 2.0.0-beta.4; 2.0.0-beta.3 checked fetches only):
- Fetch: a config is used only when a listed key signed those exact bytes. An unsigned response, an unknown key ID or a wrong signature rejects the fetch with a
PushHubErrorwhosecodeis'signature'(statusCode0, not retryable), and the active values stay as they were. A304 Not Modifiedcarries no signature and keeps the response you already verified. - Activate:
activate()checks the fetched config again against the keys you trust now. A config fetched before you set the keys is therefore never activated unchecked: if it fails,activate()rejects with code'signature', discards it, and the active values stay. - Saved config and restarts: the client saves the exact response bytes and their signature with the config, and checks them again at every start. A saved config that is unsigned (for example, activated before your app set keys), saved by 2.0.0-beta.3 or earlier, or doesn't verify is not used. The getters return your in-app defaults (
source: 'default',version0), a warning is logged,onUpdatedlisteners hear the change, and the next fetch skips the minimum fetch interval. - Saved keys: the keys are saved in the config storage, under
pushhub:{appId}:remote-config:signing-keys. A later client on the same storage enforces them from the start, even if its code never sets keys, for example a service worker or a page that creates the client before your settings load. Setting keys again replaces the saved set. Public keys are not secrets. - Changing keys: a new key set is applied at once. The active config stays in use when a key that is still trusted signed it; otherwise the client drops to your defaults and the next fetch goes to the server straight away.
- Without keys (the default): nothing is checked, as before.
setSigningKeys({})clears the saved keys and turns the checks off again. - Bad keys: the constructor and
setSigningKeysthrow for an empty key ID or a key that isn't a P-256 public key, so a typo shows up in development. Nothing changes when they throw. PEM text works too. - Crypto: the client uses Web Crypto, so in a browser the page must be a secure context (HTTPS or
localhost). On Node.js 18, which has no global Web Crypto, it falls back to thenode:cryptomodule. - Browsers: the API exposes
X-PushHub-Signatureto cross-origin scripts. A customfetchmust pass response headers through.
With synchronous storage, a saved config is readable as soon as the client is created only if a key that is still trusted verified it before. It is checked again immediately, and ready() resolves once that check is done.
Rotating keys. List every key you trust; an app can list more than one.
- Prepare next key in App setup. It doesn't sign anything yet.
- Ship an app version that trusts both keys:
{ [currentKeyId]: currentSpki, [nextKeyId]: nextSpki }. Players keep their current config, because the key that signed it is still trusted. - Rotate once enough players have updated. Responses are then signed with the new key, which those players already trust.
- In a later version, drop the old key. A player whose saved config was still signed by the old key (nothing was published since the rotation) gets your defaults until the next fetch, which goes to the server at once.
An emergency rotation without preparing signs with the new key at once. App versions that don't list it fail their fetches and keep their last verified values until they update.
Errors and retry
Methods reject with PushHubError, containing statusCode, optional code, and a retryable convenience property. HTTP 429 and 5xx failures are classified as retryable. Authentication and validation failures require configuration or input correction.
Do not create an unbounded retry loop. Native SDKs add the persistent bounded queue in Phase 4B; JavaScript hosts should use their platform's durable job mechanism.
API summary
| Method | Purpose |
|---|---|
restore() |
Restore installation state from configured storage |
registerInstallation(options) |
Bootstrap or recover one provider-token installation |
updateInstallation(options) |
Refresh token, identity, attributes (merged unless replaceMetadata), consent, device context, or SDK metadata |
identify(userId, metadata?) |
Associate the installation with a customer user ID and merge traits |
syncContext() |
Report the device context if it changed since the last report |
deviceContext() |
The context the client would report |
unlinkUser() |
Remove that association on logout |
setConsent(status) |
Store Unknown, Granted, or Denied |
trackEvent(options) |
Submit an installation-bound event |
trackReceipt(token, event, actionId?) |
Report signed receipt/open/action evidence |
reset() |
Remove locally stored installation state |
config.setDefaults(values) |
In-app remote config defaults |
config.fetchAndActivate({ force? }) |
Fetch remote config and apply it; resolves true when values changed |
config.fetch() / config.activate() |
Fetch and apply remote config as separate steps |
config.getString/getNumber/getBoolean/getJson/getAll/getValue |
Read remote config values |
config.onUpdated(listener) |
Be told which keys changed; returns an unsubscribe function |
config.handleRefreshMessage(data) |
Act on a PushHub silent config refresh from your push library |
config.setSigningKeys(keys) |
Require config signed by these keys, and save them for later clients (remoteConfig.signingKeys at construction) |
config.ready() |
Resolves once the saved config is loaded and, with signing keys, checked again |
See the hosted OpenAPI reference for the exact /sdk contract. Direct server-to-server event and campaign operations continue to use scoped ph_live_ API keys.