Expo Updates Protocol v1

Otapush speaks the Expo Updates Protocol v1 — the wire protocol the stock expo-updates client library already implements. There is no Otapush SDK, no patched fork, no custom native module: you point updates.url at an Otapush server and the client that shipped with your Expo SDK does the rest.

This page documents the protocol as Otapush actually implements it, including the parts that are underspecified upstream and the workarounds that took real devices to discover. Everything below was checked against the server source and against expo-updates 0.26.19; where Otapush deviates from the specification or leaves something unimplemented, it says so.

The normative reference is Expo Updates v1. It is short, and it deliberately leaves several things open.

What a conformant server must return

A conformant server exposes exactly one endpoint the client polls, plus however many URLs it wants for asset downloads. Otapush uses two:

GET /api/updates/:slug/manifest
GET /api/updates/:slug/assets?asset=<key>&runtimeVersion=<version>

:slug is your app slug from the portal — the same value otapush init writes into app.json. Both are public: no portal session, no API key. The API key protects publishing, not serving.

Given a manifest request, the server must answer with exactly one of three things:

  1. A manifest — there is an update the client should download and launch.
  2. A directive — there is no update to serve, but the client still needs an instruction (noUpdateAvailable, or rollBackToEmbedded).
  3. An error — the request was malformed, or the app/channel does not exist.

Everything else — how updates are selected, what a channel is, how rollbacks work — is outside the protocol. The protocol only fixes the shape of the answer.

How the expo-protocol-version header selects the response shape

The client sends expo-protocol-version with its request. Otapush accepts 0 and 1; anything else is a 400:

{ "error": "Unsupported protocol version. Expected either 0 or 1." }

A missing header is treated as 0. That matters, because version 0 and version 1 disagree about how to say "nothing to do":

Situation Protocol 0 Protocol 1
Update available 200, multipart body with a manifest part 200, multipart body with a manifest part
Client already on the newest update 404 with a JSON error body 200, multipart body with a noUpdateAvailable directive
No update for this platform/runtime 404 with a JSON error body 200, multipart body with a noUpdateAvailable directive
Channel rolled back past its first update 200 + rollBackToEmbedded 200 + rollBackToEmbedded

This is the single most confusing part of the protocol when you write a server yourself. Under version 0, "no update" is an HTTP error; under version 1 it is a successful response whose body carries the bad news. A server that answers 404 to a version-1 client leaves the client with an unexplained network failure instead of a clean "you are up to date", and the client's retry/backoff behaviour changes accordingly.

The response always echoes the negotiated version back:

expo-protocol-version: 1
expo-sfv-version: 0
cache-control: private, max-age=0
content-type: multipart/mixed; boundary=----ota-boundary-<uuid>

One deviation worth knowing: Otapush always answers with multipart/mixed, including for protocol 0. The specification allows a bare application/expo+json manifest body, and older third-party servers use it; Otapush does not. The expo-updates client parses multipart responses regardless of the protocol version it asked for, so this has not caused a problem in practice — but if you are testing against a hand-rolled client, expect multipart.

What the client sends on every manifest request

Header Example Meaning
expo-protocol-version 1 Protocol version. Missing means 0
expo-platform ios / android Client platform. Required
expo-runtime-version 1.0.0 The binary's runtime version. Required, matched exactly
expo-current-update-id UUID The update the client is running right now
expo-embedded-update-id UUID The update baked into the binary
expo-expect-signature SFV dictionary The client wants a signed response
expo-extra-params SFV dictionary Client-set key/value pairs (see below)
expo-recent-failed-update-ids SFV list Updates that crashed on launch recently

Otapush also accepts platform and runtime-version as query parameters, which is what makes the endpoint testable with plain curl:

curl -i "https://your-server.example.com/api/updates/demo/manifest?platform=ios&runtime-version=1.0.0&deviceId=curl-test" \
  -H "expo-protocol-version: 1"

expo-embedded-update-id and expo-recent-failed-update-ids are accepted and ignored — Otapush does not currently read either. The consequence of ignoring the second one is spelled out under “What Otapush records, and what it does not” below.

A real manifest response

This is a genuine response body, captured from the server with an update published to the prod channel for iOS at runtime version 1.0.0 (the signature is truncated here for width, nothing else is):

HTTP/1.1 200 OK
expo-protocol-version: 1
expo-sfv-version: 0
cache-control: private, max-age=0
content-type: multipart/mixed; boundary=----ota-boundary-dba97f22-13bb-4b23-926d-2df77d77b7bc

