Publish an App on the Marketplace

Take a private App to a listing any tenant can install — the manifest fields a listing needs, pricing in billing.json, the consent screen you are writing, publishing the version, and what changes once other people's tenants depend on you.

Most partner work starts as a private App built for one named customer. This page is the sequence that turns one into a listing any tenant can find and install: what to decide first, which fields a listing actually needs, how to publish and verify the install, and what stops being optional the moment somebody else's tenant depends on you.

This is for whoever owns the commercial side of an App that already works. You need an App deployed and verified on its first tenant (see Deploy with the CLI) and the authority to make pricing and support commitments on your organisation's behalf.

Decide whether it should be public at all

manifest.type decides who may ever install your App. private means the owner tenant only, never listed. public means any tenant, once you publish it — publishing is a step you run yourself, not a review you wait for.

There is nothing second-class about private. It is a full App: its own tables, its own capabilities on the public gateway, its own Cockpit screens and schedules. The only difference is reach.

Two things about the decision are easy to get wrong.

Reach and pricing are independent. manifest.type declares reach, billing.json declares pricing, and neither can be inferred from the other. A public App can be free. An App with no billing.json at all is private to you — the file is what a listing is built from, so going public means writing one.

Publishing is effectively a one-way door. Going from public back to private does not uninstall the App from tenants who already have it.

You cannot rehearse it on somebody else's tenant first. A private App installs only on its owner tenant, and a public App cannot be installed until it is published — so the first install by a tenant you do not control happens after the door has shut. What you can do beforehand is read schema.json for anything specific to one customer's business, read your handlers for hardcoded codes, thresholds and locales, and run the App on your own tenant against data shaped like somebody else's. Step 6 is where the real thing gets tested.

1. Finish the manifest identity

A listing is rendered from fields you may have left thin while there was one customer who already knew who you were.

manifest.json
{
  "name": "serials",
  "vendor": "acme",
  "version": "1.2.0",
  "type": "public",
  "title": { "en": "Device Serials", "de": "Seriennummern" },
  "description": "Tracks device serial numbers from receipt through sale and into service.",
  "icon": "assets/icon.svg"
}

title is the display name in Cockpit and the Marketplace, and it is required. description and icon are optional in the schema and not optional in practice: the description is what a merchant reads in a search result, and it doubles as the default OpenAPI tag description.

title, description and every merchant-facing string accept either a plain string or a map of BCP-47 language tag to text. In the map form, en is required — Cockpit falls back to it when its UI locale has no entry, so a half-translated manifest degrades to English rather than to a blank label.

name can never change, and treat vendor the same way. Together they namespace your tables, prefix your event names and identify the App in the registry; a push whose name does not match the registered App is rejected outright. If you are renaming anything, do it before you publish, not after. The same holds for anything else a caller can see once tenants you do not control are calling you: a break is a new route, never a changed one. See Versioning.

2. Declare pricing

billing.json carries everything the Marketplace shows that is not identity — pricing here, and the listing copy and your grant reasons further down this page. type is the field every App must declare: free, paid or included. A paid App must also declare price.

billing.json
{
  "$schema": "https://schemas.revenexx.com/billing.schema.json",
  "type": "paid",
  "price": { "currency": "EUR", "monthly": 49 },
  "support": { "email": "support@acme.example", "url": "https://docs.acme.example" },
  "trial": { "days": 14 },
  "categories": ["commerce"],
  "available_countries": ["*"]
}

The schema rejects any field it does not name, so the shape above is the whole of it. What costs time is what is not in it.

One App, one price. There are no plan tiers to choose between, so there is nothing to name them and no plan id to keep stable. If your product needs two price points, that is two Apps.

monthly is in whole units of the declared currency, not cents. "monthly": 49 with "currency": "EUR" is forty-nine euros.

There is no yearly price to declare. A yearly price is derived from the monthly one, not written down by you.

