Subscriptions

Mixed Carts

One cart can hold any number of one-time lines and up to ten subscriptions — the same plan or different ones, each on its own cadence, installment plan or trial. The buyer pays once, shipping is charged once, and the checkout lands on one order linked to every subscription it created.

Selling one

Put a recurring block on each cart line that should become a subscription. Leave recurring off the checkout session: the lines carry it. Throttle validates every subscription line when the session is created, so the total on the hosted page, the amount authorized in the embed and the amount settled are the same number.

bash
# 1. Create the cart.
curl -X POST https://api.usethrottle.dev/api/v1/carts \
  -H "x-api-key: $THROTTLE_SECRET_KEY" -H "content-type: application/json" \
  -d '{ "applicationId": "'"$THROTTLE_APPLICATION_ID"'", "currency": "USD" }'

# 2. Each line with a recurring block becomes ONE subscription.
curl -X POST https://api.usethrottle.dev/api/v1/carts/$CART_ID/items \
  -H "x-api-key: $THROTTLE_SECRET_KEY" -H "content-type: application/json" \
  -d '{ "name": "Plan A", "unitPrice": 2000, "recurring": { "plan": "plan_a", "interval": "monthly" } }'
curl -X POST https://api.usethrottle.dev/api/v1/carts/$CART_ID/items \
  -H "x-api-key: $THROTTLE_SECRET_KEY" -H "content-type: application/json" \
  -d '{ "name": "Plan B", "unitPrice": 2500, "recurring": { "plan": "plan_b", "interval": "monthly" } }'

# 3. The one-time goods, as usual.
curl -X POST https://api.usethrottle.dev/api/v1/carts/$CART_ID/items \
  -H "x-api-key: $THROTTLE_SECRET_KEY" -H "content-type: application/json" \
  -d '{ "name": "Item-1", "unitPrice": 1500, "quantity": 2, "referenceId": "sku_item_1" }'
server
// MERCHANT BACKEND — 4. the session. No `recurring` here: the lines carry it.
import { createSubscriptionsClient } from '@usethrottle/subscriptions/server';
const subscriptions = createSubscriptionsClient({ apiKey: process.env.THROTTLE_SECRET_KEY! });

const session = await subscriptions.createCheckoutSession({
  applicationId: process.env.THROTTLE_APPLICATION_ID!,
  cartId,
  // Required whenever the cart holds a subscription line.
  customer: { externalCustomerId: user.id, email: user.email },
  returnUrl: 'https://shop.example.com/thanks',
  cancelUrl: 'https://shop.example.com/cart',
});
// Due today, in ONE capture: 2000 + 2500 + 2 × 1500 + shipping (once) + tax.
browser
// Browser — the embed tells you every subscription it created.
<PaymentEmbed
  sessionId={session.sessionId}
  parentOrigin={window.location.origin}
  onSucceeded={({ orderId, subscriptionIds }) => {
    // subscriptionIds: ['sub_…A', 'sub_…B'], in line order.
    // subscriptionId is set only when exactly one subscription was created.
    router.push(`/orders/${orderId}`);
  }}
/>

