Expo setup

How to wire an Expo project (managed or prebuild) to your Otapush server and build release binaries locally, without EAS. For projects without app.json, read Bare React Native instead — the native configuration lives in different files there.

What does otapush init actually change in my project?

Three files, nothing else:

  • app.json — the expo.updates block gains url, enabled, codeSigningCertificate and codeSigningMetadata; an existing updates block is merged rather than replaced. expo.runtimeVersion is written only if the key is absent — a value you set by hand is never overwritten. No other fields (ios, android, plugins, splash) are touched.
  • certs/certificate.pem — the app's public self-signed X.509 certificate, fetched from the server on every run. It is public material; commit it.
  • otapush.config.json — the binding between this directory and the app: server URL, app id, slug, runtime version, project type.

What init deliberately does not do: install any npm package, write checkAutomatically or fallbackToCacheTimeout (the expo-updates defaults apply), or modify native files — in a managed project those are generated by expo prebuild from app.json.

If your project uses app.config.js / app.config.ts instead of app.json, automatic patching is not supported: init prints the exact JSON to add to the expo object and continues with the certificate and config files.

How should app.json look when configured by hand?

{
  "expo": {
    "updates": {
      "url": "https://ota.example.com/api/updates/my-app/manifest",
      "enabled": true,
      "checkAutomatically": "ON_LOAD",
      "fallbackToCacheTimeout": 0,
      "codeSigningCertificate": "./certs/certificate.pem",
      "codeSigningMetadata": { "keyid": "main", "alg": "rsa-v1_5-sha256" }
    },
    "runtimeVersion": "1.0.0",
    "ios": { "bundleIdentifier": "com.example.myapp" },
    "android": { "package": "com.example.myapp" }
  }
}

Field by field:

  • updates.url — the Otapush manifest endpoint. The stock expo-updates client speaks the protocol natively; there is no Otapush SDK. Without a ?channel= parameter the server serves the prod channel.
  • runtimeVersion — the compatibility gate. The server serves an update only when the version matches exactly. Bump it on every native change (new native dependency, SDK upgrade). A policy object like { "policy": "appVersion" } also works: the client resolves it to a plain string before sending, and the server matches that string.
  • checkAutomatically: "ON_LOAD" — the client checks on every launch; fallbackToCacheTimeout: 0 launches the cached bundle immediately and fetches in the background. Both are expo-updates defaults and both are recommended.
  • codeSigningCertificate / codeSigningMetadata — make the client verify manifest signatures before applying an update. keyid must match the keyid the server signs with, which is main.

How does the app identify itself to the server?

Every update request needs a stable device id — it drives the analytics and MAU metering, and without it the server answers 400 deviceId is required (details in the protocol). expo-updates SDK 52 has no API to change the update URL or add headers at runtime, but it can attach extra params that the native client sends on every manifest and asset request:

npx expo install expo-application
import * as Application from "expo-application";
import * as Updates from "expo-updates";
import { Platform } from "react-native";

async function registerDeviceId() {
  const deviceId =
    Platform.OS === "android"
      ? Application.getAndroidId()                    // SSAID: stable across launches
      : await Application.getIosIdForVendorAsync();   // IDFV: stable across launches
  if (deviceId && Updates.isEnabled) {
    await Updates.setExtraParamAsync("deviceid", deviceId);
  }
}

Both identifiers are stable across launches — the hard requirement, because a random id per launch inflates your MAU. The param is persisted natively, so setting it once is enough (setting it on every launch is fine too).

The key must be lowercase (deviceid). Extra params travel as an RFC 8941 structured-field dictionary, and its keys reject uppercase letters. On iOS the serializer throws while building the request and the whole header is silently dropped — from the app everything looks fine, from the server the request has no id at all. Otapush reads the id case-insensitively, but the client can only send lowercase.

The full working example is examples/expo-demo/App.tsx in the Otapush repo; it also uses checkAutomatically: "ON_ERROR_RECOVERY" plus manual Updates.checkForUpdateAsync() calls, which avoids the one harmless edge case below.

