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
- How OTA works (mental model)
- Key concepts & versioning rules
- Part A — Create your application
- Part B — Integrate the SDK into your app
- Part C — Build & package the JS bundle
- Part D — Create, upload & deploy a release
- Part E — What the device does at runtime
- Part F — Verify & troubleshoot
- SDK distribution — how customers install it
- 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
| Term | Meaning | Example | Who sets it |
|---|---|---|---|
appKey | Public app identifier the SDK sends on every call | pf_a1b2c3… (24 hex) | Auto-generated at app creation |
| Channel | A release track (Development / QA / Staging / Production) | production | Auto-created with the app |
storeVersion | The native app version in the store | 1.0.0 | Release form — must match the device's installed native version |
bundleVersion | The OTA (JS) version, incremented per release | 1.0.0.1 | Release form |
| Ring | Rollout audience tier | Production | Deploy dialog |
| Rollout % | Fraction of devices that get this deployment | 25 | Deploy 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 thestoreVersionas the prefix and a 4th segment as the OTA counter.
⚠️ Today the SDK derives
appVersionfrom stored OTA state, defaulting to0.0.0on a fresh install — it does not yet read the real native version fromreact-native-device-info. Until that's fixed, deploy your first releases withstoreVersion = 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
- Dashboard → Applications → New Application (
/applications/new). - 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.
- Name (required) — e.g.
- 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
appKeyis 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
environmentconfig (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 (futureappVersionsource).@react-native-community/netinfo— required for thewifiOnlygate 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, ornullto fall back to the bakedindex.android.bundle(or Metro in dev). It never throws.- The path is captured once, at cold start. This is why
ON_NEXT_RESTARTupdates apply on the next launch and a JSreload()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)
| Field | Type | Default | Notes |
|---|---|---|---|
appKey | string | — (required) | From the dashboard. |
environment | string | — (required) | Channel name; sent as channel. |
apiUrl | string | — (required) | Server base URL; /api/sdk/* appended. |
autoCheck | boolean | true | Check for updates on launch. |
autoDownload | boolean | unset | Auto-download a found update. |
wifiOnly | boolean | unset | Skip downloads off Wi-Fi (needs netinfo peer). |
installMode | 'IMMEDIATE' | 'ON_NEXT_RESTART' | 'MANUAL' | ON_NEXT_RESTART | When to activate. |
debug | boolean | unset | Verbose 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.jsbundleand 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 bytesc6 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.jsbundlefrom 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)
- App → Releases → New Release (
/applications/[id]/releases/new). - 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(Mandatorymakes the SDK emitonMandatoryUpdate). - 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).
- Submit. The release is created in
Draftstatus.
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:
- Downloads the stored bundle, computes
sha256of the bytes (as-is). - Atomically transitions
Uploaded → Processing. - Signs the hash with
SIGNING_PRIVATE_KEY(RSA-SHA256) →signature+keyVersion(fromSIGNING_KEY_VERSION, supports rotation). - Builds
manifest.json(releaseId,bundleVersion,storeVersion,keyVersion,hash,algorithm,createdAt) and stores manifest + signature. - Transitions
Processing → Ready; setsbundleHash+keyVersionon the release. - 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
- Open the release detail page (
/applications/[id]/releases/[releaseId]) → Deploy. - Set:
- Ring —
Internal/Beta/EarlyAccess/Production. - Rollout % —
1 / 5 / 10 / 25 / 50 / 75 / 100. - Notes (optional).
- Production confirmation — for a Production channel you must type the
bundleVersionexactly to confirm.
- Ring —
- 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).
- locks the channel, supersedes the previous Active deployment (→
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:
POST /api/sdk/checkwith{ appKey, platform, channel, appVersion, currentBundleVersion, deviceId, … }.- Server matches: app → channel → Active deployment → Ready release where
storeVersion === appVersion→ rollout-bucket thedeviceId→ decidefullvspatch. - If
updateAvailable, the response carries a signeddownloadUrl,checksum,signature,keyVersion. The SDK downloads, verifies sha256 + RSA-4096, and stages. - 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 JSreload()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"
| Symptom | Likely cause | Fix |
|---|---|---|
updateAvailable: false though you deployed | storeVersion ≠ device appVersion | Match 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 latency | Expected; the auto-flow completes within ~1–2 min. Use events, not polling. |
| App loads an old OTA bundle after reinstall | A previous OTA bundle is still staged in app storage and shadows the baked one | Full uninstall (not reinstall) clears staged bundles. (This shadowing is a known rollback gap.) |
| Device can't reach the server | adb reverse not set / cleartext blocked | Set adb reverse; allow cleartext-to-localhost in dev. |
onFailure stage verify | checksum/signature mismatch or wrong keyVersion | Ensure the app embeds the public key matching the worker's SIGNING_KEY_VERSION. |
| Bundle won't load after a full update | You uploaded a zip | Upload the raw index.android.bundle. |
Release stuck in Uploaded | Worker not running | Start @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:
| Limitation | Practical guidance |
|---|---|
| iOS native is an authored stub — no native calls run yet | Android only today. |
| ✅ Resolved — signature + keyVersion are included in the check response | RSA-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 bundle | Don'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 update | Upload 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.