Dbrij Ship Documentation

Everything you need to wire Dbrij Ship into an app: the zero dependency SDK, the React Native and Capacitor plugins, feature flags, remote config, signed releases and staged rollouts, store version policy, and the raw device protocol for native stacks.

What Ship does

Ship changes what your mobile app does after it is installed, without a store submission. It works in two layers, and the difference between them matters for every decision below.

The dynamic layer is flags, remote config and the kill switch. It is data over HTTPS, so it works on every stack there is: React Native, Capacitor, Flutter, Swift, Kotlin, anything that can make an HTTP request. If your app can read a boolean from a server, Ship works.

Code push replaces the app's JavaScript bundle on devices that already have it. It works on JS stacks only, because that is what the stores permit: interpreted code may be updated, compiled native code may not. That is a store rule, not a Ship limitation.

Every other stack gets asset packs on the same rail instead: a zip of the files the app reads at runtime, staged and rolled back exactly like a bundle. Not code, so it is permitted everywhere, and it covers most of what teams actually reach for updates over the air to fix.

What you need before you start

Add the app on the Ship dashboard. It mints two things, both on its Setup card, and they are not the same kind of thing.

App key (shp_…)*publicIdentifies the app on check in. It ships inside your binary and anybody can read it out. That is fine: it grants nothing but the right to ask what this app's flags are.
Signing public key*publicPinned in your build so the device can prove a bundle came from you. The matching PRIVATE key never leaves Dbrij; releases are signed server side.

Pin the signing key in the binary rather than fetching it. A key downloaded at runtime is a key an attacker on the network can replace, and then the signature it checks proves nothing.

The five minute version

Flags and config, on any JS stack, with no native work and no store release beyond the one that carries this code.

Install
npm i @dbrij/ship
Create the client once, at module scope
import { createShip } from '@dbrij/ship';
import AsyncStorage from '@react-native-async-storage/async-storage';

export const ship = createShip({
  appKey: 'shp_…',            // Setup card
  signPublicKey: 'MCowBQ…',   // Setup card, pinned in the build
  channel: 'production',
  binaryVersion: '1.0.0',     // your STORE build version
  platform: Platform.OS,
  storage: AsyncStorage,      // read the note below before skipping this
});
Check in on launch, then read values anywhere
await ship.checkIn();

if (ship.getFlag('new_checkout')) { /* … */ }
const banner = ship.getConfig<string>('banner', '');

getFlag and getConfig read the last answer, which is cached, so they keep working offline after the first successful check in. Always pass the fallback your app shipped with: it is what runs on first launch, on a flight, and under the kill switch.

The storage adapter is not optional

Leave storage out and the SDK keeps the device id in memory. It works, and it quietly breaks two things:

  • Your bill. The device id is what the monthly active device meter counts. A fresh id every launch turns one phone into thirty devices a month.
  • Your rollouts. Bucketing is deterministic on the device id, which is what makes a 10% rollout the same 10% tomorrow. Reroll the id and a phone flickers in and out of every staged flag and release you have.

Any get/set pair works, sync or async: AsyncStorage, Capacitor Preferences, MMKV, localStorage, or six lines of your own.

A hand rolled adapter
storage: {
  getItem: (k) => myStore.read(k),        // string | null, or a promise of one
  setItem: (k, v) => myStore.write(k, v),
}

Privacy opt out

disabled: true (or ship.setDisabled(true) at runtime) stops every request. Check in then returns the cached answer, or an empty one, and your fallbacks run. Wire it to whatever consent switch your app already has.

Install

Packages
npm i @dbrij/ship @dbrij/ship-react-native
cd ios && pod install

The core package is zero dependency and does the protocol. The plugin adds the part that has to be native: download, verify, stage, swap the bundle path, and the boot canary.

Wire the bundle path, once, in the store build

This is the step that cannot be done from JavaScript, and it is why code push needs one store release before it can do anything. Until the host app asks Ship where the bundle is, Ship has nowhere to put one.