------ota-boundary-dba97f22-13bb-4b23-926d-2df77d77b7bc
content-disposition: form-data; name="manifest"
content-type: application/json; charset=utf-8
expo-signature: sig="AwQcjhmgfRrl4HPWlaAXC4ff0grA8mSo…VntcjKA9OaW4CWcThoz7ig==", keyid="main", alg="rsa-v1_5-sha256"

{"id":"ea9bf56f-2bd4-4fad-8d41-f3b817cbe0cb","createdAt":"2026-08-28T11:14:51.545Z","runtimeVersion":"1.0.0","launchAsset":{"hash":"CzoEzLUgFRIP1Haw5sbm7NCF9YZuIn7A89Qwb0uBv1E","key":"0b3a04ccb52015120fd476b0e6c6e6ecd085f5866e227ec0f3d4306f4b81bf51","fileExtension":".js","contentType":"application/javascript","url":"https://ota.example.com/api/updates/demo/assets?asset=bundles%2F5bc457ff%2Fea9bf56f%2Fbundle.js&runtimeVersion=1.0.0&deviceId=dev-123"},"assets":[{"hash":"VB_sVHYq7M9qOe-SnVWLf4iU9DMpEOt9QEjrvDSnhH8","key":"541fec54762aeccf6a39ef929d558b7f8894f4332910eb7d4048ebbc34a7847f","fileExtension":".png","contentType":"image/png","url":"https://ota.example.com/api/updates/demo/assets?asset=assets%2F541fec…847f.png&runtimeVersion=1.0.0&deviceId=dev-123"}],"metadata":{},"extra":{"message":"fix crash on checkout","gitCommit":"9f2c1ab"}}
------ota-boundary-dba97f22-13bb-4b23-926d-2df77d77b7bc
content-disposition: form-data; name="extensions"
content-type: application/json

{"assetRequestHeaders":{}}
------ota-boundary-dba97f22-13bb-4b23-926d-2df77d77b7bc--

Field by field:

  • id — the update's UUID. This is what comes back as expo-current-update-id on the next request.
  • createdAt — publish time, ISO 8601. The client uses it to order updates.
  • runtimeVersion — always equal to the requested one; Otapush never serves a mismatched update.
  • launchAsset — the JavaScript bundle. hash is the bundle's SHA-256 in base64url without padding; key is the same digest in hex. Both are required and they are not the same encoding — a server that puts hex in hash fails integrity verification on the device.
  • assets — images, fonts, anything the bundle references, with the same hash/key encoding rule.
  • metadata — Otapush always sends {}. The field exists so a server can tag updates for client-side filtering through the expo-manifest-filters header; Otapush does not send that header, so there is nothing to filter on.
  • extra — free-form. Otapush puts the publish message and gitCommit here when they were supplied, so you can read them from Updates.manifest inside the app.

The extensions part carries assetRequestHeaders, which lets a server demand extra headers on asset downloads (a signed-URL scheme, for example). Otapush sends an empty object: asset URLs are self-contained.

Why expo-signature must be a string item, not a byte sequence

When the client sends expo-expect-signature, the server must sign its response and return the signature in an expo-signature header. The signature covers the exact bytes of that part's body — the manifest JSON string as serialized, or the directive JSON string. Re-serializing the JSON before verifying breaks the signature; treat the body as opaque bytes.

The header is an RFC 8941 structured-field dictionary. RFC 8941 offers two ways to carry base64 data: a string (sig="AwQc…") and a byte sequence (sig=:AwQc…:). A byte sequence is the semantically correct choice, and it is what a careful reading of the RFC suggests.

It does not work. expo-updates parses the header and then requires the sig member to be a string item specifically:

val signature = if (sigFieldValue is StringItem) {
  sigFieldValue.get()
} else {
  throw Exception("Structured field sig not found in expo-signature header")
}

A byte sequence parses into a different item type, falls into the else branch, and the client reports the signature as missing — not malformed. The update is rejected and the error message points at the wrong problem. The upstream specification does not say which item type to use, so this is only discoverable by reading the client.

Otapush therefore emits, on both the manifest and directive parts:

expo-signature: sig="<base64 of the RSA-SHA256 signature>", keyid="main", alg="rsa-v1_5-sha256"

rsa-v1_5-sha256 is the only algorithm expo-updates accepts. keyid defaults to main on both sides and must match the codeSigningMetadata.keyid in your app.json.

