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 &mdash; 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:

  1. Looks up the caller's WorkspaceDeployment row.
  2. Pulls the Plugipay customer id from config.plugipay_customer_id. Mints one just-in-time if missing.
  3. Resolves the plan's primary active price (prices[] filtered to active: true, take first).
  4. If config.plugipay_subscription_id exists, PATCHes the subscription to switch plan + price (upgrade / downgrade / variant switch).
  5. Otherwise creates a fresh subscription with collection_method: send_invoice.
  6. Persists plugipay_subscription_id + plugipay_plan_id back 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 &mdash; 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-issued Authorization: Bearer access token (OIDC device flow) — not a cat_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