Skip to content
View as Markdown
How-to guideWorkspace

Wallet & Payments

Prepaid workspace balance with multiple payment providers and auto-recharge

Owners and adminsUpdated

Every organization has a prepaid wallet. Admins add funds under Settings → Wallet with any payment provider the server has enabled, and can turn on auto-recharge so a saved card tops the balance up whenever it drops below a threshold.

Several providers can be enabled at once, so top-ups never depend on a single gateway. Each one is offered as soon as its keys are in the [payments] config (see config.example.toml).

Provider What customers pay with Saved cards / auto-recharge Test mode
Sandbox (built in) Test card numbers on an in-app page Yes Always; no keys needed
Stripe Cards on Stripe Checkout Yes sk_test_… key, card 4242 4242 4242 4242
Razorpay UPI, cards, netbanking, wallets (Payment Links) No rzp_test_… keys
PayPal PayPal balance or cards (Orders v2) No live = false (sandbox), sandbox buyer accounts
Card Result
4242 4242 4242 4242, 5555 5555 5555 4444 Succeeds
4000 0000 0000 0002 Declined
4000 0000 0000 9995 Insufficient funds
4000 0000 0000 0341 Succeeds now; later auto-recharges with it fail

Any future expiry date and any 3-digit CVC work.

  1. POST /api/wallet/topup creates a pending payment and returns the provider’s page.
  2. The customer pays and is sent back to /settings/wallet?payment=…, which asks the server to confirm with the provider.
  3. Provider webhooks (/api/wallet/webhooks/{stripe|razorpay|paypal}) and a background check every few minutes also confirm payments, so a closed tab never loses a payment.

The server always reads the result back from the provider’s API with its own keys; a webhook body is never trusted for amounts. Each payment credits the wallet at most once.

A top-up is only marked canceled once the provider confirms its checkout can no longer be paid: the Stripe Checkout Session is expired, the Razorpay Payment Link is cancelled (links are also created with a 24-hour expire_by), and a PayPal order is simply never captured. If the provider can’t be reached, or reports the payment was made a moment earlier, the payment stays pending or is credited. This happens when the customer backs out of the provider page, and in the background for top-ups still pending after 24 hours; age alone never cancels a payment.

If a provider ever reports a canceled or failed payment as paid, it is credited then, still at most once.

Choose a threshold, an amount and a saved card. When the balance falls below the threshold (after any debit, or at the next once-a-minute check) the card is charged off-session. Only an explicit decline counts as a failed attempt: if the outcome is unknown (timeout, lost response, provider error) the attempt stays pending and is resolved with the same payment id, by replaying it under the same idempotency key or searching Stripe for it, before any new charge is made. Attempts are at least 10 minutes apart, at most 5 succeed or run per 24 hours, the threshold can’t exceed the maximum top-up, and auto-recharge turns itself off after 3 failures in a row, showing the last error on the wallet page.

Server code debits the wallet through wallet.Service.Debit, which records a ledger entry (give each one a stable reference such as message:<id> so a retried event is charged once), refuses to go below zero unless told to, and starts an auto-recharge when needed. Super admins can also credit or debit any workspace by hand under Platform → Organizations → (org) → Wallet (POST /api/admin/organizations/{id}/wallet/adjust).

The billing resource controls access: billing:read to see the wallet, billing:write to add funds and manage auto-recharge and cards. Admin roles get both.