Billing
The /v1/billing namespace surfaces Catentio's subscription tiers, the caller's current period spend, and the subscribe action. It's a thin façade over Plugipay — the actual billing engine is plugipay.com, with Catentio joining KNOWN_PARTNERS as a Pattern-2 (Shopify-style) partner.
In internal_noop billing mode (the default on adi's dev-machine deployment) every endpoint short-circuits with placeholder data so the portal renders without a Plugipay round-trip. In live mode the CP forwards to Plugipay's admin API.
Modes
| Mode | What |
|---|---|
internal_noop |
Default. CP isn't wired to Plugipay; portal renders placeholders; subscribe is a no-op. |
live |
CP holds Plugipay admin creds; subscribe / catalog / summary all hit plugipay.com. |
Flip via the PLUGIPAY_BILLING_MODE env var. Live mode requires PLUGIPAY_ADMIN_KEY and PLUGIPAY_PARTNER_ACCOUNT_ID (defaults to acc_forjio_platform).
The catalog
GET /v1/billing/catalog
Pricing catalog the portal renders — a typed read of pricing/catalog.json, THE pricing source of truth (the frontend build imports the same file, so the two surfaces can't disagree). In live mode each tier is decorated with plugipay_plan_live (whether that tier's plan has actually been minted in Plugipay yet — the portal disables Subscribe for tiers where it's false).
{
"version": 2,
"mode": "live",
"tiers": [
{
"key": "starter",
"name": "Starter",
"tagline": "Solo / small team.",
"monthly_idr_cents": 9000000,
"monthly_usd_cents": 600,
"plugipay_plan_id": "pln_catentio_v2_starter",
"can_provision_managed": true,
"caps": [
"1 workspace · 3 users",
"20 agents · 50 tools · 50 skills",
"5,000 runs / day",
"50,000 memory entries",
"Email support"
],
"support": "Email",
"plugipay_plan_live": true
}
],
"modifiers": {
"infra": { "managed_usd_micros_per_minute": 1667, "display_managed_usd_per_month": 72 },
"anthropic": { "byo_only": true },
"openrouter": { "managed_cost_markup": 1.2 }
},
"topup": {
"min_initial_usd_cents_with_managed_infra": 3000,
"min_initial_usd_cents_models_only": 1000,
"min_usd_cents": 500,
"max_usd_cents": 100000,
"presets_usd_cents": [1000, 2500, 5000, 10000]
}
}
Four tiers, each a flat object (no BYO/hosted variant split): free, starter, pro, scale. monthly_idr_cents / monthly_usd_cents are in cents (so Rp 290,000 is 29_000_000; $18.00 is 1800). free and scale have plugipay_plan_id: null and monthly_idr_cents/monthly_usd_cents: null — free doesn't subscribe through Plugipay at all, and scale is "contact_only": true. Only starter and pro (can_provision_managed: true) may provision managed infrastructure or managed models; free and scale cannot.
If Plugipay is unreachable in live mode, the response includes error: "plugipay <status> <code>: <message>" and portal falls back to "Contact sales".
Modifiers: infra and models, priced separately from the tier
A tier subscription buys the platform cap (agents/tools/runs/etc.); it does not include hosting or model spend. Those are modifiers, billed from the USD-canonical credit wallet, not the IDR subscription:
| Modifier | Real value | Notes |
|---|---|---|
infra (managed hosting) |
managed_usd_micros_per_minute: 1667 (≈ $72/mo) |
Only if you opt into a managed droplet instead of BYO infra. |
anthropic |
byo_only: true |
Anthropic is BYO-only, full stop. The Anthropic Admin API cannot create API keys (Console-only policy), so there is no managed-Anthropic product — POST /v1/workspaces rejects manage_anthropic with 400 managed_anthropic_not_offered. |
openrouter |
managed_cost_markup: 1.2 |
The actual "managed models" option: Catentio mints a capped OpenRouter key for you and bills provider cost × 1.2 from your wallet. Toggle via POST /v1/billing/addons/openrouter below. |
The summary
GET /v1/billing/summary
Current-period rollup. In internal_noop mode:
{
"mode": "internal_noop",
"current_tier": "internal",
"current_period": { "start": "2026-05-01", "end": "2026-06-01" },
"subscription_idr": 0,
"addons_idr": 0,
"managed_anthropic_idr": 0,
"total_idr": 0,
"lines": [],
"note": "Billing is in internal_noop mode — the catentio team owns this deployment and isn't billed."
}
In live mode the CP calls Plugipay's partner usage rollup for the current calendar-month window and returns lines[] keyed by merchant id:
{
"mode": "live",
"current_tier": "unknown",
"current_plan_id": "pln_01HX...",
"current_period": { "start": "2026-05-01", "end": "2026-06-01" },
"subscription_idr": 0,
"addons_idr": 0,
"managed_anthropic_idr": 0,
"total_idr": 27800000,
"lines": [
{
"merchant_id": "acc_01HX...",
"period_start": "2026-05-01",
"period_end": "2026-06-01",
"plugipay_revenue_idr": 99000000,
"partner_share_idr": 297000,
"settlement_idr": 27800000
}
]
}
current_tier resolution from the subscription record lands in a later release; today only current_plan_id is populated (from config.plugipay_plan_id on the deployment row).
Subscribe
POST /v1/billing/subscribe
Move the caller onto a plan. Body:
{ "plugipay_plan_id": "pln_01HXxxx" }
The CP:
- Looks up the caller's
WorkspaceDeploymentrow. - Pulls the Plugipay customer id from
config.plugipay_customer_id. Mints one just-in-time if missing. - Resolves the plan's primary active price (
prices[]filtered toactive: true, take first). - If
config.plugipay_subscription_idexists, PATCHes the subscription to switch plan + price (upgrade / downgrade / variant switch). - Otherwise creates a fresh subscription with
collection_method: send_invoice. - Persists
plugipay_subscription_id+plugipay_plan_idback onto the deployment row.
Response (success):
{
"ok": true,
"change": "created",
"subscription": { /* Plugipay subscription object */ },
"plan_id": "pln_01HXxxx"
}
change is "created" on first subscribe, "updated" on plan switch.
Response (payment method required):
{
"ok": false,
"payment_method_required": true,
"checkout_url": "https://plugipay.com/checkout/..."
}
The portal redirects the user to checkout_url for hosted card capture, then re-calls subscribe.
In internal_noop mode this endpoint short-circuits with:
{
"ok": true,
"noop": true,
"message": "billing_mode=internal_noop — subscribe is a no-op. Flip PLUGIPAY_BILLING_MODE=live to actually subscribe."
}
Currency is geo-routed, not chosen by the caller: an ID CF-IPCountry (or no header at all, e.g. self-hosted/non-Cloudflare) bills in IDR via Plugipay's local rails (QRIS/VA/e-wallet/card); anything else bills in USD via PayPal. Pass "currency": "IDR"|"USD" in the body to force one.
const result = await catentio.billing.subscribe({ plugipay_plan_id: 'pln_catentio_v2_starter' });
result = catentio.billing.subscribe({"plugipay_plan_id": "pln_catentio_v2_starter"})
curl -X POST 'https://catent.io/api/v1/cp/v1/billing/subscribe' \
-H "Cookie: catentio_session=<session cookie set at login>" \
-H "Content-Type: application/json" \
-d '{"plugipay_plan_id":"pln_catentio_v2_starter"}'
Auth on
/v1/billing/*. These routes accept a verified portal session or a Huudis-issuedAuthorization: Beareraccess token (OIDC device flow) — not acat_pk_*API key, which the control plane never checks (see API keys).
Wallet, top-up, and model add-ons
Beyond the tier subscription, the CP exposes the USD-canonical credit wallet the modifiers above draw from:
| Route | What |
|---|---|
GET /v1/billing/wallet |
Balance (balance_usd_micros) + recent ledger. Params: limit (default 50), offset (default 0). Ledger rows: {id, delta_usd_micros, balance_after_usd_micros, kind, paid_idr_cents, fx_usd_idr, meta, created_at}. |
GET /v1/billing/topup/options |
Presets + bounds for the top-up dialog: {mode, presets_usd_cents, min_usd_cents, max_usd_cents, min_initial_usd_cents: {managed_infra, models_only}, fx_usd_idr}. |
POST /v1/billing/topup |
Start a Plugipay hosted-checkout top-up. Body: {amount_usd_cents: int, currency?: "IDR"|"USD", return_path?}. amount_usd_cents must fall within the bounds from topup/options, or the CP 400s with {code: "invalid_amount"}. Response: {ok, checkout_session_id, checkout_url, charged: {amount, currency}, credit_usd_micros, fx_usd_idr}. The credit only lands once Plugipay's checkout-completed webhook fires — the exact amount travels in the session metadata so nothing does FX twice. internal_noop mode short-circuits to {ok: true, noop: true, message: "..."}, same as subscribe. |
GET /v1/billing/addons |
Current add-on state: {deployment_status, openrouter: {managed, key_hash, key_limit_usd}, anthropic: {mode: "byo", kind}}. |
POST /v1/billing/addons/openrouter |
Toggle the managed OpenRouter key on a ready deployment. Body: {enabled: bool}. Enabling mints a credit-capped OpenRouter key sized to the current wallet balance and SSHes it onto the workspace's box; disabling revokes the key and strips it. Requires status = ready (else 409 addon_requires_ready_deployment) and, in live mode, a minimum wallet top-up (else 402 insufficient_credit). |
There is no equivalent add-on for Anthropic — per the modifiers table above, Anthropic is BYO-only and always will be.
Errors
| Status | Code | When |
|---|---|---|
| 400 | plugipay_plan_id required |
Body missing the plan id. |
| 400 | invalid_amount |
POST /topup's amount_usd_cents is out of bounds. |
| 402 | payment_method_required |
Surfaced as a response field on subscribe (see above), not a bare error. |
| 402 | insufficient_credit |
POST /addons/openrouter enable, or workspace provisioning with a managed modifier, without the mandatory minimum top-up. |
| 404 | no_deployment_for_customer |
Caller has no WorkspaceDeployment row — shouldn't happen in normal flow. |
| 404 | plan {id} not found |
Plan id isn't in the live Plugipay catalog. |
| 409 | plan {id} has no active {currency} price |
Plan exists but has no active price in the resolved currency; admin needs to add one. |
| 409 | addon_requires_ready_deployment |
Toggling the OpenRouter add-on before the deployment reached ready. |
| 500 | could_not_resolve_plugipay_customer_id |
Just-in-time customer mint failed. |
| 502 | Plugipay error pass-through | Wrapped in plugipay <status> <code>: <message>. |
Inbound webhooks
The CP also receives Plugipay's outbound webhooks at /v1/billing/webhooks and writes them to the customer_billing_events table (no-op consumer in v1, but rows accumulate for audit + replay). This is internal billing plumbing between Catentio and Plugipay — it is not a customer-facing webhook surface, and it is unrelated to webhook gates.
Events
Catentio doesn't emit billing-shaped webhook events of its own — subscription state changes propagate from Plugipay. Subscribe to Plugipay's subscription.created / subscription.updated / invoice.paid if you want push notifications on tier changes; the Catentio CP is the consumer side, not the source.
Next
- Workspaces — how the deployment row pairs with the Plugipay customer.
- Authentication — the three credential shapes that hit this resource.
- Portal → Billing — the dashboard equivalents.