---
title: "App updates and version blocking"
description: "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."
section: "interfaces"
status: "Building for 0.7 · ships as a beta API"
spec: "updates"
canonical: "https://mainsdk.dev/docs/interfaces/updates"
updated: "2026-10-28"
---

# App updates and version blocking

> 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.

Status: Building for 0.7 · ships as a beta API · Canonical: https://mainsdk.dev/docs/interfaces/updates · 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

- Status: Building for 0.7 · ships as a beta API
- Phase: 2 (Cloud and Teams, 1.0)
- Milestone: 0.7 Teams and assure
- Plan: free in your app (Apache-2.0)
- Spec: https://github.com/hubxero/mainsdk/blob/develop/docs/specs/updates.md
- Category: Platform, OS and network

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)`

In main.yaml:

```yaml
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 }
```

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

Pick one in main.yaml with `updates.driver`.

| Driver | Id in main.yaml | Default | Note |
| --- | --- | --- | --- |
| Local fake for tests and development | `local` | yes |  |
| Expo Updates | `expo-updates` |  |  |
| Google Play | `google-play` |  |  |
| App Store | `app-store` |  |  |
| Tauri updater | `tauri` |  |  |

## Codes

| Code | Where | Meaning |
| --- | --- | --- |
| `MAIN-UPDATE-0003` | check | The `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-0004` | check | `updates.min-version` is above the `stable` channel's version, so every current client would be blocked |
| `MAIN-UPDATE-0001` | runtime |  |
| `MAIN-UPDATE-0002` | runtime |  |

## Features

| Feature | Phase | Milestone | Spec ticket |
| --- | --- | --- | --- |
| updates@1 contract and the updates main.yaml keys | 2 | 0.7 | `updates#contract` |
| Minimum version that only tightens | 2 | 0.7 | `updates#min-version` |
| Server gate with 426 MAIN-UPDATE-0001 | 2 | 0.7 | `updates#server-gate` |
| Web version skew banner over SSE | 2 | 0.7 | `updates#web-skew` |
| Stable and beta channels with a staged rollout by percentage and market | 2 | 0.7 | `updates#channels` |
| Deprecation and Sunset headers for API clients | 2 | 0.7 | `updates#api-sunset` |
| Update checks | 2 | 0.7 | `updates#checks` |
| Size budgets and no network in the request path | 2 | 0.7 | `updates#budgets` |
| Store drivers for Expo Updates | 3 | 1.1 | `updates#drivers` |
| Update metadata signed and delivered through TUF | 2 | 0.7 |  |
| Automatic halt of a staged rollout on a crash or error spike | 2 | 0.7 |  |

## MainSDK Cloud

Everything on this page is free in your app.
Cloud adds:

| Plan | What it adds |
| --- | --- |
| teams | Release dashboards |
| fleet | Fleet-wide version rules |


## Quick start

```yaml title="main.yaml"
schema: 1
updates:
  min-version: 2.4.0
  channels:
    stable: { version: 2.6.0 }
```

```ts title="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

```yaml title="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 }
```

| Key | Type | Default | What it does |
| --- | --- | --- | --- |
| `driver` | string | `local` | The store the app's client talks to on the device; the server needs none |
| `min-version` | version | none | The lowest client version the server serves. Dotted numbers with an optional `-pre` suffix; a pre-release sorts below its release |
| `build` | string | none | The build id of this release; blank means none is published |
| `stream` | duration | `2m` | How long a build stream stays open; the browser then reconnects to whichever release is live |
| `strict` | boolean | `false` | Also refuse a request with no version header. A browser sends none, so use it only for native-only APIs |
| `exempt` | list 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>` | map | none | `stable` or `beta`: the `version` offered, the `rollout` percent of users (default 100) and `markets` that replace it per market |
| `api.deprecated` | list | `[]` | 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.

| Name | What 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:

```ts title="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`.

> **Note:** Built: the minimum, the server gate, the build stream, channels, API sunsets, the checks and the `mainsdk/updates/client` helpers. The four store drivers in `mainsdk/updates/drivers` wrap each platform's own module by shape and are tested against fakes of those shapes; a real Play, App Store or Tauri release cannot run offline, so the first run on a device is a manual step. The server trusts the version the client names; proving it with the device is a later addition.

## 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`](https://mainsdk.dev/docs/interfaces/config) to tighten a running fleet in seconds.

```ts title="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

```ts title="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

```ts title="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

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

See [check codes](https://mainsdk.dev/docs/reference/check-codes) and [errors](https://mainsdk.dev/docs/reference/errors).

## Swapping drivers

```yaml title="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.

```ts title="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](https://mainsdk.dev/docs/concepts/free-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

- [Change config without a deploy](https://mainsdk.dev/docs/guides/change-config-without-a-deploy)
- [Feature flags](https://mainsdk.dev/docs/interfaces/flags)
- [Where the app is used](https://mainsdk.dev/docs/interfaces/analytics)
- [Use app updates and version blocking](https://mainsdk.dev/docs/guides/use-updates)
