Documentation

Using OpenOTA

Everything you need to ship your first OTA update — CLI commands, SDK configuration, and what the Cloud dashboard actually does today (no fabricated features).

Introduction

OpenOTA lets you ship JavaScript changes to a React Native app instantly — no app store review, no waiting days for a rollout. You build a bundle, push it to your own server (or OpenOTA Cloud), and every installed app checks in, downloads, verifies, and applies the update on its own. If something goes wrong, rollback is instant and happens entirely on-device.

It's the same idea as CodePush or Expo Updates, with two differences that shape everything else in these docs: you own the server (self-host it, or use the hosted Cloud — same code, your choice), and the device never trusts the server blindly — the native runtime re-verifies every bundle's checksum itself before running it.

How it works

Your RN app (SDK)   ──check/download──▶   OpenOTA Server
      ▲                                        │
      │ verify + apply                         ├──▶ Postgres  (metadata)
      │                                        └──▶ Storage   (bundle .zip)
 openota CLI  ─────────release────────────────▶
  1. You release. openota release bundles your JS (via Metro), computes a SHA-256 checksum, and uploads it to your server.
  2. The server stores it two ways. Postgres holds only metadata (which version is "active" per environment); the actual bundle bytes go to your storage backend. Kept separate on purpose — see What you need.
  3. A device checks in. OTA.check() asks the server "what's active for my platform + environment?" and compares it to the device's current version.
  4. It downloads and verifies independently. The native (Kotlin/Swift) runtime re-computes the checksum itself before ever running the new code — the server's word is never enough on its own.
  5. Rollback is instant and local. The previous bundle stays on-device, so rolling back is a pointer swap, not a re-download.

Core concepts

Release

One versioned JS bundle for one platform — built by openota build, checksummed, and uploaded. Immutable once uploaded.

Environment (Channel)

Production / Staging / Development. Each has its own independent "active" release per platform — releasing to Staging never touches Production.

Runtime version

A compatibility fence, not a feature version. A device only accepts an OTA release whose runtimeVersion exactly matches its own native binary.

Rollback

Moves an environment's active pointer back to a previously-uploaded release. Instant, on-device, no re-download needed — nothing is ever deleted.

What you need

If you're using OpenOTA Cloud, all of this is already running — skip straight to Quickstart. If you're self-hosting, here's exactly what's required vs. optional.

A server

Required

Self-hosted (docker compose up) or OpenOTA Cloud — identical API either way, your app never knows the difference.

Postgres

Recommended

Unset falls back to an embedded file-based DB (PGlite) — fine for one instance/testing, not for anything you'd call production.

Storage backend

Required

local disk (needs a persistent volume) or supabase (Supabase Storage). This is what actually holds your JS bundle bytes — kept deliberately separate from Postgres.

Email sending

Optional

Unset means verification/reset links are logged to the server console instead of emailed — fully functional without it.

No Redis, no message queue, no separate auth provider — deliberately minimal.

Quickstart

npm install -D @openota/cli

npx openota init --server-url https://YOUR-SERVER/api/v1 --runtime-version 1.0.0
npx openota doctor
npx openota release --version 1.0.1 --platform android

openota init detects your package manager (npm, yarn, or pnpm) and automatically installs @openota/sdk, @openota/native-android, and every native peer dependency the SDK needs — nothing to install by hand. Pass --skip-install to opt out. If a dependency ever goes missing later, npx openota doctor --fix repairs it the same way.

Full native wiring (Android/iOS) and SDK integration walkthrough lives in docs/GETTING_STARTED.md.

Self-hosted vs. Cloud

Self-hosted

You run the server. One optional shared secret (OPENOTA_API_KEY), one flat storage namespace, CLI only — no dashboard concept needed.

Cloud (multi-tenant)

Per-user accounts, per-project API keys, isolated storage per project, full dashboard — projects, releases, rollback, devices, analytics.

Both modes run from the exact same server binary and coexist — turning on Cloud never breaks the self-hosted flat routes. Full reference: docs/CLOUD.md.

CLI reference

