Subscriptions

View as Markdown

Recurring 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}
ScopeGrants
plans:readList and fetch plans
plans:writeCreate a plan, server-only, a pk_ is refused
subscriptions:readList and fetch subscriptions
subscriptions:writeStart, 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#

bash
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"}'
json
{
  "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#

bash
curl "https://api.worthypayments.com/v1/plans?active=true" \
  -H "x-api-key: sk_test_xxx"
json
{
  "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

FieldRequiredNotes
customerYescus_.... Missing it is 400 parameter_invalid.
planYesplan_... from step 1. The bare uuid is accepted too.
quantityNoSeats / units. Defaults to 1.
couponNoA coupon id to apply.
default_payment_methodNopm_.... Omitted, the customer's default is used.
collection_methodNoCHARGE_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_behaviorNodefault_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-KeyYesHeader. See below.
bash
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 real Idempotency-Key keyed on your order, not a fresh UUID per click. A retried POST must resolve to the same subscription rather than starting a second one on the same card. For the same reason a FRAUD_ONLY key can never reach this route; see Integration modes. payment_behavior=default_incomplete, or no card on file: the same POST answers status: "incomplete" and latest_invoice.payment_intent.client_secret. Confirm that secret with mountPaymentElement (same as a one-off intent). Completing the Element takes the opening charge and activates the subscription. There is still no listable invoice on GET /v1/invoices until a later renewal. Do not put plan or interval on the Element.

The incomplete response (the Element loop):

json
{
  "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.

bash
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.

FieldEffect
planMove to a different plan. Upstream prorates.
paused: true | falseSuspend or resume billing. Optionally resumes_at (ISO-8601) with true to schedule the resume.
cancel_at_period_end: trueSchedule 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: false is refused, not accepted as a no-op. There is no uncancel: taking false would 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}

json
{ "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:

EventMeaning
subscription.createdStarted, opening charge taken
subscription.renewedA period was billed
subscription.updatedPlan, quantity or pause state changed
subscription.past_dueA renewal failed; retries are in progress
subscription.suspendedRetries were exhausted
subscription.canceledEnded
subscription.usage_exceededA 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 PATCH note.
  • Invoice create / finalize / pay / void. List and retrieve are on GET /v1/invoices. Email an OPEN invoice with POST /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.