Ship a breaking change to an App
You have an App that other tenants and other apps already call, and you need to change what a capability returns. You cannot ask every caller to move first, and you cannot see who they are. This guide takes one change from decision to verified release: you add the new capability beside the old one, mark the old one deprecated, ship both in the same release, and check the result on a tenant you own.
The example is the locations.availability route from Add a data model. Its response carries available as a plain number per item. You want it to say how that number was made up: on_hand, reserved and net. That turns a number into an object, so a caller that reads available as a number now breaks.
This is for developers who already deploy an App with the CLI. If the change also needs a new column, Ship a schema change covers that half; this page links to it instead of repeating it.
What a break costs you
You do not control when a tenant moves to your new version, and you cannot see who calls the old capability. Both lead to one rule: never take away a capability a caller might still use. A breaking change is a new capability, on a new route, next to the old one. This is the rule from Versioning; this guide is how to carry it out. The last section, What you cannot know, lists exactly what stays hidden from you.
1. Decide whether it is a break
The test is one question: could a caller written against the old contract notice? It is not "did the schema change". The full lists of safe and breaking changes are in Versioning. In short, you add things (a capability, an optional request field, a response field) or you change what callers already have.
For the example, available goes from a number to an object. That is a changed type, so it is a break. Adding available_by_location next to available would not be.
When the answer is no, stop reading here. Change the capability in place and bump a minor or a patch.
The platform runs its own check on every version you push, and it can only say yes to what it can see. It compares your contract with the highest approved version below the one you push, and works out which bump the difference needs. It counts as a break:
- a removed capability, operation or emitted event,
- a moved route, or a changed contract major version,
- a new required parameter or request field,
- a removed response field,
- a constraint added or changed on a column or entity that already existed.
A break needs a major bump, an addition a minor. Below 1.0.0 each is one step lower: a break needs a minor and an addition a patch. A version that declares less is rejected at once, and the message names the bump it needs (as of 2026-11). It cannot see everything. It compares field names, one level deep, not types, so a nested field is invisible even when renamed: a field that changes from a number to an object keeps its name, so the check does not see it. That is the change in this guide's example, and it would pass the push check unnoticed. A renamed field reads as one removed and one added, and a changed default or changed behaviour is invisible too. A clean push does not prove you are safe. Your own answer to the question is the test, and it is why you add a new capability instead of editing the old one.
dependencies range stops matching your new major is named in a warning on your push, and nothing blocks it. See Dependencies.2. Add the new capability and route
Give the new shape its own capability key and its own route. Leave the old one as it is.
The code below goes in the src/main.js and scripts/capabilities.js of the Locations app, where app, db, num and parseItems are the ones from Add a data model. app and db exist only inside buildApp(db), so put everything in the first block inside that function, with the mountCrud calls above it. It replaces the tutorial's existing app.post('/locations/availability', …) handler, so you do not end up with two of the same route. The old route keeps its old behaviour, as shown, and the new route is mounted beside it:
// The old handler's two reads, now shared by both routes.
async function loadStock() {
const locations = await db.locations.list({ where: { enabled: true }, order: 'priority.asc', limit: 1000 });
const ids = new Set(locations.map((l) => l.id));
return (await db.stock_levels.list({ limit: 10000 })).filter((r) => ids.has(r.location_id));
}
const sum = (rows, key) => rows.reduce((total, r) => total + Number(r[key]), 0);
function describe(item, stock) {
const rows = stock.filter((r) => (item.product_id && r.product_id === item.product_id) || (item.sku && r.sku === item.sku));
const on_hand = sum(rows, 'on_hand');
const reserved = sum(rows, 'reserved');
const net = num(on_hand - reserved);
return { on_hand, reserved, net, tracked: rows.length > 0, orderable: rows.length > 0 ? net >= item.quantity : false };
}
// The existing route. Same response as before: `available` stays a number.
app.post('/locations/availability', async (c) => {
const items = parseItems(c.body);
const stock = await loadStock();
return c.json({
availability: items.map((item) => {
const d = describe(item, stock);
return { product_id: item.product_id, sku: item.sku, requested: item.quantity, available: d.net, tracked: d.tracked, orderable: d.orderable };
}),
});
});
// The new route, on its own path. `available` is now an object.
app.post('/locations/availability-v2', async (c) => {
const items = parseItems(c.body);
const stock = await loadStock();
return c.json({
availability: items.map((item) => {
const d = describe(item, stock);
return {
product_id: item.product_id,
sku: item.sku,
requested: item.quantity,
available: { on_hand: d.on_hand, reserved: d.reserved, net: d.net },
tracked: d.tracked,
orderable: d.orderable,
};
}),
});
});
Share the logic between the two routes, so a fix lands in both. Never copy the old handler and edit the copy.
Declare the new capability with its own request and response schema. A capability is a plain object in the array scripts/capabilities.js writes (it is what the tutorials' def(...) helper returns); the request is the old one. These snippets assume the Locations tutorial App. If your App came from revenexx create app and you did not follow the tutorials, your routes and keys are /<app>/<entity> and <app>.<entity>.<op>, and there is no num or parseItems: write your own equivalents. The script only generates the CRUD entries, so add the same objects by hand to manifest.capabilities.json, whose hand-written entries the next npm run capabilities keeps. JSON has no constants, so write each schema inline instead of AVAILABILITY_REQUEST and the V2 response. Never put deprecated on a generated CRUD capability in that JSON, because a regeneration overwrites it; declare it in the script instead.
const AVAILABILITY_RESPONSE_V2 = {
type: 'object',
properties: {
availability: {
type: 'array',
items: {
title: 'ItemAvailabilityV2',
type: 'object',
properties: {
product_id: { type: ['string', 'null'], format: 'uuid' },
sku: { type: ['string', 'null'] },
requested: { type: 'number' },
available: {
type: 'object',
properties: { on_hand: { type: 'number' }, reserved: { type: 'number' }, net: { type: 'number' } },
},
tracked: { type: 'boolean' },
orderable: { type: 'boolean' },
},
},
},
},
};
// Add to the capabilities array:
{
type: 'define',
capability: 'locations.availability-v2',
version: '1.0.0',
summary: 'Batch availability with the breakdown. Replaces locations.availability.',
route: { method: 'POST', path: '/locations/availability-v2' },
request: AVAILABILITY_REQUEST,
response: AVAILABILITY_RESPONSE_V2,
},
Leave the old capability's request and response schemas exactly as they are. Changing them is the break you are avoiding, and the push check reads them.
3. Mark the old capability deprecated
A deprecation is a marker. It does not remove or slow anything. Add deprecated to the old capability's entry, as a key of the entry itself, with a date and the key that replaces it:
{
type: 'define',
capability: 'locations.availability',
version: '1.0.0',
summary: 'Batch availability across locations. Deprecated: use locations.availability-v2.',
route: { method: 'POST', path: '/locations/availability' },
request: AVAILABILITY_REQUEST,
response: AVAILABILITY_RESPONSE,
deprecated: { since: '2026-11-10', replacement: 'locations.availability-v2' },
},
Three things act on that marker (as of 2026-11):
- The gateway tells the caller, in its current source. A response from the deprecated route carries
Deprecation: @1794268800(thesincedate as Unix seconds) andLink: <locations.availability-v2>; rel="successor-version". The link names the replacement key, not a URL. Check both on your own tenant in step 6 before you rely on them. - Apps that require the capability get a warning. The next time one of them pushes a version, it is told it uses a deprecated capability. It is never blocked.
- The OpenAPI document does not mark it. The published spec shows no deprecated flag for these routes, so a person reading it sees only your
summaryanddescription. Say it there too, as the example does, and name the replacement.
Do not set a removal date and do not promise one. You cannot know that every caller has moved.
4. Handle schema and grants in the same release
A response change often drags a schema change or a new grant with it. Decide both now, so they ship with the capability and not after it.
Schema. If the new response needs a new column, follow Ship a schema change. The old code must keep working against the new schema.
Grants. If the new capability needs an entity, capability or outbound grant the old one did not, add it with its reason in the same commit. The merchant consented to an access register at install, and a wider one is a new decision for them. For an installed tenant that has not consented again, the new capability is not routed: its -v2 route does not answer there, while the old route stays routed (as of 2026-11). Where version pins are switched on, the platform also keeps that installation on its last consented version until the merchant agrees.
So the old route has to work for every tenant on any version, and it must never depend on the new grant. Say in your release notes what the new version asks for, in the merchant's words.
5. Release
Raise the version in manifest.json. For the example, that is a new major:
"version": "2.0.0"
Regenerate the capability list, then deploy to a tenant you own without publishing:
npm run capabilities
revenexx deploy app --tenant acme-scratch --no-publish
acme-scratch is a placeholder for the slug of a tenant you own, such as a scratch tenant; see Find your tenant slug. --tenant targets this one deploy and saves nothing, as in Deploying.
--no-publish matters here. A plain deploy app also publishes the newest approved version, so the Marketplace would offer your new major to new installs before you have checked anything (as of 2026-11). With the flag, the version is built and installed on that tenant only. Publishing is a separate, deliberate step at the end of step 6; see Publish and install. Where the platform reviews releases, a version goes live only once its round is approved; see When the release waits for approval.
If the push is rejected because you dropped a capability that is in use, the message names it. Put it back.
6. Verify on each tenant you can reach
You can reach your own tenants and no one else's. A green deploy is not proof. The build shows your declarations were accepted; the routes of an existing install are written when the version is approved and installed, and again when a pin, a release channel or a consent changes. Run these checks on each tenant you own.
$REVENEXX_API_KEY is a machine-to-machine key for that tenant; create one if you have none. Check that both capabilities are in the contract:
curl -s https://api.revenexx.com/v1/openapi.json \
-H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
-H "X-Revenexx-Tenant: acme-scratch" \
| grep -o '/locations/availability[-a-z0-9]*' | sort -u
Call the old route and read its headers. It must answer exactly as before, with available still a number, and carry the deprecation:
curl -si -X POST https://api.revenexx.com/v1/locations/availability \
-H "X-Revenexx-Api-Key: $REVENEXX_API_KEY" \
-H "X-Revenexx-Tenant: acme-scratch" \
-H "Content-Type: application/json" \
-d '{"items":[{"sku":"SKU-1","quantity":1}]}'
Look for Deprecation: @1794268800 and the Link header. Then call /locations/availability-v2 the same way and check that available is the object. If a header is missing, the header is not live where you are, and your description is the only warning your callers get.
Repeat on every tenant you own. A version running on one tenant tells you nothing about another.
When every check passes, publish. Every build needs a version nobody has used, so raise the patch (2.0.1) and deploy without the flag. The code must be exactly what you verified, with only the version changed:
revenexx deploy app --tenant acme-production
A private App is never published, so for one this step is the same command with --no-publish.
7. If it goes wrong: roll forward
There is no downgrade. The version tenants follow is the highest approved one by version number, and a lower number approved later does not move anyone back (as of 2026-11). A fix is a new, higher version.
- A bad new capability: ship a higher version that fixes it. The old capability kept serving the whole time, so callers that never moved are unaffected.
- Unpublishing does not recall anything. It takes the App off the Marketplace for new installs and leaves installed tenants running. It applies to the App, not to one version, so it withdraws your last good version from the listing along with the bad one. See Publish and install.
- A tenant pinned to an older version stays there until the tenant or an operator moves it. You cannot do that for them.
What you cannot know
- Who still calls the old capability. The platform stops you removing a capability that a tenant routes to your App or an installed app requires, and an event an installed app listens to. An API key identifies one tenant, so no key you hold shows you another tenant's traffic. Nothing tells you the last caller has moved. The
Deprecationheader reaches callers; nothing reports back to you. - Which version a tenant runs, and when it moves. Unless an installation is pinned, it follows your newest approved version. A tenant that opted in to release candidates runs your newest candidate before it is approved, as Ship a schema change describes. A pin is set by the tenant or an operator, never by you, and whether pins are switched on is a platform setting this page cannot confirm.
- Whether a range keeps a caller off your new version. A
dependenciesrange that no longer matches is named in a warning on your push. Acompatiblerange is recorded and never compared with a version. Whether anything enforces either one when a tenant installs or upgrades is not confirmed (as of 2026-11).
If you remove the old capability one day, do it in a new major, after warning callers through the deprecation for as long as you can afford, and accept that someone may still be calling it.
Where to go next
- Ship a schema change is the one to read next when the new response needs a column: the apply reaches every tenant at once, and the old code must still work.
- Versioning — the bump rules and what counts as a break.
- Consent — what a wider register asks of a merchant.
- Dependencies — the ranges your dependents declare.
- Publish on the Marketplace — versioning once other tenants depend on you.