Billing

billing.json — free, paid or included, plans and trials, categories and country availability, the Marketplace listing, and why billing.limits is 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. type is the only required field.

FieldRequiredDescription
typeYesfree, paid or included.
supportNo{ email, url } — shown in the Marketplace and the Cockpit.
plansFor paidSubscription plans. At least one.
trialNo{ days } — a free trial before billing starts, 1 to 90 days.
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.typeprivate or public. A public app can be free, and a private app still needs a billing.json. 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" },
  "trial": { "days": 14 },
  "categories": ["commerce", "analytics"],
  "plans": [
    {
      "id": "serials-starter",
      "name": { "en": "Starter" },
      "description": { "en": "Up to 5 000 tracked devices." },
      "currency": "EUR",
      "price": { "monthly": 49, "yearly": 468 },
      "features": [
        { "en": "Serial registry" },
        { "en": "Service history" }
      ],
      "limits": { "tracked_devices": 5000 }
    },
    {
      "id": "serials-professional",
      "name": { "en": "Professional" },
      "currency": "EUR",
      "price": { "monthly": 149, "yearly": 1428 },
      "features": [{ "en": "Unlimited devices" }, { "en": "Warranty automation" }]
    }
  ]
}

Plan fields:

FieldRequiredDescription
idYesLowercase, hyphenated, unique.
nameYesDisplay name. String or locale map.
currencyYesISO 4217 code, uppercase, e.g. EUR.
priceYes{ monthly, yearly? } in the declared currency — whole units, not cents.
descriptionNoOne line about what the plan includes.
featuresNoBullet list shown in the Marketplace. Display only.
limitsNoMap of limit name to number. Declared here; not enforced by the platform on your behalf.

A plan id is a stable identifier a subscription refers to, so treat it the way you treat a capability key: choose it once and do not rename it.

limits is your app's job to enforce

limits is a declaration and a promise to the buyer. The platform does not enforce it for you.

If your app has to refuse the 5 001st device, your app is what refuses it. That means reading the tenant's plan and applying the limit in your own code:

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:

  • Declare a limit only if you will enforce it. A limits entry your code ignores is a number in a Marketplace listing 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.

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.read",
      "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 permission key with a reason, and the reason is what the merchant reads on the consent screen when deciding whether to install. Write it as a sentence about their 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?