MainSDK
Search⌘K
Interfaces

Platform, OS and network

App updates and version blocking

updates

Tell users an update is ready, refuse old versions on the server as well as in the app, tell open tabs about a new build, and roll releases out by percentage and market.

Building for 0.7ships as a beta APIPhase 2 · Cloud and Teams, 1.0Spec updatesFree in your app, Apache-2.0

Status Status is where the work is: Planned (in a milestone, not started), Building (tickets in progress), Preview (usable, may change), then the stability it ships at.

Stability The promise an interface ships with.

  • Stable Follows semantic versioning; breaking changes need a new major, 12 months of overlap and a codemod.
  • Beta Usable in production; may change before it becomes stable.
  • Experimental May change or go away; needs an explicit opt-in in main.yaml.
Stability levels
Updated 2026-10-28

updates tells users an update is ready and refuses old versions on the server as well as in the app. Use it after a security fix, a breaking API change or a staged release.

At a glance

updates:
  driver: local
  min-version: 2.4.0
  build: 2026.10.27-1
  exempt: [/api/me/export]
  channels:
    stable: { version: 2.6.0, rollout: 50, markets: { IN: 100, EU: 10 } }
    beta: { version: 2.7.0-beta.1, rollout: 100 }
  api:
    deprecated:
      - { path: /api/v1, since: 2026-10-01, sunset: 2027-06-30, successor: /api/v2 }

Calls

  • updates.minVersion()
  • updates.check({ version, channel?, user?, market? })
  • updates.channel({ user?, market?, channel? })
  • updates.build()
  • updates.announce(build)
  • createUpdates(deps)
  • parseUpdates(raw)
  • updatesIssues(raw)
  • versionHeaders(version, platform?)
  • refusal(res)
  • applyUpdate(status, driver)
  • watchBuild({ build, onUpdate, banner? })
  • expoUpdates(module)
  • googlePlay(module)
  • appStore({ url, open })
  • tauri(module)

Guarantees

Whatever the driver.

  • Blocking enforced by the server as well as the app
  • The minimum version can only rise through a push, and every change is audited
  • Blocked users can still sign out and export their data
  • Open web tabs hear about a new build over server-sent events
  • The same user always gets the same answer in a staged rollout
  • API clients get Deprecation and Sunset headers before a route goes away
  • Reads memory only, so a config or store outage never changes an answer

Drivers

5 drivers. Select one to see its line in main.yaml.

main.yamlLocal fake for tests and development (default)
updates:
  driver: local

Change this one line to swap providers; your calls stay the same.Built-in and local drivers integration

Codes

CodeWhere it appearsMeaning
MAIN-UPDATE-0003Checkmainsdk check, before deployThe updates section is invalid: a version that isn't dotted numbers, a rollout outside 0 to 100, a channel other than stable or beta, or a deprecation without a path and a sunset date
MAIN-UPDATE-0004Checkmainsdk check, before deployupdates.min-version is above the stable channel's version, so every current client would be blocked
MAIN-UPDATE-0001RuntimeError responses and logsSee the spec
MAIN-UPDATE-0002RuntimeError responses and logsSee the spec

Features

Nothing here has shipped yet. Each feature lands in its phase; the spec ticket builds it.

FeaturePhaseMilestoneSpec ticket
updates@1 contract and the updates main.yaml keysPhase 20.7updates#contract
Minimum version that only tightensPhase 20.7updates#min-version
Server gate with 426 MAIN-UPDATE-0001Phase 20.7updates#server-gate
Web version skew banner over SSEPhase 20.7updates#web-skew
Stable and beta channels with a staged rollout by percentage and marketPhase 20.7updates#channels
Deprecation and Sunset headers for API clientsPhase 20.7updates#api-sunset
Update checksPhase 20.7updates#checks
Size budgets and no network in the request pathPhase 20.7updates#budgets
Store drivers for Expo UpdatesPhase 31.1updates#drivers
Update metadata signed and delivered through TUFPhase 20.7Not specified yet
Automatic halt of a staged rollout on a crash or error spikePhase 20.7Not specified yet

MainSDK Cloud

Everything above is free in your app. Cloud adds these on top, by plan:

PlanWhat it adds
TeamsRelease dashboards
FleetFleet-wide version rules

Quick start

main.yaml
schema: 1
updates:
  min-version: 2.4.0
  channels:
    stable: { version: 2.6.0 }
