Billing
Subscriptions, trials, seats, metered usage, invoices and the customer portal for users or workspaces, with Stripe or Paddle and verified webhooks processed on the queue.
On this page
Introduction
Featurebilling navree-billing charges your customers through Stripe or Paddle and keeps a copy of their subscriptions in your database, so a check like "is this workspace on Pro?" is a local query, not an API call.
You make a type billable (a user or, in most SaaS apps, a workspace), list your plans in configuration, and the Billing service does the rest:
- Checkout. Send the customer to the provider's hosted checkout for a plan, with or without a trial.
- Subscriptions. Swap plans with proration, change the seat count, cancel at the end of the period, resume, pause.
- Trials, with or without a payment method up front.
- Metered usage. Record usage as it happens; Navree reports it to the provider in batches.
- Invoices and the customer portal, where customers update their card and download receipts.
- Webhooks. The provider's events are verified, stored once and processed on the queue, and each one becomes a typed event your listeners handle.
Card numbers never touch your servers: checkout and the portal are hosted by the provider.
Installation
Turn on the billing feature of the navree facade. It brings navree-billing, and with it the auth, workspace, database, events, HTTP client and scheduling features it builds on. Billing is opt-in: the default full feature leaves it out.
[dependencies]
navree = { git = "https://github.com/NavreeHQ/navree", features = ["billing"] }Add BillingModule to your app root. It needs the database. Its routes need an authenticator (auth) and, for workspace billing, access control and workspaces. The queue runs the webhook jobs, the scheduler reports metered usage, the events bus delivers the billing events, and the HTTP client calls the provider's API (a default client is built when HttpClientModule is not in the app):
use navree::prelude::*;
pub fn app(sources: ConfigSources) -> AppBuilder {
Navree::app()
.module(ConfigModule::for_root(sources))
.module(CryptoModule::from_config())
.module(DatabaseModule::from_config())
.module(EventsModule::for_root())
.module(QueueModule::from_config())
.module(ScheduleModule::for_root()) // reports metered usage every minute
.module(HttpClientModule::from_config())
.module(AuthModule::from_config())
.module(AccessModule::for_root(AccessOptions::default()))
.module(WorkspaceModule::from_config())
.module(BillingModule::from_config()) // section `billing`
.module(SettingsModule)
}The module contributes the migration set billing, which runs after auth and workspace and creates the tables in the tables. navree migrate runs it with your own migrations.
Configuration
Choose a provider and list your plans in the billing section. Each plan has a key you use in code (pro; lower-case letters, digits, _ and -) and the provider's price ids:
[billing]
provider = "stripe" # stripe | paddle | fake
return_url = "https://app.acme.test/settings/billing"
[billing.plans.starter]
name = "Starter"
prices = { monthly = "price_1Q8StarterMonthly", yearly = "price_1Q8StarterYearly" }
trial_days = 14
[billing.plans.pro]
name = "Pro"
prices = { monthly = "price_1Q8ProMonthly", yearly = "price_1Q8ProYearly" }
trial_days = 14
seats = true # quantity = member count
[billing.plans.pro.meters]
ai_summaries = "price_1Q8AiSummaries" # a metered priceThe API keys and the webhook signing secret are secrets. Put them in .env, never in the TOML file:
APP_BILLING__STRIPE__SECRET_KEY=sk_live_51Q8...
APP_BILLING__STRIPE__WEBHOOK_SECRET=whsec_Jx2...Create the webhook endpoint in the Stripe dashboard, pointing at https://api.acme.test/api/billing/webhook, and copy its signing secret. Select the checkout.session.*, customer.*, customer.subscription.* and invoice.* events, or run navree billing:webhook https://api.acme.test/api/billing/webhook to create the endpoint with the right events through the API; it prints the signing secret to put in .env.
Every request is pinned to Stripe API version 2025-03-31.basil (navree::billing::STRIPE_API_VERSION) with the Stripe-Version header, and billing:webhook creates the endpoint on the same version, so the payloads have the shape Navree reads.
APP_BILLING__PROVIDER=paddle
APP_BILLING__PADDLE__API_KEY=pdl_live_apikey_01j...
APP_BILLING__PADDLE__WEBHOOK_SECRET=pdl_ntfset_01j...
APP_BILLING__PADDLE__ENVIRONMENT=productionenvironment is sandbox (the default) or production. Create a notification destination for https://api.acme.test/api/billing/webhook with the subscription.*, transaction.* and customer.* events, or run navree billing:webhook <url>. Paddle is the merchant of record, so it also handles sales tax and VAT for you.
Paddle's checkout is a transaction opened on your account's default payment link, so set one in the Paddle dashboard. A few checkout options work differently on Paddle: the trial is the trial period of the price in Paddle (a checkout's trial_days is not sent), coupon is a discount id, and the customer returns to the URLs configured on the payment link rather than success_url and cancel_url.
Every key also comes from the environment with the APP_ prefix and __ between levels: APP_BILLING__PLANS__PRO__TRIAL_DAYS=30. A plan without a monthly or yearly price, an unknown provider or a missing secret fails the boot, naming the key; secrets are never printed. The secret key (billing.stripe.secret_key or billing.paddle.api_key) is always required for its provider, the webhook secret while billing.webhook_route is on. To configure the module in code, pass BillingOptions to BillingModule::for_root.
Billable models
navree-billing implements Billable for the workspace model (navree::workspace::Workspace) and for the auth User, and for their request extractors CurrentWorkspace and CurrentUser, so a handler passes what it already has. Register the type you bill with for_feature, in the module that owns billing:
use navree::prelude::*;
use navree::workspace::Workspace;
#[module(
imports = [BillingModule::for_feature::<Workspace>()],
controllers = [BillingController],
)]
pub struct SettingsModule;Bill users instead with BillingModule::for_feature::<navree::auth::User>(). To bill a type of your own, such as an organizations table, implement the trait:
use navree::billing::Billable;
use uuid::Uuid;
use super::entity::Model as Organization;
impl Billable for Organization {
/// Stored with customers and subscriptions; keep it stable.
const BILLABLE_TYPE: &'static str = "organization";
fn billable_id(&self) -> Uuid {
self.id
}
fn billing_name(&self) -> String {
self.legal_name.clone()
}
fn billing_email(&self) -> Option<String> {
self.billing_email.clone()
}
}billing_email may return None (a workspace has no email of its own); the checkout then uses the email passed to customer_email, which the built-in routes fill with the signed-in user's, or the provider's checkout asks for one. The provider's customer is created on the first checkout and linked to the billable in billing_customers; on Paddle, which needs an email for a customer, a checkout without one links the customer when the payment's webhook arrives.
A type of your own works with every Billing method. To use it on routes (the built-in routes and the Subscribed extractor), also implement Billable::from_request, which finds the request's billable; the built-in billables resolve it from CurrentWorkspace and CurrentUser.
Checkout
Start a subscription by sending the customer to the provider's checkout. checkout returns a builder; create returns the hosted page's URL:
use navree::billing::{Billing, CheckoutUrl};
use navree::prelude::*;
use navree::workspace::CurrentWorkspace;
use serde::Deserialize;
#[derive(Debug, Deserialize, garde::Validate)]
pub struct Upgrade {
#[garde(length(min = 1))]
pub plan: String,
#[garde(skip)]
pub yearly: bool,
}
#[injectable]
pub struct BillingController {
billing: Arc<Billing>,
}
#[controller("/billing/upgrade", version = "1")]
#[guard(InWorkspace::guard())] // `billing.manage` is checked in the workspace
impl BillingController {
/// `POST /api/v1/billing/upgrade`: a checkout URL for the current workspace.
#[post("/")]
#[permissions("billing.manage")]
async fn upgrade(
&self,
workspace: CurrentWorkspace,
CurrentUser(user): CurrentUser,
Valid(Json(input)): Valid<Json<Upgrade>>,
) -> Result<Json<CheckoutUrl>> {
let mut checkout = self
.billing
.checkout(&workspace, &input.plan)
.customer_email(&user.email)
.success_url("https://app.acme.test/settings/billing?upgraded=1")
.cancel_url("https://app.acme.test/settings/billing");
if input.yearly {
checkout = checkout.yearly();
}
Ok(Json(checkout.create().await?.into()))
}
}{ "url": "https://checkout.stripe.com/c/pay/cs_live_a1B2c3..." }The front end redirects the browser to url. When the customer pays, the provider sends a webhook, the subscription appears in your database and SubscriptionCreated is emitted. Don't treat the redirect to success_url as proof of payment: it only means the customer came back.
| Builder method | Effect |
|---|---|
yearly(), monthly(), interval(i) | Which of the plan's prices to charge (monthly by default) |
trial_days(n) | Overrides the plan's trial_days; 0 skips the trial |
quantity(n) | Seats; with seats = true the default is the workspace's member count, otherwise 1 |
coupon(code) | A provider coupon or promotion code (Stripe: a coupon id or a promo_... id; Paddle: a discount id) |
allow_promotion_codes() | Lets the customer type a code on the checkout page (Stripe) |
customer_email(email) | Prefills the email when the billable has none |
success_url(url), cancel_url(url) | Where the customer returns; both default to billing.return_url |
metadata(key, value) | Stored on the provider's subscription |
create returns a Checkout (id, url, plan, interval, quantity, trial_days), which converts into the CheckoutUrl response. An unknown plan key is BillingError::UnknownPlan, a 400 in a handler (errors.plan); a plan without a price for the interval is BillingError::NoPrice (errors.interval). A billable that already has a valid subscription gets a 409: change its plan with swap instead.
Checking subscriptions
subscription reads the local copy, so it costs one indexed query. It returns the billable's valid subscription, else its latest one that has not ended, else its latest one:
let subscription = billing.subscription(&workspace).await?;
match subscription {
Some(sub) if sub.valid() => {
println!("{} on {} until {:?}", workspace.name, sub.plan, sub.current_period_end);
}
Some(sub) if sub.past_due() => { /* ask for a new card */ }
_ => { /* free tier */ }
}
if billing.subscribed(&workspace, "pro").await? {
// Pro features
}Subscription | |
|---|---|
plan, interval | The plan key and Monthly or Yearly |
status | Trialing, Active, PastDue, Paused, Canceled or Incomplete |
quantity | Seats |
trial_ends_at, ends_at, current_period_end | UTC timestamps; ends_at is set once it is canceled |
valid() | Trialing, active, on its grace period after a cancellation, or past due for less than billing.past_due_grace_days |
on_trial(), on_grace_period(), canceled(), past_due(), paused(), ended() | What they say |
on_plan(plan) | Whether it is on that plan key |
The checks are evaluated at the time the subscription was read, with the app's dyn Clock. subscriptions(&billable) lists every subscription, newest first.
Requiring a subscription on a route
The Subscribed extractor answers 402 unless the request's billable (the CurrentWorkspace, or the CurrentUser when you bill users) has a valid subscription or a trial without one. It derefs to the Subscription:
use navree::billing::Subscribed;
#[post("/{id}/summary")]
#[can(Read, Deal, from = "id")]
async fn summarize(&self, subscription: Subscribed, Path(id): Path<Uuid>) -> Result<Json<Summary>> {
if !subscription.on_plan("pro") {
return Err(AppError::forbidden().with_detail("AI summaries are part of the Pro plan"));
}
// ...
}{ "statusCode": 402, "message": "This workspace needs an active subscription", "error": "Payment Required", "code": "subscription_required" }A front end maps the code to its "upgrade your plan" screen in the user's language (see error codes).
billing.access(&billable) gives the same answer outside a request. To show a feature to some plans before you gate it, combine billing with feature flags.
Changing subscriptions
billing.swap(&workspace, "business").await?; // prorated, effective now
billing.swap_yearly(&workspace, "pro").await?; // the plan's yearly price
billing.update_quantity(&workspace, 12).await?; // seats
billing.cancel(&workspace).await?; // at the end of the period
billing.cancel_now(&workspace).await?; // at once, no refund
billing.resume(&workspace).await?; // undo a cancel before it ends
billing.pause(&workspace).await?; // stop charging, keep it
billing.unpause(&workspace).await?;Every call updates the provider first and the local copy after it, in the current unit of work (the request's in a handler) or its own, and returns the updated Subscription. The webhook that follows confirms the change and is a no-op when nothing differs. A billable without a subscription gets BillingError::NoSubscription, a 404.
- Proration. Swaps and quantity changes are prorated and charged on the next invoice (
Proration::NextInvoice).swap_with(&workspace, "business", Proration::InvoiceNow)charges the difference at once;Proration::Nonedoesn't prorate.swapkeeps the current interval;swap_monthly,swap_yearlyandswap_to(&workspace, plan, Some(interval), proration)choose it. Metered prices follow the new plan's meters. - Seats. For a plan with
seats = true, Navree listens to the workspace eventsMemberAddedandMemberRemoved(a queued listener) and updates the quantity when someone joins or leaves; a swap onto such a plan takes the member count. Setseats = falseand callupdate_quantityyourself to bill something else per unit. - Grace period. After
cancel,valid()stays true untilends_at, the end of the paid period.resumeworks until then.
Trials
A plan's trial_days applies to every checkout of that plan. Pass .trial_days(n) to override it for one checkout. The provider collects the card at checkout and charges it when the trial ends; on Stripe, three days before, it sends a "trial ending" webhook and Navree emits TrialEnding. Paddle sends no such webhook, so TrialEnding is not emitted there.
To let a new workspace try the product before it enters a card, start a trial without a subscription:
billing.start_trial(&workspace, 14).await?;
// True for 14 days, with or without a subscription:
billing.on_trial(&workspace).await?;
billing.trial_ends_at(&workspace).await?;A trial without a subscription lives in billing_customers.trial_ends_at and never reaches the provider. Subscribed lets the workspace through while it lasts, with subscription.plan set to the plan in billing.trial_plan (default: the first plan by key) and is_trial_without_subscription() true.
Metered usage
Bill per use (AI summaries generated, emails sent, API calls) with a metered price. Add it under the plan's meters and record usage where it happens:
billing.report_usage(&workspace, "ai_summaries", 1).await?;report_usage writes one row to billing_usage in the current unit of work, so a request that fails records nothing and the call costs no network round trip. A scheduled task, billing:report-usage, runs every minute with ScheduleModule in the app: it sums the unreported rows per billable and meter and sends them to the provider, then marks them reported. On Stripe that is a meter event for the meter behind the metered price (Navree reads the meter's event name from the price once); on Paddle, a one-time charge added to the next renewal. Without the scheduler, run the command yourself, from cron or a worker.
// This period's total, reported or not:
let used = billing.usage(&workspace, "ai_summaries").await?;Reporting a meter no plan has, or usage for a billable whose plan has no such meter, is BillingError::UnknownMeter (a 400); usage for a billable without a valid subscription is BillingError::NoSubscription. Rows of a billable whose subscription ended before they were reported stay unreported.
Invoices and the customer portal
for invoice in billing.invoices(&workspace).await? {
println!("{} {} {} {}", invoice.number, invoice.status, invoice.total_cents, invoice.currency);
// invoice.pdf_url and invoice.hosted_url link to the provider's documents
}
let url = billing.portal_url(&workspace, "https://app.acme.test/settings/billing").await?;Invoices are read from the provider (on Paddle, the customer's billed transactions, without document links) and cached in memory for billing.invoice_cache_secs (300); a payment webhook for the billable clears its cache. The customer portal is the provider's hosted page where customers change their card, switch plans you allow there, cancel and download every invoice. Changes made in the portal reach you as webhooks, like any other change. A billable without a provider customer yet gets BillingError::NoCustomer from portal_url.
Built-in routes
With billing.routes = true (the default), the module mounts JSON routes for your billing settings page, under /api/billing. They act on the request's billable. For a workspace they run behind the InWorkspace guard and require the billing.manage permission in the workspace; a workspace's owner has every permission, so grant it to admins in the workspace roles. For users, any signed-in user manages their own billing. For a billable type of your own they require the billing.manage permission.
[workspace.roles.admin]
permissions = ["billing.manage"]The routes:
| Route | Does |
|---|---|
GET /api/billing | The subscription, the trial, this period's usage per meter and the plans |
GET /api/billing/plans | {data}: the configured plans with their names, intervals and prices |
POST /api/billing/checkout | {plan, interval?}: {url} of a checkout (prefilled with the signed-in user's email) |
POST /api/billing/swap | {plan, interval?}: the updated subscription |
POST /api/billing/cancel, POST /api/billing/resume | The updated subscription |
GET /api/billing/invoices | {data}: the invoices |
POST /api/billing/portal | {return_url?}: {url} of the customer portal; return_url must be on the origin of billing.return_url |
POST /api/billing/webhook | The provider's webhooks; public and verified; {received, duplicate} |
When you register both User and Workspace as billables, set billing.routes_billable to workspace or user; until you do, the boot fails, naming the key. Turn the routes off with billing.routes = false and keep only the webhook with billing.webhook_route = true.
Webhooks and events
The provider tells you about every change through webhooks. POST /api/billing/webhook handles them in three steps:
- It verifies the signature:
Stripe-Signature(t=..,v1=.., an HMAC-SHA256 of the timestamp and the raw body) orPaddle-Signature(ts=..;h1=.., the same scheme), with 5 minutes of tolerance in either direction, and answers 400 to anything that fails, including a body it cannot read. The comparison is constant-time and several signatures in one header (secret rotation) are accepted. - It stores the event in
billing_events, keyed by the provider's event id. A redelivered event is answered 200 and not stored again. - It answers 200 and dispatches a
billing.process_eventjob onbilling.queue(without a queue module, the job runs at once). The job updates the local customer and subscription rows, ignores states older than the one it already has (events can arrive out of order), clears the billable's cached invoices and emits a Navree event once its unit of work commits.
The events are ordinary events:
| Event | NAME | When |
|---|---|---|
SubscriptionCreated | subscription.created | A checkout completed |
SubscriptionUpdated | subscription.updated | Plan, quantity, status or period changed |
SubscriptionCanceled | subscription.canceled | Canceled, now or at the period's end (ends_at) |
TrialEnding | subscription.trial_ending | Three days before a trial with a subscription ends (Stripe) |
InvoicePaid | invoice.paid | A payment succeeded |
PaymentFailed | invoice.payment_failed | A payment failed; the provider retries it |
Each carries billable_type, billable_id, subscription_id and plan; the invoice events add invoice_id, amount_cents and currency, and their subscription_id and plan are Options (an invoice need not belong to a subscription). This listener tells a workspace's owner that a payment failed, through a notification:
use navree::BoxFuture;
use navree::billing::PaymentFailed;
use navree::events::Listener;
use navree::notify::Notifier;
use navree::prelude::*;
use navree::workspace::Workspaces;
use crate::notify::{NotifiableUser, PaymentFailedNotice};
#[injectable]
pub struct WarnOwnerOfFailedPayment {
workspaces: Arc<Workspaces>,
auth: Arc<AuthService>,
notifier: Arc<Notifier>,
}
impl Listener<PaymentFailed> for WarnOwnerOfFailedPayment {
fn handle<'a>(
&'a self,
event: &'a PaymentFailed,
_ctx: &'a AppContext,
) -> BoxFuture<'a, Result<(), BoxError>> {
Box::pin(async move {
let Some(workspace) = self.workspaces.find(event.billable_id).await? else {
return Ok(()); // deleted since
};
if let Some(owner) = self.auth.find_user(workspace.owner_id).await? {
let notice = PaymentFailedNotice { amount_cents: event.amount_cents };
self.notifier.send(&NotifiableUser(owner), notice).await?;
}
Ok(())
})
}
fn queued(&self) -> bool {
true
}
}A job that fails is retried with backoff (8 attempts, from 10 seconds to 10 minutes apart); each failure is recorded on its billing_events row (attempts, error), and billing:events --failed lists the events that have not succeeded since. billing:sync <type> <id> pulls a billable's subscriptions from the provider when you need to repair a copy by hand (billing:sync workspace 0199...).
The tables
| Table | Holds |
|---|---|
billing_customers | One row per billable: billable_type, billable_id, the provider and its customer id, email, trial_ends_at |
subscriptions | plan, interval, status, quantity, trial_ends_at, ends_at, current_period_start, current_period_end, past_due_since, the provider's ids and the time of the provider state it reflects |
subscription_items | Each price of a subscription, metered prices included (with their meter key) |
billing_usage | Recorded usage, with reported_at once sent |
billing_events | Every webhook received: type, payload, processed_at, attempts, error |
Read them through Billing rather than querying them: a subscription row is only as fresh as the last webhook, and the service methods know which fields the provider owns. The entities are in navree::billing::entity. Subscriptions are an access subject, Subscription (SUBSCRIPTION_SUBJECT, permissions subscriptions.*), so an admin panel can list them with Helm and routes can check #[can(Read, subscription::Entity)], as the SaaS starter kit's admin panel does.
Testing
Set billing.provider = "fake". The fake provider records checkouts and usage instead of calling Stripe or Paddle, and simulates the provider's webhooks so your listeners run as they would in production:
use navree::billing::Billing;
use navree::db::Db;
use navree::prelude::*;
use navree::queue::Queue;
use navree::testing::{TestApp, principal};
use navree::workspace::{NewWorkspace, Workspaces};
use serde_json::json;
#[tokio::test]
async fn upgrading_starts_a_pro_trial() {
let app = TestApp::builder()
.temp_database()
.fake_auth()
.config("app.key", navree::crypto::generate_key())
.config("billing.provider", "fake")
.config("billing.plans.pro.prices.monthly", "price_pro")
.config("billing.plans.pro.trial_days", 14)
.config("queue.driver", "memory")
.module(CryptoModule::from_config())
.module(DatabaseModule::from_config())
.module(EventsModule::for_root())
.module(QueueModule::from_config())
.module(AuthModule::from_config())
.module(AccessModule::for_root(AccessOptions::default()))
.module(WorkspaceModule::from_config())
.module(BillingModule::from_config())
.module(SettingsModule)
.build()
.await;
let owner = app
.get::<AuthService>()
.await
.create_user("Ada Lovelace", "[email protected]", "password123")
.await
.unwrap();
let uow = app.get::<Db>().await.uow(None, None).await;
let workspace = app
.get::<Workspaces>()
.await
.create(&uow, owner.id, NewWorkspace::named("Acme"))
.await
.unwrap();
uow.commit().await.unwrap();
let response = app
.acting_as(principal(owner.id))
.post("/api/v1/billing/upgrade")
.header("X-Workspace", "acme")
.json(&json!({ "plan": "pro", "yearly": false }))
.send()
.await
.assert_status(200);
let billing = app.get::<Billing>().await;
let fake = billing.fake().expect("the fake provider");
let checkout = fake.assert_checkout(|c| c.plan == "pro" && c.trial_days == Some(14));
assert_eq!(response.json_value()["url"], json!(checkout.url));
fake.complete_checkout(&checkout.id).await.unwrap();
app.get::<Queue>().await.run_pending().await.unwrap();
let sub = billing.subscription(&workspace).await.unwrap().unwrap();
assert!(sub.on_trial() && sub.on_plan("pro"));
}BillingFake | |
|---|---|
checkouts(), assert_checkout(predicate) | The checkouts created; assert_checkout returns the match (id, url, plan, interval, price_id, quantity, trial_days, coupon, customer_email, metadata, ...) |
complete_checkout(id) | Sends the webhooks of a paid checkout: customer, subscription, first invoice |
fail_next_payment(&billable) | The next payment (a renewal, or the end of a trial) sends invoice.payment_failed |
end_trial(&billable), renew(&billable) | Sends the webhooks of a trial ending or a period renewing (or, after cancel, of the subscription ending) |
reported_usage(&billable, meter) | The usage the reporter sent |
send_webhook(type, json) | Any event: json is a Stripe-shaped data.object |
webhook_secret() | The secret the fake signs with (Stripe-Signature), to post to the webhook route yourself |
The simulated webhooks are Stripe-shaped events signed with the fake's own secret, and they go through the same verification, storage, job and mapping as real ones, so run the queue (or use queue.driver = "memory" and run_pending) before you assert on subscriptions. Changes made through Billing (swap, cancel, ...) update the fake and the local copy at once; the fake sends no webhook for them. Fake invoices cost 1000 cents per seat and period (nothing during a trial). Override dyn Clock with a FixedClock to test trials and grace periods.
To test against the provider's own test mode, use real test keys (sk_test_..., the Paddle sandbox) in a separate, opt-in test suite, and forward webhooks to your machine with the provider's CLI. billing.stripe.api_base and billing.paddle.api_base point the clients at a mock server or a proxy.
Reference
| Key | Default | Meaning |
|---|---|---|
billing.provider | stripe | stripe, paddle or fake |
billing.currency | usd | The currency of the fake provider's invoices (Stripe and Paddle take it from the price) |
billing.return_url | app.url | Where checkout and the portal return |
billing.queue | default | The queue of the webhook jobs |
billing.past_due_grace_days | 7 | Days a past-due subscription stays valid |
billing.trial_plan | the first plan by key | The plan a trial without a subscription grants |
billing.invoice_cache_secs | 300 | How long invoice lists are cached (0: not cached) |
billing.routes | true | Mounts the /api/billing routes |
billing.routes_billable | the only registered billable | workspace, user or the BILLABLE_TYPE of a type of your own |
billing.webhook_route | true | Mounts POST /api/billing/webhook |
billing.webhook_tolerance_secs | 300 | Accepted clock skew of webhook signatures |
billing.stripe.secret_key, billing.stripe.webhook_secret | none | Secrets; from .env |
billing.stripe.api_base | https://api.stripe.com | The Stripe API's base URL |
billing.paddle.api_key, billing.paddle.webhook_secret | none | Secrets; from .env |
billing.paddle.environment | sandbox | sandbox or production |
billing.paddle.api_base | the environment's | The Paddle API's base URL |
billing.plans.<key>.name | the key | Display name |
billing.plans.<key>.prices | none | { monthly, yearly } price ids; at least one |
billing.plans.<key>.trial_days | 0 | Trial on checkout |
billing.plans.<key>.seats | false | Quantity follows the workspace's member count |
billing.plans.<key>.meters | {} | Meter key to metered price id |
BillingError returned from a handler with ? becomes an error response with its own code:
| Error | Status | code |
|---|---|---|
UnknownPlan, NoPrice, UnknownMeter, Invalid | 400 | validation_failed, with the field under errors |
AlreadySubscribed | 409 | already_subscribed |
NoSubscription | 404 | no_subscription |
NoCustomer | 404 | no_billing_customer |
InvalidWebhook | 400 | invalid_webhook_signature |
InvalidPayload | 400 | invalid_webhook_payload |
Provider | 502 | payment_provider_error (no URL or key reaches the client) |
Db, Fake, Config, Other | 500 | internal_error |
no valid subscription (Subscribed) | 402 | subscription_required |
| Command | What it does |
|---|---|
billing:plans | Lists the plans and checks every price id against the provider |
billing:webhook <url> | Creates the provider's webhook endpoint with the events Navree handles, and prints its secret |
billing:report-usage | Reports unreported usage (scheduled every minute) |
billing:sync <type> <id> | Pulls a billable's subscriptions from the provider |
billing:events [--failed] | Lists received webhook events |
billing:prune-events [--days N] | Deletes processed events older than N days (default 90) |