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.
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.
Issue a Module License
| Action | Endpoint |
|---|---|
| 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` |
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
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.
Process a Refund
Refunds are a four-state pipeline: pending → approved → processing → succeeded (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.