Subscriptions
View as MarkdownRecurring billing on a customer's saved card. You pick a plan, name the customer, and we take the opening charge and every renewal after it, running fraud screening and routing on each one. 3-D Secure runs only while the buyer is present: renewals are merchant-initiated and are charged without it.
POST https://api.worthypayments.com/v1/plans
GET https://api.worthypayments.com/v1/plans
GET https://api.worthypayments.com/v1/plans/{id}
POST https://api.worthypayments.com/v1/subscriptions
GET https://api.worthypayments.com/v1/subscriptions
GET https://api.worthypayments.com/v1/subscriptions/{id}
PATCH https://api.worthypayments.com/v1/subscriptions/{id}
DELETE https://api.worthypayments.com/v1/subscriptions/{id}
| Scope | Grants |
|---|---|
plans:read | List and fetch plans |
plans:write | Create a plan, server-only, a pk_ is refused |
subscriptions:read | List and fetch subscriptions |
subscriptions:write | Start, modify and cancel, server-only, a pk_ is refused |
Before you start#
You need two things, and only one of them comes from the API.
A plan. Create one with POST /v1/plans (name, amount in cents, interval
month/year/week/day) or in the dashboard under Billing → Plans. Both write
the same object; GET /v1/plans lists it either way.
A customer. With a stored payment method, create charges immediately. That first
charge is not yet a listable invoice, calendar renewal and test
bill-now are. Without a card, create returns status: "incomplete" and a latest_invoice.payment_intent.client_secret you pass to the existing
Payment Element, same iframe, no plan / interval on the
Element. See Customers & Saved Cards for attaching a card up front.
1. Create a plan#
curl -X POST https://api.worthypayments.com/v1/plans \
-H "x-api-key: sk_test_xxx" \
-H "Content-Type: application/json" \
-d '{"name":"Pro","amount":1999,"currency":"usd","interval":"month"}'
{
"id": "plan_2f9d1a77-6c30-4be1-9a54-0f7c8b21d4e3",
"object": "plan",
"name": "Pro",
"amount": 1999,
"currency": "usd",
"interval": "MONTH",
"interval_count": 1
}
GET /v1/plans lists it. Dashboard Billing → Plans writes the same object.
2. Resolve the plan#
curl "https://api.worthypayments.com/v1/plans?active=true" \
-H "x-api-key: sk_test_xxx"
{
"object": "list",
"data": [
{
"id": "plan_2f9d1a77-6c30-4be1-9a54-0f7c8b21d4e3",
"object": "plan",
"name": "Pro monthly",
"amount": 2900,
"currency": "usd",
"interval": "MONTH",
"interval_count": 1,
"trial_days": 14,
"active": true,
"archived": false,
"subscription_count": 42
}
],
"has_more": false
}
amount is minor units like everywhere else on this API, 2900 is $29.00. The
billing period is interval × interval_count, so a quarterly plan is MONTH × 3.
Archived plans are hidden unless you pass include_archived=true. An archived plan
cannot take a new subscription, so an id from that list is one POST /v1/subscriptions
will refuse.
id is the exact value the subscription object reports as its plan, so you can match
the two directly.
3. Start the subscription#
POST /subscriptions
| Field | Required | Notes |
|---|---|---|
customer | Yes | cus_.... Missing it is 400 parameter_invalid. |
plan | Yes | plan_... from step 1. The bare uuid is accepted too. |
quantity | No | Seats / units. Defaults to 1. |
coupon | No | A coupon id to apply. |
default_payment_method | No | pm_.... Omitted, the customer's default is used. |
collection_method | No | CHARGE_AUTOMATICALLY (default, omit it) charges the saved card and writes a PAID invoice on renewal. SEND_INVOICE emails an OPEN invoice on renewal. Opening create still charges when a card is on file and does not mint the first invoice (see Invoices.) |
payment_behavior | No | default_incomplete, skip the opening charge even when a card is on file and return the Payment Element secret. Omit it to charge a saved card immediately. |
Idempotency-Key | Yes | Header. See below. |
curl https://api.worthypayments.com/v1/subscriptions \
-H "x-api-key: sk_test_xxx" \
-H "Idempotency-Key: sub_order_1234" \
-H "Content-Type: application/json" \
-d '{
"customer": "cus_9fK2pQ",
"plan": "plan_2f9d1a77-6c30-4be1-9a54-0f7c8b21d4e3",
"payment_behavior": "default_incomplete"
}'
This call moves money when the customer already has a card and you omit
payment_behavior. Unless the plan is trialing, the opening charge is taken as part of creation, which is why it needs a realIdempotency-Keykeyed on your order, not a fresh UUID per click. A retriedPOSTmust resolve to the same subscription rather than starting a second one on the same card. For the same reason aFRAUD_ONLYkey can never reach this route; see Integration modes.payment_behavior=default_incomplete, or no card on file: the samePOSTanswersstatus: "incomplete"andlatest_invoice.payment_intent.client_secret. Confirm that secret withmountPaymentElement(same as a one-off intent). Completing the Element takes the opening charge and activates the subscription. There is still no listable invoice onGET /v1/invoicesuntil a later renewal. Do not putplanorintervalon the Element.
The incomplete response (the Element loop):
{
"id": "sub_8c1e...",
"object": "subscription",
"status": "incomplete",
"customer": "cus_9fK2pQ",
"plan": "plan_2f9d1a77-6c30-4be1-9a54-0f7c8b21d4e3",
"quantity": 1,
"latest_invoice": {
"object": "invoice",
"payment_intent": {
"object": "payment_intent",
"status": "requires_payment_method",
"client_secret": "pi_abc_secret_sess_elem_1"
}
},
"cancel_at_period_end": false
}
next_billing_at is the field to store: it is when money moves next.
Advance a test subscription#
There is no Test Clock. In test mode only, run the calendar renewal now:
POST https://api.worthypayments.com/v1/test/subscriptions/{id}/bill
Same function the renew worker uses. CHARGE_AUTOMATICALLY (default) charges the saved
card for the next period and writes a PAID invoice listed on GET /v1/invoices.
SEND_INVOICE finalizes and emails an OPEN invoice, no card charge. A LIVE key is refused
with 400 test_mode_only before billing is touched. Scope subscriptions:write.
Idempotency-Key is required. A new key per advance.
The charge id is not the invoice id. A decline leaves the sub past_due and emits
subscription.past_due. There is no invoice.payment_failed event.
Retries of a declined renewal are ours only for subscriptions we bill. If your own system
schedules renewals (a WooCommerce Subscriptions store, your own billing job) and charges a
saved card with POST /v1/charges, those renewals
are yours to retry: we decline once and never retry them on our own, so the buyer is never
charged by two retry schedules for one period.
curl -X POST https://api.worthypayments.com/v1/test/subscriptions/sub_8c1e.../bill \
-H "x-api-key: sk_test_xxx" \
-H "Idempotency-Key: bill_sub_8c1e_period_2"
4. Modify one#
PATCH /subscriptions/{id}, exactly one intention per call.
| Field | Effect |
|---|---|
plan | Move to a different plan. Upstream prorates. |
paused: true | false | Suspend or resume billing. Optionally resumes_at (ISO-8601) with true to schedule the resume. |
cancel_at_period_end: true | Schedule a cancellation for the end of the current period. Optionally reason. |
Setting two of them is a 400, not a guess: each routes to a different operation with
different money semantics (a plan change prorates, a pause suspends billing) and there is
no defined order for combining them.
cancel_at_period_end: falseis refused, not accepted as a no-op. There is no uncancel: takingfalsewould read as "this un-cancelled" while the subscription still lapsed at period end. To keep the customer, start a new subscription.
Unlike POST, PATCH takes no Idempotency-Key. These are state transitions
expressed as the state you want, so repeating one lands in the same place (pausing a
paused subscription is a no-op) and a claim would only let a legitimate correction be
swallowed as a duplicate.
5. Cancel#
DELETE /subscriptions/{id}
{ "immediately": true, "reason": "customer_request" }
Without immediately, the subscription runs to the end of the paid period and stops
there. With it, it ends now. Neither refunds the period already charged, issue a
refund if you owe one.
Reconciling#
Poll nothing. The lifecycle arrives on webhooks:
| Event | Meaning |
|---|---|
subscription.created | Started, opening charge taken |
subscription.renewed | A period was billed |
subscription.updated | Plan, quantity or pause state changed |
subscription.past_due | A renewal failed; retries are in progress |
subscription.suspended | Retries were exhausted |
subscription.canceled | Ended |
subscription.usage_exceeded | A metered plan passed its limit |
subscription.past_due is the one to build for. There is no invoice.payment_failed.
A renewal failing is normal (cards expire) and the customer keeps their access until
subscription.suspended says otherwise. Treating past_due as cancellation cuts off
people who are about to pay.
What is not here#
- Editing a plan. Dashboard only. Create is
POST /v1/plans. - An uncancel. See the
PATCHnote. - Invoice create / finalize / pay / void. List and retrieve are on
GET /v1/invoices. Email an OPEN invoice withPOST /v1/invoices/{id}/send(invoices:write). Finalize, pay and void stay in the dashboard. Auto-charge renewals mint a PAID invoice; opening create does not. See Invoices.
