Shopify Delivery Customization: Hide and Rename Rates

A Shopify delivery customization function is a small WebAssembly module that runs at checkout and returns a list of operations against the shipping rates Shopify has already calculated. It can hide a rate, rename it or move it to a new position. It cannot create a rate, change a price or run anywhere except checkout. Everything below targets the 2026-07 version of the Delivery Customization Function API.

The usual brief sounds like this. Next-day delivery should vanish when the basket holds a sofa, or when the address is on Shetland. The function below handles both in about seventy lines of JavaScript, with the rules kept in a metafield the merchant can edit.

What can a Shopify delivery customization function change?

Three operations, and only three. The run target is cart.delivery-options.transform.run, and its result type, CartDeliveryOptionsTransformRunResult, holds an ordered operations array where each entry is one of:

  • deliveryOptionHide, taking a deliveryOptionHandle
  • deliveryOptionRename, taking a deliveryOptionHandle and a new title
  • deliveryOptionMove, taking a deliveryOptionHandle and an index within the delivery group

Those names are new as of 2025-07. Shopify standardised target and operation names in June 2025: the old target was purchase.delivery-customization.run and the operations were plain hide, move and rename. A function pinned to an earlier version keeps the old names, so tutorials written before mid-2025 use names that a 2025-07 or later function will not accept.

Two of those operations come with rules that the field names alone do not tell you.

Rename cannot remove the carrier

The carrier name is prepended to whatever title you return, and the API gives you no way to strip it. Shopify’s own example: UPS Standard can become UPS Standard Shipping, but never Standard Shipping. If the merchant wants carrier-neutral labels, that belongs in the carrier or rate configuration, not in a function.

Move cannot promote a pricier rate

The reference is blunt about reordering. If you move shipping options, you are prohibited from automatically selecting a higher-priced option by default; the cheapest shipping option must stay first. So whatever else deliveryOptionMove does in your function, it leaves the cheapest shipping rate in first position.

Writing the function

Scaffold it with the CLI and pick JavaScript or Rust when prompted:

shopify app generate extension --template delivery_customization --name delivery-customization

Shopify recommends Rust for performance, and the numbers in the reference back that up. The published “hide free delivery for perishable items” sample costs 47,049 instructions in Rust and 300,897 in JavaScript, against a limit of 11 million for carts up to 200 lines. JavaScript is fine for logic this size. It stops being fine when you loop over every line in a large B2B cart for every rate.

The input query decides what data the function sees. This one reads a JSON configuration metafield, the shipping address and a tag check on each line in each delivery group:

query RunInput {
  deliveryCustomization {
    metafield(namespace: "$app", key: "function-configuration") {
      jsonValue
    }
  }
  cart {
    deliveryGroups {
      deliveryAddress {
        countryCode
        zip
      }
      cartLines {
        merchandise {
          __typename
          ... on ProductVariant {
            product {
              hasAnyTag(tags: ["oversize"])
            }
          }
        }
      }
      deliveryOptions {
        handle
        title
        deliveryMethodType
      }
    }
  }
}

Input queries are capped at 3,000 bytes and a calculated cost of 30, and any metafield field costs 3, so there is room here but not unlimited room. Run shopify app function typegen after editing the query so the JSDoc types match.

The logic hides any express rate when a group contains an oversize product or ships to a remote postcode, and appends a note to the remaining rates when oversize goods are present:

// @ts-check

/**
 * @typedef {import("../generated/api").RunInput} RunInput
 * @typedef {import("../generated/api").CartDeliveryOptionsTransformRunResult} CartDeliveryOptionsTransformRunResult
 * @typedef {import("../generated/api").Operation} Operation
 */

/** @type {CartDeliveryOptionsTransformRunResult} */
const NO_CHANGES = { operations: [] };

/**
 * @param {RunInput} input
 * @returns {CartDeliveryOptionsTransformRunResult}
 */
export function run(input) {
  const config = input.deliveryCustomization.metafield?.jsonValue ?? {};
  /** @type {string[]} */
  const expressTitles = (config.expressTitles ?? []).map((t) => t.toLowerCase());
  /** @type {string[]} */
  const remotePostcodes = (config.remotePostcodes ?? []).map((p) => p.toUpperCase());
  const oversizeNote = config.oversizeNote ?? "";

  if (expressTitles.length === 0) return NO_CHANGES;

  /** @type {Operation[]} */
  const operations = [];

  for (const group of input.cart.deliveryGroups) {
    const hasOversize = group.cartLines.some(
      (line) =>
        line.merchandise.__typename === "ProductVariant" &&
        line.merchandise.product.hasAnyTag
    );
    const isRemote =
      group.deliveryAddress?.countryCode === "GB" &&
      matchesPostcode(group.deliveryAddress.zip ?? "", remotePostcodes);

    for (const option of group.deliveryOptions) {
      if (option.deliveryMethodType !== "SHIPPING") continue;

      const title = (option.title ?? "").toLowerCase();
      const isExpress = expressTitles.some((t) => title.includes(t));

      if (isExpress && (hasOversize || isRemote)) {
        operations.push({
          deliveryOptionHide: { deliveryOptionHandle: option.handle },
        });
      } else if (hasOversize && oversizeNote && option.title) {
        operations.push({
          deliveryOptionRename: {
            deliveryOptionHandle: option.handle,
            title: `${option.title} (${oversizeNote})`,
          },
        });
      }
    }
  }

  return { operations };
}

