Skip to content
heapbyte - A name of excellence

Engineering · 11 September 2026

Shipping rates that depend on the product, not the product record

A 2.4-metre stone slab and a 300mm shelf can be the same Shopify product with different options selected. One goes on a pallet, on a lorry with a tail lift, to a kerbside delivery someone has to be present for. The other goes in a van. Shopify's native shipping does not have a way to tell them apart: weight bands and rate tables both assume what it costs to deliver something can be worked out from the product record. For a configured product it cannot, because the thing that determines the size does not exist until the customer builds it.

8 min read
Written by the HeapByte engineering team

Shipping rates that depend on the product

The two APIs, and what each is for

This is where most of the wasted time happens, and the naming does not help.

Delivery Customization Functions modify the shipping options a customer already sees. There are three operations and they are exactly what they sound like: hide an option, rename an option, reorder the list. That is the entire surface. A Function can suppress "Express" for a hazardous item, rename "Standard" to "Kerbside delivery, 3–5 days", or float the cheapest option to the top.

It cannot create an option, and it cannot change a price.

The Carrier Service API is the other thing entirely. You register an endpoint; at checkout, Shopify posts the cart to it and displays whatever rates come back. It is the only route to a rate that is calculated rather than configured.

The mistake is reaching for the Function. It is the newer API, it runs on Shopify's infrastructure, it has no endpoint to host, and everything modern about Shopify extensibility points at Functions — so people build one, get it working, and only then discover it can reorder the wrong rate but not produce the right one. That is an easy mistake, and it costs a week.

What the Carrier Service API demands

It is the right answer sometimes. It is never the cheap answer, and the cost is operational rather than technical.

A plan requirement, with more routes than people expect. Advanced or higher qualifies. So does the Shopify plan on yearly billing, and so does paying a monthly fee for the carrier-calculated shipping feature on a lower plan. Worth knowing the reverse too: if a store changes plan and no longer qualifies, its carrier service association is deactivated. Nothing announces this to the customer.

An endpoint in the checkout path. Every rate request at checkout now depends on a service you operate. That is a different availability conversation than the one you were having about your app yesterday.

A deadline that tightens as you get busier. Shopify allows ten seconds under 1,500 requests a minute, five seconds between 1,500 and 3,000, and three seconds above that — with no retry. The response has to be right first time. This is the constraint that catches people, because it is backwards from how load usually feels: the busier the day, the less time you get. A rate service that calls a carrier's API live will pass every test you write and fail on the day it matters.

Caching that makes debugging feel haunted. Successful responses are cached for fifteen minutes, errors for thirty seconds. You will change your rate logic, reload checkout, see the old number, and lose an afternoon. Worth knowing precisely what counts as the same request: variant IDs, box weight and dimensions, quantities, carrier service ID, origin, destination, item weights. Line item properties are not named in that list. In practice two configurations that differ in size usually differ in weight, which is enough to miss the cache — but a configuration that changes shape without changing weight can be served a stale rate for a quarter of an hour.

A failure mode the customer never sees. If your endpoint times out or errors, Shopify shows backup rates: its own generic rates, so checkout still completes. The customer picks one. The order goes through. For an oversized item that rate is very unlikely to cover what the delivery actually costs. You find out from a banner on the order page in your admin, telling you a backup rate was used — on an order you may already have shipped.

That is the same shape as most expensive commerce bugs. Nothing failed loudly. Something just quietly cost you money.

Getting the configuration into the rate request

The good news is that the configuration can travel. Shopify's rate request includes each line's properties — the same line item properties that carry a configured product's price.

The bad news is what is not in it: variant dimensions. You get grams, and you do not get size. So if size drives the rate, size has to arrive in the properties or be looked up against the variant ID before the deadline expires.

Which means this sits directly on top of a problem we have written about separately: those properties are authored by the browser, and where price authority lives applies unchanged to size. A dimension arriving in a rate request is a claim, exactly as a price is. The practical consequence for shipping is narrower and more immediate — an item whose configuration is missing or unreadable should be declined rather than guessed at. Guessing is how a two-metre slab ships on a parcel rate.

What you actually calculate

None of the API documentation covers this part, and it is most of the work.

Chargeable weight is the greater of actual weight and volumetric weight, because carriers bill for space as well as mass. The divisor that converts volume to weight is a commercial term you negotiate — 5000 and 6000 are common — not a constant, which means it belongs in configuration and not in code.

The pallet threshold is usually a longest-side rule rather than a weight rule. A long thin item can be light and still need a lorry, which is exactly the case that breaks weight-band thinking.

Consolidation, because two items may or may not travel together, and whether they do changes the answer more than either item's own dimensions.

Constraints that are not prices at all. Some goods need two people. Some need a tail lift or a booked slot. Those are delivery methods that must be offered or withheld, and getting that wrong produces a delivery that cannot be completed rather than one that is merely mispriced. Building custom rate logic for a stone merchant, the conclusion the work kept arriving at was that shipping rules are business logic and belong in a testable layer — not in the theme, and not in a rate table pretending to be one.

js
/** Reads a configuration value out of Shopify's line item properties. */
export function readProperty(item, key) {
  const props = item.properties;
  if (!props || typeof props !== "object") return undefined;
  // Shopify sends properties as an object, and null when there are none.
  const value = props[key];
  return value === undefined || value === null || value === "" ? undefined : value;
}

