Billing administration
Top up and refund user balances, maintain the price catalog, and manage Stripe plans, promo codes and webhooks.
This page is for operators and tenant administrators. It covers the admin side of billing: crediting and refunding balances, the price catalog, and, when Stripe is connected, subscription plans, promo codes and the webhook log. For what users see, read Balance and usage and Payments and plans.
Everything is per tenant
Balances, ledgers, prices, plans and promo codes all belong to a tenant. Every admin billing call acts in the tenant named by the X-Tenant-Id header, which in the dashboard is the tenant selected in the tenant switcher.
This has two practical effects:
- A top-up credits the user's balance in the tenant you are working in. If a user works in their personal tenant, a top-up made from the default tenant lands in a balance they never see. To credit a balance in another tenant you need a membership there.
- Plans and promo codes created in one tenant are only visible to members of that tenant.
Lahijan does not check that the user id you top up is a member of the tenant.
Who can use it
| Permission | Allows | Held by |
|---|---|---|
billing.balance.adjust |
Top-ups, refunds, balance rebuild | Owner, admin |
billing.balance.read, billing.ledger.read |
Reading any user's balance and ledger in the tenant | All tenant roles |
billing.price_catalog.update |
Setting prices | Owner, admin |
billing.plan.manage |
Creating, editing, deleting and pushing plans | Owner, admin |
billing.promo_code.manage |
Creating, listing and revoking promo codes | Owner, admin |
billing.webhook.read |
Reading the Stripe webhook log | Owner, admin |
platform.admin passes all of these in its tenant. The dashboard shows Administration > Billing only to platform.admin, but the API accepts any role that holds the permission. In particular, the owner of a personal tenant can top up their own balance in that tenant; see Roles and permissions.
Top up or refund a user in the dashboard
- Switch to the tenant whose balance you want to change.
- Open Administration > Billing and select the Users tab.
- Paste the user's id into User id. Users can find their id in
GET /api/v1/auth/me; you can also look it up in the database (see Users and tenants). - The card shows the user's current balance. Select Top up or Refund.
- Enter Amount (¢) in cents, a Currency and an optional Reference (for example an invoice number), then confirm.
- Select User ledger to see every entry for that user.
Both actions add a credit row to the ledger (source Top-up or Refund). The ledger is append-only, so a mistaken top-up cannot be deleted. There is no manual debit in this release.
Top up or refund with the API
curl -X POST https://cloud.example.com/api/v1/admin/users/<user id>/topup \ -H "Authorization: Bearer $LAHIJAN_TOKEN" \ -H "X-Tenant-Id: $LAHIJAN_TENANT_ID" \ -H "Content-Type: application/json" \ -d '{"amountCents": 10000, "currency": "USD", "reference": "INV-2026-041"}'| Method and path | Body | Result |
|---|---|---|
POST /api/v1/admin/users/{userId}/topup |
amountCents (required, greater than 0), currency, reference |
The new ledger row (201). |
POST /api/v1/admin/users/{userId}/refund |
amountCents (required), currency, reference, chargeLedgerId (the charge being refunded) |
The new ledger row (201). |
GET /api/v1/admin/users/{userId}/ledger |
limit, offset query parameters |
The user's ledger entries. |
GET /api/v1/admin/users/{userId}/balance |
The user's cached balance. | |
POST /api/v1/admin/users/{userId}/balance |
Recomputes the cached balance from the ledger and returns it. |
Currency defaults to USD, the only currency in this release. Each top-up, refund and rebuild is recorded in the audit log (billing.balance.topup, billing.balance.refund, billing.balance.rebuild) before it runs and marked with its outcome afterwards.
Price catalog
The price catalog holds a price per resource type and unit, for example compute.cpu per vCPU-hour. In the dashboard it is the Price catalog tab; select Set price and fill in Resource type, Unit, Price (¢), Currency and Effective from.
With the API, GET /api/v1/admin/billing/prices lists prices (needs billing.price_catalog.read, which every tenant role has) and POST /api/v1/admin/billing/prices sets one:
{ "resourceType": "compute.cpu", "unit": "vCPU-hour", "priceCents": 2, "currency": "USD", "effectiveFrom": "2026-10-01T00:00:00Z"}Setting a price closes the price currently in effect for that resource type and unit and starts the new one at effectiveFrom (now, if omitted). Old prices are kept with their end date.
Note
Resource metering is not implemented in this release, so no charges are calculated from the price catalog yet. Domain orders are priced by the registrar configuration, not by this catalog.
Stripe
Card payments, plans and promo codes need the Stripe gateway. Turn it on in the configuration:
| Key | Environment variable | Purpose |
|---|---|---|
billing.stripe.enabled |
LAHIJAN_BILLING_STRIPE_ENABLED |
Turns the gateway on. |
billing.stripe.secretKey |
LAHIJAN_BILLING_STRIPE_SECRETKEY |
sk_test_... or sk_live_.... |
billing.stripe.publishableKey |
LAHIJAN_BILLING_STRIPE_PUBLISHABLEKEY |
Given to browsers through GET /api/v1/billing/config. |
billing.stripe.webhookSecret |
LAHIJAN_BILLING_STRIPE_WEBHOOKSECRET |
whsec_..., used to verify webhook signatures. |
The production compose file does not pass these variables to the lahijan service, so add them to that service's environment block (or set the keys in a mounted configuration file). See Environment file.
In the Stripe dashboard, add a webhook endpoint pointing at https://<your server>/api/v1/webhooks/stripe. Lahijan acts on these event types:
| Event | Effect |
|---|---|
payment_intent.succeeded |
Credits the top-up to the user's ledger. |
invoice.paid |
Credits the subscription plan's included amount. |
customer.subscription.created, customer.subscription.updated |
Marks the local subscription active and updates its period end. |
customer.subscription.deleted |
Marks the local subscription expired. |
charge.dispute.created, charge.dispute.closed |
Recorded only; no ledger change yet. |
Other event types are recorded and ignored. Each event is processed once, keyed on its Stripe event id. When the gateway is off, the webhook endpoint answers 200 and discards the body so Stripe stops retrying.
See the full list of settings in Configuration.
Plans
| Method and path | Purpose |
|---|---|
GET /api/v1/admin/billing/plans |
Every plan in the tenant, active or not (billing.plan.read). |
POST /api/v1/admin/billing/plans |
Creates a plan. |
GET /api/v1/admin/billing/plans/{planId} |
One plan. |
PATCH /api/v1/admin/billing/plans/{planId} |
Changes a plan's editable fields. |
DELETE /api/v1/admin/billing/plans/{planId} |
Deletes a plan. Fails with 409 if any subscription uses it; set active to false instead. |
POST /api/v1/admin/billing/plans/{planId}/push |
Creates the matching Product and Price in Stripe and stores their ids. Running it again does nothing. |
{ "slug": "starter", "name": "Starter", "interval": "monthly", "priceCents": 1000, "currency": "USD", "includedQuotaCents": 1200, "active": true, "sortOrder": 1}slug, name, interval (monthly or yearly) and priceCents are required. includedQuotaCents is credited to the subscriber's balance on each paid invoice. A plan must be pushed to Stripe before anyone can subscribe to it; until then, subscribing fails with 409. The overageDiscountPercent field is stored but has no effect while metering is inactive.
Promo codes
| Method and path | Purpose |
|---|---|
GET /api/v1/admin/billing/promo-codes |
Lists codes that are not revoked. |
POST /api/v1/admin/billing/promo-codes |
Creates a code. |
POST /api/v1/admin/billing/promo-codes/{promoCodeId}/revoke |
Revokes a code so it can no longer be redeemed. |
{ "code": "WELCOME-25", "creditCents": 2500, "currency": "USD", "note": "Launch campaign", "maxUses": 100, "expiresAt": "2026-12-31T23:59:59Z"}code and creditCents are required. Codes may contain letters, digits and dashes. appliesToPlanId is stored but not checked at redemption in this release, so a plan-restricted code can be redeemed by anyone in the tenant.
Webhook log
GET /api/v1/admin/billing/webhook-events lists recently received Stripe events with their type, processing status, the ledger entries they created and any error message. Use it to check that Stripe can reach Lahijan and that payments are being credited.
Agent charges
agent.billing.centsPer1kTokens sets a price per 1,000 model tokens for Agent turns that run on operator-provided models. Operator-provided models are not available yet, and turns that use a user's own key are never charged, so this setting currently has no effect. See Agent policy.