Skip to main content
When you subscribe a customer to a plan, you’re telling us: charge this customer this amount on this schedule, automatically, until further notice. That’s a subscription. It handles renewals, state transitions, and collection — you just listen to webhooks and gate access accordingly.

Lifecycle

created
  └─→ trialing ──────────────────────────────→ active
  └─→ active ──→ past_due ──→ grace ──→ delinquent
                    └─→ active (recovered)
  └─→ active ──→ paused
  └─→ active ──→ canceled

State reference

StateWhat’s happeningCustomer accessWhat’s next
incompleteStrict activation — awaiting first paymentNo accessActivates when the checkout payment settles
trialingFree trial running, no charge yetFull accessConverts to active when trial ends
activeAll good, payments collectingFull accessRenews at next_bill_at
past_dueFirst renewal attempt failedFull accessRetry schedule starts
graceAll retries exhausted, grace window openFull accessGrace expires → delinquent
delinquentGrace expired, no paymentNo accessCustomer needs to pay to restore
pausedManually pausedNo accessResume when ready
canceledEndedNo accessFinal state
During past_due and grace, the customer still has access — cutting them off immediately for one failed payment loses customers unnecessarily. Once delinquent, access is cut until they pay.

Two billing rails

Card billing — You tokenise the customer’s card once via Nomba, pass the token when creating the subscription. On each renewal, we charge the card automatically. Transfer billing — The customer has a dedicated virtual account number (bank name + account number). On renewal, instead of charging a card, we open an invoice and wait for the customer to send a bank transfer. When it arrives, we match it automatically and close the invoice. Set the rail on subscription creation:
# Card billing
{
  "payment_method": "card",
  "payment_method_id": "tok_abc123"
}

# Transfer billing
{
  "payment_method": "transfer"
}

Create a subscription

curl -X POST https://api.useplinth.com/v1/subscriptions \
  -H "Authorization: Bearer sk_test_DOCS_SHARED_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: create-sub-acme-pro-jun26" \
  -d '{
    "customer_id": "cus_01JXYZ...",
    "plan_id": "plan_pro_01...",
    "payment_method": "card",
    "payment_method_id": "tok_test_visa"
  }'
Response
{
  "id": "sub_01JABC...",
  "customer_id": "cus_01JXYZ...",
  "plan_id": "plan_pro_01...",
  "state": "active",
  "payment_method": "card",
  "billing_mode": "advance",
  "current_period_start": "2026-06-01T00:00:00Z",
  "current_period_end": "2026-07-01T00:00:00Z",
  "next_bill_at": "2026-07-01T00:00:00Z",
  "cancel_at_period_end": false,
  "canceled_at": null,
  "has_card": true,
  "scheduled_change": null,
  "created_at": "2026-06-01T00:00:00Z"
}
cancel_at_period_end
boolean
true when the subscription is set to cancel at period end (still active until then). Toggle with cancel / reactivate.
has_card
boolean
Whether a card token is on file. Transfer-funded subscriptions have none, so renewals need manual payment — a cue to nudge the customer to add a card.
scheduled_change
object | null
A pending period-end plan change (e.g. a scheduled downgrade): { id, new_plan_id, new_quantity, scheduled_for }. The current plan runs until scheduled_for, then switches.

One subscription per customer

By default a customer can have one live subscription per plan-group. Creating a second while one is still live (any non-canceled state) returns 409 customer_already_subscribed with the existing subscription’s id — reuse it (change its plan, or cancel it) rather than creating a duplicate:
409
{ "error": { "code": "customer_already_subscribed",
  "message": "Customer already has a live subscription (sub_01JABC...) in this plan group.",
  "existing_id": "sub_01JABC..." } }
Integrators that genuinely need concurrent subscriptions can set allow_multiple_subscriptions: true in their policy.
Use idempotency keys on subscription creation. If your request times out and you retry, the same key returns the original response instead of creating a duplicate subscription.