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:
- A manifest — there is an update the client should download and launch.
- A directive — there is no update to serve, but the client still needs an instruction (
noUpdateAvailable, orrollBackToEmbedded). - 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 asexpo-current-update-idon 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.hashis the bundle's SHA-256 in base64url without padding;keyis the same digest in hex. Both are required and they are not the same encoding — a server that puts hex inhashfails 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 theexpo-manifest-filtersheader; Otapush does not send that header, so there is nothing to filter on.extra— free-form. Otapush puts the publishmessageandgitCommithere when they were supplied, so you can read them fromUpdates.manifestinside 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 initwrites it to./certs/certificate.pemand references it fromapp.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 omitsexpo-signatureand the response is served unsigned — useful forcurl, 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:
- the
deviceIdquery parameter (wins when several are present), - the
x-device-idheader, - a
deviceidentry in theexpo-extra-paramsstructured-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.commitTimefield. The upstream specification defines directives as{ type, parameters?, extra? }and does not document whatrollBackToEmbeddedrequires;expo-updates0.26 accepts the bare form, which is what Otapush emits. If a future client version starts requiringcommitTime, 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 withassets/orbundles/<appId>/and must not contain.., otherwise the request is403 forbidden asset key. A bare key must match[a-zA-Z0-9._-]+and is resolved underassets/. - Responses carry
cache-control: public, max-age=31536000, immutable, which is safe precisely because the key is the content hash. runtimeVersionis 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-filtersis not sent. Client-side filtering of stored updates by manifestmetadatais unavailable, andmetadatais always{}.expo-server-defined-headersis not sent. There is no mechanism for the server to make the client persist and echo custom headers.expo-embedded-update-idis ignored. The server does not compare the embedded update against the channel state; the runtime-version match does that job.expo-recent-failed-update-idsis ignored, hence nofailureevents (above).- Responses are always
multipart/mixed, never a bareapplication/expo+jsonmanifest. rollBackToEmbeddedcarries noparameters.- Runtime versions are matched exactly. There is no range or policy matching on the server; an update published for
1.0.0is invisible to a1.0.1binary. 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.