Stripe's shipping guide states the limit in one line: "Only Checkout Sessions in payment mode support shipping options." Its guide to dynamic shipping options repeats it: "Shipping rates aren't available in subscription mode." A subscription box, a coffee club, or a refill plan still has to charge for postage on every cycle, so the shipping charge moves somewhere Stripe does support in subscription mode: a line item. Every subscription then renews as an invoice, and the box behind it is yours to pack, address, and track.
If you would rather each renewal arrive as an order with its own shipping quote and fulfillment, that is how Flint subscriptions work.
Where to start
| Where you are | Your path |
|---|---|
| Every renewal ships for the same flat rate | Shipping as a recurring price |
| Shipping depends on the address, the weight, or carrier rates | Quote shipping on each renewal |
| You need a reliable signal to pack each box | Ship each renewal from invoice.paid |
| Subscribers move, ask to skip a month, or your rates change | Between renewals |
| A box shipped twice, to the wrong address, or not at all | The six traps |
| You would rather each renewal arrive as a shipped order | Renewals as orders, on Flint |
Flat rate: shipping as a recurring price
When every box ships for the same amount, shipping is one more recurring price on the subscription. Stripe's tax docs describe this exact setup: "To charge tax on shipping for subscriptions, you can create a Product or pass product_data for a line item called "shipping" and select the shipping tax_code."
# A Product for shipping, carrying Stripe Tax's Shipping tax code
curl https://api.stripe.com/v1/products \
-u "sk_test_...:" \
-d name=Shipping \
-d tax_code=txcd_92010001
# A recurring $5.00 Price on the same interval as the box
curl https://api.stripe.com/v1/prices \
-u "sk_test_...:" \
-d product=prod_... \
-d unit_amount=500 \
-d currency=usd \
-d tax_behavior=exclusive \
-d "recurring[interval]=month"Then sell the box and the shipping together in subscription mode:
# line_items[0] is the monthly box, line_items[1] the monthly shipping price
curl https://api.stripe.com/v1/checkout/sessions \
-u "sk_test_...:" \
-d mode=subscription \
-d "line_items[0][price]=price_..." \
-d "line_items[0][quantity]=1" \
-d "line_items[1][price]=price_..." \
-d "line_items[1][quantity]=1" \
-d "shipping_address_collection[allowed_countries][0]=US" \
-d "automatic_tax[enabled]=true" \
--data-urlencode "success_url=https://example.com/thanks?session_id={CHECKOUT_SESSION_ID}"A subscription with several prices produces one invoice per billing period that combines them, paid in one charge, so the buyer sees the box and shipping on one receipt. Both prices must share a currency, and the shipping price should share the box's interval so each invoice carries exactly one shipment's postage.
Address collection is a separate parameter from shipping options. Stripe documents the payment mode limit for shipping options, and the API reference lists no mode limit for shipping_address_collection. When Checkout creates the Customer, it saves the shipping information it collected there. When you pass an existing customer, add customer_update[shipping]=auto so the new address replaces the old one; the default is never. The Customer's shipping is what renewal invoices copy and what Stripe Tax reads first for subscription invoices.
Variable rates: quote shipping on each renewal
A recurring price is a fixed amount. When postage depends on the address, the weight, or a carrier's rate, the first box and each renewal need their own quote. For the first box, collect the address on your own page, quote it, and pass shipping as a one-time price. In subscription mode, Stripe bills one-time prices on the initial invoice only:
// Your page collected the address before Checkout, because a
// subscription session can't quote shipping. The Customer already has
// that address as its shipping, which Stripe Tax reads.
const firstShipping = await quoteShipping(customerAddress, cart); // your rates
const session = await stripe.checkout.sessions.create({
mode: "subscription",
customer: customerId,
line_items: [
{ price: boxPriceId, quantity: 1 },
{
// One-time price: subscription mode bills it on the first invoice only
quantity: 1,
price_data: {
currency: "usd",
unit_amount: firstShipping,
tax_behavior: "exclusive",
product: process.env.STRIPE_SHIPPING_PRODUCT_ID, // tax code txcd_92010001
},
},
],
automatic_tax: { enabled: true },
success_url: "https://example.com/thanks?session_id={CHECKOUT_SESSION_ID}",
});For renewals, use the draft window. Stripe creates each renewal invoice, sends invoice.created, and leaves the invoice in draft for about an hour before it finalizes and charges. An invoice item created with the invoice parameter during that hour lands on that renewal:
// Webhook handler: acknowledge invoice.created at once and quote later.
// Renewal invoices stay drafts for about an hour.
if (event.type === "invoice.created") {
const invoice = event.data.object;
if (invoice.billing_reason === "subscription_cycle") {
await jobs.enqueue("quote-renewal-shipping", { invoiceId: invoice.id });
}
return res.sendStatus(200);
}
// Background job
const invoice = await stripe.invoices.retrieve(invoiceId);
if (invoice.status !== "draft") return alertMissedShipping(invoice);
const customer = await stripe.customers.retrieve(invoice.customer);
const amount = await quoteShipping(customer.shipping.address, invoice.lines.data);
await stripe.invoiceItems.create(
{
customer: invoice.customer,
invoice: invoice.id, // without it, the item waits for the next invoice
amount,
currency: invoice.currency,
description: "Shipping",
tax_code: "txcd_92010001",
tax_behavior: "exclusive",
},
{ idempotencyKey: `renewal-shipping-${invoice.id}` },
);The hour is the constraint. An item created after finalization goes to the next invoice, and an endpoint that fails to acknowledge invoice.created holds up finalization of every automatically collected invoice on the account for up to 72 hours, except invoices with a custom scheduled finalization time. Quote outside the request, and treat a renewal that finalizes without a shipping line as an alert.
Ship each renewal from invoice.paid
Stripe generates an invoice for each billing period; the shipment is yours to create. Stripe's subscription webhook guide pairs invoice.paid with provisioning, and the invoice's billing_reason says why it exists: subscription_create for the signup, subscription_cycle for a renewal, subscription_update for a change. Give every box exactly one trigger. For the first box that trigger is the session: with delayed payment methods such as ACH Direct Debit, the session completes with payment_status unpaid and checkout.session.async_payment_succeeded follows when the money arrives, so the handler listens for both:
switch (event.type) {
case "checkout.session.completed":
// Delayed methods such as ACH Direct Debit complete the session unpaid,
// then send this event when the payment succeeds.
case "checkout.session.async_payment_succeeded": {
const session = event.data.object;
if (session.mode !== "subscription" || session.payment_status === "unpaid") break;
// The first box, shipped once per session
await shipOnce(`session:${session.id}`, {
subscription: session.subscription,
shipTo: session.collected_information.shipping_details,
});
break;
}
case "invoice.paid": {
const invoice = event.data.object;
// Renewals only. subscription_create is the first box, shipped above;
// subscription_update is a plan change, not a delivery.
if (invoice.billing_reason !== "subscription_cycle") break;
await shipOnce(`invoice:${invoice.id}`, {
// API 2025-03-31.basil and later; older versions use invoice.subscription
subscription: invoice.parent.subscription_details.subscription,
// The address as it stood when this invoice finalized
shipTo: invoice.customer_shipping,
});
break;
}
}shipOnce is your own table with a unique key, written in the same transaction as the shipment record. Stripe's webhook docs warn that an endpoint "might occasionally receive the same event more than once," and keying on the session or invoice ID also covers the cases where Stripe sends two separate events for one object, and the session that both checkout events describe. If your page collected the address before Checkout, as in the variable rate flow, the session has no collected_information.shipping_details: ship the first box to the Customer's shipping instead. A renewal whose payment fails sends no invoice.paid until a retry succeeds, so a box can go out days after its renewal date. The webhook events reference covers ordering and duplicate delivery in more depth.
Between renewals: moves, skips, and rate changes
Address changes
The customer portal can edit the Customer's shipping address when shipping is in its allowed updates:
curl https://api.stripe.com/v1/billing_portal/configurations \
-u "sk_test_...:" \
-d "features[customer_update][enabled]=true" \
-d "features[customer_update][allowed_updates][]=shipping" \
-d "features[customer_update][allowed_updates][]=email"A change applies to the next renewal invoice that has not finalized. Until finalization, invoice.customer_shipping mirrors the Customer's shipping; after it, the invoice keeps the address it had, and its tax was calculated there. A subscriber who moves in the gap between payment and packing needs a decision from you, which the traps cover. For subscriptions that charge automatically, Stripe also sends invoice.upcoming a few days before each renewal, with the lead time set in your Dashboard, which is the natural moment to email the subscriber the address their next box is going to.
Skipping a month
Stripe has no skip action. The usual stand-in is pause_collection: the subscription stays active, invoices keep being created, and with behavior set to void they are voided. While collection is paused, Stripe sends no upcoming invoice emails or webhooks for those invoices, so no invoice.paid fires and nothing ships:
# Skip the November 1 renewal: void invoices created until November 2
curl https://api.stripe.com/v1/subscriptions/sub_... \
-u "sk_test_...:" \
-d "pause_collection[behavior]=void" \
-d "pause_collection[resumes_at]=1793577600"resumes_at has to fall after the renewal being skipped and before the next one, and Stripe does not send customer.subscription.paused for a collection pause, so record the skip in your own system for support and reporting.
Stripe also has a full pause, POST /v1/subscriptions/{id}/pause, for subscriptions in flexible billing mode. It takes effect immediately, ends the current period at the pause time, and stops invoice generation until you resume, and resuming can start a new billing period with an invoice right away. That suits an open-ended hold. For skipping one box while later boxes keep their dates, pause_collection is the closer fit.
Changing the shipping rate
A Stripe price's amount can't change after you create it. A new carrier rate means a new price and an update to every subscription that carries the old one:
# 1. A new shipping Price; an existing price's amount can't change
curl https://api.stripe.com/v1/prices \
-u "sk_test_...:" \
-d product=prod_... \
-d unit_amount=700 \
-d currency=usd \
-d tax_behavior=exclusive \
-d "recurring[interval]=month"
# 2. For each subscription, point its shipping item at the new price
curl https://api.stripe.com/v1/subscriptions/sub_... \
-u "sk_test_...:" \
-d "items[0][id]=si_..." \
-d "items[0][price]=price_..." \
-d proration_behavior=noneproration_behavior defaults to create_prorations, so without none the switch creates proration items for the rest of the current period. Existing subscriptions keep the old price until you move them.