iOS — AppDelegate
override func sourceURL(for bridge: RCTBridge) -> URL? {
  #if DEBUG
    return RCTBundleURLProvider.sharedSettings().jsBundleURL(forBundleRoot: "index")
  #else
    return DbrijShip.bundleURL() ?? Bundle.main.url(forResource: "main", withExtension: "jsbundle")
  #endif
}
Android — MainApplication, inside your ReactNativeHost
override fun getJSBundleFile(): String? =
  com.dbrij.ship.DbrijShipModule.getJSBundleFile(applicationContext)

Autolinking normally registers DbrijShipPackage() for you; add it to getPackages() by hand if your app opts out of autolinking. Keep the debug branch as it is, or Metro stops working.

Call two functions on launch

Root component
import { createShip } from '@dbrij/ship';
import { notifyAppReady, shipBinaryVersion, syncShipUpdates } from '@dbrij/ship-react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';

const SIGN_KEY = 'MCowBQ…';   // Setup card

const ship = createShip({
  appKey: 'shp_…',
  signPublicKey: SIGN_KEY,
  channel: 'production',
  binaryVersion: shipBinaryVersion() ?? '1.0.0',  // read off the native build
  platform: Platform.OS,
  storage: AsyncStorage,
});

useEffect(() => {
  notifyAppReady(ship)
    .then(() => syncShipUpdates(ship, { signPublicKey: SIGN_KEY }))
    .catch(() => undefined);   // updates must never be able to break the app
}, []);

shipBinaryVersion() reads CFBundleShortVersionString / versionName off the native module, so the version you target releases against is the one the store actually built, not a constant somebody forgot to bump.

notifyAppReady is the whole safety model

When a new bundle first boots, the native side arms a canary. notifyAppReady disarms it. A bundle that crashes before reaching that call is judged crash on launch and reverted to the store build on the next start, and the failure is reported so the rollout can freeze itself for everybody else.

So call it once your first screen has actually mounted, not at the top of the file. Call it too early and a bundle that white screens is declared healthy and keeps shipping. Never call it, and every update you push reverts itself and you will conclude code push is broken.

What a sync returns

up-to-datestatusNothing to do, or the kill switch is on.
stagedstatusVerified and written; it takes effect on the next launch.
appliedstatusA mandatory update: the app is restarting now.
revertedstatusThe running release was rolled back or halted; the store bundle is back.
failedstatusDownload or verification failed. Reported, and the store bundle keeps running.

binaryUpdate rides along on every result. See Store versions for what to do with it.

Install

Packages
npm i @dbrij/ship @dbrij/ship-capacitor
npx cap sync

A Capacitor release bundle is a zip of your built web assets (the dist/ folder), not a JS bundle. Everything else reads the same as React Native.

Use

On launch
import { createShip } from '@dbrij/ship';
import { notifyAppReady, syncShipUpdates } from '@dbrij/ship-capacitor';
import { Preferences } from '@capacitor/preferences';

const SIGN_KEY = 'MCowBQ…';

const ship = createShip({
  appKey: 'shp_…',
  signPublicKey: SIGN_KEY,
  channel: 'production',
  binaryVersion: '1.0.0',
  platform: 'capacitor',
  storage: {
    getItem: async (k) => (await Preferences.get({ key: k })).value,
    setItem: async (k, v) => { await Preferences.set({ key: k, value: v }); },
  },
});

await notifyAppReady(ship);                                  // disarms the boot canary
await syncShipUpdates(ship, { signPublicKey: SIGN_KEY });

The same rule applies: notifyAppReady goes after your app has rendered, and a bundle that never reaches it is reverted on the following start.

Install

pubspec.yaml
dependencies:
  dbrij_ship: ^0.1.0

