A shipping rate is a display name, an amount, an optional delivery estimate, a tax code and tax behavior, and metadata. Its type has one value, fixed_amount. Nothing on it says pickup, names a location, or tells Checkout to skip the address. Below are the three ways teams build pickup and address-based pricing from those parts, each with its code, then the five places they go wrong in production. If you would rather have pickup handled by the checkout, skip to Flint's delivery methods.
Which path applies to you
| What you need | Your path |
|---|---|
| You need a pickup choice on the Stripe-hosted page today | Add a free Pickup rate |
| Pickup buyers should not type an address, or should be taxed at the store | Ask first, then create the session |
| The shipping price depends on where the buyer lives | Rates that follow the address |
| You have more than one store | Five options and no store picker |
| Your Pickup rate is live and orders look wrong | The five traps |
| You want pickup and local delivery handled by the checkout | Pickup and local delivery on Flint |
A free Pickup rate in shipping_options
This is the workaround most stores ship first, and it works on the Stripe-hosted page with no code beyond the session request. Add a $0 rate next to your paid shipping rate. Put the paid rate first, because Checkout preselects the first option, and put the store in the rate's metadata so your fulfillment code does not have to parse display text:
curl https://api.stripe.com/v1/checkout/sessions \
-u "sk_test_...:" \
-d mode=payment \
-d "line_items[0][price]=price_..." \
-d "line_items[0][quantity]=1" \
-d "automatic_tax[enabled]=true" \
-d "shipping_address_collection[allowed_countries][0]=US" \
-d "shipping_options[0][shipping_rate]=shr_standard_..." \
-d "shipping_options[1][shipping_rate_data][type]=fixed_amount" \
-d "shipping_options[1][shipping_rate_data][fixed_amount][amount]=0" \
-d "shipping_options[1][shipping_rate_data][fixed_amount][currency]=usd" \
--data-urlencode "shipping_options[1][shipping_rate_data][display_name]=Pick up at 120 Main St, Brooklyn" \
-d "shipping_options[1][shipping_rate_data][metadata][fulfillment]=pickup" \
-d "shipping_options[1][shipping_rate_data][metadata][store]=main-st" \
--data-urlencode "custom_text[shipping_address][message]=Picking up at Main St? Choose that option below." \
--data-urlencode "success_url=https://example.com/thanks?session_id={CHECKOUT_SESSION_ID}"The address form still appears for everyone. custom_text[shipping_address] puts a short note next to it for pickup buyers. After payment, the session records the rate the buyer chose in shipping_cost.shipping_rate:
// Inside your checkout.session.completed handler
const session = event.data.object;
const rate = await stripe.shippingRates.retrieve(session.shipping_cost.shipping_rate);
if (rate.metadata.fulfillment === "pickup") {
await queuePickup(session.id, rate.metadata.store);
} else {
await queueShipment(session.id, session.collected_information.shipping_details);
}With automatic tax on, as here, the pickup buyer is taxed at the address they typed. That is the first trap below, and the reason for the next pattern.
Ask pickup or delivery first, then create the session
Checkout cannot hide the address form based on a choice made inside it, so make the choice before Checkout. Your cart page asks "Ship or pick up?", and your server creates one of two sessions:
// Your cart page asked "Ship or pick up?" and, for pickup, which store.
const pickup = req.body.fulfillment === "pickup";
const store = pickup ? STORES[req.body.store_id] : null;
const session = await stripe.checkout.sessions.create({
mode: "payment",
line_items: cart.map((item) => ({
price: item.priceId,
quantity: item.quantity,
// Pickup: the store's own Tax Rate, which you create and keep current
...(pickup ? { tax_rates: [store.taxRateId] } : {}),
})),
...(pickup
? {} // no address form, no shipping options
: {
automatic_tax: { enabled: true },
shipping_address_collection: { allowed_countries: ["US"] },
shipping_options: [{ shipping_rate: STANDARD_RATE_ID }],
}),
metadata: { fulfillment: pickup ? "pickup" : "ship", store: store?.id ?? "" },
success_url: "https://example.com/thanks?session_id={CHECKOUT_SESSION_ID}",
});The pickup session has no shipping_address_collection and no shipping_options, so the buyer sees no shipping form. Leaving out the shipping address does not move the tax location to your store: with automatic tax, Checkout taxes a new customer at the billing address, and an existing customer at their saved shipping address when they have one. If your pickup sales are taxed where the store is, apply that store's rate with line_items.tax_rates and leave automatic tax off for pickup sessions. Stripe applies the tax rates you create but does not choose them for you, so each store's rate is yours to maintain.
The cost is a step before payment, and a buyer who changes their mind on the Stripe page has to go back to yours. In return, pickup buyers type nothing they do not need and the order says unambiguously which store it belongs to.
Shipping rates that follow the address
Stripe can replace a session's shipping options after the buyer enters an address, which is how you offer local delivery only nearby, free shipping over a threshold, or carrier rates. Where it works depends on ui_mode:
| Checkout UI | ui_mode | Rates by address |
|---|---|---|
| Stripe-hosted page | hosted_page (default) | Not supported |
| Full embedded page | embedded_page | Not supported |
| Embedded form | form | Supported: your server updates the session |
| Checkout elements | elements | Supported, with permissions.update_shipping_details=server_only |
Stripe's shipping guide calls the feature a preview, and it works in payment mode only. With the embedded form, create the session with a $0 placeholder rate that your server replaces:
curl https://api.stripe.com/v1/checkout/sessions \
-u "sk_test_...:" \
-d mode=payment \
-d ui_mode=form \
-d "line_items[0][price]=price_..." \
-d "line_items[0][quantity]=1" \
-d "shipping_address_collection[allowed_countries][0]=US" \
-d "shipping_options[0][shipping_rate_data][type]=fixed_amount" \
-d "shipping_options[0][shipping_rate_data][display_name]=Shipping" \
-d "shipping_options[0][shipping_rate_data][fixed_amount][amount]=0" \
-d "shipping_options[0][shipping_rate_data][fixed_amount][currency]=usd" \
--data-urlencode "return_url=https://example.com/return"If wallets stay on, set that starting rate to one you would accept for any address you ship to, because a wallet payment skips your server (see the wallet trap below).
Your server receives the address, works out the options, and writes them to the session with the Update a Checkout Session API:
app.post("/calculate-shipping-options", async (req, res) => {
const { checkout_session_id, shipping_details } = req.body;
const options = ratesFor(shipping_details.address); // your rules, up to 5
if (options.length === 0) {
return res.json({ type: "error", message: "We don't deliver to this address." });
}
await stripe.checkout.sessions.update(checkout_session_id, {
shipping_options: options,
});
res.json({ type: "object", value: { succeeded: true } });
});
function ratesFor(address) {
const rate = (display_name, amount) => ({
shipping_rate_data: {
type: "fixed_amount",
display_name,
fixed_amount: { amount, currency: "usd" },
},
});
const local = LOCAL_DELIVERY_ZIPS.has(address.postal_code);
return [
rate("Standard shipping", 900),
...(local ? [rate("Local delivery, same day", 500)] : []),
];
}In the browser, wait for a complete shipping address, then run the request inside runServerUpdate so the form picks up the new options:
const checkout = await stripe.initCheckoutFormSdk({ clientSecret });
const checkoutForm = checkout.createForm();
checkoutForm.mount("#checkout-form");
let quoted = false;
checkoutForm.on("change", async ({ value, status }) => {
if (!status.shippingAddress?.complete) {
quoted = false; // the buyer is editing; quote again when it is complete
return;
}
if (quoted) return;
quoted = true;
const loaded = await checkout.loadActions();
if (loaded.type !== "success") {
quoted = false;
return;
}
try {
// Refreshes the form with the options your server set; 20 second timeout
await loaded.actions.runServerUpdate(async () => {
const result = await postJson("/calculate-shipping-options", {
checkout_session_id: loaded.actions.getSession().id,
shipping_details: value.shippingAddress,
}); // your fetch wrapper, returning the parsed JSON body
if (result.type === "error") throw new Error(result.message);
return result;
});
} catch (error) {
quoted = false;
showShippingError(error.message); // "We don't deliver to this address."
}
});The embedded form shows no loading indicator while this runs, so the endpoint has to be fast. With Checkout elements the flow is the same, except you also send the address back in collected_information.shipping_details on the update. Each option is still a fixed amount for the whole order, and the address form still appears, so this pattern prices delivery well and does nothing for pickup on its own.