app/update-screen.ts
import { createTestApp } from "@mainsdk/testing";

const app = createTestApp({
  yaml: "schema: 1\nupdates:\n  min-version: 2.4.0\n  channels:\n    stable: { version: 2.6.0 }\n",
});
const status = app.updates.check({ version: "2.3.9", user: "usr_1" });
console.log(status.required, status.minVersion);   // true "2.4.0"

updates.check() answers { required, available, minVersion, latest, channel, build }. required means the client is below the minimum: show your own update screen. In a running app you reach the same object with app().updates from mainsdk.

The adapters enforce the minimum for you. A request that carries MainSDK-App-Version: 2.3.9 is refused with 426 MAIN-UPDATE-0001 before any route runs. Add the header from the client with versionHeaders from mainsdk/updates/client.

Configuration

main.yaml
updates:
  driver: local                 # expo-updates | google-play | app-store | tauri (1.1)
  min-version: 2.4.0            # the lowest client the server serves; a push may only raise it
  build: 2026.10.27-1           # this release's build id, published to open tabs
  stream: 2m                    # how long one build stream stays open before the browser reconnects
  strict: false                 # true also refuses a request that names no version
  exempt: [/api/me/export]      # paths a blocked client may still reach, next to sign-out
  channels:
    stable: { version: 2.6.0, rollout: 50, markets: { IN: 100, EU: 10 } }
    beta: { version: 2.7.0-beta.1, rollout: 100 }
  api:
    deprecated:
      - { path: /api/v1, since: 2026-10-01, sunset: 2027-06-30, successor: /api/v2 }
KeyTypeDefaultWhat it does
driverstringlocalThe store the app's client talks to on the device; the server needs none
min-versionversionnoneThe lowest client version the server serves. Dotted numbers with an optional -pre suffix; a pre-release sorts below its release
buildstringnoneThe build id of this release; blank means none is published
streamduration2mHow long a build stream stays open; the browser then reconnects to whichever release is live
strictbooleanfalseAlso refuse a request with no version header. A browser sends none, so use it only for native-only APIs
exemptlist of paths[]Paths a blocked client may still reach, such as the data export. /_mainsdk/auth/signout and /_mainsdk/updates are always open
channels.<name>mapnonestable or beta: the version offered, the rollout percent of users (default 100) and markets that replace it per market
api.deprecatedlist[]Path prefixes on their way out: path, sunset (a date) and optionally since and successor

Building it by hand

The SDK builds updates from main.yaml. Call these only to wire it yourself, for example in a test or a custom runtime.

NameWhat it is
parseUpdates(raw)Reads the updates section into a config: the minimum, build, channels with their markets in capitals, exempt paths and deprecations. An invalid key fails with MAIN-CONFIG-0003.
updatesIssues(raw)Every problem in a raw section as { key, code, message }, the list mainsdk check reports as MAIN-UPDATE-0003 and MAIN-UPDATE-0004.
createUpdates(deps)Builds the interface from a config and a reader of live values (value), with now and timers. Optional: market.

What you get

The minimum version

updates.minVersion() returns the higher of the main.yaml value and a pushed one, or null. A push goes through config as main.updates.min-version. It only rises: a lower value is refused with MAIN-CONFIG-0021, the last good value stays and the refusal is audited with hashes, never the value. Every applied change is audited the same way. The check reads memory only, so a config store outage leaves the last good minimum in force.

Version skew on the web

With build set, updates.build() returns the id, GET /_mainsdk/updates/build answers { build } and GET /_mainsdk/updates/stream?build=<id> opens a server-sent event stream. Its first event is build with { build, stale }, where stale is true when the tab was loaded from another build. updates.announce(build) sends a new one to the streams open on this instance. In the browser:

app/build-watch.ts
import { watchBuild } from "mainsdk/updates/client";

const stop = watchBuild({ build: "2026.10.27-1", banner: true });

watchBuild calls onUpdate once for a stale tab and, with banner: true, shows a status banner with a reload button. It never reloads on its own, because a reload can lose unsaved work.

Release channels

updates.channel({ user, market?, channel? }) offers the release of stable (the default) or beta. A user is eligible when the stable hash of the channel, the version and the user id falls under the rollout, the same hash flags uses. A market under markets replaces the channel's percent. Raising the rollout never removes a user who already had the release, and a user with no id is eligible only at 100. An ineligible user is offered nothing (version: null).

API clients

