Billing — API v1 and MCP | Nodea Documentation
The Billing module is read-only over the API. It exposes what the web UI shows the customer: wallet balances, the wallet transaction ledger and invoices with their line items — and nothing more. This document describes the REST API v1 layer (/api/v1/billing/*) and the module's MCP tools.
Authentication and scopes
API v1 authenticates with a Passport personal access token via the Authorization: Bearer <token> header. All billing endpoints are guarded by the account:read scope (there is no write path). The whole module is additionally gated by the Pennant flag features:hosting-suite (mirroring the web /app/account/* groups). Rate limit: throttle:api-v1 = 240/min per token user.
Read-only — what is NOT exposed
Deliberately web-only (interactive and/or Stripe-bound) and absent from the API:
- top-ups (Stripe Checkout),
- payment-method management (SetupIntents + Stripe Elements),
- auto-recharge configuration (a mutation — in the API the auto-recharge fields are read-only),
- invoice PDF download (binary rendering, a web-only route).
Responses contain no Stripe identifiers (customer / payment method / intent) and no internals: whmcs_id, wfirma_id, pdf_path. This module never resolves a Stripe client.
Ownership and visibility
Neither Wallet nor Invoice has a policy class — row-level access is owner-scoped inline. A foreign or unknown id consistently yields 404 (never 403), so the API doesn't confirm that another tenant's row exists.
API v1 — endpoints
| Method | Path | Scope | Description |
|---|---|---|---|
| GET | /api/v1/billing/wallets | account:read | The user's wallets (one row per currency). Strictly read-only: does NOT lazily create the PLN/EUR pair, so without history the list may be empty. |
| GET | /api/v1/billing/transactions | account:read | Wallet transaction ledger, newest first. Filters wallet_id and/or currency. Simple pagination. |
| GET | /api/v1/billing/invoices | account:read | The user's invoices (issued_at DESC, id DESC). status filter. Simple pagination. |
| GET | /api/v1/billing/invoices/{invoice} | account:read | One invoice with its line items (items[]). Inline ownership (foreign → 404). |
GET /billing/wallets — response
A WalletResource collection — fields: id, currency (PLN|EUR), balance_cents, balance_formatted (string, e.g. "123.45 EUR"), auto_recharge_enabled, auto_recharge_threshold_cents, auto_recharge_amount_cents, is_below_threshold, created_at, updated_at. The auto-recharge fields are read-only (changing them is web-only).
GET /billing/transactions — parameters and filter semantics
wallet_id(int, optional) — names a concrete resource, so a foreign/unknown id is404.currency(enumPLN|EUR, optional) — a plain filter: a value outside the enum is422, a valid currency with no wallet yields an empty page.per_page(int 1–100, default 20),page(int ≥1).
The two filters intersect (e.g. a PLN wallet_id with currency=EUR returns an empty page, not an error). Response: a WalletTransactionResource collection — fields: id, wallet_id, type, amount_cents (signed), balance_after_cents, currency (when the wallet relation is loaded), note, created_at. Internal fields (idempotency key, polymorphic reference, admin author, legacy WHMCS id) stay server-side.
GET /billing/invoices — parameters and fields
status(enum, optional) — a value outside the enum is422.per_page(int 1–100, default 20),page(int ≥1).
A InvoiceResource collection — fields: id, number, status, currency, subtotal_cents (net), vat_rate, vat_cents, total_cents (gross), issued_at, due_at (date), paid_at, period_start, period_end. In the list the items key is absent; in the invoice detail items[] is InvoiceItemResource: id, description, quantity (decimal, serialized as a string, e.g. "2.5000"), unit_price_cents, subtotal_cents, vat_cents, total_cents. Omitted: whmcs_id, wfirma_id, pdf_path and any PDF URLs.
Error codes
401— missing/invalid token.403— missingaccount:readscope or inactivehosting-suiteflag.404— foreign/unknownwallet_idor invoice.422— filter validation (e.g.currency/statusoutside the enum).
Examples — API
Wallets
curl -s https://app.nodea.io/api/v1/billing/wallets \
-H "Authorization: Bearer $NODEA_TOKEN" \
-H "Accept: application/json"
Transactions filtered by currency
curl -s "https://app.nodea.io/api/v1/billing/transactions?currency=PLN&per_page=25" \
-H "Authorization: Bearer $NODEA_TOKEN" \
-H "Accept: application/json"
Paid invoices
curl -s "https://app.nodea.io/api/v1/billing/invoices?status=paid" \
-H "Authorization: Bearer $NODEA_TOKEN" \
-H "Accept: application/json"
One invoice with line items
curl -s https://app.nodea.io/api/v1/billing/invoices/123 \
-H "Authorization: Bearer $NODEA_TOKEN" \
-H "Accept: application/json"
MCP — tools
MCP tools share the auth and scope model with API v1 (Bearer + scope checked in ensureScope) and the features:hosting-suite flag — a tool on an inactive flag disappears from tools/list. All are read-only (IsReadOnly/IsIdempotent annotations) and never touch Stripe. Invoice ids are integers; a missing and a foreign id raise the same error (AuthorizationException: "Invoice not found or not accessible with your permissions.") — no cross-tenant enumeration.
| Tool | Scope | Type | Description |
|---|---|---|---|
| billing-wallets | account:read | read-only | Wallet balances (one row per currency): balance_cents, balance_formatted, auto-recharge config. No parameters. A fresh account may return an empty list. |
| billing-transactions | account:read | read-only | Transaction ledger, newest first. Filters type and/or currency, page, per_page (max 50, default 15). |
| billing-invoices | account:read | read-only | Invoices, newest first (issued_at DESC, id DESC). status filter, page, per_page (max 50, default 15). |
| billing-invoice-get | account:read | read-only | One invoice with line items by id. Owner-scoped. |
The type filter in billing-transactions accepts WalletTransactionType values (incl. topup_stripe, topup_admin, refund, debit_meter, debit_invoice, adjustment). The status filter on invoices accepts InvoiceStatus values (incl. draft, issued, paid, void, migrated_historical). Note: the MCP shapes are slightly richer than the API — billing-invoice-get returns extra per-item vat_rate, period_start, period_end (absent from InvoiceItemResource), while walletArray omits the wallet id and is_below_threshold. Internal fields (polymorphic reference, idempotency key, admin attribution, wfirma_id/whmcs_id, pdf_path) are never returned.
Example — MCP (JSON-RPC)
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "billing-transactions",
"arguments": { "currency": "PLN", "type": "debit_meter", "per_page": 25 }
}
}
{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/call",
"params": {
"name": "billing-invoice-get",
"arguments": { "id": 123 }
}
}