To add a fixed handling fee to a live carrier-calculated rate at Shopify checkout, without baking it into product prices or getting it taxed as a separate line, compute it inside the carrier-service endpoint and fold it into the returned rate. Here is how, plus how to keep charging it when the rate API is slow or down.
Carrier-calculated shipping works through the CarrierService API. During checkout Shopify sends a POST to your endpoint with the cart. Call a carrier-rate API for live prices, then return a rates array. That return value is the entire menu of shipping options the shopper sees.
The request payload carries the cart under rate: origin, destination, the line items with weights, the currency, and an order_totals object with the subtotal. Return each rate in a small, fixed shape. Shopify renders only service_name and description to the shopper, and total_price is a string of cents.
{
"rates": [
{
"service_name": "Standard Shipping & Handling",
"service_code": "standard",
"total_price": "889",
"currency": "USD",
"description": "$6.89 shipping + $2.00 handling — Allow 7-10 days"
}
]
}
Note the platform requirement: carrier-calculated rates need Shopify Plus, or the carrier-calculated shipping add-on that stores on the Advanced plan and up can buy annually. Without one of those, Shopify never calls your endpoint.
A separate handling line item would be taxed as its own charge. Fold a fixed handling fee into the shipping line instead, so it stays under the shipping charge.
On one high-volume store this is a flat $2.00 added to every order, plus optional per-SKU handling fees. Both are configurable, and the per-SKU default covers a single SKU. Keep the flat fee in a rule you can override:
// Flat handling added to every order, in cents. Configurable.
const DEFAULT_BLANKET_UPCHARGE_CENTS = 200; // $2.00
function totalHandlingCents(items, rules) {
const blanket = rules.blanketUpcharge?.enabled
? (rules.blanketUpcharge.amountCents || 0) : 0;
// Optional per-SKU fees, charged once per line, not times quantity.
let perSku = 0;
for (const item of items || []) {
const fee = rules.handlingFees?.fees?.[item.sku];
if (rules.handlingFees?.enabled && fee) perSku += fee;
}
return blanket + perSku;
}
Once you have the live carrier price for a service, add the handling on top and name it on the label so the shopper is not confused by an extra two dollars:
function splitShippingAndHandling(totalCents, handlingCents) {
// Clamp so shipping is never negative if a rate ever comes back tiny.
const handling = Math.max(0, Math.min(handlingCents, totalCents));
return { shippingCents: totalCents - handling, handlingCents: handling };
}
function label(baseName, baseDesc, totalCents, handlingCents) {
if (handlingCents <= 0) return { name: baseName, description: baseDesc };
const s = splitShippingAndHandling(totalCents, handlingCents);
return {
name: baseName + " & Handling",
// Lead with the split so it survives truncation at checkout.
description: "$" + (s.shippingCents/100).toFixed(2) + " shipping + $"
+ (s.handlingCents/100).toFixed(2) + " handling — " + baseDesc,
};
}
Lead the description with the split so it survives truncation at checkout. On that store, shoppers on free-shipping orders were seeing a bare "Standard Shipping $2.00", and the recorded complaint was that the $2.00 is handling, not shipping.
Quote on the weight of the packed box, not the bare items. Weight-tiered ground services price on total package weight. On the store above, an audit of 1,224 shipments found the packed weight ran heavier than item weight on 99% of them, with a median gap near 8 ounces. Add a fixed, configurable packing weight before the rate call:
const itemOunces = Math.max(1, Math.ceil(totalGrams * 0.035274));
const packingOunces = rules.packingWeight?.enabled
? (Number(rules.packingWeight.ounces) || 0) : 0; // e.g. 8
const weightOunces = itemOunces + packingOunces;
A live rate call can time out or error. If your endpoint returns an empty rates array, Shopify falls back to its own backup rate. Return a flat fallback rate of your own instead, one that still carries the handling fee:
const FALLBACK_RATES = { standard: 689, express: 1454, overnight: 4123 }; // cents
function buildFallbackRates(currency, { qualifiesForFreeShipping, handlingCents }) {
return ["standard", "express", "overnight"].map(tier => {
let priceCents = FALLBACK_RATES[tier] + handlingCents;
let desc = TIER_DESCRIPTIONS[tier];
if (tier === "standard" && qualifiesForFreeShipping) {
priceCents = handlingCents; // free shipping, handling still applies
desc = "Free shipping applied on orders $89+";
}
const l = label(TIER_NAMES[tier], desc, priceCents, handlingCents);
return { service_name: l.name, service_code: tier,
total_price: String(priceCents), currency, description: l.description };
});
}
Two details make this reliable. First, decide whether an order qualifies for free shipping from the request payload alone, never from the database or the rate API, and compute it before the code that can fail. On that store a 32-second database blip once threw inside the handler, and every qualifying order in that window was quoted full price instead of the handling-only amount, because the free-shipping check had depended on a lookup that failed.
Second, make the fallback fire on a real failure and not on ordinary tail latency. A carrier call that usually finishes in three seconds will occasionally take eight. Because Shopify caches a successful rate response for about fifteen minutes, two identical carts to the same address can then quote different prices, one live and one fallback. Derive the abort from a whole-request budget instead of a fixed number:
const REQUEST_DEADLINE_MS = 9000; // whole callback budget
const CAP_MS = 8500, FLOOR_MS = 2000;
const setupMs = Date.now() - requestStartedAt;
const budgetMs = Math.max(FLOOR_MS, Math.min(CAP_MS, REQUEST_DEADLINE_MS - setupMs));
// abort the carrier call at budgetMs; on abort or error, buildFallbackRates()
One more option: return a rate that quotes a single named service and nothing else, for shoppers who want one specific carrier. That option depends on a live comparison between two carriers, so the fallback does not offer it.
Tell me what you need and I'll tell you what it takes.
Or run a free teardown of your store first.
Tell me what you're working on. I'll reply within a business day.