Controlling Shopify Shipping Choices Without Plus

The problem

Our checkout offers carrier-calculated UPS rates plus a $0 "Ship Collect" option for customers who ship on their own carrier account (often required by our .gov/.mil customers). Shopify always lists rates cheapest-first, pre-selects the cheapest one, and labels any $0 rate FREE. So Ship Collect was the default for everyone, and many customers picked it without supplying a carrier, account number or service level.

On a non-Plus plan Shopify gives you no way to fix this inside checkout:

  • No "default shipping method" setting; the cheapest rate always wins.
  • The FREE label on a $0 rate cannot be changed.
  • Required input fields on the shipping step need Checkout UI extensions, which are Shopify Plus only.

The cart page, however, is ordinary theme code you fully control, and Shopify Functions (available on all plans) can hide delivery options at checkout. Those two facts are the whole workaround.

The workaround in one paragraph

Move the decision out of checkout and onto the cart page. The customer picks UPS (prepaid) or Ship Collect on the cart page; choosing Ship Collect reveals required carrier, account-number and service-level fields that our own JavaScript validates before the Check Out button works. The answers are saved as ordinary Shopify cart attributes, which travel with the order. At checkout a tiny Shopify Function reads those attributes and hides the options the customer did not choose: UPS customers never see Ship Collect (so no FREE option), and Collect customers see only Ship Collect. Nothing on the shipping step is customized, so no Plus features are needed.

  1. Cart page (theme code): choice + required fields → saved as cart attributes.
  2. Checkout (Shopify Function): read attributes → hide the other options.
  3. Order (admin): the attributes appear under Additional details for the shipping desk.

Step 1 — the cart page

