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
archiveas "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
- API → Plans — full field reference and HTTP error catalog.
- Subscriptions — bind a customer to a plan.
- Checkout sessions — one-shot purchase flow.
- Templates — invoice and receipt rendering against a plan.