The same recurring block works on the Cart API, the browser cart session (whose hosted cart page shows each line's terms) and quotes.

terms
recurring: { plan: 'box', interval: 'monthly' }                            // every month until cancelled
recurring: { plan: 'hourly', intervalUnit: 'hour', intervalCount: 6 }       // any cadence, 1 hour to 1 year
recurring: { plan: 'sofa', interval: 'monthly', totalPayments: 3 }          // installment plan: exactly 3 payments
recurring: { plan: 'club', interval: 'monthly', trialDays: 14 }             // free trial (not with totalPayments)
recurring: { plan: 'build', schedule: [{ amount: 4000 }, { amount: 1500, intervalUnit: 'day', intervalCount: 10 }] } // custom schedule: unitPrice must be 4000

Starting checkout from an existing cart

A checkout session can also be created from a cart that already exists — Throttle's hosted cart page (/cart/<id>), an abandoned-cart recovery link, or a plain cartId hand-off. These hand-offs carry no recurring block of their own, so Throttle recovers the plan's recurring intent from the cart before anything is charged:

  • It reuses the intent of an earlier session on the same cart for the same plan, keeping the merchant's exact fields and create mode; or, if there is none, it rebuilds the intent from the cart's plan line (its plan reference, cadence, amount, and trial days). If the earlier session's cadence disagrees with the plan line the buyer sees and pays, the plan line wins.
  • The shipping step is kept. The session inherits the earlier session's address collection; with none to inherit, it collects a shipping address whenever the cart holds any line besides the plan line. The cart-based default described in Shipping and the address step does not apply to a hand-off.
  • A recurring session needs a buyer identity. A hand-off that supplies no email inherits the earlier session's. If there is none to inherit and the request names no customer.email, customer.customerId, customer.externalCustomerId, or customerEmail, it is refused with 422 recurring_customer_required ("Recurring checkout sessions require a customer email or customer identifier").
  • If the terms cannot be determined (the plan line has no plan reference or no valid cadence), the session is refused with 422 subscription_terms_undeterminable before the buyer is charged, so a buyer is never charged for a plan whose subscription would not be created. Fix the cart's plan line, or create the session with an explicit recurring block.

This applies to native Throttle carts only, and only to the plan line Throttle writes for a recurring checkout (type: "subscription" with metadata.subscriptionLineItem: true). A line that carries its own recurring.plan brings its own terms and needs no session intent. A cart that holds both kinds (a legacy plan line and lines with their own recurring.plan) is refused with 422 recurring_source_conflict, whatever the line order.

What the buyer is charged

MomentAmountWhat happens
Checkoutgoods + every subscription line (unit price × quantity) + shipping + tax − discountOne authorization and one capture. A free-trial line adds 0. Shipping is charged once, for the whole order.
Period 1included aboveEach subscription records its first period as paid and linked to this order, for its line's share of the capture. A free-trial line records no paid first period: its first charge comes from the renewal job.
Every renewalthat subscription onlyEach subscription renews on its own schedule: its unit price × its quantity (seats), plus any usage and tax. Renewals never charge shipping, and never repeat the goods.

What you get back

json
// GET /api/v1/orders/:id after completion
{
  "id": "ord_…",
  "type": "mixed",                       // one_time | recurring | mixed
  "paymentStatus": "captured",
  "subtotal": 7500,
  "shippingTotal": 500,                  // charged once, for the whole order
  "subscriptionId": null,                // set only when there is exactly ONE
  "subscriptionIds": ["sub_…A", "sub_…B"],
  "lineItems": [
    { "name": "Plan A", "type": "subscription", "total": 2000, "subscriptionId": "sub_…A",
      "recurring": { "plan": "plan_a", "planName": null, "interval": "monthly", "intervalUnit": "month",
                     "intervalCount": 1, "totalPayments": null, "trialDays": 0, "amount": null, "count": null } },
    { "name": "Plan B", "type": "subscription", "total": 2500, "subscriptionId": "sub_…B", "recurring": { "plan": "plan_b", "…": "…" } },
    { "name": "Item-1", "type": "product",      "total": 3000, "subscriptionId": null, "recurring": null }
  ]
}

subscriptionIds lists every subscription on the order, in line order, and each line's subscriptionId names the one it created. subscriptionId is kept for integrations written before this: it is set when the order has exactly one subscription and is null otherwise, so read subscriptionIds. The completion response, the embed's onSucceeded, and the order and payment webhooks carry the same pair. The ?subscriptionId= filter matches an order through any of its lines.

bash
# Every order a subscription is linked to: its signup order and each renewal.
curl -s "https://api.usethrottle.dev/api/v1/orders?subscriptionId=$SUB_ID" \
  -H "x-api-key: $THROTTLE_SECRET_KEY"

The same plan twice, and seats

Every subscription line is its own subscription. Two lines with the same plan make two independent subscriptions, which cancel, refund and renew separately. Nothing is merged, not even when a guest cart is merged into a signed-in buyer's cart. For several seats on one subscription, send one line with a higher quantity: quantity is seats, and each renewal bills unit price × seats.

ts
// Two lines, same plan → two independent subscriptions (cancel, refund and renew separately).
await client.items.add(cartId, { name: 'Club — Alex', unitPrice: 2000, recurring: { plan: 'club', interval: 'monthly' } });
await client.items.add(cartId, { name: 'Club — Sam',  unitPrice: 2000, recurring: { plan: 'club', interval: 'monthly' } });

// One subscription with three seats → ONE line, quantity 3 (renews at 3 × 2000).
await client.items.add(cartId, { name: 'Team plan', unitPrice: 2000, quantity: 3, recurring: { plan: 'team', interval: 'monthly' } });

Shipping and the address step

Subscription lines never ship. They are left out of weight, item-count and subtotal rules, so a free-shipping threshold is measured on the goods alone, and requiresShipping: true on a subscription line is rejected. A type: 'subscription' line with no recurring never becomes a subscription, so it ships by the usual rules.

Omit collect and Throttle decides from the cart: on a checkout with subscriptions, the address step is on when the cart holds at least one shippable one-time line and off otherwise; any other checkout keeps asking. A collect object that leaves out shippingAddress sets it to true. An explicit value always wins.

A checkout with no shipping step still needs an address to tax when your application uses a tax provider on the strict fallback policy. In that case an omitted collect.billingAddress defaults to true, and tax is calculated from the billing address. See collection flags.

Trials

A free-trial line is charged nothing today. Throttle stores it at a unit price of 0 and keeps your price on recurring.amount for the renewals, the same way it stores the plan line for a trial on the session intent. A free-trial line cannot carry taxAmount or discountAmount.

ts
// You send:   { name: 'Barista Club', unitPrice: 2000, recurring: { plan: 'club', interval: 'monthly', trialDays: 14 } }
// Stored as:  name "Barista Club — 14-day free trial", unitPrice 0, recurring.amount 2000
// Charged today:  0 for this line (the goods and any other plans are charged as usual)
// Charged day 14: 2000, by the renewal job, which also writes that period's invoice

Trial eligibility is checked once per checkout, against the card's history before this checkout, and the answer applies to every trial line in it. When it is blocked, the whole checkout is refused with 409 trial_not_available before any order exists: no subscription gets a free first period, nothing is charged, and one subscription.trial_blocked fires with subscriptionId: null. The buyer can pay with another card, or you can offer the plan without a trial.

Discounts

A discount code — yours or one the buyer types — applies to the first charge only. It is spread across the order's lines in proportion to their subtotals, so each subscription's first period records its own share. Renewals bill the undiscounted price. Before payment, the public session's subscriptions[].firstChargeAmount states each line's discounted first charge (before tax), and the hosted page's "today" figure uses it.

Invoices and emails

A checkout with more than one subscription, or with a subscription and anything else, gets one order invoice listing every line — each subscription line with its period ("Oct 1 – Nov 1, 2026", or "Oct 1 – Nov 1, 2026 · Payment 1 of 3" on an installment plan) — plus shipping and tax. Its total equals the capture. The buyer gets one order confirmation, sent once the payment has succeeded and the subscriptions exist, with a "Your subscriptions" section, instead of a separate welcome email per subscription. A checkout with a single plan and nothing else keeps its subscription invoice and welcome email, unchanged.

Refunds never stop billing

No refund cancels an order or a subscription. A refund of a subscription's cycle goes to that subscription. A refund of the order that names no cycle comes out of the goods and shipping first, then across the subscriptions in proportion to what each still has refundable, so no cycle ever goes below zero. Refunds are placed in the order they happened, each against what was left at that moment. To stop billing as well, cancel the subscription, or refund its cycle with refund_and_cancel (see Managing Subscriptions).

Cancelling

Cancelling the order cancels every subscription it created, immediately — not at the end of the period. Cancelling one subscription touches neither the order nor the other subscriptions.

The session intent, for one plan

A checkout session can still carry a single recurring intent, and Throttle adds the plan line for you. It is the simpler shape for one plan, and it behaves as it always has, apart from the invoice, email and shipping rules above, which apply to it too. It is exclusive with subscription lines: a session with a recurring intent on a cart that already has subscription lines is rejected with 422 recurring_source_conflict.

ts
// The session-level intent: ONE plan, Throttle adds the plan line for you.
await subscriptions.createCheckoutSession({
  applicationId, cartId, customer, returnUrl, cancelUrl,
  recurring: { plan: 'pro_monthly', interval: 'monthly', amount: 2999 },
});
// `amount` is required when the cart holds any other line: without it the renewal
// price would be the whole cart total, so session creation answers 422 recurring_amount_required.
// A line added after the session was created is refused the same way, before any money moves:
// the hosted page, the embed token and completion answer 422 recurring_amount_required.
Session intents on quote and deposit carts skip the plan line
A session recurring intent does not add its plan line to a cart created from an accepted quote, or to a cart with a deposit/balance schedule. Their totals are already fixed. To sell a subscription through a quote, put the terms on the quote line instead: see Quotes.

Errors

Subscription checkouts add these codes to the standard error envelope: too_many_subscriptions, recurring_source_conflict, recurring_not_supported_for_cart, recurring_amount_required, recurring_terms_unsupported, recurring_line_immutable, subscription_lines_changed, trial_not_available, invalid_plan and invalid_trial_days. They reuse recurring_customer_required, invalid_line_item and the cadence codes. Completion can also answer the payment codes every card checkout shares ( checkout_total_changed, payment_unverified, transaction_not_for_session, transaction_already_used, tax_calculation_failed): see Completion errors.

StatusCodeWhen
422too_many_subscriptionsAn 11th subscription line is added to a cart, or a quote with more than 10 is issued.
422recurring_source_conflictThe session carries a recurring intent and the cart already has subscription lines.
422recurring_not_supported_for_cartA subscription line on a cart managed by an external cart provider, or a cart with subscription lines on a deposit/balance session.
422recurring_amount_requiredA session recurring intent with no amount, on a cart that holds other lines (or any external-provider cart), unless create is manual. Answered at session create, and again before any money moves (the public session read, the embed token and completion) when a line was added to the cart after the session was created. Those later answers carry details with cartId and the session's cancelUrl (when set); the hosted page explains that the checkout cannot be completed as set up and links back to the cancelUrl.
422recurring_terms_unsupportedA quote with subscription lines is issued on net terms or with a deposit, or a checkout with subscription lines is completed with Net 30.
422recurring_line_immutableA PATCH changes a subscription line's unitPrice, taxAmount or discountAmount. Quantity and metadata may change.
409subscription_lines_changedThe subscription lines (seats included) changed after the session was created. details carries cartId and the session's cancelUrl (when set). The hosted page tells the buyer their cart changed and links back to the cancelUrl; create a new session.
409trial_not_availableThe checkout grants a free trial and the card already had one on this merchant (details.reason: "card_already_used_for_trial"). Nothing is created or charged, and subscription.trial_blocked fires with subscriptionId: null. Offer another card or a plan without a trial. See Trial Fraud Protection.
422recurring_customer_requiredA subscription and no customer email or id on the session.
400invalid_line_itemA free-trial line with taxAmount or discountAmount.
400invalid_interval / interval_conflictA cadence outside 1 hour to 1 year, or an interval that disagrees with intervalUnit + intervalCount.
400invalid_total_payments / invalid_combinationtotalPayments outside 2–60, or a trial together with installments.
400invalid_schedule / schedule_first_payment_mismatchA custom schedule with the wrong rows or gaps, or a line unitPrice that is not schedule[0].amount.
400invalid_plan / invalid_trial_daysplan empty or over 255 characters; trialDays outside 0–365.

Limits

LimitWhat to do instead
Ten subscription lines per cartSplit a larger bundle across checkouts, or model it as fewer plans with seats (quantity).
Subscription terms are fixed once the line is addedChange seats with the line's quantity. To change the price or cadence, remove the line and add it again before checkout; after the sale, change the plan on the subscription.
Line items are not editable after the saleAn order carrying a subscription line rejects line-item edits with 409 subscription_order_not_editable. Change the plan through the subscription; refund the goods.
Usage is overage, not an add-onA subscription is one plan times a quantity plus reported usage. Sell a recurring add-on as its own subscription line.
Subscriptions are paid by cardNet 30 cannot complete a checkout whose cart lines carry subscriptions. A session recurring intent completed with Net 30 takes the order but creates no subscription.
Every subscription in the cart renews on the card used at checkoutEach subscription stores that card as paymentMethodId. The buyer can move one subscription to another card afterwards; a declined renewal falls back to the default card. See Which card a renewal charges.

Next

  • Cart API — the full recurring line reference.
  • Subscription Webhooks — one subscription.created per subscription, after the order.
  • Order states — statuses, fulfilment and cancellation on the order side.