Get Started with Aeion Billing

Connect a payment provider, license your modules, open the customer portal, charge cards, and refund mistakes — all in under 20 minutes. Stripe-first today; 20+ processors (PayPal, Square, Adyen, Razorpay, Braintree, Mollie, and more) share the same surface.

1

Configure a Payment Provider

Aeion stores provider credentials (Stripe, PayPal, Square, Adyen, Razorpay, and 15+ more) in the payment_providers collection. Each row carries the provider type, an isEnabled flag, and the credentials needed to call the provider's API.

Open Settings → Billing → Payment Providers in admin, or create one directly via auto-CRUD:

```bash POST /api/v1/payment_providers Content-Type: application/json

{ "type": "stripe", "isEnabled": true, "config": { "secretKey": "sk_live_xxx", "webhookSecret": "whsec_xxx", "publicKey": "pk_live_xxx" } } ```

Response (auto-CRUD envelope):

json { "data": { "id": "01J...", "type": "stripe", "isEnabled": true, "config": { "secretKey": "...", "webhookSecret": "..." }, "createdAt": "2026-05-20T10:00:00Z" } }

Point your provider's webhooks at:

POST /api/billing/webhooks/stripe POST /api/billing/webhooks/paypal POST /api/billing/webhooks/square POST /api/billing/webhooks/adyen POST /api/billing/webhooks/razorpay

The handler verifies the signature against the webhookSecret you stored, then fans the event out through the platform's event bus (billing.transaction.created, billing.subscription.canceled, etc.) so other modules can react.

> Need an env-var fallback for local dev? If no payment_providers row is enabled, the Stripe routes fall back to STRIPE_SECRET_KEY from the environment.

2

Issue a Module License

ActionEndpoint
Upgrade or downgrade tier`POST /api/licenses/:moduleId/upgrade`
Cancel`POST /api/licenses/:moduleId/cancel`
Suspend (e.g. payment failure)`POST /api/licenses/:moduleId/suspend`
Reactivate after suspension`POST /api/licenses/:moduleId/reactivate`
Trial days remaining`GET /api/licenses/:moduleId/trial-days`
3

Open the Stripe Customer Portal

Once a license has a stripeCustomerId, customers can update their card, change plan, or cancel themselves through Stripe's hosted Customer Portal. Aeion issues short-lived portal session URLs:

bash POST /api/billing/stripe/portal-session { "returnUrl": "https://app.example.com/admin/modules/subscriptions" }

json { "data": { "url": "https://billing.stripe.com/p/session/..." } }

Redirect the user to that URL. Stripe handles the UI; webhooks (Step 1) keep Aeion's module_licenses in sync.

Change plan from inside your app (skips the portal — useful for upsell prompts):

bash POST /api/billing/stripe/change-plan { "moduleId": "crm", "newTier": "enterprise", "newPriceId": "price_xxx" }

Prorated immediately by Stripe. The local license is updated with the new tier and previous tier recorded as upgradedFrom.

Record metered usage for usage-based pricing:

bash POST /api/billing/stripe/usage-record { "subscriptionItemId": "si_xxx", "quantity": 1500, "action": "increment" }

Read current usage across all active licenses:

bash GET /api/billing/stripe/usage GET /api/billing/stripe/usage?moduleId=crm

4

Charge a Customer

For one-off charges (deposits, add-ons, marketplace purchases) use the unified charge endpoint. It writes a row to transactions, applies an advisory lock for idempotency, and calls whichever provider you specify (defaults to Stripe).

bash POST /api/billing/charge { "amount": 4999, "currency": "USD", "provider": "stripe", "source": "marketplace", "sourceId": "module:crm:upgrade-addon", "idempotencyKey": "upgrade-2026-05-20-tenant-xyz" }

json { "data": { "id": "txn_01J...", "amount": 4999, "currency": "USD", "status": "succeeded", "provider": "stripe", "providerTransactionId": "pi_xxx", "createdAt": "2026-05-20T10:15:00Z" } }

