How to offer local pickup and dynamic shipping in Stripe Checkout

Stripe Checkout has no pickup option. Its shipping options are fixed-amount rates, at most five per session, and the shipping address form belongs to the session, not to a rate. A free "Pickup" rate works, but the buyer still types an address and Stripe Tax uses it. Rates that change with the address need the embedded form or Elements.
Verified against official documentationReviewed Send a correction (opens in a new tab)

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 needYour path
You need a pickup choice on the Stripe-hosted page todayAdd a free Pickup rate
Pickup buyers should not type an address, or should be taxed at the storeAsk first, then create the session
The shipping price depends on where the buyer livesRates that follow the address
You have more than one storeFive options and no store picker
Your Pickup rate is live and orders look wrongThe five traps
You want pickup and local delivery handled by the checkoutPickup 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
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:

JavaScript
// 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:

JavaScript
// 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 UIui_modeRates by address
Stripe-hosted pagehosted_page (default)Not supported
Full embedded pageembedded_pageNot supported
Embedded formformSupported: your server updates the session
Checkout elementselementsSupported, 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
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:

JavaScript
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:

JavaScript
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.

The five places pickup and delivery go wrong

  1. Pickup orders are taxed at the address the buyer typed#

    A buyer collected the order at your counter and paid the tax rate of the town they live in.

    With automatic tax on, Checkout uses the shipping address entered during the session as the customer's location. Without shipping address collection, a new customer is taxed at the billing address, and an existing customer at their saved shipping address when they have one. A shipping rate has no location field and the session has no parameter that names your store, so a $0 Pickup rate changes the price of shipping and nothing about where the sale is taxed.

    Confirm it

    Open a pickup payment in the Dashboard. The Tax calculation section of the Transactions details page shows the location Stripe Tax used. If it is the buyer's address, every pickup order is taxed that way.

    Fix

    If your pickup sales should be taxed at the store, which is a question for your tax advisor, create pickup sessions separately, leave automatic tax off for them, and apply the store's rate with line_items.tax_rates, as in the ask-first pattern above. You maintain those rates yourself; Stripe does not set them for you.

  2. The first option is chosen for the buyer#

    Buyers who wanted a parcel paid for pickup, and found out when you emailed them.

    Checkout preselects the first entry in shipping_options. Put a $0 Pickup rate first and it becomes the default as well as the cheapest choice, so anyone who does not read the selector picks up. A similar trap shows up in wallet flows: in the WooCommerce Stripe gateway's issue tracker, a store owner reported in September 2026 that express checkout defaulted to the cheapest option, Click & Collect, and that customers paid without noticing they had to choose a delivery option.

    Confirm it

    Compare pickup orders against the addresses on them. Pickup orders from addresses hundreds of miles from the store were almost always mistakes.

    Fix

    List a paid shipping rate first and Pickup last. Name the rate with the store and street, such as "Pick up at 120 Main St, Brooklyn", and repeat the pickup details in the confirmation email so a wrong choice surfaces before the buyer is waiting for a parcel.

  3. Five options, and no store picker#

    You added a Pickup rate per store and ran out of slots, or the order does not say which store.

    A session takes at most 5 shipping options, and each store needs its own rate. The rate's display_name is the only label the buyer sees, and after payment the session records which rate was chosen, in shipping_cost.shipping_rate. Which store that rate meant lives wherever you wrote it down.

    Confirm it

    Read a completed pickup session and try to name the store from the session alone. If you need to look up the rate's name to do it, the mapping lives in display text.

    Fix

    Put the store in each rate's metadata and read it in your checkout.session.completed handler. Past three or four stores, ask for the store on your own page and send it in session metadata, or use a dropdown custom field (up to 200 options, three fields per session, not available with ui_mode=elements).

  4. The hosted page cannot change rates after the address#

    Local delivery showed up for a buyer 300 miles away, or free shipping never appeared for Alaska.

    The Stripe-hosted page and the full embedded page do not support dynamically customizing shipping options. Every buyer sees the rates you set when you created the session, whatever address they type. Each rate is also one fixed amount for the entire order, so it cannot follow the number of items either.

    Confirm it

    Search completed sessions for a local delivery rate paid with a shipping address outside your delivery area.

    Fix

    Collect the ZIP code on your own page and create the session with only the rates that apply to it, accepting that the buyer can type a different address in Checkout. Or move to the embedded form or Checkout elements, where your server replaces the options after the address is entered.

  5. Wallets skip your address check#

    The wallet buttons disappeared from your Checkout elements page after you added dynamic rates, or a wallet payment may have gone through on a rate your server never checked.

    Dynamic shipping options do not support the Express Checkout Element: Apple Pay and Google Pay collect the shipping address directly, which bypasses the server update. With Checkout elements, setting permissions.update_shipping_details to server_only disables those wallets automatically. For the embedded form, Stripe does not document what the wallet sheet shows, so test it before relying on either outcome.

    Confirm it

    Test the session with Apple Pay or Google Pay and note which rates the payment sheet offers.

    Fix

    Decide which you want: wallets with a rate that is correct for every address you accept, or address-based rates without wallets. If you keep wallets on the embedded form, make the shipping_options you create the session with safe to charge for any address you accept, since a flow that skips your server would not replace them.

Questions, answered

Does Stripe Checkout support local pickup?

Not as a feature. Checkout's shipping options are shipping rates with one type, a fixed amount, and nothing on a rate marks it as pickup. The common workaround is a $0 rate named Pickup. It works, but the buyer still fills in the shipping address form, and Stripe Tax uses that address.