Flags, config, the kill switch and the store version verdict, in one package with one dependency (the Dart team's own HTTP client). Pure Dart, so it works in a Flutter app on every platform Flutter targets, and in a plain Dart server or CLI.

No code push. A Flutter release build compiles Dart to machine code, and downloading machine code is the thing both stores forbid; Safety and the store rules has the longer version, including what would be involved in changing that answer.

What a Flutter app does get, beyond flags and config, is asset packs: the same release rail carrying the files your app reads at runtime. Copy, translations, images and price tables all become things you can fix this afternoon rather than next release. And with server driven screens, the layout itself.

Use

Once, at startup
import 'package:dbrij_ship/dbrij_ship.dart';

final ship = Ship(
  appKey: 'shp_…',                                // Setup card
  channel: 'production',
  binaryVersion: packageInfo.version,             // read it, never type it
  platform: Platform.isIOS ? 'ios' : 'android',   // picks the store link
  storage: PrefsShipStorage(),                    // see below
);

try {
  await ship.checkIn();
} catch (_) {
  // Never let a release desk break the app it exists to protect.
}
Anywhere after that
if (ship.getFlag('new_checkout')) { /* … */ }

final banner = ship.getConfig('banner', '');
final maxItems = ship.getConfig('max_items', 20);

getConfig takes its fallback as a required argument rather than an optional one, and that is deliberate: a config read with no fallback returns null under the kill switch, and the kill switch is precisely the moment the app still has to work.

The storage adapter

The same rule as every other stack, for the same two reasons, and it is the one thing worth reading twice: without it the device id lives in memory, so every launch is a new device. That inflates the meter Ship bills on and rerolls rollout bucketing. Six lines over shared_preferences is the usual answer.

ShipStorage over shared_preferences
class PrefsShipStorage implements ShipStorage {
  @override
  Future<String?> getItem(String key) async =>
      (await SharedPreferences.getInstance()).getString(key);

  @override
  Future<void> setItem(String key, String value) async =>
      (await SharedPreferences.getInstance()).setString(key, value);
}

It is an interface rather than a dependency on purpose: the package pulls no storage plugin into your app, and MMKV, Hive or your own box fit the same two methods.

Store versions are the point here

On a stack with code push, the version policy is the smaller half. On Flutter it is the whole of what a release desk can do about a broken build, so it is worth wiring properly rather than as an afterthought.

A blocking gate and a nudge, from one object
final update = (await ship.checkIn()).binaryUpdate;

if (update?.required ?? false) {
  // Below the minimum you support: block.
  showBlockingUpdateScreen(update!.message, update.url);
} else if (update != null) {
  // Behind, but fine. Offer once, let them dismiss.
  showUpdateBanner(update.message, update.url);
}

Read Store versions for what the policy actually sets, and send the real build version: a binaryVersion nobody bumps means adoption reads as zero and the block never fires.

What a flag can change without a release

More than people expect, and it is worth designing for rather than discovering. Anything your app reads at runtime is reachable: copy and banners, price tables, API bases, timeouts and retry counts, which payment methods appear, whether a screen is in the tab bar at all, feature gates around code that already shipped dark.

What it cannot change is code that is not in the binary. The habit that pays on Flutter is shipping the new path behind a flag that is off, rather than shipping it later: the store release carries both behaviours and the flag decides, which turns a two week review cycle into a check in.

Swift, Kotlin and anything else

No code push on these: the stores forbid downloading native executable code and Swift and Kotlin compile to exactly that. The dynamic layer is entirely yours, though, and it is the half most teams reach for daily. Two HTTP calls, no SDK. (Flutter has its own package: see the Flutter page.)

The one thing you have to build yourself is the device id: generate an opaque random string on first launch and keep it. Read Quickstart on why it must persist. Do not use an advertising identifier or anything else that identifies a person; this is a bucketing handle, and the store review teams treat those two things very differently.

Check in — curl
curl -X POST https://api.dbrij.com/api/v1/public/ship/check \
  -H 'Content-Type: application/json' \
  -d '{
    "appKey": "shp_…",
    "deviceId": "your-persisted-id",
    "channel": "production",
    "binaryVersion": "1.2.0",
    "platform": "ios"
  }'

Read flags and config off the answer and cache it, so the app still behaves on a plane. killSwitch: true means ignore everything and use the defaults compiled into the build, and that answer is the one thing you must not cache: it is an emergency, and a cached emergency outlives it.

Ignore update, revert and the report endpoint. They only mean something to a stack that can swap a bundle. binaryUpdate is very much for you: it is how you tell people to go to the store.

Flags

A flag is a boolean with a staged percentage. Devices are assigned deterministically on device id plus the flag key, so 10% is the same 10% on the next check in, and widening to 50% only adds devices, never shuffles them.

Reading one
// The fallback is what shipped in the binary. It runs on first launch,
// offline, and whenever the kill switch is on. Choose it deliberately.
if (ship.getFlag('new_checkout', false)) {
  return <NewCheckout />;
}

Config