A path under updates.api.deprecated[].path answers with Deprecation, Sunset and, when successor is set, Link: <…>; rel="successor-version", built by the same function app-api uses for a deprecated route. After the sunset date it answers 410 MAIN-API-0006.

Examples

Block a version after a security fix

Raise min-version in main.yaml through a reviewed change, or push main.updates.min-version through config to tighten a running fleet in seconds.

app/min-version.ts
import { createTestApp } from "@mainsdk/testing";

const app = createTestApp({ yaml: "schema: 1\nupdates:\n  min-version: 2.4.0\n" });
console.log(app.updates.minVersion());   // "2.4.0"

Roll a release out to one market first

app/rollout.ts
import { createTestApp } from "@mainsdk/testing";

const app = createTestApp({
  yaml: "schema: 1\nupdates:\n  channels:\n    stable: { version: 2.6.0, rollout: 10, markets: { IN: 100 } }\n",
});
const offer = app.updates.channel({ user: "usr_1", market: "IN" });
console.log(offer.eligible, offer.version);   // true "2.6.0"

Show the update screen

app/client.ts
declare function showUpdateScreen(minVersion: string | null): void;   // your own screen

import { check, refusal, versionHeaders } from "mainsdk/updates/client";

const status = await check("https://app.example.com", { version: "2.3.9", user: "usr_1" });
if (status.required) showUpdateScreen(status.minVersion);

const res = await fetch("https://app.example.com/api/orders", { headers: versionHeaders("2.3.9", "ios") });
const blocked = await refusal(res);   // { minVersion } for a 426 MAIN-UPDATE-0001

showUpdateScreen() is your own screen. Pass the platform driver to applyUpdate(status, driver) to open the right store flow.

Errors and checks

CodeWhereMeaning and fix
MAIN-UPDATE-0001runtimeThe client is below updates.min-version, or named none under strict (426, with min_version). Update the app
MAIN-UPDATE-0002runtimeA version, platform or channel is not valid (400). Send a version such as 2.4.0 and stable or beta
MAIN-UPDATE-0003checkThe updates section is invalid. Correct the key the finding names
MAIN-UPDATE-0004checkmin-version is above the stable release, so every client is blocked. Lower it or raise the release first
MAIN-API-0006runtimeA deprecated API path past its sunset (410). Call the successor route

See check codes and errors.

Swapping drivers

main.yaml
updates:
  driver: google-play   # was expo-updates

The server answers the same whatever the driver. The driver is the app's client side: expoUpdates(module), googlePlay(module), appStore({ url, open }) and tauri(module) from mainsdk/updates/drivers each wrap the platform's own module, which you pass in. Most apps use a different driver per target.

Testing

createTestApp runs updates with no network, and fakeUpdates from @mainsdk/testing is the local driver: it answers from a script and records every call, so your update screen runs with no store.

app/updates.test.ts
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { createTestApp, fakeUpdates } from "@mainsdk/testing";
import { applyUpdate } from "mainsdk/updates/client";

describe("updates", () => {
  it("updates AC2 asks for the full-screen flow when the version is too old", async () => {
    const app = createTestApp({ main: "./main.yaml" });
    const status = app.updates.check({ version: "1.0.0" });
    const driver = fakeUpdates();
    await applyUpdate(status, driver);
    assert.deepEqual(driver.calls, ["apply:immediate"]);
  });
});

Security and privacy

  • The server enforces the minimum, so a modified client cannot skip it, except by leaving the header out; strict closes that for native-only APIs.
  • Blocking reads memory only and makes no call (ADR-0003); a store or config outage never changes an answer.
  • The stream and the build route carry no person or tenant, and the check route stores nothing.
  • Every pushed change to the minimum is audited with hashes only.

What Cloud adds

updates is free in your app. Teams adds release dashboards. Fleet adds fleet-wide version rules, such as "no app below a version with a known bug". See Free in-app and Cloud.

FAQ

Can iOS apps update in place? iOS has no in-app update API, so the appStore driver opens the store page and never claims an install. JavaScript-only changes can go over the air with Expo Updates where Apple's rules allow.

Can a rollout be changed while the app runs? Only by a reviewed change to main.yaml, since a push may only tighten (ADR-0017). To stop a bad release, raise min-version.

Does the banner reload my page? No. It offers a reload and waits.

Next

Was this page helpful?

↑↓ moveEnter openEsc close

Not decided yet