How the client verifies it

  • Keys are per app, generated server-side when the app is created: an RSA-2048 keypair plus a real self-signed X.509 certificate, minted with @expo/code-signing-certificates — the same library the Expo CLI uses.
  • The private key never leaves the server. The certificate PEM is public: the portal shows it, and otapush init writes it to ./certs/certificate.pem and references it from app.json. Committing that file is fine.
  • The client verifies the signature against the certificate compiled into the binary, so rotating a key means shipping a new binary. Plan a rotation as a store release, not as an OTA.
  • Signing is opt-in per request. If the client does not send expo-expect-signature, Otapush omits expo-signature and the response is served unsigned — useful for curl, and the reason a captured response may show no signature at all.

Why the deviceid key in expo-extra-params must be lowercase

Otapush meters usage in MAU — monthly active users, counted as unique devices per calendar month — so every protocol request needs a stable per-device identifier. The server looks for it in three places, in this order:

  1. the deviceId query parameter (wins when several are present),
  2. the x-device-id header,
  3. a deviceid entry in the expo-extra-params structured-field dictionary.

The third is the only one the native client can produce on its own, via Updates.setExtraParamAsync. And the key must be lowercase:

// Correct.
await Updates.setExtraParamAsync("deviceid", id);

// Broken: the header never reaches the server.
await Updates.setExtraParamAsync("deviceId", id);

RFC 8941 restricts dictionary keys to lowercase letters, digits, _, -, . and *, with the first character a lowercase letter or *. The expo-updates serializer enforces exactly that:

let failureCondition1 = i == 0 && (c != Character("*") && !c.isLcAlpha)
let failureCondition2 = !(c.isLcAlpha || c.isDigit || c == "_" || c == "-" || c == "." || c == "*")
if failureCondition1 || failureCondition2 {
  throw SerializerError.invalidCharacterInKey(key: key, character: c)
}

The failure mode is nastier than a crash. On iOS the serializer throws while building the request, the error is caught and logged, and the entire Expo-Extra-Params header is dropped — the request still goes out, just without your device id. The server sees an unidentified request, answers 400 deviceId is required, and from the app's side it looks like the update server is broken.

If the id is missing entirely, Otapush replies:

{
  "error": "deviceId is required",
  "hint": "Pass a stable device id via the 'deviceId' query parameter or the 'x-device-id' header (expo-updates clients: Updates.setExtraParamAsync('deviceid', id) — key must be lowercase). Set REQUIRE_DEVICE_ID=false to disable this requirement."
}

Self-hosting and don't want the requirement? Set REQUIRE_DEVICE_ID=false (or 0) on the server: requests without an id are served normally and their events are recorded unattributed.

The id must be stable across app launches. A fresh random id per launch inflates MAU and can push you over your plan limit within a day. Use a persistent identifier — expo-application's getAndroidId() / getIosIdForVendorAsync() is what the demo app in examples/expo-demo/App.tsx does. Expo setup walks through the wiring.

Asset URLs inside a served manifest already carry the requesting device's id as a query parameter, so asset downloads stay attributable without any extra client work.

Why rollBackToEmbedded exists and what breaks without it

Here is the trap. An expo-updates client that has already downloaded update X keeps launching X forever unless something tells it otherwise. noUpdateAvailable does not mean "revert" — it means "you are current, carry on". So if you roll a channel back to nothing, every device that already has the bad bundle stays on the bad bundle, permanently, no matter how many times it polls.

The protocol's answer is the rollBackToEmbedded directive: stop using downloaded updates and launch the bundle compiled into the binary. Otapush tracks this per platform. Rolling back the only update on a channel sets updateIds[platform] = null and raises a rollbackToEmbedded[platform] flag, and the manifest endpoint then answers:

HTTP/1.1 200 OK
expo-protocol-version: 1
content-type: multipart/mixed; boundary=----ota-boundary-a6b0da9d-24a4-481f-8472-1ca3f717359e

------ota-boundary-a6b0da9d-24a4-481f-8472-1ca3f717359e
content-disposition: form-data; name="directive"
content-type: application/json; charset=utf-8
expo-signature: sig="IOln9cWe0POUZi+9qdXxm7G2Rvfpor0r…", keyid="main", alg="rsa-v1_5-sha256"

{"type":"rollBackToEmbedded"}
------ota-boundary-a6b0da9d-24a4-481f-8472-1ca3f717359e--

Any subsequent publish or promote to that channel clears the flag, and devices move forward again from the embedded bundle.