A config value is any JSON your app reads at runtime: an API base, a price table, a banner message, a feature's tuning constants. Both flags and config can carry per channel values, so staging runs tomorrow's behaviour while production stays where it is.

Reading one
const tiers = ship.getConfig<PriceTier[]>('price_tiers', BUILT_IN_TIERS);
const banner = ship.getConfig<string>('banner', '');

The kill switch

The last resort. Flip it and every device gets an empty answer and falls back to its built in defaults. Use it when a config value or a release has done something bad enough that serving nothing is safer than serving anything.

It only works if your fallbacks are real. A flag read as getFlag('x') with no fallback returns false under the kill switch, which is usually right; a config read with no fallback returns undefined, which is usually a crash. Pass fallbacks.

What a release is

One payload on one channel: a version label, the store binary versions it runs on, and a rollout percentage. A newer release on the same channel supersedes the old one. Phones already on the old one stay healthy and move up when the new rollout admits them.

What the payload IS depends on the stack, and nothing else about a release does. A JS stack ships a bundle; everything else ships an asset pack, which is data rather than code. Staging, targeting, halting and rollback are identical either way, so the rest of this page reads the same for both.

MandatorybooleanApplies with an immediate restart instead of on next launch. For the bugs that cannot wait; it is an interruption, so spend it carefully.
targetBinaryVersionstringThe store builds this bundle is compatible with. A JS bundle calling a native module that only exists in 1.3 must not reach a 1.2 phone.
rolloutPercentnumberDeterministic per device. Widen it as confidence grows; narrowing does not recall anything.
RollbackactionStops serving the release AND recalls it: devices running it revert to the store build on their next check in.

Automatic halt

Devices report applied, failed, crashed and reverted. A release whose failures outrun its applies freezes itself and notifies you, which is the difference between a bad release reaching 5% of your users and reaching all of them at three in the morning.

This is the part that depends on you: the plugins send those beacons for you, but a hand rolled integration that never calls the report endpoint gets no automatic halt, because from the server's side a release that reports nothing looks exactly like a release that is going perfectly.

Shipping one from your terminal

The dashboard is the normal way to cut a release. The command line is the one that keeps a rollout disciplined, because a pipeline that builds the artefact and ships it in the same run cannot upload yesterday's zip by hand at eleven at night.

From a build
npm i -g dbrij
dbrij login

dbrij ship apps                                   # find the app id
dbrij ship release build/app-1.4.2.zip \
  --app shp_app_id --channel production \
  --target 1.2.x --rollout 10
Then, as confidence grows
dbrij ship releases --app shp_app_id
dbrij ship rollout <releaseId> 50 --app shp_app_id
dbrij ship rollback <releaseId> --app shp_app_id

The label comes off the file name when you do not pass one, so app-1.4.2.zip releases as 1.4.2. The command reads the app first and says whether it is shipping a bundle or an asset pack, so you never have to hold that in your head.

Building an asset pack

A pack is a zip, so zip works. The tool exists for the one part it cannot do: compiling .rfwtxt into the binary format a server driven screen ships as. It uses the Flutter team's own encoder, which is the only correct one, and it means the thing you review in a pull request is text.

From the Flutter package
dart run dbrij_ship_rfw:pack ui_pack -o build/pack.zip
dbrij ship release build/pack.zip --app shp_app_id --rollout 10

A blob that does not parse fails the build, rather than failing on somebody's phone during a rollout. Anything that looks executable is refused too: a pack carries data, and a .so in one means somebody has misunderstood what it is for.

The endpoint

For a pipeline that would rather call the API directly than install anything.

POST/ship/apps/:appId/releasessession

Upload a release

The bundle goes up base64 encoded and is signed server side with the app's own private key, which never leaves Dbrij. Ceiling 50 MB: a code push bundle is JS and assets, never a whole app.

PATH / BODY PARAMETERS