Usage limits are yours to enforce, and there is no field for them. If your pricing implies a cap, nothing counts on your behalf — your handler is what refuses the write, with a status the caller can act on and a message that names the limit. Advertise a cap only if you will enforce it, and enforce it on the write rather than the read: blocking a list punishes a merchant for data they already have. See Billing.

Fill in support. It is where the merchant's own staff look when something breaks at 4 p.m., and an empty block sends them to whoever sold the project.

categories is how a merchant finds you; pick the ones that describe what the App does, because a listing in six categories reads as a listing in none. available_countries takes ISO 3166-1 alpha-2 codes, and ["*"] or omitting it means everywhere.

3. Write the listing

billing.json's listing block is the Marketplace copy. Every field in it is optional in the schema and not optional in practice, and all merchant-facing text accepts a locale map.

billing.json
"listing": {
  "longDescription": {
    "en": "# Device Serials\n\nTrack serial numbers from receipt through sale and into service."
  },
  "highlights": [
    { "en": "Serial registry with full service history" },
    { "en": "Warranty windows resolved per market" }
  ],
  "features": [
    {
      "title": { "en": "Serial registry" },
      "description": { "en": "Register, look up and retire device serials." },
      "icon": "hashtag"
    }
  ],
  "publisher": { "name": "Acme GmbH", "kind": "partner", "website": "https://acme.example" },
  "languages": ["de", "en"],
  "compatibility": { "min_platform_version": "1.0.0" }
}

Keep it honest about what is built. A highlight describing a feature that depends on an unproven platform grant is a promise you cannot keep yet, and the merchant finds out during the trial.

listing also accepts media, industries, vendors, rating and changelog.

Alongside listing, billing.json takes a legal block — privacy_policy, terms, dpa and a subprocessors list. These are what a customer has to be able to read before installing, so each is a URL that opens without an account. subprocessors names who is behind each host you declared as an outbound grant, and your privacy policy is where deletion belongs, because nothing on the platform answers an erasure request for you. See Billing.

4. Explain every grant

This is the step most Apps neglect, and it is the one that decides installs.

Your App gets no access by default. Everything it may reach is in manifest.permissions — the access register the gateway enforces at runtime and the platform shows the merchant when they install.

Claim less first

Editing the register is cheaper than justifying it, so do it before you write a word of explanation.

Grant per operation, so ["read", "create"] on a ledger is a safety property you can point at rather than a habit; ["read", "create", "update", "delete"] everywhere tells the merchant you did not think about it. Narrow an outbound host and its paths, because { "outbound": "*" } makes the register unanswerable. And do not declare an unproven grant speculatively — a merchant consenting to something that does not work yet will ask why.

manifest.json
"permissions": [
  { "entity": "device_serials", "access": ["read", "create", "update"] },
  { "capability": "orders.get", "compatible": "^1.0" },
  { "outbound": "api.acme-service.example", "paths": ["/v2/warranties/*"] }
]

Then explain what is left

billing.json's listing.scopes carries one entry per grant you want explained:

billing.json
"listing": {
  "scopes": [
    { "scope": "orders.get", "reason": { "en": "Link a registered serial to the order that sold it." } }
  ]
}

Nothing joins a scope string to a grant automatically. It is a label you choose and the merchant sees verbatim, so write the grant's own key — orders.get for { "capability": "orders.get" } — and the two read as one thing. A scope naming no grant you hold is just a different string on the page.

Write one for every grant, including the outbound ones. A reason is a sentence about the merchant's business, not about your architecture.

WriteNot
"Link a registered serial to the order that sold it.""Requires read on orders."
"Register warranties with the manufacturer.""Outbound HTTP to api.acme-service.example."

An unexplained grant is not neutral. A merchant reading a bare orders.get with no reason has to decide what you might do with it, and the safe assumption is rarely the flattering one.

5. Publish a version

Flip type to public, bump version, and deploy. Publishing is what lists the App in the Marketplace; a tenant who installs it gets your latest published version.

Terminal
# "type": "public" and a bumped "version" in manifest.json
revenexx deploy app --owner "$REVENEXX_TENANT"

