Get started / Set up your app

PatchForge OTA — End-to-End Integration Guide

The complete, org-can-self-serve walkthrough for shipping an Over-The-Air (OTA) update with PatchForge: from creating an application in the dashboard, to wiring the SDK into a React Native app, to building the JS bundle, uploading a release, deploying it to a channel, and watching the device pull it.

Every route, field name, and artifact format below is sourced from the codebase and was verified end-to-end on a real device (Galaxy S24, RN 0.84, release build) on 2026-06-28.

Scope. This is the deep reference. For dashboard concepts see concepts and how-to; for what may legally ship OTA see ota-compliance.

Platform status. Android is fully implemented and on-device verified. iOS native is an authored stub (every native call rejects NOT_IMPLEMENTED). Treat this guide as Android-complete; iOS sections are marked.


Contents

  1. How OTA works (mental model)
  2. Key concepts & versioning rules
  3. Part A — Create your application
  4. Part B — Integrate the SDK into your app
  5. Part C — Build & package the JS bundle
  6. Part D — Create, upload & deploy a release
  7. Part E — What the device does at runtime
  8. Part F — Verify & troubleshoot
  9. SDK distribution — how customers install it
  10. Known limitations (read before launch)

1. How OTA works (mental model)

PatchForge replaces App Center CodePush. The native binary (APK/IPA) ships through the stores as usual; PatchForge then swaps the JavaScript bundle the app loads at startup — no store review for JS/asset changes.

   YOU (dashboard / CI)                    DEVICE (your RN app + PatchForge SDK)
   ───────────────────                     ─────────────────────────────────────
   1. Build JS bundle  ─────upload────▶  PatchForge server
   2. Create release                         │
   3. Worker: sha256 + RSA-sign + manifest   │
   4. Deploy to channel (rollout %)          │
                                             ▼
                            cold start ──▶ POST /api/sdk/check
                                             │  updateAvailable? signed downloadUrl,
                                             │  checksum, signature, keyVersion
                                             ▼
                                          download bundle ──▶ verify sha256 + RSA-4096
                                             │                 (against embedded public key)
                                             ▼
                                          stage bundle (pointer swap)
                                             │
                                             ▼
                                          activate per installMode → app runs new JS

Trust chain: the worker signs sha256(bundle) with an RSA-4096 private key (SIGNING_PRIVATE_KEY); the SDK verifies that signature against the public key embedded in the app by keyVersion. A tampered bundle or token is rejected on-device.


2. Key concepts & versioning rules

TermMeaningExampleWho sets it
appKeyPublic app identifier the SDK sends on every callpf_a1b2c3… (24 hex)Auto-generated at app creation
ChannelA release track (Development / QA / Staging / Production)productionAuto-created with the app
storeVersionThe native app version in the store1.0.0Release form — must match the device's installed native version
bundleVersionThe OTA (JS) version, incremented per release1.0.0.1Release form
RingRollout audience tierProductionDeploy dialog
Rollout %Fraction of devices that get this deployment25Deploy dialog

The single most important rule: storeVersion must equal the device's appVersion

The check endpoint only offers a release if release.storeVersion === device.appVersion (sdk-check.service.ts). This prevents shipping a JS bundle built for native v2.0.0 to a device still running native v1.0.0 (which would crash on missing native modules).

  • Device on store build 1.0.0 → only sees releases with storeVersion = 1.0.0.
  • When you ship a new native build (e.g. 1.1.0 with new native deps), create your OTA releases under storeVersion = 1.1.0.
  • bundleVersion (e.g. 1.0.0.1, 1.0.0.2, …) is what increments per OTA within a store version. Use the storeVersion as the prefix and a 4th segment as the OTA counter.

⚠️ Today the SDK derives appVersion from stored OTA state, defaulting to 0.0.0 on a fresh install — it does not yet read the real native version from react-native-device-info. Until that's fixed, deploy your first releases with storeVersion = 0.0.0, or pass the native version explicitly. See Known limitations.


Part A — Create your application

Prereq: a PatchForge dashboard login. If you don't have one yet, sign up at patchforge.tech.

A1. Create the application

  1. Dashboard → Applications → New Application (/applications/new).
  2. Fill in:
    • Name (required) — e.g. Acme Mobile.
    • Description (optional).
    • Android Package Name (optional) — reverse-DNS, e.g. com.acme.app.
    • iOS Bundle Identifier (optional) — must match your Xcode bundle id.
    • At least one platform is required.
  3. Submit. You're taken to the application detail page.

A2. Copy the appKey

On the application detail page, copy the appKey — format pf_ + 24 hex chars (e.g. pf_a1b2c3d4e5f6a7b8c9d0e1f2). This is the public key your app uses to talk to the server; you'll paste it into the SDK config in Part B.