The same idempotencyKey will return the original transaction instead of double-charging — safe to retry from a flaky network.

Hosted Checkout flow (for subscription signup):

bash POST /api/billing/checkout/sessions { "mode": "subscription", "lineItems": [{ "price": "price_xxx", "quantity": 1 }], "successUrl": "https://app.example.com/billing/success", "cancelUrl": "https://app.example.com/billing/cancel", "trialPeriodDays": 14, "allowPromotionCodes": true }

Returns { "data": { "id": "cs_xxx", "url": "https://checkout.stripe.com/..." } }. Redirect, let Stripe collect the card, and the webhook handler will activate the license.

Cancel a subscription cleanly:

bash POST /api/billing/subscriptions/sub_xxx/cancel { "cancelImmediately": false, "reason": "customer_request" }

Pass cancelImmediately: true for an instant cancel; otherwise it lapses at the period end.

5

Process a Refund

Refunds are a four-state pipeline: pendingapprovedprocessingsucceeded (or failed / canceled / requires_review). Customer-service staff request, finance approves, the system executes.

Request:

bash POST /api/billing/refunds { "transactionId": "txn_01J...", "amount": 4999, "reason": "customer_request", "reasonDetails": "Duplicate charge — first attempt timed out" }

Amount must be ≤ original transaction. currency and metadata are optional. Returns a refund with an auto-generated refundNumber.

Approve (finance admin only):

bash POST /api/billing/refunds/<refundId>/approve

Execute against the provider:

bash POST /api/billing/refunds/<refundId>/process

The handler calls the original provider's refund API, updates the row to succeeded, and emits billing.refund_completed. If the provider succeeds but the local update fails, the row is flagged requires_review for manual reconciliation — no silent loss.

List, inspect, cancel:

bash GET /api/billing/refunds GET /api/billing/refunds?status=pending GET /api/billing/refunds/<refundId> PATCH /api/billing/refunds/<refundId> # notes, metadata, reasonDetails POST /api/billing/refunds/<refundId>/cancel

See your revenue picture:

bash GET /api/billing/analytics/overview GET /api/billing/analytics/recent

overview returns counts (transactions, refunds, invoices), time-series, and money totals in whole units (revenue / refunded / outstanding / netRevenue) — pulled directly from the database, not cached.

Quick Reference

Payment provider config (auto-CRUD):

bash POST /api/v1/payment_providers GET /api/v1/payment_providers PATCH /api/v1/payment_providers/<id> DELETE /api/v1/payment_providers/<id>

Module licenses:

```bash GET /api/licenses # list (admin) GET /api/licenses/<moduleId> # one license (admin) GET /api/licenses/<moduleId>/state # public read of state GET /api/licenses/<moduleId>/trial-days GET /api/licenses/subscription/<subId> # lookup by Stripe sub

POST /api/licenses/free POST /api/licenses/trial POST /api/licenses/paid # internal or superadmin POST /api/licenses/superadmin # superadmin override

POST /api/licenses/<moduleId>/upgrade POST /api/licenses/<moduleId>/cancel POST /api/licenses/<moduleId>/suspend POST /api/licenses/<moduleId>/reactivate ```

Stripe Customer Portal & metered billing:

bash POST /api/billing/stripe/portal-session POST /api/billing/stripe/usage-record POST /api/billing/stripe/change-plan GET /api/billing/stripe/usage

Charges, checkout, subscriptions:

bash POST /api/billing/charge POST /api/billing/checkout/sessions GET /api/billing/checkout/sessions/<id> GET /api/billing/subscriptions/<id> POST /api/billing/subscriptions/<id>/cancel

Refunds:

bash POST /api/billing/refunds GET /api/billing/refunds GET /api/billing/refunds/<id> PATCH /api/billing/refunds/<id> POST /api/billing/refunds/<id>/approve POST /api/billing/refunds/<id>/process POST /api/billing/refunds/<id>/cancel

