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.
- Cart page (theme code): choice + required fields → saved as cart attributes.
- Checkout (Shopify Function): read attributes → hide the other options.
- 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 valuesCalculated UPS ratesandShip 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 hiddenattributes[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 choicecontains "collect" → hide every option except Ship Collect, and relabel Ship Collect with the carrier, account and service the customer typed. -
Shipping choicecontains "ups" → hide Ship Collect. - No
Shipping choiceattribute → 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.
-
Create the app in the Dev Dashboard (dev.shopify.com) with the scope
write_delivery_customizations(pluswrite_shippingif you want to adjust zone rates by API). Installing it on the store issues a client ID and client secret. -
Scaffold the Function locally with the Shopify CLI (version 4 needs Node 22): a
shopify.app.tomlcarrying the app's client ID and scopes, and anextensions/<name>/folder holdingshopify.extension.toml,src/run.graphql,src/run.jsandsrc/index.js. -
Deploy with
shopify app deploy. From a CI-style shell, create an app automation token in the Dev Dashboard, export it asSHOPIFY_CLI_PARTNERS_TOKEN, and add--allow-updates. The CLI compiles the JavaScript to WebAssembly and releases a new app version. -
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. -
Enable it with one Admin GraphQL call:
shopifyFunctionsreturns the Function's id, thendeliveryCustomizationCreate(functionId, title, enabled: true)activates it. (A 24-hour Admin API token comes fromPOST /admin/oauth/access_tokenwithgrant_type=client_credentials.) - 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.