Quickstart

From zero to your first OTA update in about ten minutes. No Expo account, no EAS, no cloud lock-in — just Otapush and your app.

What you need before you start

  • An Otapush account with an app created in the portal. A new app comes with three channels (dev, staging, prod) and its own code-signing keypair — both are created for you.
  • A project to wire up: an Expo project (managed or prebuild), or a bare React Native project with expo and expo-updates installed — see Bare React Native for that path.
  • Node 18+ with npm for the CLI (Bun works too).
  • A release build on a real device or emulator. OTA updates apply only to release builds — development clients load JavaScript from Metro and never talk to the update server.

That is the whole checklist. You do not need an Expo/EAS account, a store submission, or any server-side SDK.

1. Create an account and an app

Open the web portal, register, and click New app. Pick a name and a slug — the slug becomes part of your updates URL:

https://your-server.example.com/api/updates/<slug>/manifest

The app is created with the three channels and a unique code-signing keypair (the private key stays on the server; the public certificate you will fetch in the next step). Keep the init dialog open — it shows everything you need for the steps below.

2. Install the CLI and log in

npm install -g otapush
# or: bun add -g otapush

otapush login --server https://your-server.example.com
otapush whoami

login is interactive — it asks for the portal email and password and stores a token in ~/.otapushrc.json. Publishing is authorized by a separate, app-scoped API key, not by this login; that split is covered in the CLI reference.

3. Link the project with otapush init

From the project root:

otapush init

init asks which app to bind (it lists the apps on your account and can create a new one), then writes three things:

  • certs/certificate.pem — the app's public code-signing certificate, fetched from the server. Commit it; the client verifies manifest signatures against it.
  • app.json — the expo.updates block pointing at your server, plus runtimeVersion only if app.json does not already have one (an existing value is never overwritten).
  • otapush.config.json — the project↔app binding: server URL, app id, slug, runtime version, project type.

What init does not do: install npm packages, set checkAutomatically or fallbackToCacheTimeout (the expo-updates defaults apply), or touch native files in a managed project.

The resulting app.json section looks like this:

{
  "expo": {
    "updates": {
      "url": "http://localhost:3000/api/updates/my-app/manifest",
      "enabled": true,
      "codeSigningCertificate": "./certs/certificate.pem",
      "codeSigningMetadata": { "keyid": "main", "alg": "rsa-v1_5-sha256" }
    },
    "runtimeVersion": "1.0.0"
  }
}

Bare React Native project (no app.json)? init detects that and patches AndroidManifest.xml and Expo.plist instead — follow Bare React Native and come back at step 4.

A device id is required. The server rejects update requests without a stable deviceId (400 deviceId is required). Register one at startup with Updates.setExtraParamAsync("deviceid", id) — key lowercase; the snippet is in Expo setup, and the working demo is examples/expo-demo/App.tsx.

The URL, the certificate and the runtime version are compiled into the binary at build time. That is why step 4 is a real build — and why, after it, JavaScript changes ship without one.

4. Build a release binary once

OTA applies only to release builds, so build one without EAS:

npx expo prebuild --clean
cd android && ./gradlew assembleRelease   # Android
# iOS: build the Release configuration from Xcode or xcodebuild

Install it on a device or emulator and launch it once — an embedded bundle with no server updates yet. Full details, including iOS signing, are in Expo setup. This is the last build a JavaScript-only change ever needs.

5. Publish your first update

Make a visible JavaScript change, then:

otapush publish --channel prod --platform android --message "First OTA update"

Why prod: otapush init writes the plain manifest URL without a channel parameter, and the server serves the prod channel when none is requested — prod is the channel your build actually checks. (dev and staging are for builds whose URL carries ?channel=<name>; see the channels question below.)

The CLI exports the JS bundle and assets, uploads them, signs the manifest, and points the channel at the new update. Restart the app twice: the first launch downloads the update, the second runs it. The Overview tab should now show a check, a download and an install event.

6. Promote instead of republishing

Once you use a testing channel, move to production the exact artifact you tested — not a rebuild:

otapush publish --channel staging --platform ios --message "Ready for prod"
otapush promote --from staging --to prod

promote copies the channel's current update into the target channel for that update's platform. Channels hold one update per platform, so repeat for the other platform, or use the portal's Updates tab, which does it per platform with a button.

7. Roll back when things go wrong

otapush rollback --channel prod

The channel steps back to the previous update for that platform, and devices recover on their next check — no store release involved. Rolling back the channel's first update is also handled: devices are told to revert to the bundle compiled into the binary (rollBackToEmbedded — see Protocol). The portal has one-click rollback per platform.

Questions and answers

What is a runtime version, and when do I change it?

runtimeVersion is the compatibility gate between a binary and a JavaScript update: the server serves an update only to clients whose runtime version matches exactly, and never otherwise. Change it whenever the native side changes — a new native dependency, an Expo SDK upgrade, any native code edit — and rebuild the app; see Store policies for why this boundary exists.

One trap to avoid: otapush init does not overwrite a runtimeVersion already present in app.json, while otapush publish takes the value from otapush.config.json (ultimately from the server's app record). If you hand-edit the version in app.json, pass --runtime-version on publish (or update the config) — otherwise updates go out under a version no binary requests.

Why do I have to rebuild the app after running init?

Because init changes native configuration, and native configuration ships only with a binary: the updates URL, the signing certificate and the runtime version are read by the native expo-updates module, not by your JavaScript. What ships over the air afterwards is JavaScript and assets only — that is the whole deal of the protocol.

The device doesn't see my update — what now?

Work down the list; each item is a real failure mode:

  1. Runtime version match. The update's runtimeVersion must equal the binary's exactly — no ranges, no nearest match.
  2. The channel. A build with the plain manifest URL checks prod; publishing to dev or staging is invisible to it. Point the URL at ?channel=<name> for non-prod builds.
  3. A release build. Dev clients and debug builds load from Metro and never check the server.
  4. Two launches. The first downloads, the second applies — with fallbackToCacheTimeout: 0 there is no waiting on the first one.
  5. The Overview tab. No check events at all: the device never reached the server (URL, network, ATS blocking plain HTTP on iOS). Checks but no install: updates download but fail to launch — signature problems are the usual cause.
  6. The device id. A missing or non-lowercase deviceid extra param ends in 400 deviceId is required; see Expo setup.
  7. A stale certificate. If you re-ran init and got a fresh certificate after the binary was built, rebuild with npx expo prebuild --clean — the certificate is baked in.

How is a channel different from a branch?

There are no branches in Otapush — channels only, and they are simpler than EAS branches. A channel is a named pointer that holds one current update per platform (dev, staging, prod exist by default; more can be created in the portal). Publishing moves the pointer, promote copies it to another channel, rollback steps it back. Every update ever published stays listed and can be promoted again later — but there is no history graph, no partial rollouts by percentage, and a device selects its channel only through the URL it was built with.

Can I follow this quickstart with a bare React Native app?

Yes. otapush init detects a bare project (no app.json, react-native in dependencies) and patches AndroidManifest.xml and Expo.plist instead of app.json; publishing runs npx react-native bundle instead of expo export. Everything else — channels, deviceId, publish/promote/rollback — is identical; Bare React Native has the details.

Where next

  • CLI reference — every command, flag and exit code, plus the CI setup.
  • Protocol — how the server talks to expo-updates clients, request by request.
  • Store policies — what Apple and Google allow over the air, and the pre-push checklist.