Two honest caveats:

  • Otapush sends the directive without a parameters.commitTime field. The upstream specification defines directives as { type, parameters?, extra? } and does not document what rollBackToEmbedded requires; expo-updates 0.26 accepts the bare form, which is what Otapush emits. If a future client version starts requiring commitTime, this is the line that will need to change.
  • The flag is per platform and per channel, not per runtime version. Once a channel is in the rolled-back state for iOS, an iOS client asking for any runtime version gets rollBackToEmbedded — including runtime versions that never had an update on that channel. In practice this is harmless (those clients have nothing downloaded to revert), but it is a deviation from "answer per (platform, runtimeVersion)" worth knowing if you are diffing behaviour against another server.

Rolling back a non-current update is refused with 400. Otherwise a channel would silently jump over versions its owner never inspected — see rollback in the CLI reference.

Why asset URLs are built from X-Forwarded-Proto and X-Forwarded-Host

The URLs in a manifest are absolute, and the client fetches them exactly as written. The obvious implementation — derive the origin from request.url — breaks the moment the server sits behind a TLS-terminating reverse proxy.

Traefik (or nginx, or a cloud load balancer) accepts https://ota.example.com/… from the device, terminates TLS, and forwards plain http://ota-api:3000/… to the application. request.url is therefore http://…, the manifest goes out with http:// asset URLs, and the download fails on both platforms for platform-specific reasons: Android blocks cleartext HTTP by default since API 28, and iOS App Transport Security blocks it too. The manifest fetch itself succeeded, so the app reports a download failure with no obvious cause.

Otapush rebuilds the public origin from the proxy's own headers, falling back to the request URL when they are absent:

proto = first value of X-Forwarded-Proto  (fallback: request.url protocol)
host  = first value of X-Forwarded-Host   (fallback: request.url host)
origin = proto + "://" + host

Both headers are comma-separated when several proxies are chained; only the first value is used. If you self-host behind your own proxy, make sure it sets both — X-Forwarded-Host in particular is often omitted, and without it asset URLs point at the internal service name instead of your domain.

How assets are addressed and cached

GET /api/updates/:slug/assets?asset=bundles/<appId>/<updateId>/bundle.js&runtimeVersion=1.0.0&deviceId=<id>
GET /api/updates/:slug/assets?asset=assets/<sha256hex>.png&runtimeVersion=1.0.0&deviceId=<id>
  • Non-bundle assets are content-addressed: the storage key is the file's SHA-256. Two updates that share an image share one stored object, and a device that already has that image skips the download.
  • Bundles live under bundles/<appId>/<updateId>/bundle.js, because they are not shared between updates.
  • Keys are validated before anything is read: an asset key containing / must start with assets/ or bundles/<appId>/ and must not contain .., otherwise the request is 403 forbidden asset key. A bare key must match [a-zA-Z0-9._-]+ and is resolved under assets/.
  • Responses carry cache-control: public, max-age=31536000, immutable, which is safe precisely because the key is the content hash.
  • runtimeVersion is accepted on this endpoint for symmetry with the manifest URL; the asset lookup does not depend on it.
  • The device id rule applies here too. The manifest embeds it in every URL, so a stock client never has to think about it.

What Otapush records, and what it does not

The protocol endpoints double as the analytics source — there is no separate telemetry SDK and no client-side reporting call.

Event Recorded when
check Any manifest request, including ones that end in noUpdateAvailable
download Any successful asset or bundle response
install A check whose expo-current-update-id names a published update — written once per device+update pair

The install trick is worth spelling out, because it is how Otapush gets install counts without asking the app to report anything: if a device tells you it is currently running update X, then X downloaded, launched, and survived long enough to poll. The server checks that no install event exists yet for that device+update pair and writes one. There is no client-side call to make and nothing to add to your app.

failure events are not recorded today. The event type exists in the data model and the stats endpoint counts them, but no code path writes one — so the failure counter in the portal currently stays at zero. The information is available: expo-updates sends expo-recent-failed-update-ids on every request, listing updates that crashed on launch. Otapush does not read that header yet. Until it does, use crash reporting for launch failures rather than the failure column, and treat a stalled install count after a publish as your real signal.

MAU is counted from distinct device ids per (app, calendar month), upserted on every protocol request. Going over the limit does not stop updates from being served — the stats response simply flags overLimit. See Billing for what the limits are.

Errors you can get from the manifest endpoint