Everything the customer decides is captured in templates/cart.liquid as form inputs named attributes[...]. Shopify stores any such input as a cart attribute when the form posts, and copies cart attributes onto the order.

  • Two radio buttons, both named attributes[Shipping choice], with values Calculated UPS rates and Ship Collect (my carrier account).
  • When Ship Collect is selected, a block appears with a carrier select (UPS / FedEx / DHL / Other), an account number text field (attributes[Collect account #]), and a service level select whose options change with the carrier (attributes[Collect service level]). The chosen carrier is written to a hidden attributes[Collect carrier] input.
  • Plain JavaScript (no jQuery) handles the show/hide, fills the service list per carrier, and checks the account number against each carrier's usual format (UPS 6 letters/digits, FedEx 9 digits, DHL 8–10 digits). A format mismatch shows a yellow warning but does not block; a blank field does.
  • The validation runs on the Check Out button's click. If anything required is missing, the click is cancelled and a red message appears, so the customer never reaches checkout without the details. Choosing UPS blanks the Collect fields so stale values never reach an order.

Two details that matter: bind the click handler after the DOM has loaded (the Check Out button sits below the block in the template, so a script that runs during parsing finds nothing), and keep the form's existing attribute fields (purchase order number, partial-ship preference) untouched.

We edited a duplicate of the live theme and tested it with Shopify's theme preview, so customers saw nothing until it was published.

Step 2 — the Shopify Function

A delivery customization Function runs every time checkout builds the shipping-option list. It receives the cart (including attributes) and the options, and returns operations such as hide or rename. Ours is about 30 lines of JavaScript with two rules and two safety limits:

  • Shipping choice contains "collect" → hide every option except Ship Collect, and relabel Ship Collect with the carrier, account and service the customer typed.
  • Shipping choice contains "ups" → hide Ship Collect.
  • No Shipping choice attribute → return no operations. Carts from the old cart page behave exactly as before, so the Function can be enabled before the new theme goes live.
  • Never hide every option in a delivery group; if the rules would, leave that group alone.

The input query asks only for what the rules need:

query RunInput {
  cart {
    choice:  attribute(key: "Shipping choice")       { value }
    carrier: attribute(key: "Collect carrier")       { value }
    account: attribute(key: "Collect account #")     { value }
    service: attribute(key: "Collect service level") { value }
    deliveryGroups { deliveryOptions { handle title } }
  }
}

The core of the Function (target cart.delivery-options.transform.run, API version 2026-01):

export function run(input) {
  const choice = (input.cart.choice?.value || "").toLowerCase();
  const mode = choice.includes("collect") ? "collect" : choice.includes("ups") ? "ups" : null;
  if (!mode) return { operations: [] };

  const isCollect = (o) => /ship\s*collect/i.test(o.title || "");
  const operations = [];
  for (const group of input.cart.deliveryGroups) {
    const options = group.deliveryOptions;
    const toHide = options.filter((o) => (mode === "collect" ? !isCollect(o) : isCollect(o)));
    if (toHide.length === 0 || toHide.length === options.length) continue;
    for (const o of toHide) operations.push({ deliveryOptionHide: { deliveryOptionHandle: o.handle } });
  }
  return { operations };
}

Shopify's rules forbid reordering options to push a pricier one on top; hiding the options the customer did not choose is allowed.

Step 3 — deploying and switching it on

A Function has to belong to an app, but the app needs no server and no UI; ours is a bare custom app created in the Shopify Dev Dashboard and installed only on our store.

  1. Create the app in the Dev Dashboard (dev.shopify.com) with the scope write_delivery_customizations (plus write_shipping if you want to adjust zone rates by API). Installing it on the store issues a client ID and client secret.
  2. Scaffold the Function locally with the Shopify CLI (version 4 needs Node 22): a shopify.app.toml carrying the app's client ID and scopes, and an extensions/<name>/ folder holding shopify.extension.toml, src/run.graphql, src/run.js and src/index.js.
  3. Deploy with shopify app deploy. From a CI-style shell, create an app automation token in the Dev Dashboard, export it as SHOPIFY_CLI_PARTNERS_TOKEN, and add --allow-updates. The CLI compiles the JavaScript to WebAssembly and releases a new app version.
  4. Re-authorize the store whenever scopes change: open https://admin.shopify.com/store/<store>/oauth/install?client_id=<client id> and accept. Shopify does not prompt for this on its own.
  5. Enable it with one Admin GraphQL call: shopifyFunctions returns the Function's id, then deliveryCustomizationCreate(functionId, title, enabled: true) activates it. (A 24-hour Admin API token comes from POST /admin/oauth/access_token with grant_type=client_credentials.)
  6. Publish the theme once the preview checks out: UPS-only options for a UPS cart, Collect-only for a Collect cart, and a blocked Check Out when a Collect field is empty.

Result and limits

UPS is now the effective default: a customer who does not actively choose Ship Collect never sees a $0 option, and a customer who does has already supplied a carrier, account number and service level. The whole thing cost a theme edit and a 30-line Function on our existing plan; no app subscription and no Shopify Plus.

Safety notes that made the rollout painless:

  • The Function ignores carts without the choice attribute, so it was enabled first and the theme published second with no window where live customers were affected.
  • The theme change was made on a duplicate and previewed before publishing.
  • If the carrier account is ever reconnected, Shopify assigns it a new carrier-service id and silently drops its rate lines from every zone; keep a copy of the zone configuration so it can be recreated.

What this approach cannot do: it cannot add fields to the checkout pages themselves, cannot remove Shopify's FREE label from a $0 option that is still shown, and cannot reorder options to put a more expensive one first. Each of those remains a Plus-only (or simply unavailable) capability; this method works around them rather than through them.

Disclaimer

This page was written by Claude Code (an AI assistant) working with Pulse Research Lab, and it may contain errors or describe behavior that Shopify has since changed. It is provided as-is, for information only, with no warranty of any kind.

You are welcome to copy and adapt anything here, including the code, at your own risk. Test it on a duplicate theme and a development store before using it on a live store. Pulse Research Lab accepts no liability for any loss, downtime, lost orders or other harm arising from your use of this material. Shopify, UPS, FedEx and DHL are trademarks of their respective owners and are not affiliated with this page.