Billing

billing.json — free, paid or included, the single monthly price, trials, the legal URLs a customer reads before installing, categories and country availability, the Marketplace listing, and why usage limits are your app's job to enforce.

billing.json says how your app is priced and how it reads in the Marketplace. If you are shipping a private app for one customer, declare type: "free" and move on.

The authoritative schema is at https://schemas.revenexx.com/billing.schema.json, and it rejects any field it does not name. type is the only field required of every app; type: "paid" additionally requires price.

FieldRequiredDescription
typeYesfree, paid or included.
priceFor paid{ currency, monthly } — one price, in whole units of the currency.
supportNo{ email, url } — shown in the Marketplace and the Cockpit.
trialNo{ days } — a free trial before billing starts, 1 to 90 days.
legalNo{ privacy_policy, terms, dpa, subprocessors } — the URLs a customer reads before installing.
categoriesNoMarketplace categories for discovery, e.g. ["commerce", "integration"].
available_countriesNoISO 3166-1 alpha-2 codes. Omit or use ["*"] for everywhere.
listingNoThe Marketplace listing: description, highlights, features, media, publisher, scopes.

billing.json is pricing only. Who may install the app is manifest.type — private or public. A public app can be free. An app with no billing.json at all is private to you: the file is what a Marketplace listing is built from, so a public app needs one and a private app does not. See Private or Marketplace.

type: "free"

billing.json
{
  "$schema": "https://schemas.revenexx.com/billing.schema.json",
  "type": "free",
  "support": { "email": "support@acme.example", "url": "https://docs.acme.example" },
  "categories": ["commerce"],
  "available_countries": ["*"]
}

No charge, no plans, nothing to configure. This is what a custom app for one customer declares.

type: "paid"

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

Price fields:

FieldRequiredDescription
currencyYesISO 4217 code, uppercase, e.g. EUR.
monthlyYesPrice per month, charged to the tenant that installs the app.

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; writing one into billing.json is not possible and not needed.

Usage limits are your app's job to enforce

There is no limits field, and there is no platform that counts for you. If your pricing implies a cap, the cap lives in your code and nowhere else.

If your app has to refuse the 5 001st device, your app is what refuses it:

src/main.js
app.post('/serials', async (c) => {
  const { total } = await db.device_serials.page({ limit: 1 });
  if (total >= limitForPlan(c)) {
    throw new HttpError(402, 'your plan allows 5 000 tracked devices', 'plan_limit_reached');
  }
  return c.json(await db.device_serials.create(c.body), 201);
});

Three things follow:

  • Only advertise a limit you will enforce. A cap you describe in your listing copy and never check is a number that means nothing, and a support conversation later.
  • Fail with a status a caller can act on and a message that names the plan. "Your plan allows 5 000 tracked devices" is answerable; 403 with no body is not.
  • Enforce on the write, not on the read. Blocking a list because the tenant is over a limit punishes them for data they already have.

trial

billing.json
"trial": { "days": 14 }

A free trial before billing starts, 1 to 90 days. Worth pairing with a generous default configuration: a trial where nothing works until the merchant configures six settings is not a trial.

type: "included"

billing.json
{
  "$schema": "https://schemas.revenexx.com/billing.schema.json",
  "type": "included",
  "support": { "email": "support@acme.example", "url": "https://docs.acme.example" },
  "categories": ["commerce", "configuration"],
  "available_countries": ["*"]
}

No separate charge — the app comes as part of a larger billing package. This is how the platform's own commerce apps reach tenants without each being a purchase decision.

What a customer has to be able to read before installing. Every field is a URL a person can open without an account — a policy behind a login is not a policy they can read.

billing.json
"legal": {
  "privacy_policy": "https://acme.example/privacy",
  "terms": "https://acme.example/terms",
  "dpa": "https://acme.example/dpa",
  "subprocessors": [
    {
      "host": "api.acme-service.example",
      "purpose": "Registers warranties with the manufacturer.",
      "dpa": "https://manufacturer.example/dpa"
    }
  ]
}
FieldDescription
privacy_policyWhat the app collects, what it does with it, how long it keeps it and how it is deleted.
termsWhat the customer accepts by installing.
dpaWhere, and on whose behalf, personal data is processed.
subprocessors{ host, purpose, dpa? } — who else sees data the app sends out of the platform.

subprocessors and permissions.outbound are the same list seen twice. The manifest's outbound grants say where data may go and default-deny everything else; subprocessors says who is behind that host in words a customer reads. A host in one and not the other is the thing a security review finds.

privacy_policy is also where deletion belongs. The platform does not answer an erasure request on your behalf — see Publish and install — so the page you link here is what tells a customer how you handle one.

categories and available_countries

categories is how a merchant finds you in the Marketplace. Pick the ones that describe what the app does, not every one it touches; 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. Narrow it when the app is genuinely country-specific — a tax or compliance app whose rules only apply in one jurisdiction is more useful listed only there.

listing

The Marketplace copy. Everything in it is optional, and all operator-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"
    }
  ],
  "scopes": [
    {
      "scope": "orders.get",
      "reason": { "en": "Link a registered serial to the order that sold it." }
    }
  ],
  "publisher": {
    "name": "Acme GmbH",
    "kind": "partner",
    "website": "https://acme.example"
  },
  "languages": ["de", "en"],
  "compatibility": { "min_platform_version": "1.0.0" }
}

listing.scopes is the part worth care. Each entry pairs a scope string with a reason, and both are shown to the merchant as you wrote them — the scope verbatim, the reason beneath it.

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

Write the reason as a sentence about the merchant's business, not about your architecture: "Link a registered serial to the order that sold it", not "requires read on orders". See Consent.

listing also accepts media, industries, vendors, rating and changelog. See the schema.

Practical guidance

Fill in support even for a private app. It is where a merchant's operator looks when something breaks, and an empty block sends them to whoever sold the project.

Keep the listing honest about what is built. A highlight describing a feature that depends on an unproven platform grant is a promise you cannot keep yet.

Revisit the listing when you add a permission. A new grant is a new line on the consent screen and deserves a new reason.

Next steps

Was this page helpful?