Plans
A plan is a recurring billing template — "Pro at IDR 299,000/month", "Team at USD 49/month with a 14-day trial". A plan doesn't bill anyone by itself; a subscription references a plan and binds it to a specific customer. One plan can back thousands of subscriptions.
The plan owns the shape of the offer (interval, trial, portal features); the prices nested under it own the amounts, one per currency. To raise a price, you don't mutate the existing one — you add a new price and migrate subscribers when you're ready.
For the portal walk-through see Portal → Plans; for the signing recipe see Authentication.
Plans are templates, not bills. Creating a plan never moves money. The first charge happens when a subscription that references it reaches the end of its trial (or immediately, if there's no trial).
Endpoints
| Method | Path | Purpose |
|---|---|---|
POST |
/v1/plans |
Create a plan |
GET |
/v1/plans/:id |
Retrieve a plan |
GET |
/v1/plans |
List plans |
PATCH |
/v1/plans/:id |
Update a plan |
POST |
/v1/plans/:id/archive |
Archive a plan |
POST |
/v1/plans/:id/prices |
Add a price |
PATCH |
/v1/prices/:id |
Activate/deactivate a price |
All endpoints require plugipay:plan:read (reads) or plugipay:plan:create / plugipay:plan:write (mutations) on the calling key.
Create a plan
POST /v1/plans
Creates a plan with one or more nested prices. Idempotency-Key is required.
Body parameters:
| Field | Type | Required | Notes |
|---|---|---|---|
name |
string (1–255 chars) | yes | Display name. Shown on checkout and the customer portal. |
description |
string (≤1024 chars) | no | One-liner under the title. |
interval |
day | week | month | year |
yes | The billing period unit. |
intervalCount |
integer ≥ 1 | no, default 1 |
interval=month, intervalCount=3 is quarterly. |
trialDays |
integer ≥ 0 | no, default 0 |
Default trial for new subscriptions. Override per-subscription at signup. |
portalFeatures |
object | yes | See portal features below. |
dunningPolicyId |
string (dp_...) | null |
no | Smart-retry policy applied on failed renewals. |
usageAggregate |
sum | max | last | null |
no | Required if any price has model=usage. |
meteredUnit |
string (≤64 chars) | null |
no | Display unit ("api_call", "GB"). |
active |
boolean | no, default true |
Hide from checkout without archiving. |
metadata |
object | no | Up to 50 string/string pairs. See Conventions. |
prices |
array of price input | yes, ≥1 entry | At least one price; typically one per currency. |
portalFeatures object — all five fields are required booleans:
{
"selfServeCancel": true,
"selfServePause": true,
"selfServeUpgrade": true,
"selfServeDowngrade":false,
"updatePaymentMethod": true
}
These enable the corresponding buttons in the hosted customer portal. Changing them later takes effect immediately for every subscriber on the plan.
Price input — the entries inside prices[]:
| Field | Type | Required | Notes |
|---|---|---|---|
currency |
ISO 4217 (3 chars) | yes | IDR, USD, SGD, … |
model |
flat | tiered | volume | usage |
yes | See pricing models. |
unitAmount |
integer ≥ 0 | required for flat/usage |
Smallest unit (cents/rupiah). |
tiers |
array of {upTo, unitAmount, flatAmount} |
required for tiered/volume |
upTo is integer or "inf". Must be strictly increasing. |
taxMode |
inclusive | exclusive |
no, default exclusive |
Whether unitAmount already includes VAT. |
active |
boolean | no, default true |
Inactive prices are hidden from checkout. |
// Node
import { PlugipayClient } from '@plugipay/node';
const plugipay = new PlugipayClient({ keyId: process.env.PLUGIPAY_KEY_ID, secret: process.env.PLUGIPAY_SECRET });
const plan = await plugipay.plans.create({
name: 'Pro',
description: 'Everything in Starter, plus team seats and SSO.',
interval: 'month',
intervalCount: 1,
trialDays: 14,
portalFeatures: {
selfServeCancel: true, selfServePause: true,
selfServeUpgrade: true, selfServeDowngrade: true,
updatePaymentMethod: true,
},
prices: [
{ currency: 'IDR', model: 'flat', unitAmount: 299000, taxMode: 'exclusive' },
{ currency: 'USD', model: 'flat', unitAmount: 1900, taxMode: 'exclusive' },
],
metadata: { product: 'pro' },
});
# Python
from plugipay import Plugipay
plugipay = Plugipay(key_id=os.environ["PLUGIPAY_KEY_ID"], secret=os.environ["PLUGIPAY_SECRET"])
plan = plugipay.plans.create(
name="Pro",
interval="month",
interval_count=1,
trial_days=14,
portal_features={
"selfServeCancel": True, "selfServePause": True,
"selfServeUpgrade": True, "selfServeDowngrade": True,
"updatePaymentMethod": True,
},
prices=[
{"currency": "IDR", "model": "flat", "unitAmount": 299000, "taxMode": "exclusive"},
{"currency": "USD", "model": "flat", "unitAmount": 1900, "taxMode": "exclusive"},
],
metadata={"product": "pro"},
)
// Go
client := plugipay.NewClient(os.Getenv("PLUGIPAY_KEY_ID"), os.Getenv("PLUGIPAY_SECRET"))
plan, err := client.Plans.Create(ctx, &plugipay.PlanCreateParams{
Name: "Pro",
Interval: "month",
IntervalCount: 1,
TrialDays: 14,
PortalFeatures: plugipay.PortalFeatures{
SelfServeCancel: true, SelfServePause: true,
SelfServeUpgrade: true, SelfServeDowngrade: true,
UpdatePaymentMethod: true,
},
Prices: []plugipay.PriceInput{
{Currency: "IDR", Model: "flat", UnitAmount: 299000, TaxMode: "exclusive"},
{Currency: "USD", Model: "flat", UnitAmount: 1900, TaxMode: "exclusive"},
},
Metadata: map[string]string{"product": "pro"},
})
# curl
plugipay_curl POST '/v1/plans' '{
"name": "Pro",
"interval": "month",
"intervalCount": 1,
"trialDays": 14,
"portalFeatures": {
"selfServeCancel": true, "selfServePause": true,
"selfServeUpgrade": true, "selfServeDowngrade": true,
"updatePaymentMethod": true
},
"prices": [
{ "currency": "IDR", "model": "flat", "unitAmount": 299000, "taxMode": "exclusive" }
]
}'
Returns 201 Created with the plan object (prices inlined).
Possible errors:
400 VALIDATION_ERROR—unitAmountmissing for aflatprice, tiers not strictly increasing, missingportalFeatures.400 UNSUPPORTED_CURRENCY— the currency code is unknown or not enabled on your account.409 IDEMPOTENCY_MISMATCH— sameIdempotency-Key, different body.
Retrieve a plan
GET /v1/plans/:id
const plan = await plugipay.plans.get('pln_01HXxxxxxxxxxxxxxxxxxxxxxx');
plugipay_curl GET '/v1/plans/pln_01HXxxxxxxxxxxxxxxxxxxxxxx'
Returns 200 OK with the plan object, or 404 NOT_FOUND.
List plans
GET /v1/plans
Query parameters:
| Param | Type | Default | Notes |
|---|---|---|---|
limit |
integer 1–100 | 20 |
|
cursor |
opaque string | — | From meta.cursor of a previous response. |
order |
asc | desc |
desc |
By creation time. |
active |
boolean | (all) | true shows live plans; false shows archived. Omit for both. |
// Iterate every live plan.
let cursor;
do {
const page = await plugipay.plans.list({ active: true, limit: 100, cursor });
for (const plan of page.data) console.log(plan.id, plan.name);
cursor = page.cursor;
} while (cursor);
plugipay_curl GET '/v1/plans?active=true&limit=100'
Returns 200 OK with data as an array of plan objects and pagination metadata in meta.page. See Pagination for the cursor recipe.
Update a plan
PATCH /v1/plans/:id
Updates the mutable fields. Idempotency-Key is required.
| Field | Mutable? | Notes |
|---|---|---|
name |
yes | Cosmetic. Existing subscribers see the new name on their next invoice. |
description |
yes | Cosmetic. |
trialDays |
yes | Applies to new subscriptions only. Existing trials are unaffected. |
portalFeatures |
yes | Takes effect immediately for every subscriber. |
dunningPolicyId |
yes | Takes effect on the next failed renewal. |
active |
yes | Setting false is not the same as archiving — it hides the plan from checkout but doesn't set archivedAt. |
metadata |
yes | Send null to clear all keys. |
interval, intervalCount, currency, amount |
no | Add a new price and migrate subscribers via subscriptions.update. |
Price fields don't mutate — they version. To change the amount on a live plan, add a new price, deactivate the old one, and update each subscription's
priceIdon your own schedule. This is by design: silently bumping prices on existing subscribers triggers chargebacks. See Portal → Plans → Editing a plan.
const plan = await plugipay.plans.update('pln_01HXxxxxxxxxxxxxxxxxxxxxxx', {
description: 'Now with priority support.',
metadata: { product: 'pro', featured: 'true' },
});
plugipay_curl PATCH '/v1/plans/pln_01HXxxxxxxxxxxxxxxxxxxxxxx' '{
"description": "Now with priority support."
}'
Returns 200 OK with the updated plan object.
Archive a plan
POST /v1/plans/:id/archive
Sets archivedAt to now and active to false. Idempotency-Key is required.
An archived plan:
- Is hidden from checkout — no new subscriptions can attach to it.
- Is filtered out of
GET /v1/plansby default. Pass?active=falseto see archived plans. - Keeps billing every existing subscription that already referenced it.
There is no DELETE /v1/plans/:id. Plans are kept forever for audit and reconciliation. To re-enable a plan after archiving, use PATCH with active: true — that clears archivedAt and brings it back into the default list.
Archive is not cancel. Archiving a plan stops new signups; it does not cancel existing subscriptions. To stop billing the people already on the plan, cancel each subscription (
POST /v1/subscriptions/:id/cancel) individually or in batch.
await plugipay.plans.archive('pln_01HXxxxxxxxxxxxxxxxxxxxxxx');
plugipay_curl POST '/v1/plans/pln_01HXxxxxxxxxxxxxxxxxxxxxxx/archive' '{}'
Add a price
POST /v1/plans/:id/prices
Adds a price to a plan — a second currency, a new tier ladder, or the next version of an existing price. Body is a single price input. Idempotency-Key is required.
const price = await plugipay.plans.addPrice('pln_01HXxxxxxxxxxxxxxxxxxxxxxx', {
currency: 'USD',
model: 'flat',
unitAmount: 2400, // raising USD price from $19 -> $24
taxMode: 'exclusive',
});
plugipay_curl POST '/v1/plans/pln_01HXxxxxxxxxxxxxxxxxxxxxxx/prices' '{
"currency": "USD", "model": "flat", "unitAmount": 2400, "taxMode": "exclusive"
}'
Returns 201 Created with the price object. New subscriptions can opt into it by passing the new priceId to subscriptions.create.
Activate or deactivate a price
PATCH /v1/prices/:id
Only active is mutable on an existing price. Use this to retire an old price after you've added a new one: deactivated prices are rejected by subscriptions.create, but existing subscriptions referencing them keep billing.
plugipay_curl PATCH '/v1/prices/pr_01HXxxxxxxxxxxxxxxxxxxxxxx' '{ "active": false }'
Sending any other field returns 400 VALIDATION_ERROR ("only active is mutable on price"). To change the amount, add a new price instead.
The plan object
{
"id": "pln_01HXxxxxxxxxxxxxxxxxxxxxxx",
"arn": "arn:plugipay:acc_01HX...:plan/pln_01HX...",
"accountId": "acc_01HXxxxxxxxxxxxxxxxxxxxxxx",
"name": "Pro",
"description": "Everything in Starter, plus team seats and SSO.",
"interval": "month",
"intervalCount": 1,
"trialDays": 14,
"prices": [
{
"id": "pr_01HYxxxxxxxxxxxxxxxxxxxxxx",
"planId": "pln_01HXxxxxxxxxxxxxxxxxxxxxxx",
"currency": "IDR",
"model": "flat",
"unitAmount": 299000,
"tiers": null,
"taxMode": "exclusive",
"active": true,
"createdAt": "2026-05-12T10:42:00.123Z"
}
],
"usageAggregate": null,
"meteredUnit": null,
"portalFeatures": {
"selfServeCancel": true, "selfServePause": true,
"selfServeUpgrade": true, "selfServeDowngrade": true,
"updatePaymentMethod": true
},
"dunningPolicyId": null,
"active": true,
"archivedAt": null,
"metadata": { "product": "pro" },
"createdAt": "2026-05-12T10:42:00.123Z",
"updatedAt": "2026-05-12T10:42:00.123Z"
}
| Field | Type | Notes |
|---|---|---|
id |
string | pln_ + ULID. |
arn |
string | Fully-qualified resource name, useful for cross-service references. |
accountId |
string | The workspace this plan belongs to. |
name |
string | Display name. |
description |
string | null |
One-liner. |
interval |
enum | day | week | month | year. |
intervalCount |
integer | Combined with interval: e.g. month × 3 = quarterly. |
trialDays |
integer | Default trial length applied to new subscriptions. |
prices |
array | One price per currency/model. At least one is always present. |
usageAggregate |
enum | null |
For usage-priced plans: how to roll up reported usage in a period (sum / max / last). |
meteredUnit |
string | null |
The display unit ("api_call", "GB"). |
portalFeatures |
object | See portal features. |
dunningPolicyId |
string | null |
The smart-retry policy applied on failed renewals. |
active |
boolean | false hides from checkout without archiving. |
archivedAt |
timestamp | null |
Set when the plan is archived. |
metadata |
object | null |
Your own key/value pairs. |
createdAt, updatedAt |
timestamp | UTC ISO 8601. |
Price objects
A price is the amount-and-currency leg of a plan. Subscriptions reference a price (not just a plan), so changing the price on a subscription is how you migrate one subscriber between price versions.
| Field | Type | Notes |
|---|---|---|
id |
string | pr_ + ULID. |
planId |
string | Back-reference to the parent plan. |
currency |
ISO 4217 | One currency per price. |
model |
enum | See pricing models. |
unitAmount |
integer | null |
Smallest unit. null for tiered/volume. |
tiers |
array | null |
[{ upTo: 100, unitAmount: 500, flatAmount: 0 }, { upTo: "inf", unitAmount: 400, flatAmount: 0 }] — for tiered/volume. |
taxMode |
inclusive | exclusive |
Whether unitAmount already includes VAT. |
active |
boolean | Inactive prices are rejected by subscriptions.create; existing subscribers are unaffected. |
createdAt |
timestamp | When this price was added to the plan. |
Pricing models
| Model | When to use |
|---|---|
flat |
Same amount every billing period. The default. |
tiered |
Graduated: first 100 at IDR 5,000/unit, next 1,000 at IDR 4,000/unit, …. |
volume |
Single-tier: the customer's total usage falls into one tier and the entire amount is priced at that tier's unitAmount. |
usage |
Metered. subscriptions.reportUsage records consumption; the period's usageAggregate (sum/max/last) is multiplied by unitAmount at renewal. |
Events
The plan resource emits these webhook events:
| Event | When |
|---|---|
plan.created |
POST /v1/plans succeeded. |
plan.updated |
A mutable field changed, a price was added, or a price's active flag flipped. |
plan.archived |
POST /v1/plans/:id/archive succeeded. |
The payload's data is the full plan object (with nested prices) at the moment the event fired. There is no separate price.* event family — price-level changes ride on plan.updated. To replay or audit, query Events (retained 30 days).
Next
- Subscriptions — the resource that binds a plan to a customer and moves money.
- Invoices — the per-period billing record generated from a plan + subscription.
- Portal → Plans — the same concepts from the dashboard's point of view.
- Pagination, Idempotency, Errors — the shared conventions every resource follows.