Analytics & webhooks:

bash GET /api/billing/analytics/overview GET /api/billing/analytics/recent POST /api/billing/webhooks/<provider> # stripe|paypal|square|adyen|razorpay|braintree|mollie|… (any registered provider)

Underlying collections (all auto-CRUD at /api/v1/<slug>):

payment_providers · payment_methods · transactions · refunds · invoices · module_licenses · billing_plans · billing_subscriptions

FAQ

20+ processors — Stripe, PayPal, Square, Adyen, Razorpay, Braintree, Mollie, Authorize.Net, and more. Each is registered with the platform's provider registry and stored as a row in `payment_providers`, and the webhook endpoint is a single generic route (`/api/billing/webhooks/:provider`). Stripe is the most fully-wired today (Customer Portal, metered usage, plan changes); the others handle charges, subscriptions, refunds, and signed webhooks.

Disable the Stripe row in `payment_providers` and enable a new row of the desired type. Existing licenses keep their `provider` field, so they stay on Stripe until renewed or migrated.

In `payment_providers.config`. Sensitive fields are encrypted at rest by the platform's column encryption layer before they hit Postgres. The unencrypted env-var fallback (`STRIPE_SECRET_KEY`) is for local development only — production should always use the DB-stored config.

The handler at `POST /api/billing/webhooks/stripe` reads the `Stripe-Signature` header, looks up the webhook secret you stored for that provider, and verifies it using Stripe's official signature-verification method. Verification failures return a 400; verified events are routed to the appropriate handler.

Stripe sends `invoice.payment_failed` to the webhook endpoint. The handler updates the related `module_license` to `suspended`, emits `billing.license_suspended`, and the Notifications module fans out an email plus an in-app alert. Customers regain access automatically when the next charge succeeds (via `invoice.paid` → `reactivate`).

Pass an `idempotencyKey` in the request body. The charge engine wraps the transaction in a Postgres advisory lock keyed on that string; a duplicate request returns the original transaction instead of charging twice. Generate keys deterministically from your domain object (e.g. `order-<orderId>-attempt-<n>`).

Yes — issue them a portal session (`POST /api/billing/stripe/portal-session`) and Stripe handles the upgrade/downgrade UI. The webhook keeps Aeion's license in sync. For in-app upsell flows, use `POST /api/billing/stripe/change-plan` directly.

Customer-service staff create the refund (`pending`), a `billing_admin` / `finance_admin` approves it (`approved`), and either the same admin or a system job calls `process` (`processing` → `succeeded`). If the provider call succeeds but the local DB update fails, the row is marked `requires_review` for manual reconciliation — the provider refund is never lost silently.

Each metered subscription item gets a `subscriptionItemId` from Stripe. POST to `/api/billing/stripe/usage-record` whenever the meter advances (`action: "increment"`) or to set the absolute value (`action: "set"`). Aeion calls Stripe's `usage_records` API and returns the record verbatim.

Yes — pass `allowPromotionCodes: true` when creating a checkout session and Stripe will surface the promo-code field on the hosted page. Codes are managed in your Stripe dashboard.

The overview endpoint totals every invoice that's still open, unpaid, overdue, or sent, and returns the sum as a whole-dollar figure in the response.

Charges emit `billing.transaction.created`. Subscriptions emit `billing.subscription.canceled` and `billing.plan_changed`. Refunds emit `billing.refund_requested` / `billing.refund_approved` / `billing.refund_completed` / `billing.refund_canceled`. Licenses emit `billing.license_created`, `billing.trial_started`, `billing.subscription_activated`, `billing.license_upgraded`, `billing.license_cancelled`, `billing.license_suspended`, `billing.license_reactivated`. Wire other modules' subscribers to these names.

Yes — schema and admin UI register unconditionally. Charge and portal calls return a `503 Stripe is not configured` until you create an enabled provider row. Free and superadmin licenses don't need a provider at all.

Open the Billing Dashboard