Installment Plans
An installment plan is a subscription with a fixed number of payments — "4 payments of
$100 every 15 days". It bills like any subscription, then stops by itself: the moment the
last payment succeeds it becomes completed and is never charged
again.
How it works
-
Set
totalPayments(a whole number from 2 to 60) when you create the subscription. It cannot be added, changed, or removed later. - The plan uses any cadence, named or custom — see Custom Cadence.
-
The signup charge is payment 1 of N.
paymentsMadecounts the payments collected so far. -
When payment N succeeds the status becomes
completed,completedAtis set, no further renewal is scheduled,subscription.completedfires, and the buyer gets a paid-in-full email.completedis terminal. - The amount, cadence, quantity, and plan are fixed. There is no trial and no metered usage.
- Only you can pause, resume, or cancel it. The buyer cannot, from Throttle's buyer portal or its storefront API.
status === 'active', add completed. A completed plan's currentPeriodEnd is the end of the period the final payment
covered; whether access continues after it is your decision.
Lifecycle
stateDiagram-v2 [*] --> active: signup is payment 1 active --> active: installment paid active --> past_due: installment declined past_due --> active: recovered active --> completed: final installment paid past_due --> completed: final installment recovered past_due --> cancelled: dunning exhausted active --> paused: pause paused --> active: resume active --> cancelled: merchant cancels completed --> [*] cancelled --> [*]
A merchant can cancel from active, paused, or past_due.
A past-due installment is recovered by a retry, a buyer card update, or a waive; the fourth
failed attempt cancels the plan. A plan created by a session intent on a quote or
deposit-split cart starts without payment 1: see Counting payments. Everything else about active, past_due, paused, and cancelled is the
same as for an ongoing subscription; see Lifecycle and States.
A 4-payment plan, start to finish
Four payments of 100.00 USD every 15 days, bought on October 1 at 09:00 UTC:
sequenceDiagram autonumber participant B as Buyer participant T as Throttle participant M as Your webhook endpoint B->>T: Pays 100.00 USD at checkout on Oct 1 T->>T: Creates the subscription, paymentsMade 1 of 4 T-->>M: subscription.created and payment.captured Note over T: Oct 16, renewal job T->>B: Charges the stored card 100.00 USD T->>T: paymentsMade 2 of 4 T-->>M: subscription.renewed and payment.captured Note over T: Oct 31, renewal job T->>B: Charges 100.00 USD T-->>M: subscription.renewed and payment.captured Note over T: Nov 15, final installment T->>B: Charges 100.00 USD T->>T: paymentsMade 4 of 4, status completed T-->>M: subscription.renewed, payment.captured, subscription.completed T->>B: Receipt email and paid-in-full email
| Payment | Charged | paymentsMade after | Period it opens | Status after |
|---|---|---|---|---|
| 1 of 4 | Oct 1 at checkout | 1 | Oct 1 – Oct 16 | active |
| 2 of 4 | Oct 16, renewal job | 2 | Oct 16 – Oct 31 | active |
| 3 of 4 | Oct 31, renewal job | 3 | Oct 31 – Nov 15 | active |
| 4 of 4 | Nov 15, renewal job | 4 | Nov 15 – Nov 30 | completed |
The plan completes on November 15, when payment 4 succeeds — not on November 30, when the period it pays for ends. Nothing is charged on November 30.
Counting payments
| What happens | paymentsMade |
|---|---|
| Checkout charged the plan at signup (the normal case) | Starts at 1 |
POST /api/v1/subscriptions | Starts at 1 — the route books period 1 as paid |
| Checkout with a session recurring intent on a quote or deposit-split cart, whose charge does not include the plan | Starts at 0; the first renewal is payment 1, so all N payments come from renewals |
| A renewal charge succeeds (the renewal job, a merchant retry, or the buyer paying a past-due installment) | +1 |
| A past-due period is waived | +1 — a waived installment counts, and a waived final installment completes the plan |
| A charge is declined | Unchanged |
| An installment is refunded | Unchanged |
| Periods skipped because the renewal job fell behind | Never counted |
| The plan is paused | Waits — nothing is charged or counted while paused |
installmentProgress in @usethrottle/subscriptions turns those fields into progress. remainingCents is remainingPayments × amount × quantity on an equal plan. Pass
the subscription itself (it carries paymentSchedule) and on a
custom schedule it is the sum of the unpaid payments' net
amounts instead. Both are before tax.
import { installmentProgress } from '@usethrottle/subscriptions';
installmentProgress({ totalPayments: 4, paymentsMade: 1, amount: 10000 });
// → { paymentsMade: 1, totalPayments: 4, remainingPayments: 3, remainingCents: 30000 }
installmentProgress({ totalPayments: 4, paymentsMade: 4, amount: 10000, quantity: 1 });
// → { paymentsMade: 4, totalPayments: 4, remainingPayments: 0, remainingCents: 0 }
installmentProgress({ totalPayments: null, paymentsMade: 0, amount: 10000 });
// → null (an ongoing subscription)
// A custom schedule (3000 · 2000 · 5000) after payment 1: pass the subscription so its
// paymentSchedule rows are used. Not remainingPayments × amount (which would be 6000).
installmentProgress(sub);
// → { paymentsMade: 1, totalPayments: 3, remainingPayments: 2, remainingCents: 7000 } The buyer sees the schedule before paying. The hosted checkout page reads "Pay 100.00 USD today, then 3 more payments of 100.00 USD every 15 days. Total: 400.00 USD." — with "plus applicable tax" added when the application taxes renewals, and without "Cancel anytime". With a discount code, only the first charge is discounted: "Pay 80.00 USD today, then 3 more payments of 100.00 USD every 15 days. Total: 380.00 USD." When the charge today does not include the plan it reads "Then 4 payments of 100.00 USD every 15 days, starting at the next billing date. Total: 400.00 USD."
Creating one
Hosted or embedded checkout
Put totalPayments on the session's recurring block. With the default create: 'auto', Throttle charges payment 1 and creates the
plan when the buyer pays; the embed's onSucceeded
receives subscriptionIds (and subscriptionId, set when exactly one subscription was
created). The session is validated when
you create it: a bad totalPayments or a trial fails then,
not at the end of checkout.
# 1. An empty cart. Here the plan rides on the session (one plan per session).
curl -X POST https://api.usethrottle.dev/api/v1/carts \
-H "x-api-key: sk_test_…" \
-H "content-type: application/json" \
-d '{ "applicationId": "9b1f6c1e-3a52-4c0e-9d7a-2f1e8c4b7a10", "currency": "USD" }'
# → { "data": { "id": "c4a8e2f1-7b3d-4e9a-8c1f-0d6e5b4a3c21", ... }, "meta": { ... } }
# 2. Four payments of 100.00 USD, every 15 days.
curl -X POST https://api.usethrottle.dev/api/v1/checkout/sessions \
-H "x-api-key: sk_test_…" \
-H "content-type: application/json" \
-d '{
"applicationId": "9b1f6c1e-3a52-4c0e-9d7a-2f1e8c4b7a10",
"cartId": "c4a8e2f1-7b3d-4e9a-8c1f-0d6e5b4a3c21",
"returnUrl": "https://shop.example.com/thanks",
"cancelUrl": "https://shop.example.com/course",
"customer": { "externalCustomerId": "user_42", "email": "[email protected]" },
"recurring": {
"plan": "course_4x15",
"planName": "Design course, 4 payments",
"intervalUnit": "day",
"intervalCount": 15,
"totalPayments": 4,
"amount": 10000
}
}' // 201 — the session route returns these fields at the top level (no data envelope)
{
"sessionId": "0f3b7c2d-9e1a-4b6c-8d5f-2a7e9c1b4d60",
"checkoutUrl": "https://checkout.usethrottle.dev/s/0f3b7c2d-9e1a-4b6c-8d5f-2a7e9c1b4d60",
"expiresAt": "2026-10-02T09:00:00.000Z"
}
// The hosted page tells the buyer, with no "Cancel anytime":
// "Pay 100.00 USD today, then 3 more payments of 100.00 USD every 15 days.
// Total: 400.00 USD."
// The plan line on the cart reads "$100.00 / 15 days · 4 payments".
With create: 'manual', checkout takes payment 1 and vaults
the card, and your backend creates the plan. Pass the checkout's orderId so the order and the plan point at each other:
// recurring.create: 'manual' — checkout only vaults the card and takes payment 1.
// Your backend then creates the plan and links the order that collected payment 1.
const sub = await subscriptions.create({
customerId,
planReference: 'course_4x15',
planName: 'Design course, 4 payments',
intervalUnit: 'day',
intervalCount: 15,
totalPayments: 4,
amount: 10000,
currentPeriodStart: paidAt,
currentPeriodEnd: new Date(paidAt.getTime() + 15 * 86_400_000),
orderId, // the checkout order: payment 1's invoice links to it, and the order to the plan
});
// sub.paymentsMade === 1 A cart with goods and an installment plan
Put the plan on a cart line: recurring with totalPayments on POST /api/v1/carts/{id}/items, with the
installment amount as the line's unitPrice. Leave recurring off the session. The first charge covers the goods
plus installment 1 (paymentsMade: 1); renewals charge the
installment only. Each such line becomes its own subscription, so one checkout can sell
several plans, up to ten. See Mixed Carts for pricing,
shipping, and refunds on that shape.
# Goods plus an installment plan in one checkout: the plan is a cart line.
curl -X POST https://api.usethrottle.dev/api/v1/carts/c4a8e2f1-7b3d-4e9a-8c1f-0d6e5b4a3c21/items \
-H "x-api-key: sk_test_…" \
-H "content-type: application/json" \
-d '{
"name": "Design course, 4 payments",
"unitPrice": 10000,
"recurring": { "plan": "course_4x15", "intervalUnit": "day", "intervalCount": 15, "totalPayments": 4 }
}'
# Add the one-time lines as usual, then create the session with no "recurring" block.
# Due today: the goods + 10000 (payment 1 of 4). Each renewal: 10000 only.
The session recurring block still works for a single plan on
a cart of goods. It needs amount whenever the cart holds
other lines (422 recurring_amount_required), and it cannot
be combined with subscription lines on the same cart
(422 recurring_source_conflict).
recurring.count is
read-only display data ("· 4 payments"): it mirrors the line's totalPayments, and the cart item endpoints do not accept it
on input. On a line, recurring.totalPayments is the
installment count. To cap the number of charges, send totalPayments in a cart or quote line's recurring block, on the session's recurring block, or on POST /api/v1/subscriptions.
Directly, with the API
# POST /subscriptions never charges. It books period 1 as PAID (payment 1 of 4)
# and the renewal job charges payments 2 to 4. Collect payment 1 yourself first,
# and pass "orderId" when an order records it.
curl -X POST https://api.usethrottle.dev/api/v1/subscriptions \
-H "x-api-key: sk_test_…" \
-H "content-type: application/json" \
-d '{
"customerId": "3f6a2b8e-1c4d-4e7a-9b2f-5d8c7e6a1b90",
"planReference": "course_4x15",
"planName": "Design course, 4 payments",
"intervalUnit": "day",
"intervalCount": 15,
"totalPayments": 4,
"amount": 10000,
"currency": "USD",
"currentPeriodStart": "2026-10-01T09:00:00.000Z",
"currentPeriodEnd": "2026-10-16T09:00:00.000Z"
}' // 201 (trimmed — the response carries every subscription field)
{
"data": {
"id": "7c2e9a14-5b3d-4f8e-a1c6-2d9b0e7f4a31",
"customerId": "3f6a2b8e-1c4d-4e7a-9b2f-5d8c7e6a1b90",
"status": "active",
"planReference": "course_4x15",
"planName": "Design course, 4 payments",
"interval": "custom",
"intervalUnit": "day",
"intervalCount": 15,
"billingAnchorAt": "2026-10-01T09:00:00.000Z",
"totalPayments": 4,
"paymentsMade": 1,
"completedAt": null,
"amount": 10000,
"quantity": 1,
"currency": "USD",
"currentPeriodStart": "2026-10-01T09:00:00.000Z",
"currentPeriodEnd": "2026-10-16T09:00:00.000Z",
"cancelAtPeriodEnd": false,
"failureCount": 0,
"trialEnd": null,
"renewalIssue": null,
"metadata": {}
},
"meta": { "requestId": "req_01J9..." }
} paymentsMade: 1, on the basis that you already collected it.
If you did not, the buyer is charged only N − 1 times. Charge the signup first, pass its orderId when an order records it, and make sure the
customer has a default vaulted card — the renewal job charges that card.
Quotes
A quote line can carry the same recurring block, with totalPayments. Paying the accepted quote takes installment 1
in the same capture as the rest of the quote, so the plan starts at paymentsMade: 1. A session recurring intent on an accepted quote's cart is
different: the session keeps the quote's agreed total, so the plan is not on the
signup charge. That plan starts at paymentsMade: 0 and the renewal job collects all N payments,
the first one a cadence after signup. See Mixed Carts and Quotes.
Custom schedules
An equal plan charges the same amount every cadence. A custom schedule lets
each payment differ in amount and in the gap before it: "$40 today, $15 36 hours later,
$10 ten days after that, $35 a month after that." You describe the plan as a schedule, an ordered list of 2 to 60 payments. Row 0 is the
payment taken at checkout and has no gap; every later row has an intervalUnit and intervalCount
saying how long after the previous payment it falls due. Amounts are integer minor units, per
unit; the buyer pays the amount times the line's quantity.
# A plan whose payments differ: $40 today, $15 after 36 hours, $10 ten days later, $35 a month after that.
curl -X POST https://api.usethrottle.dev/api/v1/carts/c4a8e2f1-7b3d-4e9a-8c1f-0d6e5b4a3c21/items \
-H "x-api-key: sk_test_…" \
-H "content-type: application/json" \
-d '{
"name": "Website build",
"unitPrice": 4000,
"quantity": 1,
"recurring": {
"plan": "website-build",
"planName": "Website build",
"schedule": [
{ "amount": 4000 },
{ "amount": 1500, "intervalUnit": "hour", "intervalCount": 36 },
{ "amount": 1000, "intervalUnit": "day", "intervalCount": 10 },
{ "amount": 3500, "intervalUnit": "month", "intervalCount": 1 }
]
}
}'
# unitPrice must equal schedule[0].amount (4000), or the call fails with 400 schedule_first_payment_mismatch.
The line's unitPrice is the amount due today, so it must
equal schedule[0].amount
(400 schedule_first_payment_mismatch otherwise). A quote
line takes the same recurring.schedule. Quotes with a
recurring line are card, pay-in-full only. On POST /api/v1/subscriptions, send schedule at the top level:
curl -X POST https://api.usethrottle.dev/api/v1/subscriptions \
-H "x-api-key: sk_test_…" \
-H "content-type: application/json" \
-d '{
"customerId": "3f6a2b8e-1c4d-4e7a-9b2f-5d8c7e6a1b90",
"planReference": "website-build",
"planName": "Website build",
"currentPeriodStart": "2026-10-03T10:00:00.000Z",
"schedule": [
{ "amount": 4000 },
{ "amount": 1500, "intervalUnit": "hour", "intervalCount": 36 },
{ "amount": 1000, "intervalUnit": "day", "intervalCount": 10 },
{ "amount": 3500, "intervalUnit": "month", "intervalCount": 1 }
]
}'
# amount is optional; if you send it, it must equal schedule[0].amount.
# currentPeriodEnd is optional too: Throttle derives it as payment 2's due time.
# schedule cannot be combined with interval, intervalUnit, intervalCount, totalPayments or trialEnd. recurring block does not
accept schedule; it answers 400 invalid_combination. Put the plan on a cart line
instead.
schedule cannot be combined with interval, intervalUnit, intervalCount, totalPayments, trialDays (or trialEnd), or amount on the line's recurring
object; those are derived from the schedule. A read-back line recurring can be sent again as it reads: the derived interval, intervalUnit, intervalCount, totalPayments, trialDays: 0 and read-only scheduleUnitTotal are accepted when they agree with the
schedule (scheduleUnitTotal must equal the sum of the
amounts); amount and count are
still stripped as before.
Gap units
| intervalUnit | intervalCount |
|---|---|
hour | 1 to 8,760 |
day | 1 to 365 |
week | 1 to 52 |
month | 1 to 12 |
Gaps are canonicalized like a custom cadence, but only exact multiples reduce: 24 hours is stored as 1 day and 7 days as 1 week, while 36 hours stays 36 hours. Months never convert, so a plan you read back may show a different but equal unit.
When each payment is due
Due dates are anchored on the first payment and counted cumulatively: payment N falls due after the sum of the gaps up to it. Month gaps clamp to the end of the month and never drift: starting Jan 31 with gaps of 1, 1, 1 month, the payments fall on Feb 28, Mar 31, and Apr 30. When a schedule mixes months with fixed durations (hours, days, weeks), the months are applied first, then the fixed durations.
The dates of later payments are estimates and can move, because Throttle counts from when things actually happen:
- A late payment that later succeeds moves the remaining dates: they are counted from the moment it succeeded.
- A pause shifts the remaining dates by the time the plan was paused.
- A payment missed by a whole gap or more is charged once when billing catches up, and later dates count from then. Nothing is skipped.
- A waived payment is forgiven, counts as made, and later dates count from the waive.
Tax, shipping, orders
Tax is calculated per payment, at charge time. Shipping is charged once, with payment 1. Each payment creates its own renewal order and invoice, titled "Payment i of N". The hosted checkout lists the dated schedule, each later date an estimate: "Pay $40.00 today, then $15.00 on ~Oct 4, … Total: $100.00 plus applicable tax. Later dates move if a payment is late."
Discount codes
A discount code on a cart with an installment line, whether a custom schedule or an equal totalPayments plan, is evaluated on the whole plan and spread
across its payments pro rata:
- A fixed-amount code is taken once across the plan, not once per payment.
- A code's minimum order compares against the plan total.
- A code scoped to specific products spreads only over the payments of the products it applies to.
-
A line's merchant-typed
discountAmountreduces payment 1 only. - Carts managed by an external cart provider are not spread; the discount reduces payment 1 only.
- A 100% code makes every payment $0. They still count, and the plan completes.
What is locked
A schedule is fixed at creation. Changing a scheduled plan's amount, cadence, quantity or
plan answers 409 installment_plan_locked, and so does a
manual period renew of a scheduled plan. Buyers cannot pause, resume or cancel it (
403 installment_plan_merchant_only).
Reading the schedule
Every subscription response carries paymentSchedule, scheduleTotal and nextPaymentAmount, and so does the subscription in every subscription.* webhook payload and in every event stored from this
release on (GET /api/v1/events; events stored earlier keep their
original payload). Equal installment plans get a synthesized
schedule, so you can read one shape; ongoing subscriptions return null for all three.
| Field | Meaning |
|---|---|
paymentSchedule | One row per payment: index (0-based), amount (gross, per unit), discount, net, intervalUnit and intervalCount (null on row 0), status, and dueAt. |
paymentSchedule[].status | paid (a waived payment shows as paid), due (only while the plan is past due and this is the payment being retried), or upcoming (every other unpaid row, including an active plan's next payment). |
paymentSchedule[].dueAt | An estimate that moves as described above. null on paid rows. |
scheduleTotal | The sum of the rows' net amounts, before tax. |
nextPaymentAmount | The next payment's net amount; null when the plan is completed or cancelled. |
amount | On a scheduled subscription, the next payment's gross amount per unit. |
// GET /api/v1/subscriptions/{id} after payment 1 (trimmed). Amounts are minor units.
{
"data": {
"id": "9d1b6e3a-2c4f-4a7e-8b5d-1f0c9a2e7d64",
"status": "active",
"totalPayments": 4,
"paymentsMade": 1,
"amount": 1500,
"paymentSchedule": [
{ "index": 0, "amount": 4000, "discount": 0, "net": 4000, "intervalUnit": null, "intervalCount": null, "status": "paid", "dueAt": null },
{ "index": 1, "amount": 1500, "discount": 0, "net": 1500, "intervalUnit": "hour", "intervalCount": 36, "status": "upcoming", "dueAt": "2026-10-04T22:00:00.000Z" },
{ "index": 2, "amount": 1000, "discount": 0, "net": 1000, "intervalUnit": "day", "intervalCount": 10, "status": "upcoming", "dueAt": "2026-10-14T22:00:00.000Z" },
{ "index": 3, "amount": 3500, "discount": 0, "net": 3500, "intervalUnit": "month", "intervalCount": 1, "status": "upcoming", "dueAt": "2026-11-14T22:00:00.000Z" }
],
"scheduleTotal": 10000,
"nextPaymentAmount": 1500
}
} subscription.renewed and subscription.completed on an installment plan carry paymentIndex, the 0-based index of the payment just made.
Every subscription.* payload carries the three fields above,
and the line's recurring.schedule rides subscription.create_failed. payment.vaulted carries only a session recurring intent, which never has a schedule.
// subscription.renewed for the second payment (trimmed)
{
"type": "subscription.renewed",
"data": {
"subscription": {
"id": "9d1b6e3a-2c4f-4a7e-8b5d-1f0c9a2e7d64",
"status": "active",
"totalPayments": 4,
"paymentsMade": 2,
"paymentSchedule": [ /* same shape as the API; payment 2 now "paid" */ ],
"scheduleTotal": 10000,
"nextPaymentAmount": 1000
},
"paymentIndex": 1
}
} Schedule errors
| Status | Code | When |
|---|---|---|
| 400 | invalid_schedule | Not 2 to 60 rows, row 0 carries a gap, a later row lacks one or is outside its unit's bounds, or an amount is not a positive integer. The message names the row, for example "schedule[2].intervalCount: …". |
| 400 | schedule_first_payment_mismatch | The line unitPrice (or the subscription amount) differs from schedule[0].amount. |
| 400 | invalid_combination | schedule together with interval, intervalUnit, intervalCount, totalPayments, trialDays or trialEnd, or on the checkout session recurring intent. |
Reading them
Every subscription response carries totalPayments (
null on an ongoing subscription), paymentsMade (0 on an
ongoing subscription), and completedAt (set only on completed). GET
/api/v1/subscriptions filters with installment=true
or installment=false, combined with status (including completed
), interval, customerId, or externalCustomerId.
Each also carries paymentSchedule, scheduleTotal and nextPaymentAmount; see
"Reading the schedule" under Custom schedules above.
# Installment plans only (installment=false: ongoing subscriptions only)
curl -s "https://api.usethrottle.dev/api/v1/subscriptions?installment=true&status=active" \
-H "x-api-key: sk_test_…"
# Plans that finished paying
curl -s "https://api.usethrottle.dev/api/v1/subscriptions?installment=true&status=completed" \
-H "x-api-key: sk_test_…"
# One plan, and its payment history (one invoice per counted charge)
curl -s https://api.usethrottle.dev/api/v1/subscriptions/7c2e9a14-5b3d-4f8e-a1c6-2d9b0e7f4a31 \
-H "x-api-key: sk_test_…"
curl -s https://api.usethrottle.dev/api/v1/subscriptions/7c2e9a14-5b3d-4f8e-a1c6-2d9b0e7f4a31/invoices \
-H "x-api-key: sk_test_…" // GET /api/v1/subscriptions/7c2e9a14-… after payment 4 (trimmed)
{
"data": {
"id": "7c2e9a14-5b3d-4f8e-a1c6-2d9b0e7f4a31",
"status": "completed",
"interval": "custom",
"intervalUnit": "day",
"intervalCount": 15,
"totalPayments": 4,
"paymentsMade": 4,
"completedAt": "2026-11-15T09:02:41.318Z",
"amount": 10000,
"quantity": 1,
"currency": "USD",
"currentPeriodStart": "2026-11-15T09:00:00.000Z",
"currentPeriodEnd": "2026-11-30T09:00:00.000Z",
"cancelAtPeriodEnd": false,
"lastPaymentAt": "2026-11-15T09:02:41.318Z",
"renewalIssue": null,
"customer": { "id": "3f6a2b8e-1c4d-4e7a-9b2f-5d8c7e6a1b90", "email": "[email protected]" }
},
"meta": { "requestId": "req_01J9..." }
} -
quantityis1on an installment plan created from a session intent or the API, since neither accepts a quantity; a cart or quote line'squantitybecomes its seats. Either waychange-quantityis locked. -
GET /api/v1/subscriptions/{id}/invoicesreturns{ data: { invoices } }, newest first: one row per installment with itsamount,periodStart,periodEnd,statusandwaived. A waived installment, or one that cost nothing (a 100% discount), leaves a $0 order and a $0paidrow withwaived: true, so it can be told apart from a charged one. -
renewalIssueworks as on any subscription: set by the renewal job when renewals fail for a system reason or a charge was not recorded, andnullotherwise. See Lifecycle and States. - Installment plans are excluded from MRR in analytics.
What you can change
An installment plan's terms are fixed when it is created. The lock is checked before anything is charged. To change the terms, cancel the plan and create a new one.
Merchant API (secret key)
| Action | Active, past-due, or paused installment plan |
|---|---|
PATCH with planName, metadata, or taxAddress | 200 |
PATCH with planReference, amount, interval, intervalUnit, or intervalCount | 409 installment_plan_locked |
PATCH with totalPayments (any value, on any subscription) | 400 total_payments_immutable |
change-plan, effective now or period_end | 409 installment_plan_locked |
change-quantity, now or period_end | 409 installment_plan_locked |
| A manual period renew of a scheduled plan | 409 installment_plan_locked |
POST …/usage | 400 invalid_state — "Usage can't be recorded on an installment plan — it has a fixed total" |
pause | Allowed on active. The payment counter waits. |
resume | Allowed. The schedule shifts by the time spent paused. |
cancel, now or at period end, with or without a refund | Allowed |
retry-charge | Allowed on past_due; a successful charge counts |
waive-period | Allowed on past_due; the waived installment counts |
POST …/invoices/{invoiceId}/refund | Allowed; paymentsMade does not change |
Checks run in a fixed order, so the first one that applies wins. On change-plan and change-quantity
a paused plan with effective: 'now' is 422 invalid_state, and a plan with an unrecorded (orphan)
charge is 409 renewal_needs_attention, before the lock is
reached.
Creation rules
| Request | Result |
|---|---|
totalPayments: 1, 0, -1, or 61 | 400 invalid_total_payments — "totalPayments must be a whole number from 2 to 60" |
totalPayments: 2.5, "4", or null | 400 validation_error — the body schema needs an integer |
totalPayments with trialEnd (API, even a past one) or trialDays above 0 (checkout) | 400 invalid_combination — "An installment plan cannot have a free trial" |
totalPayments with trialDays: 0 | Accepted |
schedule with 1 or 61 rows, a gap on row 0, or an out-of-range gap | 400 invalid_schedule, naming the row |
schedule with a unitPrice or amount other than schedule[0].amount | 400 schedule_first_payment_mismatch |
schedule with totalPayments, interval, intervalUnit, intervalCount or a trial | 400 invalid_combination |
Buyer surfaces
| Surface | Behaviour on an installment plan |
|---|---|
POST /v1/storefront/me/subscriptions/{id}/pause, /resume, /cancel | 403 installment_plan_merchant_only — "Installment plans can only be paused, resumed or cancelled by the merchant" |
Buyer portal (SubscriptionsPanel in @usethrottle/auth) | Shows "Payment 2 of 4" under the plan and "Paid in full" as the status once completed. On a custom schedule it also shows the next payment, the plan total and a list of the upcoming payments with their estimated dates; a payment that follows a gap of hours also shows its time, in UTC and labelled (@usethrottle/auth 0.10.0). Pause, Resume, and Cancel are hidden. |
Billing page and POST /api/v1/me/subscriptions/{id}/retry-charge | Allowed on past_due: the buyer can update their card and pay the missed installment. The response carries charged, which is true even when that payment completes the plan. On a custom schedule the billing page also lists the remaining payments with their estimated dates. |
| A portal you build on the subscription proxy | @usethrottle/subscriptions' proxy refuses every buyer mutation of an installment plan by default (403 installment_plan_merchant_only). If you supply authorizeMutation, the default no longer applies and your function must enforce the rule itself (below). |
// Throttle's buyer portal component: shows "Payment 2 of 4", the status pill
// "Paid in full" when completed, and hides Pause, Resume, and Cancel on
// installment plans.
import { SignedIn, SubscriptionsPanel } from '@usethrottle/auth/components';
<SignedIn>
<SubscriptionsPanel />
</SignedIn> // The proxy refuses every buyer mutation of an installment plan by default
// (403 installment_plan_merchant_only). Supplying authorizeMutation replaces that
// default, so your function must enforce the rule itself:
import { createSubscriptionProxyHandler } from '@usethrottle/subscriptions/server';
const handler = createSubscriptionProxyHandler({
apiKey: process.env.THROTTLE_SECRET_KEY!,
async getExternalCustomerId() {
return (await auth())?.id ?? null;
},
authorizeMutation: ({ subscription }) => subscription.totalPayments == null,
});
export { handler as GET, handler as POST, handler as PATCH }; After completion
A completed plan is terminal. The renewal job never selects
it again, and every action that would bill or change it is refused:
| Action on a completed plan | Result |
|---|---|
pause | 400 invalid_subscription_state — "Cannot pause subscription in 'completed' status" |
resume | 400 invalid_subscription_state — "Cannot resume subscription in 'completed' status" |
cancel | 200 — returns the plan unchanged, still completed, and emits no event |
cancel with refund: prorated or last_cycle | 409 invalid_state — "This subscription is already completed; cancelling it again cannot refund anything" |
change-plan, change-quantity, POST …/usage | 422 invalid_state — "This subscription is completed, so it can no longer be changed" |
retry-charge | 422 invalid_state — "Only a past-due subscription has a failed charge to retry (this one is completed)" |
waive-period | 422 invalid_state — "Only a past-due subscription has a period left to forgive (this one is completed)" |
PATCH amount (or any other term) | 409 installment_plan_locked |
PATCH metadata, planName, taxAddress | 200 |
POST …/invoices/{invoiceId}/refund | Allowed. With intent: "refund_and_cancel" the money is refunded and the plan stays completed. |
Failed installments
A declined installment follows the same dunning ladder as any subscription, with retry delays scaled to the cadence (see Custom Cadence). On a 15-day plan:
flowchart LR
due["Installment 3 due Oct 31"] --> d1{"Charge succeeds?"}
d1 -- "yes" --> ok["paymentsMade 3, next due Nov 15"]
d1 -- "no" --> pd["past_due, retry in 1 day"]
pd --> r1{"Retry 1 on Nov 1"}
r1 -- "yes" --> rec["paymentsMade 3, grid restarts, next due Nov 16"]
r1 -- "no" --> r2{"Retry 2, 3 days later"}
r2 -- "yes" --> rec2["paymentsMade 3, grid restarts at the retry"]
r2 -- "no" --> r3{"Retry 3, 7 days later"}
r3 -- "yes" --> rec2
r3 -- "no" --> cx["cancelled, dunning_exhausted, paymentsMade stays 2"] -
The first decline moves the plan to
past_dueand emitssubscription.past_due; each of the first three declines emitssubscription.payment_failedwithattempt,nextRetryAt, andlastError. A decline never counts towardpaymentsMade. -
Retry 1, 2, and 3 wait
min(n × cadence, [1 day, 3 days, 7 days][n − 1])after the failed attempt. The fourth failed attempt cancels the plan withreason: 'dunning_exhausted'; the payments already collected stay collected and the plan never reachescompleted. - A recovered installment counts, and the schedule restarts at the moment it was recovered: on a 15-day plan, payment 3 recovered on November 1 moves payment 4 to November 16. If the recovered installment is the last one, the plan completes.
-
You can recover it yourself with
POST /api/v1/subscriptions/{id}/retry-charge, or forgive it withPOST /api/v1/subscriptions/{id}/waive-period(areasonis required). The buyer can update their card from the link in the payment-failed email, which pays the missed installment on the spot.
Cancelling and refunding
| Call | Effect on the plan |
|---|---|
cancel { atPeriodEnd: false } | Cancelled now. No further installments. paymentsMade keeps its value. |
cancel { atPeriodEnd: true } | Cancelled at currentPeriodEnd instead of charging the next installment, even if only one is left. It never becomes completed. |
cancel { atPeriodEnd: false, refund: "last_cycle" } | Refunds everything charged for the current installment, then cancels |
cancel { atPeriodEnd: false, refund: "prorated" } | Refunds the unused part of the current installment period, then cancels |
POST …/invoices/{invoiceId}/refund { intent: "money_only" } | Refunds one installment; the plan keeps billing and paymentsMade does not change |
POST …/invoices/{invoiceId}/refund { intent: "refund_and_cancel" } | Refunds one installment and cancels the plan |
A refund never lowers paymentsMade, so a plan with a
refunded installment still completes after N successful charges. There is no early payoff:
to settle the balance at once, cancel the plan and charge the remainder separately. Refund
rules are the same as for any subscription; see
Managing Subscriptions
.
Webhooks and emails
| Moment | Webhooks | Buyer email |
|---|---|---|
| Signup | subscription.created; payment.captured for the signup charge | Welcome |
| Each installment after the first | subscription.renewed { subscription, payment, order }; payment.captured | Receipt |
| Final installment | subscription.renewed, then subscription.completed { subscription, payment, order } | Receipt and "Your plan is paid in full" |
| A declined installment | subscription.payment_failed on attempts 1–3; subscription.past_due on the first | Payment failed, with an update-card link |
| Cancelled | subscription.cancelled with reason merchant_action, period_end, or dunning_exhausted | Cancellation |
subscription.renewed and subscription.completed on an installment plan carry paymentIndex (0-based), and every subscription.* payload carries paymentSchedule, scheduleTotal
and nextPaymentAmount. Webhooks the renewal engine emits use
the same public subscription shape as the API.
A waived final installment emits the same two events with a $0 payment. subscription.completed needs the subscriptions:read scope to subscribe to, like the other
lifecycle events. The receipt states the amount charged and the payment-failed email the
amount attempted, tax included. In the order-confirmation, welcome and renewal-reminder
emails a payment that follows a gap of hours shows its time next to its date. The
payment-failed and renewal-reminder emails behave as for any subscription; the reminder goes out only for cadences of 7 days or
longer. Your dashboard also gets an in-app notification when a plan is paid in full.
// subscription.completed (trimmed)
{
"id": "0192f4c1-7a3e-7b2d-9c1f-5e8a2d4b6c10",
"type": "subscription.completed",
"version": "1",
"createdAt": "2026-11-15T09:02:41.402Z",
"environmentId": "e1d2c3b4-a5f6-4789-8abc-def012345678",
"environmentKind": "non_production",
"workspaceId": "a0b1c2d3-e4f5-4a6b-8c7d-9e0f1a2b3c4d",
"data": {
"subscription": {
"id": "7c2e9a14-5b3d-4f8e-a1c6-2d9b0e7f4a31",
"status": "completed",
"planReference": "course_4x15",
"interval": "custom",
"intervalUnit": "day",
"intervalCount": 15,
"totalPayments": 4,
"paymentsMade": 4,
"completedAt": "2026-11-15T09:02:41.318Z",
"amount": 10000,
"currency": "USD",
"paymentSchedule": [
{ "index": 0, "amount": 10000, "discount": 0, "net": 10000, "intervalUnit": null, "intervalCount": null, "status": "paid", "dueAt": null },
{ "index": 1, "amount": 10000, "discount": 0, "net": 10000, "intervalUnit": "day", "intervalCount": 15, "status": "paid", "dueAt": null },
{ "index": 2, "amount": 10000, "discount": 0, "net": 10000, "intervalUnit": "day", "intervalCount": 15, "status": "paid", "dueAt": null },
{ "index": 3, "amount": 10000, "discount": 0, "net": 10000, "intervalUnit": "day", "intervalCount": 15, "status": "paid", "dueAt": null }
],
"scheduleTotal": 40000,
"nextPaymentAmount": null
},
"paymentIndex": 3,
"payment": { "id": "d9e8f7a6-…", "amount": 10000, "currency": "USD", "status": "captured" },
"order": { "id": "6a5b4c3d-…", "type": "recurring" },
"customer": {
"id": "3f6a2b8e-1c4d-4e7a-9b2f-5d8c7e6a1b90",
"email": "[email protected]",
"firstName": "Ada",
"lastName": "Lovelace",
"phone": null,
"externalId": "user_42",
"externalCustomerId": null
}
}
} import { verifyWebhookSignature, type ThrottleEvent } from '@usethrottle/webhook-types';
export async function POST(req: Request) {
const rawBody = await req.text();
const header = req.headers.get('x-throttle-signature') ?? '';
if (!verifyWebhookSignature({ header, rawBody, secret: process.env.THROTTLE_WEBHOOK_SECRET! })) {
return new Response('Invalid signature', { status: 400 });
}
const event = JSON.parse(rawBody) as ThrottleEvent;
switch (event.type) {
case 'subscription.renewed': {
const sub = event.data.subscription as { id: string; totalPayments: number | null; paymentsMade: number };
if (sub.totalPayments != null) await recordInstallment(sub.id, sub.paymentsMade, sub.totalPayments);
break;
}
case 'subscription.completed':
// Paid in full. Fires right after the subscription.renewed for the final
// payment. If you gate access on status === 'active', treat 'completed' as paid.
await markPaidInFull(event.data.subscription.id as string);
break;
case 'subscription.cancelled':
// reason: 'merchant_action' | 'period_end' | 'dunning_exhausted'.
// paymentsMade tells you how many installments were collected.
await closePlan(event.data.subscription.id as string, event.data.reason);
break;
}
return Response.json({ ok: true });
} SDK and React
@usethrottle/subscriptions 3.x types totalPayments, paymentsMade, completedAt, and the completed
status. list() and useSubscriptions() take installment: true | false. SubscriptionStatusBadge renders completed as "Paid in full", and isTerminalSubscription is true for it.
import {
createSubscriptionsClient,
ThrottleSubscriptionsError,
} from '@usethrottle/subscriptions/server';
const subscriptions = createSubscriptionsClient({
apiKey: process.env.THROTTLE_SECRET_KEY!, // sk_test_… in a test environment
});
// Hosted or embedded checkout. Throttle creates the plan when the buyer pays.
const session = await subscriptions.createCheckoutSession({
applicationId: process.env.THROTTLE_APPLICATION_ID!,
cartId,
returnUrl: 'https://shop.example.com/thanks',
cancelUrl: 'https://shop.example.com/course',
customer: { externalCustomerId: user.id, email: user.email },
recurring: {
plan: 'course_4x15',
planName: 'Design course, 4 payments',
intervalUnit: 'day',
intervalCount: 15,
totalPayments: 4,
amount: 10000,
},
});
// Read
const open = await subscriptions.list({ installment: true, status: 'active' });
const done = await subscriptions.list({ installment: true, status: 'completed' });
const plan = await subscriptions.get(planId);
// Terms are fixed once created
try {
await subscriptions.changePlan({
id: planId, planReference: 'course_2x30', amount: 20000,
intervalUnit: 'day', intervalCount: 30, effective: 'now',
});
} catch (err) {
if (err instanceof ThrottleSubscriptionsError && err.code === 'installment_plan_locked') {
// 409: cancel this plan and create a new one to change the terms
}
}
// Stop collecting and refund the current installment
await subscriptions.cancel(planId, { atPeriodEnd: false, refund: 'last_cycle' }); import {
cadenceNoun,
installmentProgress,
SubscriptionStatusBadge,
useSubscriptions,
} from '@usethrottle/subscriptions';
const usd = (cents: number) =>
(cents / 100).toLocaleString('en-US', { style: 'currency', currency: 'USD' });
export function InstallmentPlans({ userId }: { userId: string }) {
const { data, isLoading } = useSubscriptions({ externalCustomerId: userId, installment: true });
if (isLoading || !data) return <p>Loading…</p>;
return (
<ul>
{data.data.map((sub) => {
const progress = installmentProgress(sub)!; // non-null: installment=true
return (
<li key={sub.id}>
<strong>{sub.planName ?? sub.planReference}</strong>{' '}
{/* "Paid in full" for status completed */}
<SubscriptionStatusBadge status={sub.status} />
<div>{usd(sub.amount)} / {cadenceNoun(sub)}</div>
<div>
{sub.status === 'completed'
? `All ${progress.totalPayments} payments made`
: `Payment ${progress.paymentsMade} of ${progress.totalPayments} · ${usd(progress.remainingCents)} remaining`}
</div>
{/* No cancel or pause button: those are merchant-only on installment plans */}
</li>
);
})}
</ul>
);
} Errors
| Status | Code | When |
|---|---|---|
| 400 | invalid_total_payments | totalPayments is a number outside 2–60 |
| 400 | invalid_combination | totalPayments together with a trial; schedule together with interval, cadence, totalPayments or a trial, or on the session recurring intent |
| 400 | invalid_schedule | A custom schedule that breaks the rules above; the message names the row |
| 400 | schedule_first_payment_mismatch | unitPrice (or amount) differs from schedule[0].amount |
| 400 | total_payments_immutable | PATCH that includes totalPayments, on any subscription |
| 400 | invalid_state | Recording usage on an active, past-due, or paused installment plan |
| 400 | invalid_subscription_state | pause or resume on a completed plan |
| 403 | installment_plan_merchant_only | A buyer storefront session pausing, resuming, or cancelling an installment plan |
| 409 | installment_plan_locked | change-plan, change-quantity, a manual period renew of a scheduled plan, or a PATCH of the amount, cadence, or plan reference |
| 409 | invalid_state | A cancel with a refund on a completed plan |
| 422 | invalid_state | change-plan, change-quantity, usage, retry-charge, or waive-period on a completed plan |
FAQ
Can a buyer pay the rest early?
No. Early payoff is not supported. Cancel the plan and take the remainder as a separate charge if you want to offer it.
Can I turn an ongoing subscription into an installment plan, or the reverse?
No. totalPayments is set at creation only. Cancel and
create a new subscription.
What if the renewal job was down for several periods?
The plan is charged once, for the period that contains now, and that one charge counts once. The missed periods are skipped, so the plan still takes exactly N payments; it simply finishes later.
Is each installment taxed?
Each installment is taxed at charge time like any renewal, against the address frozen at signup. On a custom schedule the tax differs with each payment's amount. The checkout disclosure adds "plus applicable tax" when renewals are taxed.
Does pausing extend the plan?
Yes. Nothing is charged or counted while paused, and resuming shifts every remaining installment by the time spent paused. On a custom schedule the remaining due dates move the same way, and also when a late payment finally succeeds.
Can a custom schedule have a free trial or an equal-payment count?
No. A schedule defines the payments itself, so totalPayments,
a cadence and a trial are all 400 invalid_combination with it.
Can I show "Payment 2 of 4" myself?
Use installmentProgress(sub) from @usethrottle/subscriptions, or read paymentsMade and totalPayments
from any subscription response or webhook.
What does an older SDK do with a completed plan?
Releases before @usethrottle/subscriptions 3.0 and @usethrottle/webhook-types 5.0 do not know the completed status or the installment fields. They keep
working but may show raw values, and an old buyer portal may still offer Cancel, which the
storefront API refuses with 403. Upgrade.
Next
- Custom Cadence — every cadence an installment plan can use, and how its dates are computed.
- Managing Subscriptions — cancel, refund, retry, and waive in detail.
- Subscription Webhooks — the full event reference.