How do I add an in-store pickup option to Stripe Checkout?

Add a fixed_amount shipping rate with an amount of 0 and a display name that names the store, put the store in the rate's metadata, and list it after your paid shipping rates. When checkout.session.completed arrives, read shipping_cost.shipping_rate, retrieve the rate, and route the order by its metadata.

Can Stripe Checkout skip the shipping address when the buyer chooses pickup?

No. shipping_address_collection is set on the session and applies to every option in it. To skip the address, ask pickup or delivery on your own page first and create the pickup session without shipping_address_collection or shipping_options.

Which address does Stripe Tax use for a pickup order in Checkout?

The shipping address collected during the session when you collect one. Without it, a new customer is taxed at the billing address, and an existing customer at their saved shipping address when they have one, otherwise their billing address. No session or shipping rate parameter makes your store the tax location. If pickup sales should be taxed at the store, create pickup sessions separately and apply the store's rate with line_items.tax_rates instead of automatic tax.

Can Stripe Checkout calculate shipping based on the customer's address?

Yes, with the embedded form (ui_mode=form) or Checkout elements (ui_mode=elements). Your server receives the address, computes the options, and replaces shipping_options with the Update a Checkout Session API. The Stripe-hosted page and the full embedded page do not support it, and Stripe's shipping guide describes the feature as a preview.

How many shipping options can a Stripe Checkout Session have?

Up to 5. Each one is a fixed amount for the entire order, so a rate cannot change with the number of items. Checkout preselects the first option in the list.

Can I offer local delivery only within a radius in Stripe Checkout?

Not on the Stripe-hosted page, which shows every rate to every address. With the embedded form or Checkout elements, your server can measure the distance from the address the buyer entered and include a local delivery rate only when it is in range.

On Flint, pickup and local delivery are delivery methods

Flint's hosted checkout offers shipping, pickup, and local delivery as separate choices. A buyer who picks up chooses a store from a list sorted by distance instead of filling in a shipping address, and the store becomes the order's tax location. Each choice is a delivery method with its own type, origin, and pricing. A pickup method lists the stores buyers can choose from:

cURL
curl -X POST https://api.withflintpay.com/v1/delivery-methods \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Idempotency-Key: method-pickup-shops-1" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Pick up in store",
    "type": "pickup",
    "status": "active",
    "configuration": {
      "origin": {
        "type": "pickup_location_collection",
        "location_ids": ["loc_1kmn0aShopA", "loc_1kmn0aShopB"]
      },
      "pricing": {
        "type": "fixed",
        "fixed": { "currency_options": { "USD": { "amount": 0, "currency": "USD" } } }
      },
      "public_details": {
        "pickup_mode": "in_store",
        "instructions": "Bring your order number to the front counter."
      }
    }
  }'

A method can list up to 1,000 Locations inline, or point at a reusable location set. Offer it with your shipping and local delivery methods when you create the checkout, or once for every checkout in checkout.default_delivery_method_ids:

cURL
curl -X POST https://api.withflintpay.com/v1/checkout-sessions \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Idempotency-Key: checkout-order-1042" \
  -H "Content-Type: application/json" \
  -d '{
    "order_id": "ord_1kmn0aExample",
    "delivery_method_ids": ["dmet_1kmn0aShipping", "dmet_1kmn0aPickup", "dmet_1kmn0aLocal"],
    "redirects": { "success_redirect_url": "https://example.com/thanks" }
  }'

The buyer switches between Ship and Pick up in the delivery section. Choosing Pick up replaces the address form with a store search, and switching back keeps the address they had typed. Once every item is picked up at one store, automatic tax uses that store, and the order shows it:

JSON
"tax": {
  "status": "calculated",
  "location": {
    "address_source": "location",
    "location_id": "loc_1kmn0aShopA",
    "address": {
      "line1": "120 Main Street",
      "city": "Brooklyn",
      "state": "NY",
      "postal_code": "11249",
      "country": "US"
    }
  }
}

Automatic tax needs one origin and one destination per order, so an order that ships some items and picks up others needs external tax instead. Address-based pricing runs on the hosted page as well:

  • Rate tables price shipping by zone, such as $9 to the contiguous US and $25 to Alaska and Hawaii.
  • Tiered pricing gives free shipping over a threshold, or prices by weight or item count.
  • Local delivery limits a method to a radius around the store, prices it by distance with a minimum and maximum fee, and lets the buyer pick a delivery window.
  • Rate callbacks call your server, signed, when the buyer quotes delivery, for carrier or courier rates. Callback pricing has required minimum and maximum fees, so a bug returning $0 or $10,000 makes the method unavailable instead of charging it.

The delivery options guide sets up all of these for a store with one warehouse and two shops.

Stripe Checkout and Flint checkout delivery, side by side

Stripe CheckoutFlint hosted checkout
Pickup choiceA $0 shipping rate you name PickupA pickup method with a store list
Address form for a pickup buyerShown, unless you create a separate sessionReplaced by a store search
Tax location for a pickupThe buyer's shipping or billing addressThe store
Several storesOne rate each, five options in totalUp to 1,000 Locations per method, sorted by distance
Rates by address on the hosted pageNot supported; embedded form or Checkout elements with your serverZones, rate tables, distance pricing, or a rate callback
Price by item count or weightOne fixed amount for the entire orderTiered pricing
Which choice the buyer madeshipping_cost.shipping_rate and the metadata you wroteThe order's delivery selection

Sources

All sources accessed October 9, 2026.