revenexx deploy app builds, publishes and installs by default. --no-publish skips the Marketplace publish and still installs; --no-publish --no-install builds and stops there. Skipping the publish on a public App's first deploy fails the install that follows it, because a public App that is not published yet cannot be installed.

Unpublishing is not a recall. It takes the App off the Marketplace so nobody new can install it. It does not uninstall it from tenants who already have it, and it does not stop their traffic.

It is also a switch on the App rather than on one version, so it withdraws your last good version along with the bad one — there is nothing left listed for a new merchant to install. When you find a bad version, unpublishing stops the bleeding for new installs; fixing the tenants already on it means shipping a new version.

6. Verify the install path, not just your install

You have only ever installed this App yourself. A Marketplace install differs in one way that bites:

app.installed does not reliably fire on a Marketplace install. An App that seeds its defaults only in an install-event handler reaches a merchant with an empty configuration and no obvious reason why.

You cannot reach in and fix that from outside. An API key identifies one tenant, so no credential you hold points at a merchant's tenant, and nobody is going to call a seeding route there on your behalf — see Architecture. Seeding is the App's own job, on two paths: seed on the app.installed event, and have the App ensure its defaults on first use as well, so a missed event costs nothing. Make both idempotent by checking before you write, because both may land.

On a tenant you own, expose that seed as an idempotent /defaults capability too. It is how you prove the seeding works without waiting for an install:

Request
curl -X POST https://api.revenexx.com/v1/serials/defaults \
  -H "X-Revenexx-Tenant: <YOUR_TENANT_SLUG>" \
  -H "X-Revenexx-Api-Key: rvxk_..." \
  -H "Content-Type: application/json" \
  -d '{}'

Then install onto a staging tenant that is not the one you built the App for, and work through Verifying live there. That is the first honest test of everything you assumed about the data.

What changes once other people's tenants depend on you

Five things change.

You no longer know the data

Row counts, label lengths, null columns, locales and currencies all vary, and a screen that renders beautifully for your first customer is the one every other merchant judges you by. Every business rule you hardcoded becomes a setting, a vocabulary, or a decision you make for everybody and defend.

Versioning discipline

With one customer you can coordinate a change. With fifty you cannot. A breaking change is three things at once:

  • a new major version,
  • a new capability key,
  • and a new route.

You do not change the meaning of serials.list at GET /v1/serials; you define a new capability on its own path, and both serve traffic until callers move. Deprecating the old one is a documentation act — say so in its description, point at the replacement, leave the route serving. See Versioning.

You cannot assume which version a tenant is on

Whether and when an installed tenant moves to your next version is decided outside your App, and not something this page can state accurately today. Do not design around an assumed upgrade window. What you can rely on is the contract discipline above — and it is discipline, not a platform property: if you keep every break on a new route, a tenant on an older version keeps working whether or not anybody upgrades them. Break that rule once and nothing catches it for you.

A new grant is a new thing the merchant consents to, whatever the version number says — and this one is not left to your conscience. The platform records which register a tenant consented to and flags an install whose grants have since widened, so an escalation waits on the merchant rather than taking effect quietly.

That makes the discipline cheap to keep and expensive to skip. Add the reason in the same commit as the grant, remove the grant in the same version you remove the code, and say what changed in the release notes. Six versions of drift and your register describes an App that no longer exists.

An uninstall is your handler's decision

app.uninstalled is delivered to Apps that listen for it. What that handler does is yours to decide, and the usual right answer is nothing destructive: cancel outbound work, stop expecting to be called, leave the rows alone, so a merchant who uninstalls and reinstalls still has their data.

That is a choice you are making, not a platform retention rule and not an answer to an erasure request. If a merchant asks you to delete their data, that is yours to handle — which is why billing.json's legal.privacy_policy is where you say how, in a document they could read before they installed.

What's next

If you read one more page before publishing, make it Consent — step 4 is the one that decides installs.

Was this page helpful?