/**
 * Chargeable weight is the greater of actual and volumetric weight. Carriers
 * bill for the space a thing occupies as well as its mass, which is the whole
 * reason a configured product cannot be priced from its weight alone.
 *
 * `divisor` is the carrier's volumetric factor in cm³ per kg — 5000 and 6000
 * are the common ones, and it is a commercial term, not a constant.
 */
export function chargeableWeightGrams(actualGrams, dimsCm, divisor) {
  if (!Number.isFinite(divisor) || divisor <= 0) {
    throw new Error(`volumetric divisor must be a positive number, got ${divisor}`);
  }
  if (!dimsCm) return actualGrams;
  const { lengthCm, widthCm, heightCm } = dimsCm;
  if (![lengthCm, widthCm, heightCm].every((n) => Number.isFinite(n) && n > 0)) {
    return actualGrams;
  }
  const volumetricGrams = Math.ceil(((lengthCm * widthCm * heightCm) / divisor) * 1000);
  return Math.max(actualGrams, volumetricGrams);
}

/** Longest single dimension across the cart, which is what decides the vehicle. */
function longestSideCm(parcels) {
  return parcels.reduce((max, p) => {
    if (!p.dimsCm) return max;
    return Math.max(max, p.dimsCm.lengthCm, p.dimsCm.widthCm, p.dimsCm.heightCm);
  }, 0);
}

/**
 * Builds the parcel list from the rate request, taking dimensions from the
 * line item properties the configurator wrote.
 *
 * An item whose configuration is missing is returned in `unconfigured` rather
 * than guessed at. Guessing is how a two-metre slab ships on a parcel rate.
 */
export function parcelsFrom(rateRequest, { divisor, dimensionKeys }) {
  const items = rateRequest?.rate?.items ?? [];
  const parcels = [];
  const unconfigured = [];

  for (const item of items) {
    if (item.requires_shipping === false) continue;

    const lengthCm = Number(readProperty(item, dimensionKeys.length));
    const widthCm = Number(readProperty(item, dimensionKeys.width));
    const heightCm = Number(readProperty(item, dimensionKeys.height));
    const configured = [lengthCm, widthCm, heightCm].every((n) => Number.isFinite(n) && n > 0);

    if (!configured) {
      unconfigured.push(item);
      continue;
    }

    const dimsCm = { lengthCm, widthCm, heightCm };
    for (let i = 0; i < item.quantity; i++) {
      parcels.push({
        dimsCm,
        chargeableGrams: chargeableWeightGrams(item.grams, dimsCm, divisor),
      });
    }
  }

  return { parcels, unconfigured };
}

/**
 * Rates for one cart.
 *
 * Returns Shopify's expected shape: total_price is an integer of subunits, and
 * an empty array means "this carrier service cannot rate this cart", which is a
 * successful response rather than an error. Returning an error instead would
 * put the customer on backup rates, which for oversized goods is usually a
 * price that does not cover the delivery.
 */
export function ratesFor(rateRequest, tariff) {
  const { parcels, unconfigured } = parcelsFrom(rateRequest, tariff);

  // Anything we cannot size, we decline to rate. Silence is safer than a guess.
  if (unconfigured.length > 0 || parcels.length === 0) return [];

  const totalGrams = parcels.reduce((sum, p) => sum + p.chargeableGrams, 0);
  const longest = longestSideCm(parcels);
  const currency = rateRequest.rate.currency;

  const rates = [];
  for (const band of tariff.bands) {
    if (longest > band.maxLongestSideCm) continue;
    if (totalGrams > band.maxChargeableGrams) continue;

    const subunits =
      band.baseSubunits + Math.ceil((totalGrams / 1000) * band.perKgSubunits);

    rates.push({
      service_name: band.name,
      description: band.description,
      service_code: band.code,
      currency,
      total_price: String(subunits),
    });
  }

  return rates;
}
Pure and synchronous, because the deadline shrinks to three seconds under load with no retry — do the I/O before the request arrives and make the request itself arithmetic. An unsized cart returns an empty array, which is the documented "cannot handle this request" response; returning an error instead would put the customer on backup rates.

The cheaper answers, and when they're right

Most merchants reading this should not build a rate service.

A flat rate with margin built in is correct far more often than it is given credit for. If your spread of real delivery costs is narrow enough that one number loses acceptably little on the worst case, that number costs nothing to run and never times out at checkout.

Quote-on-request for oversized items is not an automation failure. For a genuinely bespoke item it is the honest process — it puts a person in front of a delivery that needs one, and customers buying a two-metre slab are not surprised to be asked.

Rate tables by product group approximate well when the variation within each group is small. Three groups with three rates will beat a broken rate service every day.

The test is straightforward. Estimate the error band on the simplest option that could work. Compare it with the cost of building, hosting and maintaining a service that sits in your checkout path with a three-second deadline. If the error band is cheaper, build nothing — that is a real answer and it is frequently the right one.

When this needs an engineer

You need computed rates when the cost to deliver is genuinely a function of what the customer configured, when the spread is wide enough that approximating it loses real money, and when getting it wrong produces deliveries that cannot be completed rather than margins that are slightly off.

Those three together are a narrow case. When they hold, the work is a rate service built properly — inside the deadline, honest about what it cannot size, and reconciled against what the carrier actually invoiced. Across the configurator work the pattern was consistent: the calculation was never the hard part. Everything around it was.

Send us the store and the symptom.

Insights

Apply this to your store.

An audit turns the general principle into a specific list of changes, ordered by what actually pays back.