Billing & Events

App Marketplace billing models and event-driven architecture

This page is for app developers and covers two related-but-separate topics: how your app gets paid (if it does) when distributed through the App Marketplace, and how your app communicates with other apps once it's installed.

Billing is about distribution. If you're shipping a free internal tool, you can mostly skip the first half — declare type: "free" and move on. If you're publishing a paid public app, the billing and plan model below is what you need.

Events is about runtime architecture. Apps publish events when interesting things happen ("order created", "customer activated"). Other apps subscribe to those events to react. This is how the platform stays loosely coupled — if Apps had to know about each other through direct API calls, every new app would have to be wired into every existing one. The event bus makes the integration N+1 instead of N², and it makes the order of installation irrelevant.

A useful rule of thumb for when to use events vs. direct calls vs. workflows:

  • Direct API call when the caller needs an answer right now (e.g., "is this product in stock?"). Synchronous.
  • Event when something happened and other apps should react without the publisher caring who. Asynchronous, loosely coupled, fire-and-forget from the publisher's view.
  • Integration Studio workflow when the reaction involves external systems (ERP, CRM, etc.) or needs durable execution with retries. Async with persistence.

The rest of this page covers each topic in detail.

App Marketplace Billing (billing.json)

The billing.json file defines how your app is priced and distributed in the App Marketplace.

Three Billing Models

The type field picks one of three models, and they line up with three distribution stories: give the app away, sell it on a subscription, or fold it into a larger package the customer already pays for.

1. Free

A free app costs the tenant nothing, carries no usage limits, and still appears in the Marketplace for anyone to install. The declaration is almost empty — a type and a support contact:

JSON
{
  "$schema": "https://revenexx.com/schemas/billing.schema.json",
  "type": "free",
  "support": {
    "email": "support@example.com",
    "url": "https://docs.example.com"
  }
}

This is the model for internal tools and anything you're shipping at no charge; there's nothing more to configure.

2. Paid (Subscription Plans)

A paid app is sold through one or more subscription plans, with the platform's billing handling the actual money. You describe the plans — typically a cheaper entry tier and a fuller one — and each plan carries its price, the features it advertises, and any usage limits. The example below offers a capped "Starter" alongside an unlimited "Professional":

JSON
{
  "type": "paid",
  "support": {
    "email": "support@example.com",
    "url": "https://docs.example.com"
  },
  "plans": [
    {
      "id": "orders-starter",
      "name": "Starter",
      "description": "Up to 500 orders/month",
      "currency": "EUR",
      "price": { "monthly": 49, "yearly": 468 },
      "features": ["Order management", "Basic reporting"],
      "limits": { "orders_per_month": 500 }
    },
    {
      "id": "orders-professional",
      "name": "Professional",
      "description": "Unlimited orders + ERP integration",
      "currency": "EUR",
      "price": { "monthly": 149, "yearly": 1428 },
      "features": ["Unlimited orders", "Advanced reporting", "ERP sync"],
      "limits": {}
    }
  ]
}

Tenant Flow:

  1. Browse app in Marketplace
  2. See plans and pricing
  3. Select a plan
  4. Billing processes payment
  5. App installed and active

3. Included (Bundled with Subscription)

An included app isn't sold on its own — it comes as part of a larger billing package, the way a "Professional" plan might bundle Orders, Inventory, and Checkout together. You name the package it belongs to:

JSON
{
  "type": "included",
  "package": "professional",
  "description": "Included with Professional and Enterprise plans"
}

There's no separate charge and nothing for the customer to buy: if their plan includes the app, it's auto-installed when they sign up, and any change to their subscription is reflected automatically. This is how core apps reach customers without making each one a separate purchase decision.

Plan Fields

FieldTypeDescription
idstringUnique plan identifier (e.g., orders-starter)
namestringDisplay name (e.g., Starter)
descriptionstringShort description
currencystringISO 4217 code (e.g., EUR, USD)
priceobjectmonthly and yearly prices in cents (e.g., 49 = €0.49)
featuresarrayList of included features (display only)
limitsobjectUsage limits (e.g., orders_per_month: 500)

Limit Enforcement

Limits are declarative — you define them, the platform can enforce them:

JSON
"limits": {
  "orders_per_month": 500,
  "concurrent_integrations": 3,
  "storage_gb": 10
}

Your app can query limits via API:

JavaScript
const limits = await platform.getLimits('orders')
// { orders_per_month: 500, ... }

// In your app:
if (orderCount >= limits.orders_per_month) {
  throw new Error('Monthly limit reached. Upgrade to Professional.')
}

Event-Driven Architecture

Apps communicate asynchronously via events. This provides loose coupling and enables real-time integrations.

Publishing Events

In your app, publish events when important things happen:

JavaScript
// When an order is created
await platform.events.publish('order.created', {
  order_id: '123',
  customer_id: '456',
  total_amount: 1500.00,
  created_at: new Date().toISOString()
})

// When an order status changes
await platform.events.publish('order.status_changed', {
  order_id: '123',
  from_status: 'draft',
  to_status: 'confirmed',
  timestamp: new Date().toISOString()
})

