Subscriptions
A subscription binds a customer to a plan (and a specific price on it), tracks where they are in the billing cycle, and tells Plugipay when to issue the next invoice. The subscription itself never moves money — on each cycle the renewal worker materialises the schedule into an invoice and (for charge_automatically) charges the stored payment token. Portal-side narrative: Portal → Subscriptions. IDs are prefixed sub_.
Plans first. A subscription always points at an existing plan + price. Create the plan before you create the subscription that references it.
Lifecycle states
Every subscription is in one state at a time. Transitions come from explicit API calls or the renewal / dunning workers.
| Status | What it means | How you get here |
|---|---|---|
trialing |
Inside the free trial. No invoice, no charge. | Created with trialDays > 0. |
active |
Normal recurring billing. Last invoice paid (or free sub); next scheduled. | Trial-less create, trial end + first charge OK, dunning retry OK, or resume from paused. |
past_due |
Most recent charge failed; inside the dunning window. | Renewal cron charged the stored token and the provider declined. |
paused |
Billing suspended. No invoices, no charges, no period progression. | Explicit POST /v1/subscriptions/:id/pause. |
canceled |
Terminal. No further charges will ever be attempted. | Explicit cancel, dunning exhausted, or scheduled cancelAt reached. |
incomplete |
First-ever charge failed before activation. | Provider declined the initial charge (rare). |
Forward transitions: trialing → active → past_due → canceled, plus the reversible pair active ↔ paused. canceled and incomplete are terminal — you can't reuse the ID; create a new subscription instead.
Endpoints
All endpoints are nested under /v1/subscriptions. Reads require the plugipay:subscription:read scope; writes require plugipay:subscription:write (create needs plugipay:subscription:create). Mutating endpoints require an Idempotency-Key header — see Idempotency.
| Method | Path | Purpose |
|---|---|---|
POST |
/v1/subscriptions |
Create a new subscription. |
GET |
/v1/subscriptions/:id |
Retrieve a single subscription. |
GET |
/v1/subscriptions |
List subscriptions (paginated). |
PATCH |
/v1/subscriptions/:id |
Update the plan/price, default payment token, or metadata. |
POST |
/v1/subscriptions/:id/pause |
Pause billing. |
POST |
/v1/subscriptions/:id/resume |
Resume a paused subscription. |
POST |
/v1/subscriptions/:id/cancel |
Cancel (now or at period end). |
Create a subscription
POST /v1/subscriptions
Request body
| Field | Type | Required | Notes |
|---|---|---|---|
customerId |
string (cus_) |
yes | Must exist in this workspace. |
planId |
string (pln_) |
yes | Must be active (non-archived). |
priceId |
string (pr_) |
yes | Must belong to planId. |
paymentTokenId |
string (pt_) |
conditional | Required when collectionMethod=charge_automatically. Must belong to customerId. |
collectionMethod |
enum | no | charge_automatically (default) or send_invoice. |
trialDays |
integer ≥ 0 | no | Non-zero starts the subscription in trialing. |
couponId |
string (cpn_) or null |
no | Discount coupon applied to invoices. |
startAt |
ISO 8601 | no | Defaults to now. Future-dated start is allowed; past-dated is rejected. |
initialDiscount |
integer ≥ 0 | no | One-off minor-unit credit applied to the first auto-issued invoice. Used for plan-change proration — see Plan changes & proration. |
metadata |
object | no | Up to 50 string keys. See Conventions → Metadata. |
Behaviour
trialDays > 0→ subscription starts intrialing; first invoice is issued when the trial ends.trialDays = 0andunitAmount > 0→ first-period invoice is auto-issued alongside thesubscription.createdevent. Invoice issuance is best-effort — if it fails, the renewal cron retries on the next pass.- Free subscriptions (
unitAmount = 0) becomeactiveimmediately, no invoice.
Response — 201 Created with the full subscription object in data.
Errors
| Status | Code | When |
|---|---|---|
404 |
not_found |
customerId, planId, priceId, or paymentTokenId doesn't exist in this workspace. |
400 |
validation_error |
priceId belongs to a different plan; payment token belongs to a different customer; paymentTokenId missing on charge_automatically. |
422 |
validation_error |
Plan is archived. |
409 |
idempotency_key_conflict |
Same Idempotency-Key was reused with a different body. |
Always send
Idempotency-Key. Double-creating a subscription doubles billing. A deterministic key likesub-create-<orderId>makes retries safe by construction.
Samples
// Node
const sub = await plugipay.subscriptions.create({
customerId: 'cus_01HXxxx...',
planId: 'pln_01HYxxx...',
priceId: 'pr_01HYxxx...',
paymentTokenId: 'pt_01HZxxx...',
collectionMethod: 'charge_automatically',
trialDays: 14,
metadata: { campaign: 'spring-2026' },
});
# Python
sub = plugipay.subscriptions.create(
customer_id="cus_01HXxxx...",
plan_id="pln_01HYxxx...",
price_id="pr_01HYxxx...",
payment_token_id="pt_01HZxxx...",
collection_method="charge_automatically",
trial_days=14,
metadata={"campaign": "spring-2026"},
)
// Go
trial := 14
method := "charge_automatically"
token := "pt_01HZxxx..."
sub, err := client.Subscriptions.Create(ctx, plugipay.SubscriptionCreateInput{
CustomerID: "cus_01HXxxx...",
PlanID: "pln_01HYxxx...",
PriceID: "pr_01HYxxx...",
PaymentTokenID: &token,
CollectionMethod: &method,
TrialDays: &trial,
})
# curl
plugipay_curl POST '/v1/subscriptions' '{
"customerId":"cus_01HXxxx...","planId":"pln_01HYxxx...",
"priceId":"pr_01HYxxx...","paymentTokenId":"pt_01HZxxx...",
"collectionMethod":"charge_automatically","trialDays":14
}'
Retrieve a subscription
GET /v1/subscriptions/:id
Returns the subscription object. 404 not_found if the ID doesn't exist or belongs to another workspace.
const sub = await plugipay.subscriptions.get('sub_01HZxxx...');
sub = plugipay.subscriptions.get("sub_01HZxxx...")
sub, err := client.Subscriptions.Get(ctx, "sub_01HZxxx...")
plugipay_curl GET '/v1/subscriptions/sub_01HZxxx...'
List subscriptions
GET /v1/subscriptions
Query parameters
| Param | Type | Notes |
|---|---|---|
limit |
integer 1–100 | Page size. Default 20. |
cursor |
string | Opaque cursor from a previous response's meta.page.nextCursor. |
order |
asc | desc |
Sort by createdAt. Default desc. |
status |
enum | Filter by exact status (trialing, active, past_due, paused, canceled, incomplete). |
customerId |
string (cus_) |
Only this customer's subscriptions. |
planId |
string (pln_) |
Only subscriptions on this plan. |
Cursor pagination follows Pagination; the response carries meta.page.{limit, hasMore, nextCursor}.
const page = await plugipay.subscriptions.list({ status: 'active', limit: 50 });
for (const sub of page.data) { /* ... */ }
page = plugipay.subscriptions.list(status="active", limit=50)
limit := 50
status := "active"
page, err := client.Subscriptions.List(ctx, plugipay.SubscriptionListParams{
Limit: &limit, Status: &status,
})
plugipay_curl GET '/v1/subscriptions?status=active&limit=50'
Update a subscription
PATCH /v1/subscriptions/:id
Use PATCH to switch plan/price (a plan change — see proration), change the default payment token, or edit metadata. Status transitions (pause, resume, cancel) have their own endpoints.
Request body
| Field | Type | Notes |
|---|---|---|
priceId |
string (pr_) |
Switch to a different price (same plan or cross-plan). |
defaultPaymentTokenId |
string (pt_) |
Token charged on charge_automatically cycles. |
metadata |
object | null |
Replace metadata. null clears; omitted leaves unchanged. |
Plan id, customer id, currency, and schedule are immutable — recreate to change them.
Response — 200 OK with the updated subscription object.
Errors
| Status | Code | When |
|---|---|---|
404 |
not_found |
Subscription, priceId, or defaultPaymentTokenId not found. |
await plugipay.subscriptions.update('sub_01HZxxx...', {
priceId: 'pr_newer_price_id',
});
plugipay.subscriptions.update("sub_01HZxxx...", price_id="pr_newer_price_id")
client.Subscriptions.Update(ctx, "sub_01HZxxx...", plugipay.SubscriptionUpdateInput{
PriceID: stringPtr("pr_newer_price_id"),
})
plugipay_curl PATCH '/v1/subscriptions/sub_01HZxxx...' '{"priceId":"pr_newer_price_id"}'
Pause a subscription
POST /v1/subscriptions/:id/pause
Suspends billing. While paused, the renewal cron skips the subscription — no invoices, no charges, no period progression. On resume, the customer picks up exactly where they left off (not where they would have been had billing continued).
Request body (optional)
| Field | Type | Notes |
|---|---|---|
resumeAt |
ISO 8601 | If set, the renewal worker auto-resumes at this timestamp. Omit for indefinite pause. |
Errors
| Status | Code | When |
|---|---|---|
409 |
conflict |
Subscription is already paused or has been canceled. |
await plugipay.subscriptions.pause('sub_01HZxxx...', '2026-08-01T00:00:00Z');
plugipay.subscriptions.pause("sub_01HZxxx...", resume_at="2026-08-01T00:00:00Z")
resumeAt := "2026-08-01T00:00:00Z"
client.Subscriptions.Pause(ctx, "sub_01HZxxx...", &resumeAt)
plugipay_curl POST '/v1/subscriptions/sub_01HZxxx.../pause' '{"resumeAt":"2026-08-01T00:00:00Z"}'
Resume a subscription
POST /v1/subscriptions/:id/resume
Brings a paused subscription back to active. Clears pausedAt and resumeAt. Twelve days left when paused means twelve days left on resume.
Errors
| Status | Code | When |
|---|---|---|
409 |
conflict |
Subscription is not in paused (e.g. active, canceled). |
await plugipay.subscriptions.resume('sub_01HZxxx...');
plugipay.subscriptions.resume("sub_01HZxxx...")
client.Subscriptions.Resume(ctx, "sub_01HZxxx...")
plugipay_curl POST '/v1/subscriptions/sub_01HZxxx.../resume' '{}'
Cancel a subscription
POST /v1/subscriptions/:id/cancel
Ends the subscription. Two modes — see Cancellation modes.
Request body
| Field | Type | Required | Notes |
|---|---|---|---|
at |
now | period_end |
yes | When the cancellation takes effect. |
reason |
enum | no | One of customer_portal, merchant, failed_payment, user_request. Defaults to user_request. Surfaces on the resulting canceledReason field. |
Errors
| Status | Code | When |
|---|---|---|
409 |
conflict |
Subscription is already canceled. |
await plugipay.subscriptions.cancel('sub_01HZxxx...', 'period_end');
plugipay.subscriptions.cancel("sub_01HZxxx...", at="period_end")
client.Subscriptions.Cancel(ctx, "sub_01HZxxx...", "period_end")
plugipay_curl POST '/v1/subscriptions/sub_01HZxxx.../cancel' '{"at":"period_end","reason":"user_request"}'
The subscription object
{
"id": "sub_01HZxxxxxxxxxxxxxxxxxxxxxx",
"arn": "forjio:plugipay:sgp1:acc_01HX...:subscription/sub_01HZ...",
"accountId": "acc_01HXxxxxxxxxxxxxxxxxxxxxxx",
"customerId": "cus_01HXxxxxxxxxxxxxxxxxxxxxxx",
"planId": "pln_01HYxxxxxxxxxxxxxxxxxxxxxx",
"priceId": "pr_01HYxxxxxxxxxxxxxxxxxxxxxx",
"status": "active",
"currentPeriodStart": "2026-05-12T10:42:00.000Z",
"currentPeriodEnd": "2026-06-12T10:42:00.000Z",
"trialEnd": null,
"cancelAt": null,
"canceledAt": null,
"canceledReason": null,
"pausedAt": null,
"defaultPaymentTokenId": "pt_01HZxxxxxxxxxxxxxxxxxxxxxx",
"discountCouponId": null,
"collectionMethod": "charge_automatically",
"metadata": null,
"createdAt": "2026-05-12T10:42:00.123Z",
"updatedAt": "2026-05-12T10:42:00.123Z"
}
| Field | Type | Notes |
|---|---|---|
id |
string | sub_-prefixed ULID. |
arn |
string | Forjio resource name for cross-service refs. |
accountId |
string | Workspace that owns this subscription. |
customerId |
string | The subscriber. Expand with ?expand=customer. |
planId |
string | Expand with ?expand=plan. |
priceId |
string | null |
Specific price within the plan. null only on legacy rows. |
status |
enum | One of the lifecycle states. |
currentPeriodStart |
ISO 8601 | Start of the cycle in progress. |
currentPeriodEnd |
ISO 8601 | End of the cycle in progress; also when the next invoice generates. |
trialEnd |
ISO 8601 | null |
When the trial flips to active. |
cancelAt |
ISO 8601 | null |
Set to currentPeriodEnd when you cancel with at=period_end; renewal cron finalises at the boundary. |
canceledAt |
ISO 8601 | null |
Wall-clock time cancellation actually took effect. |
canceledReason |
enum | null |
customer_portal | merchant | failed_payment | user_request. |
pausedAt |
ISO 8601 | null |
When pause was last invoked. Cleared on resume. |
defaultPaymentTokenId |
string | null |
Token charged on each cycle when charge_automatically. |
discountCouponId |
string | null |
Coupon applied to invoices. |
collectionMethod |
enum | charge_automatically or send_invoice. |
metadata |
object | null |
Up to 50 key/value pairs. |
createdAt / updatedAt |
ISO 8601 | Creation + last-mutation timestamps. |
Inline related objects with ?expand=customer,plan — see Conventions → Expansion.
Events
Subscription mutations emit webhook events. Register a destination via Webhook endpoints; verify signatures per Webhooks.
| Event | When |
|---|---|
subscription.created |
New subscription provisioned. Payload object is the full subscription. |
subscription.updated |
Plan/price change, token swap, metadata edit, pause, resume, or cancellation scheduled (cancelAt set). |
subscription.deleted |
Transitioned to canceled. Payload includes canceledReason. Cancellation is modelled as a delete. |
subscription.trial_will_end |
Three days before trialEnd. Use it to send a "trial ending soon" email. |
subscription.past_due |
Renewal charge failed; dunning started. Subsequent failed attempts emit invoice.payment_failed; exhausted retries emit subscription.deleted with canceledReason: failed_payment. |
The renewal cron also emits invoice.created / invoice.paid / invoice.payment_failed against Invoices — subscriptions and invoices share the billing event timeline.
Plan changes and proration
Change a plan by PATCH-ing priceId.
- Upgrade (new > old). Charge the prorated delta for the remainder of the period at the new price; the next full charge still happens at the original
currentPeriodEnd. - Downgrade (new < old). Keep the old price until
currentPeriodEnd, then renew at the new price. No mid-period refund — the credit is implicit in the smaller next invoice. - Cancel + recreate pattern. For trickier flows (immediate downgrade with partial credit, currency switch, etc.):
POST /cancelwithat=now, compute the unused-time credit yourself, thenPOST /subscriptionswith that amount ininitialDiscount. The new subscription's first auto-issued invoice picks up the credit. TheinitialDiscountfield exists for this hand-off.
See Plans → Prices and proration for the underlying model.
Editing a plan's
unitAmountdoes not migrate existing subscriptions. Plan price is locked at the moment of subscription creation (well, at the moment apriceIdis attached). To raise prices for the installed base, mint a new price andPATCHeach subscription onto it. Bulk price moves have to be explicit.
Cancellation modes
The at field on cancel picks between two very different semantics:
| Mode | Immediately | At currentPeriodEnd |
Use when |
|---|---|---|---|
period_end |
cancelAt set; status stays active. |
Renewal cron transitions to canceled and emits subscription.deleted. |
The right default. Customer keeps access until what they paid for runs out. |
now |
Status flips to canceled; canceledAt set; subscription.deleted emitted. |
Nothing — already terminal. | Fraud, dunning exhaustion, or an explicit immediate-stop request. Does not refund the current period — issue refunds manually against the linked payment. |
Both modes accept an optional reason, surfaced on the subscription object and the event payload for downstream routing (analytics, churn dashboards, CS inbox).
canceledis terminal. You can't reopen a canceled subscription — that would silently corrupt event consumers that already processed thesubscription.deletedevent. Always create a new subscription instead.
Common errors
| Status | Code | Likely cause |
|---|---|---|
400 |
validation_error |
Missing required field, wrong ID prefix, or paymentTokenId missing for charge_automatically. |
404 |
not_found |
Subscription / customer / plan / price / token not in this workspace (or doesn't exist at all). |
409 |
conflict |
Pause/resume/cancel called against an incompatible status (e.g. resume on a non-paused sub). |
409 |
idempotency_key_conflict |
Reused an Idempotency-Key with a different request body. |
422 |
validation_error |
Plan archived; price doesn't belong to the plan; backdated startAt. |
For the full error catalog see Errors.
Related
- Customers — who you're billing, plus their payment tokens.
- Plans — the recurring offer + price catalogue subscriptions reference.
- Invoices — the per-cycle billing records subscriptions generate.
- Webhooks — the signing scheme and full event catalogue.
- Portal → Subscriptions — the same lifecycle from the dashboard operator's point of view.