channelId*stringWhich channel this lands on.
label*stringYour version label, e.g. 1.4.2.
targetBinaryVersionstringThe store binaries this bundle is compatible with, e.g. 1.2.x. Devices outside it are never offered it.
mandatorybooleanApply on receipt with a restart rather than on next launch.
rolloutPercentnumber1 to 100. Bucketing is deterministic per device, so widening only ever adds devices.
notesstringWhat changed, kept on the release forever.
bundleBase64*stringThe bundle bytes. React Native: your JS bundle. Capacitor: a zip of the built web assets.
Request
curl -X POST https://api.dbrij.com/api/ship/apps/:appId/releases \
  -H "Authorization: Bearer $DBRIJ_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "channelId": "chn_…",
  "label": "1.4.2",
  "targetBinaryVersion": "1.2.x",
  "rolloutPercent": 10,
  "bundleBase64": "…"
}'
Request body
{
  "channelId": "chn_…",
  "label": "1.4.2",
  "targetBinaryVersion": "1.2.x",
  "rolloutPercent": 10,
  "bundleBase64": "…"
}
PATCH/ship/apps/:appId/releases/:releaseIdsession

Widen, pause, or roll back

Rolling back does two things, not one: it stops serving the release AND tells the devices already running it to revert to the store build on their next check in.

PATH / BODY PARAMETERS

rolloutPercentnumberWiden the rollout. Narrowing does not take the update off phones that already have it.
status"active" | "paused" | "rolledBack"Paused stops new devices; rolledBack also recalls the ones that have it.
Request
curl -X PATCH https://api.dbrij.com/api/ship/apps/:appId/releases/:releaseId \
  -H "Authorization: Bearer $DBRIJ_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "rolloutPercent": 50 }'
Request body
{ "rolloutPercent": 50 }

The release rail, carrying data

An asset pack is a zip of files your app reads at runtime, moving down the same rail a code push bundle moves down: the same channels, the same staged rollout, the same signing, the same automatic halt, the same rollback. It is what a stack that cannot take code push gets instead, which today means Flutter, Swift and Kotlin.

It is not a consolation prize, and it is worth saying why. Most of the incidents a mobile team wants updates over the air for are a wrong price, a wrong word, or an image that should not have shipped, and every one of those is data. What an asset pack cannot do is change what the app does, because that would be code, and a compiled app is forbidden from downloading code.

Copy and translationsgood fitJSON or .arb files your strings come from. A wrong word fixed the same afternoon rather than next release.
Images and animationsgood fitA banner, an onboarding illustration, a Lottie file, a logo that changed.
Data tablesgood fitPrices, tiers, country lists, shipping rules: anything you would otherwise have hardcoded.
Small valuesuse configA single URL or a boolean belongs in remote config, not in a zip. Packs are for files.
Anything executableneverNot permitted for a compiled app, and a pack is not a way around that.

What the kind field means

The check in answer's update carries a kind: bundle for interpreted code, assets for a pack. It is derived from the app's platform rather than chosen per release, because an app is one stack for its whole life and a JS bundle uploaded to a Flutter app would only be a release no device could use.

A client must treat an absent kind as bundle: servers that predate asset packs never sent the field and only ever served bundles, so a missing value is not an invitation to guess.

Using one in Flutter

Once, at startup
import 'package:dbrij_ship/dbrij_ship.dart';
import 'package:path_provider/path_provider.dart';

final assets = ShipAssets(
  ship: ship,
  directory: await getApplicationSupportDirectory(),
);

// BEFORE the first frame: point at whatever pack is already on disk.
await assets.restore();

// Then, in the background: bring it into line with the server.
unawaited(assets.sync());

restore matters more than it looks. Without it the app shows its built in assets for one whole launch after every update lands, which reads to everybody as the update not having worked.

Reading a file, with the fallback that makes it safe
// A pack is an OVERRIDE, never a replacement. The app has to work with none
// of it: on first launch, offline, and under the kill switch, there is none.
final path = assets.path('copy/en.json');
final json = path != null
    ? await File(path).readAsString()
    : await rootBundle.loadString('assets/copy/en.json');

Not available on Flutter web, which has no writable directory to unpack into. Flags and config work there as normal.

How a pack is trusted

The zip is checked against the sha256 the API served over TLS before anything is written where the app might read it, so a file swapped in object storage is refused. Entries that climb out of their own directory are refused too, and a pack is unpacked beside its target and moved into place, so a process killed halfway through never leaves half a pack for the next launch to read.

