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.
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.
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_…)* | public | Identifies 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* | public | Pinned 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.
Flags and config, on any JS stack, with no native work and no store release beyond the one that carries this code.
npm i @dbrij/shipimport { 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
});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.
Leave storage out and the SDK keeps the device id in memory. It works, and it quietly breaks two things:
Any get/set pair works, sync or async: AsyncStorage, Capacitor Preferences, MMKV, localStorage, or six lines of your own.
storage: {
getItem: (k) => myStore.read(k), // string | null, or a promise of one
setItem: (k, v) => myStore.write(k, v),
}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.
npm i @dbrij/ship @dbrij/ship-react-native
cd ios && pod installThe 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.
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.
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
}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.
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.
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.
| up-to-date | status | Nothing to do, or the kill switch is on. |
| staged | status | Verified and written; it takes effect on the next launch. |
| applied | status | A mandatory update: the app is restarting now. |
| reverted | status | The running release was rolled back or halted; the store bundle is back. |
| failed | status | Download 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.
npm i @dbrij/ship @dbrij/ship-capacitor
npx cap syncA 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.
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.
dependencies:
dbrij_ship: ^0.1.0Flags, 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.
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.
}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 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.
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.
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.
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.
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.
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.
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.
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.
// 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 />;
}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.
const tiers = ship.getConfig<PriceTier[]>('price_tiers', BUILT_IN_TIERS);
const banner = ship.getConfig<string>('banner', '');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.
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.
| Mandatory | boolean | Applies with an immediate restart instead of on next launch. For the bugs that cannot wait; it is an interruption, so spend it carefully. |
| targetBinaryVersion | string | The 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. |
| rolloutPercent | number | Deterministic per device. Widen it as confidence grows; narrowing does not recall anything. |
| Rollback | action | Stops serving the release AND recalls it: devices running it revert to the store build on their next check in. |
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.
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.
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 10dbrij ship releases --app shp_app_id
dbrij ship rollout <releaseId> 50 --app shp_app_id
dbrij ship rollback <releaseId> --app shp_app_idThe 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.
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.
dart run dbrij_ship_rfw:pack ui_pack -o build/pack.zip
dbrij ship release build/pack.zip --app shp_app_id --rollout 10A 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.
For a pipeline that would rather call the API directly than install anything.
/ship/apps/:appId/releasessessionUpload 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* | string | Which channel this lands on. |
| label* | string | Your version label, e.g. 1.4.2. |
| targetBinaryVersion | string | The store binaries this bundle is compatible with, e.g. 1.2.x. Devices outside it are never offered it. |
| mandatory | boolean | Apply on receipt with a restart rather than on next launch. |
| rolloutPercent | number | 1 to 100. Bucketing is deterministic per device, so widening only ever adds devices. |
| notes | string | What changed, kept on the release forever. |
| bundleBase64* | string | The bundle bytes. React Native: your JS bundle. Capacitor: a zip of the built web assets. |
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": "…"
}'{
"channelId": "chn_…",
"label": "1.4.2",
"targetBinaryVersion": "1.2.x",
"rolloutPercent": 10,
"bundleBase64": "…"
}/ship/apps/:appId/releases/:releaseIdsessionWiden, 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
| rolloutPercent | number | Widen 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. |
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 }'{ "rolloutPercent": 50 }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 translations | good fit | JSON or .arb files your strings come from. A wrong word fixed the same afternoon rather than next release. |
| Images and animations | good fit | A banner, an onboarding illustration, a Lottie file, a logo that changed. |
| Data tables | good fit | Prices, tiers, country lists, shipping rules: anything you would otherwise have hardcoded. |
| Small values | use config | A single URL or a boolean belongs in remote config, not in a zip. Packs are for files. |
| Anything executable | never | Not permitted for a compiled app, and a pack is not a way around that. |
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.
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.
// 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.
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.
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),
);
},
);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.
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.
dependencies:
dbrij_ship: ^0.1.0
dbrij_ship_rfw: ^0.1.0final 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 themBlobs live in the pack at ui/<name>.rfw, so they inherit everything the release rail already does: channels, staged rollout, automatic halt, rollback.
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.
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.
ShipRemoteUi(
assets: assets,
widgets: LocalWidgetLibrary({
'ProductCard': (context, source) => ProductCard(
title: source.v<String>(['title']) ?? '',
priceMinor: source.v<int>(['priceMinor']) ?? 0,
),
}),
);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.
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.
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* | string | What is in the stores now. Devices below it get a nudge. Nothing is served without this: no latest version, no verdict. |
| Minimum version | string | The oldest build you still support. Below it the verdict comes back required: true, which means block, not nudge. |
| iOS / Android store URL | string | Where the button goes. The device's platform picks which one it gets. |
| Message | string | Your words. Left empty, Ship writes a sensible line for each case. |
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.
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.
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.
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.
/public/ship/checkapp keyCheck 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* | string | The shp_ key from the app's Setup card. |
| deviceId* | string | Your own stable per install id. It is the rollout bucketing handle and the unit the device meter counts, so it MUST persist across launches. |
| channel | string | Channel name, default production. |
| binaryVersion | string | The store build's version, e.g. 1.2.0. Without it a release cannot be targeted and the version policy stays silent. |
| currentRelease | string | The releaseId this device is running, so the server knows whether to serve, hold or revert. |
| platform | string | ios, android, or anything descriptive. Chooses which store link a version notice carries. |
| sdkVersion | string | Free text, for your own metrics. |
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"
}'{
"appKey": "shp_…",
"deviceId": "shd_…",
"channel": "production",
"binaryVersion": "1.2.0",
"currentRelease": "rel_…",
"platform": "ios"
}{
"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
}
}/public/ship/reportapp keyReport 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* | string | The app key. |
| deviceId* | string | The same id you check in with. |
| releaseId* | string | The release being reported on. |
| event* | "applied" | "failed" | "crashed" | "reverted" | What happened to it on this device. |
| detail | string | A short reason, kept for the release history. |
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" }'{ "appKey": "shp_…", "deviceId": "shd_…", "releaseId": "rel_…", "event": "applied" }Returns 204 (no body).
| killSwitch | boolean | True means serve nothing: use the defaults built into the app. Do not cache this answer. |
| flags | Record<string, boolean> | Already resolved for this device; rollout bucketing has been applied server side. |
| config | Record<string, unknown> | Resolved for this device's channel. |
| update | object | null | A bundle to download, verify and apply. Null when the device is current. |
| revert | boolean | The running release was halted or rolled back: go back to the store build. |
| binaryUpdate | object | null | The store version verdict. Absent on older servers; treat that as null. |
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.
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.
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.
| Bundle size | 50 MB | Per release. A code push bundle is JS and assets, never a whole app. |
| Check in | 120 / minute | Per caller. Once per launch and once per foreground is the intended shape; polling in a loop is not. |
| Report | 60 / minute | Per caller. |
| Rollout | 1 to 100 | Deterministic per device and per release. |
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.
| 400 | Bad request | A malformed body. Usually a missing appKey or deviceId. |
| 404 | Unknown app | The app key does not match an app. Check you copied the whole thing. |
| 429 | Rate limited | Back off and try on the next launch. Never retry in a tight loop. |
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.