Subscriptions

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. paymentsMade counts the payments collected so far.
  • When payment N succeeds the status becomes completed, completedAt is set, no further renewal is scheduled, subscription.completed fires, and the buyer gets a paid-in-full email. completed is 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.
completed means paid in full
If your app grants access while 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 --> [*]
Installment plan states. There is no trialing state, and completed and cancelled are terminal.

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 1 at checkout, payments 2 to 4 from the renewal job, then completion.
PaymentChargedpaymentsMade afterPeriod it opensStatus after
1 of 4Oct 1 at checkout1Oct 1 – Oct 16active
2 of 4Oct 16, renewal job2Oct 16 – Oct 31active
3 of 4Oct 31, renewal job3Oct 31 – Nov 15active
4 of 4Nov 15, renewal job4Nov 15 – Nov 30completed

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 happenspaymentsMade
Checkout charged the plan at signup (the normal case)Starts at 1
POST /api/v1/subscriptionsStarts 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 planStarts 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 declinedUnchanged
An installment is refundedUnchanged
Periods skipped because the renewal job fell behindNever counted
The plan is pausedWaits — 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.

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

curl
# 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
    }
  }'
response
// 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:

TypeScript
// 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.

curl
# 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).

Send totalPayments, not recurring.count
A cart or order line item's 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

curl
# 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"
  }'
response
// 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..." }
}
POST /api/v1/subscriptions does not charge payment 1
The route records period 1 as a paid invoice and sets 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.

Cart line
# 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:

Direct
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.
Not on the session recurring intent
The checkout session's own 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

intervalUnitintervalCount
hour1 to 8,760
day1 to 365
week1 to 52
month1 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 discountAmount reduces 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.
Behaviour change for equal plans
Until this release a code on an equal installment plan reduced only the first payment. From now on it applies to the whole plan. Existing subscriptions are unchanged, and in-flight carts recalculate.

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.

FieldMeaning
paymentScheduleOne row per payment: index (0-based), amount (gross, per unit), discount, net, intervalUnit and intervalCount (null on row 0), status, and dueAt.
paymentSchedule[].statuspaid (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[].dueAtAn estimate that moves as described above. null on paid rows.
scheduleTotalThe sum of the rows' net amounts, before tax.
nextPaymentAmountThe next payment's net amount; null when the plan is completed or cancelled.
amountOn a scheduled subscription, the next payment's gross amount per unit.
response
// 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
// 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

StatusCodeWhen
400invalid_scheduleNot 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: …".
400schedule_first_payment_mismatchThe line unitPrice (or the subscription amount) differs from schedule[0].amount.
400invalid_combinationschedule 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.

curl
# 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_…"
response
// 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..." }
}
  • quantity is 1 on an installment plan created from a session intent or the API, since neither accepts a quantity; a cart or quote line's quantity becomes its seats. Either way change-quantity is locked.
  • GET /api/v1/subscriptions/{id}/invoices returns { data: { invoices } }, newest first: one row per installment with its amount, periodStart, periodEnd, status and waived. A waived installment, or one that cost nothing (a 100% discount), leaves a $0 order and a $0 paid row with waived: true, so it can be told apart from a charged one.
  • renewalIssue works as on any subscription: set by the renewal job when renewals fail for a system reason or a charge was not recorded, and null otherwise. 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)

ActionActive, past-due, or paused installment plan
PATCH with planName, metadata, or taxAddress200
PATCH with planReference, amount, interval, intervalUnit, or intervalCount409 installment_plan_locked
PATCH with totalPayments (any value, on any subscription)400 total_payments_immutable
change-plan, effective now or period_end409 installment_plan_locked
change-quantity, now or period_end409 installment_plan_locked
A manual period renew of a scheduled plan409 installment_plan_locked
POST …/usage400 invalid_state — "Usage can't be recorded on an installment plan — it has a fixed total"
pauseAllowed on active. The payment counter waits.
resumeAllowed. The schedule shifts by the time spent paused.
cancel, now or at period end, with or without a refundAllowed
retry-chargeAllowed on past_due; a successful charge counts
waive-periodAllowed on past_due; the waived installment counts
POST …/invoices/{invoiceId}/refundAllowed; 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

RequestResult
totalPayments: 1, 0, -1, or 61400 invalid_total_payments — "totalPayments must be a whole number from 2 to 60"
totalPayments: 2.5, "4", or null400 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: 0Accepted
schedule with 1 or 61 rows, a gap on row 0, or an out-of-range gap400 invalid_schedule, naming the row
schedule with a unitPrice or amount other than schedule[0].amount400 schedule_first_payment_mismatch
schedule with totalPayments, interval, intervalUnit, intervalCount or a trial400 invalid_combination

Buyer surfaces

SurfaceBehaviour on an installment plan
POST /v1/storefront/me/subscriptions/{id}/pause, /resume, /cancel403 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-chargeAllowed 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).
Buyer portal
// 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>
Your own portal
// 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 planResult
pause400 invalid_subscription_state — "Cannot pause subscription in 'completed' status"
resume400 invalid_subscription_state — "Cannot resume subscription in 'completed' status"
cancel200 — returns the plan unchanged, still completed, and emits no event
cancel with refund: prorated or last_cycle409 invalid_state — "This subscription is already completed; cancelling it again cannot refund anything"
change-plan, change-quantity, POST …/usage422 invalid_state — "This subscription is completed, so it can no longer be changed"
retry-charge422 invalid_state — "Only a past-due subscription has a failed charge to retry (this one is completed)"
waive-period422 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, taxAddress200
POST …/invoices/{invoiceId}/refundAllowed. 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"]
Installment 3 of a 4 × 15-day plan: up to three retries, then cancellation.
  • The first decline moves the plan to past_due and emits subscription.past_due; each of the first three declines emits subscription.payment_failed with attempt, nextRetryAt, and lastError. A decline never counts toward paymentsMade.
  • 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 with reason: 'dunning_exhausted'; the payments already collected stay collected and the plan never reaches completed.
  • 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 with POST /api/v1/subscriptions/{id}/waive-period (a reason is 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

CallEffect 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

MomentWebhooksBuyer email
Signupsubscription.created; payment.captured for the signup chargeWelcome
Each installment after the firstsubscription.renewed { subscription, payment, order }; payment.capturedReceipt
Final installmentsubscription.renewed, then subscription.completed { subscription, payment, order }Receipt and "Your plan is paid in full"
A declined installmentsubscription.payment_failed on attempts 1–3; subscription.past_due on the firstPayment failed, with an update-card link
Cancelledsubscription.cancelled with reason merchant_action, period_end, or dunning_exhaustedCancellation

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
// 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
    }
  }
}
Webhook handler
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.

TypeScript
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' });
React
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

StatusCodeWhen
400invalid_total_paymentstotalPayments is a number outside 2–60
400invalid_combinationtotalPayments together with a trial; schedule together with interval, cadence, totalPayments or a trial, or on the session recurring intent
400invalid_scheduleA custom schedule that breaks the rules above; the message names the row
400schedule_first_payment_mismatchunitPrice (or amount) differs from schedule[0].amount
400total_payments_immutablePATCH that includes totalPayments, on any subscription
400invalid_stateRecording usage on an active, past-due, or paused installment plan
400invalid_subscription_statepause or resume on a completed plan
403installment_plan_merchant_onlyA buyer storefront session pausing, resuming, or cancelling an installment plan
409installment_plan_lockedchange-plan, change-quantity, a manual period renew of a scheduled plan, or a PATCH of the amount, cadence, or plan reference
409invalid_stateA cancel with a refund on a completed plan
422invalid_statechange-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