Signature checking is optional here, and that is a deliberate difference from code push worth understanding rather than glossing. The signature's extra value is that it survives a compromise of the API itself, because the signing key lives apart from it. For code that matters enormously: even a compromised server cannot make a device run a bundle. For data the margin is thinner, since an API that could serve you a forged pack could already serve you forged config. So the Flutter package does not pull a crypto library into every app to close a gap most apps do not have. Supply a verifier and it is checked.

Verifying the signature too, with package:cryptography
ShipAssets(
  ship: ship,
  directory: dir,
  signPublicKey: 'MCowBQ…',   // Setup card
  verifier: ({required sha256Hex, required signatureBase64, required publicKeyBase64}) async {
    final algorithm = Ed25519();
    final key = SimplePublicKey(base64.decode(publicKeyBase64), type: KeyPairType.ed25519);
    return algorithm.verify(
      utf8.encode(sha256Hex),
      signature: Signature(base64.decode(signatureBase64), publicKey: key),
    );
  },
);

Rollback works the same

Rolling back an asset pack recalls it: devices holding it delete it on their next check in and go back to the assets built into the store binary. The beacons are the same too, so a pack that fails to unpack on enough devices freezes its own rollout.

Changing a screen, not just its contents

Asset packs move the files an app reads. This moves the screens themselves: layout, order, which fields a form asks for, whether a promotion appears. On Flutter, where the alternative is a store review, that is the difference between fixing a checkout on Tuesday and fixing it a week on Thursday.

It works through Remote Flutter Widgets, the Flutter team's own package, and the reason a compiled app may do this is worth understanding before you build on it. A blob can only compose widgets the app already registered: it names them and arranges them, the way HTML names elements a browser already implements. It cannot introduce a widget, a function, or a line of Dart. So it is declarative data, not downloaded code, which is exactly the distinction the stores draw.

Which means the real design decision is the widget catalogue you register, not the blobs. Anything your widgets cannot already do still needs a store release, and a blob is not a way around that.

Install and wire

pubspec.yaml
dependencies:
  dbrij_ship: ^0.1.0
  dbrij_ship_rfw: ^0.1.0
Once, at startup
final ui = ShipRemoteUi(
  assets: assets,                    // your ShipAssets
  widgets: createMaterialWidgets(),  // plus your own, below
);

await assets.restore();              // the pack already on disk
await ui.loadAll(['home', 'checkout']);
ui.publishShipValues(ship);          // flags and config, where a blob can read them

Blobs live in the pack at ui/<name>.rfw, so they inherit everything the release rail already does: channels, staged rollout, automatic halt, rollback.

The fallback is not a degraded mode

Rendering one
ShipRemoteWidget(
  ui: ui,
  name: 'checkout',
  // REQUIRED, and the most important line here.
  fallback: (context) => const LocalCheckoutScreen(),
  onEvent: (name, args) {
    if (name == 'checkout.begin') startCheckout(args);
  },
)

A remote screen is absent on first launch, absent offline before any pack has landed, absent under the kill switch, and absent the moment you roll a pack back. An app whose checkout only exists on the server is an app that stops selling when the server has an opinion. So the local screen is the app, and the remote one is an override. The API will not let you forget: fallback is required.

Events go to your code, always

A blob names an event and passes arguments. What that DOES is yours, in Dart, in the store build. Remote data decides what to show and asks for things; local code decides what happens.

That boundary is the safety model and the store compliance argument at the same time: nothing downloaded ever executes. Keep it. An event handler that interprets a string from the blob as an instruction is how a legitimate feature turns into a rejected build.

Your own widgets

Registering a library
ShipRemoteUi(
  assets: assets,
  widgets: LocalWidgetLibrary({
    'ProductCard': (context, source) => ProductCard(
          title: source.v<String>(['title']) ?? '',
          priceMinor: source.v<int>(['priceMinor']) ?? 0,
        ),
  }),
);
Using it from a blob
import core.widgets;

widget root = app.ProductCard(title: "Coffee", priceMinor: 250000);

A blob referencing a widget you did not register renders the RFW error box rather than crashing, and a blob that does not decode at all falls back to your local screen. Neither is a substitute for shipping to your staging channel first, which is what channels are for.

What this is not

It is not code push, and treating it as though it were is the way to get into trouble. Business logic, network calls, new native capability and anything your registered widgets cannot express all still live in the store build. What you get is the presentation layer, and on a stack with no code push at all that is a great deal more than nothing.