CommandWhat it does
openota initWrites openota.config.json — server URL, runtime version, platforms.
openota login --api-key <key>Stores a Cloud API key in ~/.openota/credentials.json (0600), auto-resolves projectId.
openota doctorChecks Node version, RN detection, native dirs, Metro, config, auth, server reachability, project access.
openota buildBundles JS via Metro and computes the manifest (version, runtimeVersion, SHA-256).
openota uploadUploads an already-built package without changing the active version.
openota release --version <v> --platform <p>build + upload + activate in one step — the command you run in CI.
openota rollback --platform <p> --version <v>Points the active-version pointer at an already-uploaded release.
openota logoutRemoves the stored API key for the configured server URL.

SDK config (OTA.configure)

OptionTypeRequiredNotes
serverUrlstring
required
Your OpenOTA server's API base, including /api/v1.
channelstringoptionalDefaults to "production". Selects which environment's active release this device receives — each environment tracks its own independently.
autoRestartbooleanoptionalReload the JS bundle automatically after install/rollback. Default true.
requestTimeoutnumberoptionalMilliseconds before a check/download request aborts. Default 15000.
headersRecord<string,string>optionalExtra headers sent on every request.
projectIdstringoptionalOpenOTA Cloud only — targets the project-scoped routes and enables device tracking + install-result reporting. Omit for self-hosted.

Setting projectId also makes the SDK send an anonymous, auto-generated device ID on every check/download, and report install/rollback outcomes automatically — that's what populates the dashboard's Devices and Analytics pages. Nothing extra to wire up.

Native dependencies

@openota/sdk is a thin JS layer over a few native modules. openota init installs these automatically (see Quickstart above) — but every one of them still needs a real native rebuild before the SDK will work, not just a JS reload.

PackageWhat it's for
react-native-mmkvFast on-device key/value cache for the current version, bundle path, manifest, and anonymous device ID.
react-native-nitro-modulesRequired by the Nitro-based versions of mmkv and quick-crypto. A peer dependency of both, but never auto-installed — see below.
react-native-fsFilesystem access for downloading and staging the update bundle.
react-native-zip-archiveExtracts the downloaded .zip bundle before it's verified and installed.
react-native-quick-cryptoComputes the SHA-256 checksum used to verify every bundle before it runs.
react-native-quick-base64A peer dependency of quick-crypto itself — easy to miss since npm hoists it without you ever installing it directly.
# Already installed for you by `openota init` — only needed if you're adding these manually
npm install react-native-mmkv react-native-nitro-modules \
  react-native-fs react-native-zip-archive \
  react-native-quick-crypto react-native-quick-base64

# iOS — required after installing or upgrading any of the above
cd ios && bundle exec pod install && cd ..

# Android — autolinking picks these up automatically, but only on a full rebuild
cd android && ./gradlew clean && cd ..

npx react-native run-ios   # or run-android

Forgetting the native rebuild is the single most common integration mistake — it surfaces as a vague JS error like Cannot read property 'otaStorage' of undefined the moment OTA.configure() or OTA.sync() runs, since the native module the SDK depends on was never linked into the app binary. npx openota doctor checks for this.

Remote config (optional)

Separate from OTA bundles entirely — a per-(project, platform) JSON value your app can fetch at runtime and react to however you like. Editable from the dashboard's Apps page, no new release required to change it.

GET  /projects/:projectId/apps/:platform/config   # public, no auth — same as check/download
PUT  /projects/:projectId/apps/:platform          # session-authed, dashboard/API only
     body: { "remoteConfig": { "anyKey": "anyValue" } }

OpenOTA never reads or acts on the value itself — what it means is entirely up to your app. Two things worth knowing: the endpoint sends Cache-Control: no-store, and this is a plain fetch() that does not inherit OTA.configure()'s requestTimeout — add your own AbortController timeout, or a slow/cold server can hang the request far longer than expected.

Real-time updates (optional)

By default a device only learns about a release the next time your app calls OTA.sync() — OpenOTA never polls on its own. To have the server nudge an already-open app instantly instead of waiting for the next launch or resume, open a live connection once:

OTA.connectLive(); // e.g. right after OTA.configure()
// ...
OTA.disconnectLive(); // e.g. on unmount

// or react to it yourself instead of the default silent OTA.sync():
OTA.connectLive(() => {
  console.log('a release just went out — re-checking now');
  OTA.sync();
});

Pure JS — React Native's built-in WebSocket, no native setup, no extra dependency. The server pushes a content-free "check now" nudge whenever a release, rollback, or rollout-percentage change happens on that device's channel; the real check endpoint (with its staged-rollout gate) stays the single source of truth for what a device is actually eligible for — nothing is pushed except the nudge itself. Reconnects automatically with backoff. Only reaches the app while it's open or backgrounded-but-alive — a fully closed app isn't woken up (that would need push notifications, which OpenOTA doesn't do yet).

Dashboard features (Cloud)

Auth

Email/password or Google sign-in, email verification, forgot/reset password. Google links onto an existing account by matching email rather than duplicating it.

Projects

Create, rename, delete. Each isolates its own releases, keys, and devices.

API keys

Full key shown once on creation, stored server-side only as a hash.

Environments

Production/Staging/Development per project, each with its own release, visual rollback, and deployment history. Rolling back only changes what an environment offers — a device already ahead of the rollback target won't downgrade (the check endpoint never silently downgrades a device); it applies to devices still behind it, same as any other update.

Apps

Package name, bundle identifier, runtime version, min supported version, and remote config — per (project, platform).

Devices

Real per-device last-seen registry, populated by the SDK automatically.

Analytics

Downloads, success rate, failures, rollbacks — all real, none fabricated.

Environment variables

VariableRequiredNotes
DATABASE_URLnoUnset = embedded PGlite. postgres://... for managed Postgres (Supabase in Cloud).
SESSION_SECRETyes in prodSigns dashboard session cookies.
CORS_ALLOWED_ORIGINSyes for cross-site dashboardComma-separated allowlist for the dashboard's credentialed requests.
STORAGE_PROVIDERno"local" or "supabase". Default local.
RESEND_API_KEYnoSends verification/reset emails. Unset = link is logged to the server console instead.
DASHBOARD_URLnoBase URL used to build verification/reset and Google sign-in redirect links.
OPENOTA_API_KEYnoLegacy single-tenant shared secret for self-hosted flat routes.
GOOGLE_CLIENT_ID / _SECRET / _REDIRECT_URIno, all three or noneEnables "Continue with Google" on the dashboard login page.

Full list including storage provider options: docs/CLOUD.md §5.

Reference app

OpenOTA_Example in the monorepo is a real, working OTA client — not a mock. Every screen talks to the live OpenOTA Cloud API through the real, published @openota/sdk and @openota/native-android packages: check for update, download, verify, install, and rollback, all real. It deliberately does not reimplement release/channel management — that stays on this dashboard, exactly the separation of concerns your own app should follow.

What to read

  • src/context/OtaContext.tsx — the entire integration surface: one OTA.configure() call, check/sync/rollback wired to React state.
  • android/app/.../MainApplication.kt — the required OpenOTAReactHost.create() native wiring, with the runtime-version constant.
  • openota.config.json — a real serverUrl/projectId/runtimeVersion pointed at a live project, not a placeholder.

Try it yourself

cd OpenOTA_Example
npm install
npm run android
npx @openota/cli release --version 1.0.1

Reopen the app and pull-to-refresh — the update comes from the real server response, not a fixture.

The whole integration, verbatim

This is the actual OTA.configure() call from src/context/OtaContext.tsx — copied straight out of the reference app, not rewritten for docs.

import { OTA } from "@openota/sdk";
import config from "../../openota.config.json";

// One-time, module-level configure() — real values from openota.config.json, pointed at the live
// OpenOTA Cloud server. Everything this app shows/does from here on is driven by what that server
// returns; release/channel management itself happens on the OpenOTA Dashboard, not in this app.
OTA.configure({
  serverUrl: config.serverUrl,
  projectId: config.projectId,
  channel: "production",
  autoRestart: false, // let the UI show a clear "Update ready — restart" state first
});

// Live updates: the server nudges this connection the instant a new release/rollback/rollout
// change happens on this device's channel, instead of waiting for a manual check.
OTA.connectLive(() => setUpdateAvailable(true));

Troubleshooting

Real errors this project has actually surfaced, not a generic FAQ — each one either explains intentional behavior or names a bug that's already fixed in the current release.

"Bundle checksum mismatch" or "Bundle entry not found in the uploaded zip" on release

The uploaded zip's JS bundle doesn't match the claimed sha256/path — the server independently re-verifies both rather than trusting the CLI's claim. If you're calling the upload API directly instead of using the CLI, the bundle file must be at bundle/<bundleName> inside the zip, matching what the SDK's extractor expects on-device.

"Manifest not found" when the app tries to activate an update

The native runtime independently re-verifies the downloaded package — it re-parses manifest.json from the extracted files on disk, not the JS object the check() call returned. A package missing manifest.json (a hand-built or corrupted zip) fails here even if the server accepted the upload, by design: the JS layer is never treated as a trust boundary.

"Bundle runtimeVersion does not match app runtimeVersion"

The release's runtimeVersion doesn't match the value passed to OpenOTAReactHost.create() in MainApplication.kt. This is intentional — an OTA bundle built against a different native binary generation should never activate. Fix the mismatch, don't work around it: keep openota.config.json's runtimeVersion and the native constant in sync deliberately.

A device stays on "Embedded" after reinstalling the app or clearing app data

Expected, not a bug. The OTA-installed bundle lives in the app's private storage (Android: /data/data/<package>/files/OpenOTA/...) — both a full reinstall and Settings → Clear Data wipe that storage the same way Android wipes any app's private data. Check for update once more and it re-downloads cleanly.

"Rollback failed" / "No rollback bundle is available"

Correct behavior on a device that has only ever had one OTA generation installed (or none) — there's genuinely nothing to roll back to yet. The native runtime only ever keeps one previous generation; install a second real update first if you want to test rollback.

"An unexpected error occurred" (500) on upload instead of a specific error

If this happens on a self-hosted server with a genuinely malformed (non-zip) upload, you're hitting a real fixed bug from an earlier OpenOTA version — update to the latest server. A correctly-updated server always translates a corrupted upload into a clean 400 UPLOAD_FAILED, never a bare 500.

npx openota: 404 Not Found from the npm registry

There is no package literally named "openota" on npm. The published package is @openota/cli — run npx @openota/cli <command>, or install it as a dev dependency and use your package manager's bin resolution (npm run / pnpm exec).

Self-hosting & cloud setup

Two ways to run OpenOTA: self-hosted with Docker (your own infra, zero external accounts required), or Cloud mode across Render + Vercel (what powers api.openota.xyz and this dashboard today). Both run the exact same server code — this is a deployment choice, not a different product.

Self-hosted (Docker)

services:
  server:
    build:
      context: .
      dockerfile: apps/server/Dockerfile
    ports:
      - "${PORT:-3900}:${PORT:-3900}"
    env_file: [.env]
    volumes:
      - openota_storage:/data/storage
      - openota_pgdata:/repo/apps/server/data/pgdata

volumes:
  openota_storage:
  openota_pgdata:

docker compose up -d — no .env file needed to start: local storage and an embedded PGlite database are the defaults, so this works with zero external accounts.

Cloud (Render + Vercel)

  • Server → Render. Set DATABASE_URL to a real Postgres connection string (Supabase works) so accounts/projects survive restarts, and CORS_ALLOWED_ORIGINS to your dashboard's origin.
  • Dashboard → Vercel. Set NEXT_PUBLIC_OPENOTA_SERVER_URL to your Render URL, including /api/v1.
  • Never deploy the server to Vercel — serverless has no persistent filesystem for local package storage, even with a managed database.
  • Optional: RESEND_API_KEY or SMTP_HOST/SMTP_USER/SMTP_PASS for real verification/reset emails (unset = link logged to the server console, fully functional with zero email infra); ADMIN_EMAILS to grant admin access to specific accounts.

Full environment variable reference, architecture, and API docs: docs/CLOUD.md in the repo.