Billing

Billing in Catentio is mediated by Plugipay's partner-billing pattern. The pricing catalogue lives in pricing/catalog.json on the control plane, not on Plugipay; Plugipay only holds the live subscription/plan objects once a workspace actually subscribes. Most self-hosted deployments run in internal_noop billing mode, where summary() and subscribe() return placeholder/no-op shapes instead of touching Plugipay at all.

For Catentio's wire-level reference, see API → Billing.

Auth. Billing is one of the few places in this SDK where Bearer auth actually works against the control plane: /v1/billing/* uses RequireSessionOrAgent, which honours a real Huudis-issued JWT in Authorization: Bearer. It does not honour this SDK's static apiKey (cat_pk_...) — that's a runtime-only credential the control plane never checks. See SDKs → Auth: what actually works for the base-URL caveat that still applies on top of this.

Namespace

catentio.billing — three methods:

catentio.billing.catalog()
catentio.billing.summary()
catentio.billing.subscribe(input)

The control plane also has POST /v1/billing/topup, GET /v1/billing/topup/options, GET /v1/billing/wallet, GET /v1/billing/addons, and POST /v1/billing/addons/openrouter — none of them are wrapped by this SDK. Call them via catentio.api.* directly, or the REST API.

There's also a separate catentio.cost namespace (cost.summary(query?)GET /v1/cost/summary, params days 1–180 default 30 and top_n 1–50 default 10) for the runtime's cost rollup — a different thing from billing summary, and not covered by a dedicated page in this section.

Methods

billing.catalog

Signature. catentio.billing.catalog(token?): Promise<CatalogResponse>

Returns the real pricing catalogue — pricing/catalog.json, not a Plugipay-side listing.

import { CatentioSaasClient } from '@forjio/catentio-saas-node';

const catentio = new CatentioSaasClient({ session });

const cat = await catentio.billing.catalog();
for (const t of cat.tiers) {
  console.log(t.key, t.name, t.monthly_usd_cents, t.caps);
}

Real shape:

interface CatalogResponse {
  version: 2;
  tiers: Array<{
    key: string;                     // 'free' | 'starter' | 'pro' | 'scale'
    name: string;
    tagline: string;
    monthly_idr_cents: number | null;
    monthly_usd_cents: number | null;
    plugipay_plan_id: string | null;
    can_provision_managed: boolean;
    caps: string[];
    support: string;
    highlight?: true;
    contact_only?: true;
    plugipay_plan_live?: boolean | null;   // only present when mode === 'live'
  }>;
  modifiers: { infra: object; anthropic: object; openrouter: object };
  topup: {
    min_initial_usd_cents_with_managed_infra: number;
    min_initial_usd_cents_models_only: number;
    min_usd_cents: number;
    max_usd_cents: number;
    presets_usd_cents: number[];
  };
  mode: 'internal_noop' | 'live';
  error?: string;   // present if live mode couldn't reach Plugipay
}

There is no displayName, priceCents, currency, includedAgentHours, overageCentsPerHour, or features field — those don't exist. The real field names are name, monthly_usd_cents/monthly_idr_cents, and caps (a flat list of human-readable strings, not a structured quota).

billing.summary

Signature. catentio.billing.summary(token?): Promise<SummaryResponse>

Returns the current-period billing snapshot. Shape depends on billing mode:

// internal_noop (the default for most deployments):
{
  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. ...",
}

// live:
{
  mode: 'live',
  current_tier: 'unknown',
  current_plan_id: string | null,
  current_period: { start, end },
  subscription_idr: 0,
  addons_idr: 0,
  managed_anthropic_idr: 0,
  total_idr: number,          // settled partner usage for the period
  lines: Array<{ merchant_id, period_start, period_end, plugipay_revenue_idr, partner_share_idr, settlement_idr }>,
}

There is no tierSlug, currentPeriodUsd, includedRemaining, paymentMethodStatus, nextInvoiceAt, or plugipayCustomerId field — none of those exist on the real response.

const s = await catentio.billing.summary();
console.log(s.mode, s.current_tier, s.total_idr);

billing.subscribe

Signature. catentio.billing.subscribe(input, token?): Promise<SubscribeResponse>

The SDK's declared input type is wrong. It types input as { tier: string }, but the real body field is { plugipay_plan_id: string; currency?: "IDR" | "USD" }. Sending { tier: "pro" } gets you 400 { "detail": "plugipay_plan_id required" }. Use the tier's plugipay_plan_id from catalog(), not its key:

const cat = await catentio.billing.catalog();
const pro = cat.tiers.find(t => t.key === 'pro')!;
const res = await catentio.billing.subscribe({ plugipay_plan_id: pro.plugipay_plan_id! });

Response shape varies:

  • internal_noop mode: { ok: true, noop: true, message: "billing_mode=internal_noop — subscribe is a no-op. ..." }
  • Live, success: { ok: true, change: "created" | "updated", subscription: <Plugipay subscription object>, plan_id: string }
  • Live, no payment method: { ok: false, payment_method_required: true, checkout_url: string } — note that in the current implementation checkout_url is populated straight from the upstream Plugipay error message, so verify it's actually a URL before redirecting.
  • 404 { detail: "no_deployment_for_customer" } if the caller has no workspace deployment row yet.

There is no { subscribed: true, tierSlug } shape and no checkoutUrl (camelCase) field.

Errors

Real error bodies are FastAPI's {"detail": ...}; detail is sometimes a plain string, sometimes a dict.

Status detail Cause
400 "plugipay_plan_id required" subscribe() body missing the field (see the warning above).
400 {"code": "invalid_amount", ...} Only relevant to topup, not wrapped by this SDK.
404 "no_deployment_for_customer" subscribe() before a workspace deployment exists.
404 "plan {id} not found" subscribe() with a plugipay_plan_id Plugipay doesn't have.
409 "plan {id} has no active {currency} price" Plan exists but no price row for the resolved currency.
502 Catentio couldn't reach Plugipay; the detail string includes the upstream status/code/message.

Next

  • API → Billing — HTTP-level reference, including the routes this SDK doesn't wrap (topup, wallet, addons, ...).
  • SDKs — the base-URL and auth reality that applies on top of everything above.