The appKey is public (it ships in your app). Security comes from the RSA bundle signature + signed download tokens, not from hiding the key.

A3. Channels (auto-created)

Creating an app auto-creates four channels: Development, QA, Staging, and Production (Production is the default). There is no separate "create channel" step.

  • The SDK selects a channel by name via the environment config (case-insensitive): environment: 'production' → the Production channel.
  • View/manage channels at /applications/[id]/settings/channels.

Part B — Integrate the SDK into your app

Package: @zimbstech/patchforge-react-native (published publicly on npm). See also the SDK's own README. For install details see SDK distribution.

B1. Install

npm install @zimbstech/patchforge-react-native

# iOS only (once iOS native lands)
npx pod-install

Optional peer dependencies (install only if your app doesn't already have them — the SDK degrades gracefully without them):

  • react-native-encrypted-storage — secure device-id + crash counters (recommended).
  • react-native-device-info — for the real native app version (future appVersion source).
  • @react-native-community/netinfo — required for the wifiOnly gate to work.

B2. Android native wiring (required)

The SDK swaps the bundle via a pointer file read at cold start, so your host app must ask the SDK which bundle to load. Edit MainApplication.kt:

import com.patchforge.PatchForgeBundleLoader   // ← add

override val reactHost: ReactHost by lazy {
  getDefaultReactHost(
    context = applicationContext,
    packageList = PackageList(this).packages.apply { /* your packages */ },
    // ← add: load the active OTA bundle if one is installed; null ⇒ baked bundle
    jsBundleFilePath = PatchForgeBundleLoader.getJSBundleFile(applicationContext),
  )
}
  • getJSBundleFile() returns the staged OTA bundle path, or null to fall back to the baked index.android.bundle (or Metro in dev). It never throws.
  • The path is captured once, at cold start. This is why ON_NEXT_RESTART updates apply on the next launch and a JS reload() alone does not swap bundles.

Autolinking: the package self-registers via react-native.config.cjs (it must be .cjs, not .js, because the package is type: module). No manual PackageList edit is needed for the PatchForge module itself.

B3. iOS native wiring (deferred)

iOS native is a stub today. When implemented, the host wires the SDK's bundle URL into the RCTBridge/bundleURL hook (mirror of getJSBundleFile). Until then, the SDK's JS layer runs but native calls reject — do not ship OTA on iOS yet.

B4. Initialize the SDK

Call initialize() once at app entry (root component mount), before navigation:

import { PatchForge } from '@zimbstech/patchforge-react-native';

const pf = PatchForge.getInstance();

// (optional) subscribe to lifecycle events
const unsub = [
  pf.on('onUpdateAvailable', e => console.log('update', e.bundleVersion, e.downloadType)),
  pf.on('onDownloadProgress', e => console.log('dl', e.percent)),
  pf.on('onInstallComplete',  e => console.log('installed', e.bundleVersion, e.installMode)),
  pf.on('onRollback',         e => console.log('rollback', e.reason)),
  pf.on('onFailure',          e => console.log('FAIL', e.stage, e.error)),
  pf.on('onMandatoryUpdate',  e => console.log('mandatory', e.bundleVersion)),
];

await pf.initialize({
  appKey: 'pf_YOUR_APP_KEY',                       // from Part A2
  environment: 'production',                        // → Production channel
  apiUrl: 'https://your-patchforge-instance.com',  // your server base URL
  autoCheck: true,        // check on launch (default true)
  autoDownload: true,     // download when an update is found
  installMode: 'ON_NEXT_RESTART',
  debug: __DEV__,         // verbose [PatchForge] logs
});

initialize() is idempotent and returns immediately; the check → download → install sequence runs asynchronously and never blocks startup.

Config reference (PatchForgeConfig)

FieldTypeDefaultNotes
appKeystring— (required)From the dashboard.
environmentstring— (required)Channel name; sent as channel.
apiUrlstring— (required)Server base URL; /api/sdk/* appended.
autoCheckbooleantrueCheck for updates on launch.
autoDownloadbooleanunsetAuto-download a found update.
wifiOnlybooleanunsetSkip downloads off Wi-Fi (needs netinfo peer).
installMode'IMMEDIATE' | 'ON_NEXT_RESTART' | 'MANUAL'ON_NEXT_RESTARTWhen to activate.
debugbooleanunsetVerbose logs.

Public methods

getInstance(), initialize(config), checkForUpdates(), downloadUpdate(check), installUpdate(check, path), rollback(), getCurrentBundleVersion(), on(event, cb). Use the manual trio (checkForUpdates/downloadUpdate/installUpdate) only if you set autoCheck:false/autoDownload:false to drive the flow yourself.


Part C — Build & package the JS bundle

This is the artifact you upload in Part D.

🔑 Artifact format (verified): a full release's artifact is a raw index.android.bundle (Hermes bytecode), not a zip. The SDK downloads a full update as a .jsbundle and hands it straight to the native loader; only patches (server- generated, delta) are zips. The dashboard form and docs now ask for the raw bundle (accept=".bundle,.jsbundle"); zip-with-assets support for full releases is not yet available. Upload the raw bundle.

Practical consequence: a full OTA can change JavaScript and assets already inlined/in the APK, but cannot add brand-new standalone image/font files (those need the native build or the future zip-with-assets support).

C1. Generate the Hermes bundle (Android)

From your RN app:

cd android
# RN 0.84 New Architecture / Hermes — JDK 21 (e.g. Android Studio's JBR)
JAVA_HOME="/path/to/jbr" ./gradlew :app:createBundleReleaseJsAndAssets

Output (RN 0.84):

  • JS bundle: android/app/build/generated/assets/react/release/index.android.bundle (Hermes bytecode — first bytes c6 1f bc 03).
  • Resources: android/app/build/generated/res/react/release/ (already compiled into the APK at build time; not needed for a raw-bundle OTA).

C2. What to upload

Upload index.android.bundle itself as the release's bundle file. (You may rename it, e.g. acme-1.0.0.1.android.bundle; the server stores and hashes the bytes verbatim.)

For iOS (once supported): main.jsbundle from the Xcode bundle phase, same raw-bundle rule.

C3. (Optional) CI snippet

# build the bundle, then upload via the API (see Part D5 for the curl)
cd android && ./gradlew :app:createBundleReleaseJsAndAssets
BUNDLE=app/build/generated/assets/react/release/index.android.bundle
# ... create release (get releaseId), then POST $BUNDLE to the upload route ...

Part D — Create, upload & deploy a release

D1. Create the release (metadata)

  1. App → Releases → New Release (/applications/[id]/releases/new).
  2. Fill in:
    • Channel (required) — e.g. Production.
    • Release name (required) — e.g. v1.0.0 build 1.
    • Store version (required) — ^\d+\.\d+\.\d+$, e.g. 1.0.0 (must equal the device's native version; see §2).
    • Bundle version (required) — ^\d+\.\d+\.\d+(\.\d+)?$, e.g. 1.0.0.1 (unique per [app, channel]).
    • Release type (optional) — Standard / Hotfix / Mandatory (Mandatory makes the SDK emit onMandatoryUpdate).
    • Release notes (optional).
    • Bundle file — your index.android.bundle (see the format note above).
    • OTA acknowledgment checkbox — required (you accept responsibility per the OTA compliance notice).
  3. Submit. The release is created in Draft status.

D2. Upload (if not done in the form)

The bundle upload posts to:

POST /api/applications/{applicationId}/releases/{releaseId}/upload
Content-Type: multipart/form-data
form field name: bundle      ← must be exactly "bundle"
  • Allowed only while the release is Draft.
  • The server stores the bytes verbatim and advances Draft → Uploaded.

D3. Processing (automatic, by the worker)

The BullMQ worker (must be running) picks up the upload and:

  1. Downloads the stored bundle, computes sha256 of the bytes (as-is).
  2. Atomically transitions Uploaded → Processing.
  3. Signs the hash with SIGNING_PRIVATE_KEY (RSA-SHA256) → signature + keyVersion (from SIGNING_KEY_VERSION, supports rotation).
  4. Builds manifest.json (releaseId, bundleVersion, storeVersion, keyVersion, hash, algorithm, createdAt) and stores manifest + signature.
  5. Transitions Processing → Ready; sets bundleHash + keyVersion on the release.
  6. Best-effort: enqueues patch generation against recent releases (delta downloads).

Status path: Draft → Uploaded → Processing → Ready (or Failed with a failureReason). A release must be Ready to deploy.

D4. Deploy to a channel

  1. Open the release detail page (/applications/[id]/releases/[releaseId]) → Deploy.
  2. Set:
    • RingInternal / Beta / EarlyAccess / Production.
    • Rollout %1 / 5 / 10 / 25 / 50 / 75 / 100.
    • Notes (optional).
    • Production confirmation — for a Production channel you must type the bundleVersion exactly to confirm.
  3. Confirm. This atomically:
    • locks the channel, supersedes the previous Active deployment (→ Superseded),
    • creates the new Active deployment with your ring + rollout,
    • invalidates the OTA check cache for this app+channel (Redis), so devices see it on their next check (the cache TTL is ~60 s otherwise).

Only one Active deployment per channel exists at a time. To progressively roll out, deploy at 10% → 25% → … → 100% (each deploy supersedes the prior).

D5. (Optional) End-to-end via API/CI

# 1. create release → returns { id }   (via the dashboard action or your automation)
# 2. upload the raw bundle:
curl -X POST \
  "$BASE/api/applications/$APP_ID/releases/$RELEASE_ID/upload" \
  -H "Cookie: <auth>" \
  -F "bundle=@android/app/build/generated/assets/react/release/index.android.bundle"
# 3. poll release status until "Ready"
# 4. deploy via the deployment action (ring + rolloutPercentage)

A public PAT-authenticated REST automation API + CLI is planned but deferred; today the dashboard server actions are the supported control plane.


Part E — What the device does at runtime

On cold start, with autoCheck/autoDownload on, the SDK:

  1. POST /api/sdk/check with { appKey, platform, channel, appVersion, currentBundleVersion, deviceId, … }.
  2. Server matches: app → channel → Active deployment → Ready release where storeVersion === appVersion → rollout-bucket the deviceId → decide full vs patch.
  3. If updateAvailable, the response carries a signed downloadUrl, checksum, signature, keyVersion. The SDK downloads, verifies sha256 + RSA-4096, and stages.
  4. Activation per installMode:
    • ON_NEXT_RESTART (default) — applies on the next cold start.
    • IMMEDIATE — intends an immediate restart (note: on the New Architecture this is a JS reload() which does not re-read the bundle pointer; full apply needs a true cold start).
    • MANUAL — staged; you trigger the restart.

Confirm the active version any time:

const v = await PatchForge.getInstance().getCurrentBundleVersion(); // e.g. "1.0.0.1"

Rollback (current behavior)

rollback() and crash-loop auto-rollback are wired but do not yet revert the running bundle in V1: restoreBackup() restores metadata but does not repoint the native bundle pointer, and the restart is a reload(). Treat rollback as not production-ready — to undo a bad release today, deploy the previous good release again from the dashboard (it supersedes the bad one; devices pull it on their next check). See Known limitations.


Part F — Verify & troubleshoot

Local testing against a dev server (device → your machine):

adb reverse tcp:3000 tcp:3000      # device localhost:3000 → your server
# allow cleartext to localhost in android/app/src/main/res/xml/network_security_config.xml

Watch the SDK on-device (debug:true):

adb logcat | grep -E "\[PF\]|PatchForge|onUpdateAvailable|onInstallComplete|onFailure"
SymptomLikely causeFix
updateAvailable: false though you deployedstoreVersion ≠ device appVersionMatch storeVersion to the device's native version (or 0.0.0 on fresh install).
Check seems slow (tens of seconds)First-launch device-id/secure-storage init + check latencyExpected; the auto-flow completes within ~1–2 min. Use events, not polling.
App loads an old OTA bundle after reinstallA previous OTA bundle is still staged in app storage and shadows the baked oneFull uninstall (not reinstall) clears staged bundles. (This shadowing is a known rollback gap.)
Device can't reach the serveradb reverse not set / cleartext blockedSet adb reverse; allow cleartext-to-localhost in dev.
onFailure stage verifychecksum/signature mismatch or wrong keyVersionEnsure the app embeds the public key matching the worker's SIGNING_KEY_VERSION.
Bundle won't load after a full updateYou uploaded a zipUpload the raw index.android.bundle.
Release stuck in UploadedWorker not runningStart @patchforge/worker.

SDK distribution — how customers install it

The SDK is published publicly on the npm registry as @zimbstech/patchforge-react-native. Consumers install it like any other public package — no registry configuration, no auth token, no .npmrc:

npm install @zimbstech/patchforge-react-native

# optional peers as needed:
npm install react-native-encrypted-storage react-native-device-info @react-native-community/netinfo
cd ios && pod install     # when iOS native lands

The current published version is 0.2.3 (latest).


Known limitations (read before launch)

The OTA-relevant limitations below were verified on-device 2026-06-28:

LimitationPractical guidance
iOS native is an authored stub — no native calls run yetAndroid only today.
✅ Resolved — signature + keyVersion are included in the check responseRSA-4096 verify works end-to-end.
appVersion defaults to 0.0.0 (not read from the native app version)Use storeVersion = 0.0.0 for fresh installs, or pass the native version, until this is fixed.
rollback() doesn't revert the running bundle (pointer not repointed; restart is a reload())To undo a bad release, re-deploy the previous good release.
Crash-loop auto-rollback detects but can't recover; may re-download the bad bundleDon't rely on auto-rollback; re-deploy a good release.
Full-release artifact is a raw bundle, not the .zip the UI may suggest; no standalone new assets in a full updateUpload index.android.bundle; ship asset changes via the native build.

The happy-path OTA pipeline (build → upload → sign/verify → deploy → device pulls and runs) is implemented and on-device verified. The rollback / crash-recovery paths are the immature area we're actively improving.


Last verified end-to-end on Galaxy S24 (RN 0.84, release build), 2026-06-28.