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.
# 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" }' // 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 — 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.
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
createmode; 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, orcustomerEmail, it is refused with422 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_undeterminablebefore 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 explicitrecurringblock.
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
| Moment | Amount | What happens |
|---|---|---|
| Checkout | goods + every subscription line (unit price × quantity) + shipping + tax − discount | One authorization and one capture. A free-trial line adds 0. Shipping is charged once, for the whole order. |
| Period 1 | included above | Each 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 renewal | that subscription only | Each 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
// 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.
# 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.
// 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.
// 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.
// 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. 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.
| Status | Code | When |
|---|---|---|
| 422 | too_many_subscriptions | An 11th subscription line is added to a cart, or a quote with more than 10 is issued. |
| 422 | recurring_source_conflict | The session carries a recurring intent and the cart already has subscription lines. |
| 422 | recurring_not_supported_for_cart | A subscription line on a cart managed by an external cart provider, or a cart with subscription lines on a deposit/balance session. |
| 422 | recurring_amount_required | A 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. |
| 422 | recurring_terms_unsupported | A quote with subscription lines is issued on net terms or with a deposit, or a checkout with subscription lines is completed with Net 30. |
| 422 | recurring_line_immutable | A PATCH changes a subscription line's unitPrice, taxAmount or discountAmount. Quantity and metadata may change. |
| 409 | subscription_lines_changed | The 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. |
| 409 | trial_not_available | The 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. |
| 422 | recurring_customer_required | A subscription and no customer email or id on the session. |
| 400 | invalid_line_item | A free-trial line with taxAmount or discountAmount. |
| 400 | invalid_interval / interval_conflict | A cadence outside 1 hour to 1 year, or an interval that disagrees with intervalUnit + intervalCount. |
| 400 | invalid_total_payments / invalid_combination | totalPayments outside 2–60, or a trial together with installments. |
| 400 | invalid_schedule / schedule_first_payment_mismatch | A custom schedule with the wrong rows or gaps, or a line unitPrice that is not schedule[0].amount. |
| 400 | invalid_plan / invalid_trial_days | plan empty or over 255 characters; trialDays outside 0–365. |
Limits
| Limit | What to do instead |
|---|---|
| Ten subscription lines per cart | Split a larger bundle across checkouts, or model it as fewer plans with seats (quantity). |
| Subscription terms are fixed once the line is added | Change 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 sale | An 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-on | A subscription is one plan times a quantity plus reported usage. Sell a recurring add-on as its own subscription line. |
| Subscriptions are paid by card | Net 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 checkout | Each 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
recurringline reference. - Subscription Webhooks — one
subscription.createdper subscription, after the order. - Order states — statuses, fulfilment and cancellation on the order side.