Billing
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.
| Field | Required | Description |
|---|---|---|
type | Yes | free, paid or included. |
support | No | { email, url } — shown in the Marketplace and the Cockpit. |
plans | For paid | Subscription plans. At least one. |
trial | No | { days } — a free trial before billing starts, 1 to 90 days. |
categories | No | Marketplace categories for discovery, e.g. ["commerce", "integration"]. |
available_countries | No | ISO 3166-1 alpha-2 codes. Omit or use ["*"] for everywhere. |
listing | No | The 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, and a private app still needs a billing.json. See Private or Marketplace.
type: "free"
{
"$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"
{
"$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:
| Field | Required | Description |
|---|---|---|
id | Yes | Lowercase, hyphenated, unique. |
name | Yes | Display name. String or locale map. |
currency | Yes | ISO 4217 code, uppercase, e.g. EUR. |
price | Yes | { monthly, yearly? } in the declared currency — whole units, not cents. |
description | No | One line about what the plan includes. |
features | No | Bullet list shown in the Marketplace. Display only. |
limits | No | Map 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:
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
limitsentry 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;
403with 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
"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"
{
"$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.
"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
- Private or Marketplace — the reach half.
- Consent — what the merchant reads before installing.
- Publish and install — how a listing becomes an install.