Skip to content

Shipping

Rates a store configures, matched against destination, parcel weight and order subtotal. No carrier account, and Stripe’s hosted checkout is untouched.

Zones group countries. A zone naming no countries is the catch-all, so a store can price the world without listing it. Explicit listings win over the catch-all regardless of position, so adding “rest of world” can never quietly capture a country a specific zone already prices.

Rates hang off zones, optionally, and are bounded by parcel weight and parcel subtotal, inclusively at both ends, with null meaning unbounded. “Free over $50” is a rate with minSubtotalCents: 5000 and a price of zero. “Heavy parcels cost more” is two rates with adjacent weight bands. Each rate carries its own tax behaviour, because postage is taxable in some jurisdictions and not others.

Two behaviours to state up front:

  • An unpinned rate applies everywhere. A flat-rate store needs one rate and no zones at all.
  • Every matching rate is offered, cheapest first. Not one winner. That is what a standard-versus-express pair needs.

Resolution is one pure function, resolveShippingRates in shared/shipping.ts, called by both the cart quote and the checkout route so the two cannot disagree. The rates are passed inline on the Checkout Session; there are no Stripe Shipping Rate objects to keep in sync.

It looks like a UX mistake until you know why. Hosted Checkout collects the address after the session exists, so a zone-priced store has to know the destination before it can price postage. The session is then restricted to that country, so a buyer cannot keep a domestic rate on an international address: the line-item price-integrity rule, applied to postage. Anyone who reads the cart and thinks “I’ll move this to the Stripe page” needs to hit this paragraph first.

findCoverageGaps exists because these are invisible. Treat the admin’s warnings as a routine check, not a curiosity.

  1. A coverage gap ships free. When no rate matches, the buyer is offered nothing and pays no postage. The order still completes. Deliberate, since inventing a price would be worse, but it means a misconfiguration is discovered when a parcel arrives with no postage on it. The Overview warns when a store has live physical products and no rates, and when a zone covers a country no rate can price.
  2. No recorded weights means a 0 g parcel, which matches the lightest weight band. Weight bounds configured before variant weights are filled in do not fail closed; everything quietly qualifies for the cheapest band.
  3. Subtotal bounds count physical lines only. A $40 download does not push a $10 box over a free-shipping threshold. On an upper bound it is worse: without this rule a digital-heavy cart sails past every band’s ceiling, matches nothing, and ships free.

Giving a download weightGrams: 0 looks equivalent and is not. A zero-gram line still participates, so a cart of nothing but PDFs would report a 0 g parcel, match the lightest band, and charge the buyer postage on a parcel that does not exist. physicalLines, requiresShipping and parcelFor are the API; a cart with no physical line collects no address at all, and a mixed cart is priced on its physical lines. The reasoning generalises to anything anyone adds later.

Live carrier rates, address validation, label purchase, tracking. Live rates need ui_mode: 'elements', which means Beluga owning the checkout page, and with it the card fields and PCI scope that Stripe’s hosted page keeps off your server. The trade, the provider evaluation (Shippo, EasyPost, Easyship), and a middle path that quotes on the cart page are in docs/shipping.md in the repository. Treat it as a payments phase with a shipping payload, not an increment on this.