The half OTA cannot fix

Code push moves JavaScript. The store moves everything else: native modules, permissions, the minimum OS. So Ship carries a version policy for the store binary itself, set on an app's App version tab, and every device learns its verdict on the next check in.

Latest version*stringWhat is in the stores now. Devices below it get a nudge. Nothing is served without this: no latest version, no verdict.
Minimum versionstringThe oldest build you still support. Below it the verdict comes back required: true, which means block, not nudge.
iOS / Android store URLstringWhere the button goes. The device's platform picks which one it gets.
MessagestringYour words. Left empty, Ship writes a sensible line for each case.

Handling the verdict

The answer arrives as binaryUpdate on every check in, ready to show. It is null when the device is current or when no policy is set.

A blocking gate and a nudge, from one object
const { binaryUpdate } = await ship.checkIn();

if (binaryUpdate?.required) {
  // Below the minimum: this build is not supported any more.
  showBlockingScreen(binaryUpdate.message, binaryUpdate.url);
} else if (binaryUpdate) {
  // Behind, but fine: offer it once and let them dismiss it.
  showUpdateBanner(binaryUpdate.message, binaryUpdate.url);
}

Version strings compare numerically segment by segment, so 1.10.0 is correctly newer than 1.9.0. Send the real build version: a hardcoded binaryVersion that nobody bumps means every device reports the same number forever, adoption reads as zero, and the block never fires for the people who need it.

How the two versions relate

They are separate ladders and it is worth being clear which is which. binaryVersion is the store build, set by your app project and reported on check in. A release's targetBinaryVersion says which of those builds a bundle may land on. The release label is your own name for the bundle and means nothing to the store.

The practical rule: when you ship a store build with new native code, bump the binary version, then target new bundles at it. Old phones keep getting bundles built for the old binary until they update, which is exactly what you want and the reason targeting exists.

Two endpoints, no account

Everything a device does is these two calls. The SDK is a convenience over them; a stack without one loses nothing but the typing. Base URL https://api.dbrij.com/api/v1.

POST/public/ship/checkapp key

Check in

One round trip: flags, config, the kill switch and the update decision, resolved for THIS device. No account and no bearer token; the app key is the whole credential, exactly as it is public in your binary. Rate limited to 120 per minute per caller.

PATH / BODY PARAMETERS

appKey*stringThe shp_ key from the app's Setup card.
deviceId*stringYour own stable per install id. It is the rollout bucketing handle and the unit the device meter counts, so it MUST persist across launches.
channelstringChannel name, default production.
binaryVersionstringThe store build's version, e.g. 1.2.0. Without it a release cannot be targeted and the version policy stays silent.
currentReleasestringThe releaseId this device is running, so the server knows whether to serve, hold or revert.
platformstringios, android, or anything descriptive. Chooses which store link a version notice carries.
sdkVersionstringFree text, for your own metrics.
Request
curl -X POST https://api.dbrij.com/api/public/ship/check \
  -H "Authorization: Bearer $DBRIJ_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "appKey": "shp_…",
  "deviceId": "shd_…",
  "channel": "production",
  "binaryVersion": "1.2.0",
  "currentRelease": "rel_…",
  "platform": "ios"
}'
Request body
{
  "appKey": "shp_…",
  "deviceId": "shd_…",
  "channel": "production",
  "binaryVersion": "1.2.0",
  "currentRelease": "rel_…",
  "platform": "ios"
}
Response 200
{
  "success": true,
  "data": {
    "killSwitch": false,
    "flags": { "new_checkout": true },
    "config": { "banner": "Free delivery this week" },
    "update": {
      "releaseId": "rel_…",
      "label": "1.4.2",
      "mandatory": false,
      "url": "https://…",
      "sizeBytes": 1840221,
      "sha256": "9f2c…",
      "signature": "MEUCIQ…"
    },
    "revert": false,
    "binaryUpdate": null
  }
}
POST/public/ship/reportapp key

Report a release outcome

The beacons automatic halt is computed from. A release whose failures outrun its applies freezes itself and notifies the owner, which only works if your app actually sends these. Returns 204. Rate limited to 60 per minute.

PATH / BODY PARAMETERS