Status Body Cause
400 Unsupported protocol version. Expected either 0 or 1. expo-protocol-version outside 0/1
400 Unsupported platform. Expected either ios or android. Missing or unrecognized expo-platform
400 No runtimeVersion provided. Missing expo-runtime-version
400 deviceId is required (+ hint) No device id in any of the three accepted places
404 app not found Unknown slug
404 channel '<name>' not found Unknown channel query parameter
404 No update available for this platform/runtimeVersion. Protocol 0 only — the version-1 equivalent is a noUpdateAvailable directive

Where Otapush stops short of the specification

Stated plainly, so you can tell whether Otapush fits your case before you find out the hard way:

  • expo-manifest-filters is not sent. Client-side filtering of stored updates by manifest metadata is unavailable, and metadata is always {}.
  • expo-server-defined-headers is not sent. There is no mechanism for the server to make the client persist and echo custom headers.
  • expo-embedded-update-id is ignored. The server does not compare the embedded update against the channel state; the runtime-version match does that job.
  • expo-recent-failed-update-ids is ignored, hence no failure events (above).
  • Responses are always multipart/mixed, never a bare application/expo+json manifest.
  • rollBackToEmbedded carries no parameters.
  • Runtime versions are matched exactly. There is no range or policy matching on the server; an update published for 1.0.0 is invisible to a 1.0.1 binary. That is deliberate — it is the safety boundary that keeps JavaScript away from a native binary it was not built for.

If you need one of these, the protocol endpoint is the only thing your client depends on: you can put your own server behind the same URL without touching the app.

Questions and answers

Do I need a custom client library to use Otapush?

No. Otapush is a server, not an SDK. The stock expo-updates package that ships with your Expo SDK is the whole client. Everything Otapush-specific — the URL, the certificate, the runtime version — is configuration in app.json (or AndroidManifest.xml and Expo.plist for bare React Native), written for you by otapush init.

Can I switch from EAS Update to Otapush without changing app code?

Yes, but you need a new binary. The updates URL and the code-signing certificate are compiled into the app, so pointing at a different server is a store release. The JavaScript side is untouched: Updates.checkForUpdateAsync() and friends behave identically.

Does the same implementation cover iOS and Android?

Yes. The protocol is platform-neutral and both native clients implement the same flow. Otapush stores channel state per platform, so the same channel can serve different updates to iOS and Android — publishing for one platform never touches the other.

What happens if runtimeVersion does not match?

The client gets noUpdateAvailable (protocol 1) or 404 (protocol 0), and keeps running whatever it has. There is no fuzzy matching and no "closest version" fallback. This is the mechanism that prevents a JavaScript bundle from reaching a binary whose native code cannot run it.

Why does my device get 400 deviceId is required?

Almost always the lowercase-key problem: Updates.setExtraParamAsync("deviceId", …) instead of "deviceid". On iOS the header is silently dropped, so the request arrives with no identifier at all. Fix the key, or pass the id as a deviceId query parameter to confirm the diagnosis in seconds.

Is code signing mandatory?

No. If the client does not send expo-expect-signature, responses are unsigned and the client accepts them. It is on by default for projects set up with otapush init, and there is no reason to turn it off in production: the keypair is generated for you and the private key stays on the server.

Can I exercise the protocol with curl?

Yes — that is why platform and runtime version are accepted as query parameters:

curl -i "https://your-server.example.com/api/updates/<slug>/manifest?platform=android&runtime-version=1.0.0&deviceId=curl-test" \
  -H "expo-protocol-version: 1"

You get the real multipart body back, unsigned. Add -H "expo-current-update-id: <uuid>" to see the noUpdateAvailable path, and -H "expo-expect-signature: true" to see a signed one.

Does the server push updates to devices?

No. The protocol is pull-only: the client polls on launch (or when you call checkForUpdateAsync), and the server answers. There is no push channel, no silent-notification wake-up, and no way to force an update onto a device that is not running. "Roll back now" means "the next poll from each device returns a rollback" — a device that never opens the app never learns about it.

How quickly does a published update reach users?

As fast as they open the app, given the default configuration: expo-updates checks on launch and, by default, applies the new bundle on the next launch. A user who opens the app twice sees it immediately; a user who does not open it at all never does. Nothing about that timing is Otapush-specific — it is client behaviour, and Updates.reloadAsync() is how you shorten it.

What is the update's id and can I choose it?

It is a server-generated UUID, and no. It appears in the manifest, comes back as expo-current-update-id, and is the key install events are attributed to. Use the publish message and gitCommit fields — which Otapush passes through to the manifest's extra object — to tie an update to your own version scheme.