Declare event names in manifest.json:

JSON
"events": {
  "emits": ["order.created", "order.status_changed"]
}

Subscribing to Events

Other apps (or the same app) can subscribe to events:

JavaScript
// Inventory app listens to order creation
platform.events.on('order.created', async (event) => {
  const { order_id, customer_id } = event
  
  // Fetch order details
  const order = await getOrder(order_id)
  
  // Update inventory
  for (const item of order.items) {
    await inventory.reserve(item.product_id, item.quantity)
  }
})

// Payment Gateway app listens to order confirmation
platform.events.on('order.status_changed', async (event) => {
  if (event.to_status === 'confirmed') {
    // Trigger payment processing
    await payment.charge(event.order_id)
  }
})

Declare subscriptions in manifest.json:

JSON
"events": {
  "listens": ["order.created", "order.status_changed"]
}

Event Characteristics

A few properties of the event bus shape how you should design around it. Delivery is asynchronous — the publisher fires an event and moves on, never waiting on its subscribers — but it's also guaranteed: the platform retries a subscriber that fails rather than dropping the event. Everything is scoped by tenant, so an event raised in one tenant can never reach another. Every event is kept in an event history for debugging and auditing, and anything that still can't be delivered after its retries lands in a dead-letter queue for a human to inspect rather than vanishing. Taken together, these are why events are safe to build real business logic on, not just notifications.

Event Payload

All events include standard fields:

JavaScript
{
  id: "evt_123",              // Event ID
  name: "order.created",      // Event type
  tenant_id: "acme-eu",       // Which tenant
  source_app: "orders",       // Which app emitted it
  timestamp: "2025-05-26T...", // When
  data: { ... }               // Your custom data
}

Idempotency

Design subscribers to be idempotent — same event processed twice = same result:

JavaScript
platform.events.on('order.created', async (event) => {
  // Check if we've already processed this event
  const processed = await inventory.findByEventId(event.id)
  if (processed) return
  
  // Process the event
  await inventory.reserve(...)
  
  // Store event ID to prevent reprocessing
  await inventory.recordProcessedEvent(event.id)
})

Error Handling

If a subscriber fails, the platform retries with exponential backoff:

JavaScript
platform.events.on('order.created', async (event) => {
  try {
    // Risky operation (e.g., ERP API call)
    await erp.sync(event)
  } catch (error) {
    // Platform will retry this
    console.error('ERP sync failed:', error)
    throw error  // Tell platform to retry
  }
})

Event Naming Convention

Use entity.verb format:

  • order.created
  • order.status_changed
  • order.cancelled
  • customer.activated
  • customer.deactivated
  • app.installed
  • app.upgraded

Real-World Example: Order Workflow

1. Customer creates order (Orders app)

JavaScript
// In Orders app
const order = await createOrder(customerId, items)

// Publish event
await platform.events.publish('order.created', {
  order_id: order.id,
  customer_id: customerId,
  items: items,
  total: order.total
})

2. Inventory app listens and reserves stock

JavaScript
// In Inventory app
platform.events.on('order.created', async (event) => {
  for (const item of event.items) {
    await inventory.reserve(item.product_id, item.quantity)
  }
})

3. Payment Gateway app sends invoice

JavaScript
// In Payment Gateway app
platform.events.on('order.created', async (event) => {
  await payment.createInvoice(event.order_id, event.total)
})

4. Integration Studio workflow syncs to ERP

JavaScript
// In Integration Studio (visual workflow)
Trigger: order.created
├─ Step 1: Fetch order details (Orders API)
├─ Step 2: Transform to SAP format
├─ Step 3: POST to customer's ERP via HTTP
└─ Step 4: Publish event erp.order_synced

5. Orders app listens for completion

JavaScript
// In Orders app
platform.events.on('erp.order_synced', async (event) => {
  // Update order status to "synced"
  await updateOrderStatus(event.order_id, 'erp_synced')
})

Result: Entire workflow orchestrated via event-driven architecture, no hardcoded API dependencies.

Best Practices

Billing

Good billing is mostly about being clear and uncomplicated. List every feature a plan includes in plans[].features and state its limits plainly in the description — a customer should never discover a cap after they've paid for it. Keep the plan structure simple; a couple of well-differentiated tiers beat a confusing matrix, and the same feature shouldn't cost different amounts in different plans. Price the monthly plans competitively and offer a modest yearly discount (10–15% is typical) to reward the longer commitment.

Events

Designing with events well comes down to two habits. The first is idempotency: because delivery is guaranteed through retries, a subscriber will occasionally see the same event twice, so it must produce the same result the second time — check whether you've already handled an event's ID before acting on it. The second is using events for what they're for: publish one for every meaningful business action, name them consistently as entity.verb, and include enough data in the payload that a subscriber rarely has to call back for more. Conversely, don't reach for events when you need a synchronous answer — that's what direct API calls are for — and never put sensitive data in a payload or forget to declare an event in manifest.json, or it won't flow at all. Above all, assume subscribers run eventually, not instantly, and design for that delay rather than against it.

Next Steps

Was this page helpful?