Plans

A plan is the catalog entry that subscriptions are bound to: a name, a currency, an amount in minor units, and a billing interval. Once created, a plan is immutable on its price-shaping fields (currency, amount, interval) — change those by archiving the old plan and creating a new one. The Python SDK exposes plan CRUD plus an archive action via plugipay.plans. For the HTTP shape, query parameters, and the full plan object, see API → Plans.

Namespace

plug.plans      # _Plans

Methods

create

plug.plans.create(
    *,
    name: str,
    currency: str,
    amount: int,
    interval: str,
) -> Plan

Creates a plan. amount is in minor units (cents, rupiah, etc.) — no decimals on the wire. interval is one of "day", "week", "month", "year". The SDK auto-generates an idempotency key, so retries are safe.

plan = plug.plans.create(
    name="Pro Monthly",
    currency="IDR",
    amount=149_000_00,   # IDR 149,000.00
    interval="month",
)
print(plan["id"])    # "pln_01HX..."

get

plug.plans.get(plan_id: str) -> Plan

Retrieve a single plan by id. Returns the full plan object including its current active flag, attached prices, and metadata.

plan = plug.plans.get("pln_01HXAB7K3M9N2P5QRS8TVWXY3Z")

list

plug.plans.list(
    *,
    limit: int | None = None,
    active: bool | None = None,
    cursor: str | None = None,
    order: str | None = None,
) -> PageResult[Plan]

Lists plans in the workspace, newest first by default. active=True filters to non-archived plans; active=False to archived. order is "asc" or "desc" (defaults to "desc" server-side). Booleans are serialized as true/false lowercase on the wire — handled by _qs(...) for you.

# Iterate live plans
cursor = None
while True:
    page = plug.plans.list(limit=100, active=True, cursor=cursor)
    for plan in page.data:
        print(plan["name"], plan["amount"], plan["currency"])
    if not page.has_more:
        break
    cursor = page.cursor

update

plug.plans.update(
    plan_id: str,
    *,
    name: str | None = None,
    description: str | None = None,
    active: bool | None = None,
    metadata: dict[str, Any] | None = None,
) -> Plan

Partial update. Only the fields above can be mutated via the SDK — Plugipay deliberately blocks changes to currency, amount, and interval on an existing plan to keep historical subscriptions stable. The SDK auto-generates an idempotency key.

updated = plug.plans.update(
    "pln_01HXAB7K3M9N2P5QRS8TVWXY3Z",
    name="Pro Monthly (was Standard)",
    description="Includes priority support",
    metadata={"tier": "pro"},
)

To swap pricing on a live subscription, create a new plan, then call plug.subscriptions.update(...) (when available — currently only cancel/pause/resume are SDK-exposed; reach for plug.request for update).

archive

plug.plans.archive(plan_id: str) -> Plan

Marks the plan inactive. Existing subscriptions on the plan keep billing; the plan just won't appear in list(active=True) and can't have new subscriptions created against it. There is no "unarchive" — call update(active=True) to restore.

plug.plans.archive("pln_01HXAB7K3M9N2P5QRS8TVWXY3Z")

Archive ≠ delete. Plugipay never deletes plans because subscriptions and invoices reference them historically. Treat archive as "retire from the catalog."

Types

from plugipay import Plan, PageResult

Plan is a Resource (dict-backed dataclass). Read fields as plan["amount"] or plan.get("interval"). The HTTP fields are documented at API → Plans → The plan object. Notable fields you'll likely read:

  • plan["id"]pln_ + ULID.
  • plan["currency"] — ISO 4217 ("IDR", "USD").
  • plan["amount"] — integer minor units.
  • plan["interval"]"day" | "week" | "month" | "year".
  • plan["active"]True/False.

Common patterns

Idempotent plan seeding. When bootstrapping a new workspace, idempotently create your catalog by checking before create:

def upsert_plan(plug, *, name, currency, amount, interval):
    page = plug.plans.list(limit=100, active=True)
    for p in page.data:
        if p.get("name") == name and p.get("currency") == currency:
            return p
    return plug.plans.create(
        name=name, currency=currency, amount=amount, interval=interval,
    )

pro = upsert_plan(plug, name="Pro Monthly", currency="IDR", amount=149_000_00, interval="month")

Money formatting. amount is integer minor units; format for display only:

def format_idr(amount: int) -> str:
    return f"Rp {amount // 100:,}"

print(format_idr(plan["amount"]))   # "Rp 149,000"

Cycle through archived plans for reporting. active=False keeps the history queryable without polluting the live catalog list:

archived = []
cursor = None
while True:
    page = plug.plans.list(active=False, limit=100, cursor=cursor)
    archived.extend(page.data)
    if not page.has_more:
        break
    cursor = page.cursor

Handling 409 conflict on archive. Re-archiving a plan that's already archived returns 409; treat it as a no-op:

from plugipay import PlugipayError

try:
    plug.plans.archive(plan_id)
except PlugipayError as err:
    if err.status == 409:
        pass   # already archived
    else:
        raise

Errors

Plan endpoints raise PlugipayError. Branch on err.status and err.code:

err.status err.code Cause
400 validation_error Bad interval, negative amount, invalid currency.
400 immutable_field Attempted to change currency/amount/interval via update.
404 not_found Plan id doesn't exist in this workspace.
409 conflict Archive on an already-archived plan.
409 idempotency_key_reused Same Idempotency-Key against a different body.
403 insufficient_scope Key lacks plugipay:plan:read / :write.

See Errors for the full exception hierarchy and how to recover the request_id from err.request_id for support tickets.

Next

Plugipay — Payments that don't tax your success