/**
 * UK inward codes are always three characters, so everything before them is
 * the outward code ("PA20"), and its leading letters are the area ("PA").
 * A config entry matches either, never a partial district.
 * @param {string} zip
 * @param {string[]} entries
 */
function matchesPostcode(zip, entries) {
  const compact = zip.replace(/\s+/g, "").toUpperCase();
  if (compact.length < 5) return false;
  const outward = compact.slice(0, -3);
  const area = outward.replace(/[0-9].*$/, "");
  return entries.some((e) => e === outward || e === area);
}

A few choices in there are deliberate.

It loops over every delivery group and reads group.cartLines, not cart.lines. A checkout can be split across shipping and pickup, and Shopify’s reference says outright not to assume one method per order. Checking the group’s own lines means a sofa collected in store does not suppress next-day on the cushions being posted.

It skips anything whose deliveryMethodType is not SHIPPING, so pickup and local delivery rates are never touched by accident.

It matches on title and echoes the handle back without ever hard-coding one. The reference describes a handle as a readable slug like standard-shipping, yet its own sample output shows a long hashed string. Treat the handle as an opaque ID you got from the input.

The postcode matcher compares whole outward codes or whole areas. A naive startsWith("PA2") would also catch PA20 to PA29, which is a different set of addresses.

Test it locally with shopify app function run --input=input.json --export=run before anything reaches a store.

Activating it on a store

Deploying the function does nothing on its own. It runs only once a delivery customization record exists, created through Admin GraphQL with the write_delivery_customizations scope. From 2025-10 you reference the function by the handle in shopify.extension.toml instead of querying for its ID, and DeliveryCustomizationInput accepts metafields on create, so configuration can ship in the same call:

mutation {
  deliveryCustomizationCreate(
    deliveryCustomization: {
      functionHandle: "delivery-customization"
      title: "Hide next day for oversize and remote postcodes"
      enabled: true
      metafields: [
        {
          namespace: "$app"
          key: "function-configuration"
          type: "json"
          value: "{\"expressTitles\":[\"next day\",\"express\"],\"remotePostcodes\":[\"HS\",\"ZE\",\"KW15\",\"PA20\"],\"oversizeNote\":\"2-person delivery\"}"
        }
      ]
    }
  ) {
    deliveryCustomization {
      id
    }
    userErrors {
      message
    }
  }
}

The $app namespace keeps other apps from overwriting the configuration. Shopify’s configuration tutorial starts with an open namespace for GraphiQL convenience and moves to the reserved prefix later; go straight to the reserved one.

Where delivery customizations do not run

The function runs in checkout, B2B checkout, draft order checkout and accelerated checkout buttons. It does not run on the cart page, the Storefront API, subscription renewals, order edits or orders created through the Admin API. POS support is partial: only when shipping to a store. So if a theme shows estimated shipping rates on the cart page, those estimates can still include rates your function hides at checkout.

PO Box logic has its own trap. The reference lists “hide delivery options for PO Box addresses” as a use case, then warns that it only works reliably for rates from carrier services or third-party shipping apps. With Shopify’s built-in rates, the address data your function needs may not be there.

A store can have at most 25 active delivery customization functions. And on plan eligibility: any plan can use a public App Store app that contains functions, but a custom app containing Functions needs Shopify Plus. For a bespoke build for one merchant, check their plan before you quote.

When to build one

Build a delivery customization when the rule depends on what is in the basket, who the buyer is, or where the parcel is going, and when the merchant’s shipping profiles have already sprouted duplicates to fake it. Keep the rules in the metafield so the merchant can change a postcode list without a deploy. Leave it alone if the real problem is the rate itself: a function can hide a rate, but it cannot price one, and a carrier service is the right tool there.

The payment side works the same way, with hide, rename and move operations on payment methods; our payment customization function write-up covers it. If the right answer is to stop checkout rather than hide a rate, see the cart validation function guide.

We build and maintain Shopify Functions, checkout customisations and the admin screens that configure them. If your shipping rules have outgrown your shipping profiles, our Shopify development team can help.

Need this built properly?

Whoooop Ltd has spent 15+ years building and maintaining web applications in TypeScript, React, Node.js and serverless — the same ground this post covers.

Get in touch