How do I build a release binary without EAS?

npx expo prebuild --clean

generates android/ and ios/ with the updates configuration embedded (manifest meta-data / Expo.plist). Re-run it whenever the updates block changes. Then:

Android

cd android
./gradlew assembleRelease

Install with ./gradlew installRelease. Sign the APK/AAB with your own keystore as usual (signingConfigs in android/app/build.gradle) — nothing about Play Store signing changes.

iOS

xcodebuild -workspace ios/MyApp.xcworkspace \
  -scheme MyApp \
  -configuration Release \
  -destination 'generic/platform=iOS' \
  -archivePath build/MyApp.xcarchive \
  archive

Or open ios/MyApp.xcworkspace in Xcode, pick the Release configuration and archive. You need your own Apple Developer signing certificate and provisioning profile — the same as any non-EAS build.

Simulators do not exercise the OTA download path the way devices do; verify update flow on a real device, or on an Android emulator release build.

How do I verify the whole flow?

  1. Install the release build and launch it once — it runs the embedded bundle.
  2. Change some JavaScript, then otapush publish --channel prod --platform ios.
  3. Launch the app, kill it, launch again: first launch downloads in the background, second runs the new bundle.
  4. Check the portal Overview tab: one check, one download, one install event appeared. If checks rise but installs do not, updates download but fail to launch — see below.

What went wrong?

The messages below are the ones otapush init and the server actually produce, with the fix for each:

  • "Not logged in. Run otapush login --server <url> first."init binds a project to your account, so it needs a portal session before it can list apps.
  • "Warning: no app.json / app.config.js found in the current directory — is this an Expo project root?" — you ran init outside the project. cd to the directory that contains app.json and run it again.
  • "app.config.js detected — automatic patching is only supported for app.json." — not an error: the CLI printed the JSON block to paste into the expo object of your config. The certificate and otapush.config.json were still written.
  • "App "…" has no code-signing certificate on the server. Re-create the app." — the app record predates signing support. Create a new app in the portal and bind to that one.
  • Signature errors on device after re-running init — the binary carries a stale certificate. init re-fetched a fresh one; bake it in with npx expo prebuild --clean and rebuild. The certificate is compiled into the binary, not downloaded at runtime.
  • 400 deviceId is required on the very first launch after install — with checkAutomatically: "ON_LOAD" the native client can check before your JavaScript registers the device id. Harmless (the next check succeeds); avoid it entirely with ON_ERROR_RECOVERY plus manual checks, as the demo does.
  • No update arrives — walk the checklist in the Quickstart: runtime version match, the channel (prod unless the URL says otherwise), a release build, two launches.
  • Download fails on a real iOS device but works in the simulator — release builds enforce App Transport Security: the updates URL must be HTTPS. Android blocks cleartext HTTP by default since API 28 for the same reason.

Questions and answers

Do I need an Expo account or EAS to use Otapush?

No. expo-updates ships with the Expo SDK and works against any conformant server; building is done with expo prebuild plus Gradle/Xcode on your machine. EAS Build, EAS Submit and EAS Update are all bypassed entirely.

Which checkAutomatically should I choose?

ON_LOAD (the default) checks on every launch and is what most apps want. Choose ON_ERROR_RECOVERY with manual Updates.checkForUpdateAsync() calls only if you want the first launch after install to never fire a check before your JS registers the device id — the trade the demo app makes.

Does the { "policy": "appVersion" } runtime version work?

Yes — the policy is resolved by the client into a plain version string (the app's version), and that string is what travels in every request and what the server matches exactly. What the server never does is range or "closest match" matching; see Protocol.

How do I rotate the code-signing key?

Not in place. Clients pin the certificate at build time, so a new key means a new binary: create a new app (a fresh keypair is generated with it), re-run otapush init, rebuild and ship through the store. There is no rotation endpoint on the server.