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 in trialing; first invoice is issued when the trial ends.
  • trialDays = 0 and unitAmount > 0 → first-period invoice is auto-issued alongside the subscription.created event. Invoice issuance is best-effort — if it fails, the renewal cron retries on the next pass.
  • Free subscriptions (unitAmount = 0) become active immediately, 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 like sub-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.

Response200 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 /cancel with at=now, compute the unused-time credit yourself, then POST /subscriptions with that amount in initialDiscount. The new subscription's first auto-issued invoice picks up the credit. The initialDiscount field exists for this hand-off.

See Plans → Prices and proration for the underlying model.

Editing a plan's unitAmount does not migrate existing subscriptions. Plan price is locked at the moment of subscription creation (well, at the moment a priceId is attached). To raise prices for the installed base, mint a new price and PATCH each 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).

canceled is terminal. You can't reopen a canceled subscription — that would silently corrupt event consumers that already processed the subscription.deleted event. 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.
Plugipay — Payments that don't tax your success