appKey*stringThe app key.
deviceId*stringThe same id you check in with.
releaseId*stringThe release being reported on.
event*"applied" | "failed" | "crashed" | "reverted"What happened to it on this device.
detailstringA short reason, kept for the release history.
Request
curl -X POST https://api.dbrij.com/api/public/ship/report \
  -H "Authorization: Bearer $DBRIJ_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "appKey": "shp_…", "deviceId": "shd_…", "releaseId": "rel_…", "event": "applied" }'
Request body
{ "appKey": "shp_…", "deviceId": "shd_…", "releaseId": "rel_…", "event": "applied" }

Returns 204 (no body).

Reading the answer

killSwitchbooleanTrue means serve nothing: use the defaults built into the app. Do not cache this answer.
flagsRecord<string, boolean>Already resolved for this device; rollout bucketing has been applied server side.
configRecord<string, unknown>Resolved for this device's channel.
updateobject | nullA bundle to download, verify and apply. Null when the device is current.
revertbooleanThe running release was halted or rolled back: go back to the store build.
binaryUpdateobject | nullThe store version verdict. Absent on older servers; treat that as null.

How a bundle is trusted

Every release is hashed and signed server side with a private key that belongs to your app alone and never leaves Dbrij. On the device, the native plugin checks the sha256 and verifies the ed25519 signature against the public key you pinned in the binary before a single byte is written into place. Unverified bytes never land.

On Android below 13 the platform has no Ed25519, so the plugin refuses the update and the store binary keeps running. Fail closed: a device that cannot check a signature does not get to skip checking it.

The store binary's own bundle is always the fallback, so a revert can never brick an install.

The store rules, honestly

Apple and Google both allow apps to download interpreted code, as long as it does not change what the app fundamentally is, and both forbid downloading native executable code. That line, interpreted versus compiled, is the whole reason code push exists for React Native and Capacitor and not for Swift or Kotlin, and why the dynamic layer exists for everyone: flags and config are data, not code.

Flutter sits on the line rather than on one side of it, and it is worth being exact about why. A release build compiles Dart to machine code, so a downloaded patch cannot simply be run. The Dart VM can also interpret code, though, which is the same door React Native goes through, and a specialist tool does reach it by shipping its own build of the Flutter engine. Ship does not, and the reason is honesty about cost rather than a claim it is impossible: doing it means maintaining an engine fork and a replacement build toolchain against every Flutter release forever. Flutter apps get the dynamic layer, which is most of what most teams reach for anyway.

Stay inside the spirit of it. Fixes, improvements and UI changes to the app you shipped are what this is for; a different app smuggled through an update is what gets developer accounts closed. Ship keeps an audit trail of every release for exactly this reason.

The kill switch is not a rollback

Worth separating, because they get reached for in the same panic. Rollback recalls one release: devices running it go back to the store build. Kill switch stops serving flags and config for the whole app, so every device runs on its built in defaults, and it does not move bundles at all. Bad release, roll back. Bad config value, kill switch.

Limits

Bundle size50 MBPer release. A code push bundle is JS and assets, never a whole app.
Check in120 / minutePer caller. Once per launch and once per foreground is the intended shape; polling in a loop is not.
Report60 / minutePer caller.
Rollout1 to 100Deterministic per device and per release.

Errors

The device endpoints answer plainly, and the SDK throws on a non 2xx so your own catch decides what happens. Whatever you do, do not let it be a crash: a failed check in should leave the app running on its built in defaults, which is why every example here ends in a catch that swallows.

400Bad requestA malformed body. Usually a missing appKey or deviceId.
404Unknown appThe app key does not match an app. Check you copied the whole thing.
429Rate limitedBack off and try on the next launch. Never retry in a tight loop.

What it costs

Ship is a flat monthly subscription with 2,000 monthly active devices included across all your apps. Past that, each started block of 1,000 extra devices bills as a metered overage.

Check ins and updates never stop at the cap. A mobile release that silently stops updating is worse than a bill, and you would find out about it from your users.

Dbrij is Nigeria’s company operating system: the first Nigerian built platform to put team chat, video meetings, email, HR, tax compliant payroll, customer support, marketing, analytics and cloud hosting in one product on one subscription.

Everything on Dbrij

More from Dbrij