Docs / JavaScript SDKSDK downloadsGitHub ↗

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, collectDeviceContext and syncContext().

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 own fetch, don't set credentials: 'include'.
  • Allowed request headers: Authorization, Content-Type, If-None-Match and Idempotency-Key. The client sends only these. A custom fetch that adds other headers fails the browser's preflight check.
  • Readable response headers: ETag (so remote config fetches can be answered with 304 Not Modified), Retry-After (on 429) and X-PushHub-Signature (see Signed config). The browser caches the preflight for up to two hours.
  • Methods: GET, POST, PATCH and DELETE.
  • 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 localStorage or 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.

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 under pushhub:{appId}:remote-config:signing-keys. Storage is remoteConfig.storage if set, then the client storage, then localStorage when available, then memory (Node). With synchronous storage such as localStorage, 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 answers 304 Not Modified and the values stay as they are.
  • A failed fetch rejects with the network error or a PushHubError; a timeout rejects with code timeout. 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 as pushhub.config.minimumFetchIntervalSeconds). Within that interval fetchAndActivate() makes no request and resolves false. Use fetchAndActivate({ 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.version is the active version.

Instant refresh

Requires 2.0.0-beta.3 or later.

PushHub sends refreshes only to installations that report sdkName javascript at 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 the sdkName or sdkVersion options, the installation is judged by what you report, and any sdkName other than javascript gets 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 PushHubError whose code is 'signature' (statusCode 0, not retryable), and the active values stay as they were. A 304 Not Modified carries 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', version 0), a warning is logged, onUpdated listeners 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 setSigningKeys throw 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 the node:crypto module.
  • Browsers: the API exposes X-PushHub-Signature to cross-origin scripts. A custom fetch must 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.

  1. Prepare next key in App setup. It doesn't sign anything yet.
  2. 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.
  3. Rotate once enough players have updated. Responses are then signed with the new key, which those players already trust.
  4. 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.