Shopify Custom Shipping Rates: Building a Carrier Service for Niche Logistics
Shopify custom shipping rates for oversized, hazmat and refrigerated goods need a Carrier Service. The build, the callback, the 10s budget and fallback.
You need a Shopify Carrier Service when the correct shipping price depends on logic that Shopify's built-in rate tables cannot express. Native Shopify shipping prices an order in one of three ways: a flat rate per zone, a rate looked up against a weight bracket, or a rate looked up against an order-value bracket. That covers the large majority of stores, and if your shipping is "five pounds under two kilograms, eight pounds over," you do not need any of what follows. You need a Carrier Service the moment the right number is not a lookup but a calculation, and the calculation depends on something Shopify never sees in those tables: the physical dimensions of the box, a dangerous-goods surcharge, a refrigerated handling fee, a remote-postcode uplift, or a live quote pulled from a carrier's own API.
A Carrier Service is, in plain terms, a callback endpoint that you register with Shopify and that Shopify calls during checkout. When a customer reaches the shipping step, Shopify sends a POST request to your endpoint containing the cart and the destination address, and your endpoint replies with a list of shipping rates. It is the supported way to put your own shipping logic inside Shopify's checkout instead of fighting the rate tables. This article walks the whole build: when native shipping runs out of road, how to register the service, what the request and response look like, the timeout budget that catches people in production, and the fallback pattern that keeps checkout alive when your endpoint has a bad day. It assumes you ship something awkward, because that is the only reason to be reading this.
What native Shopify shipping can and cannot do
It is worth being precise about the boundary, because a lot of stores reach for a Carrier Service when a conditional native rate would have done the job, and a lot of others stay on native rates long after they have stopped being correct.
Native Shopify shipping is a lookup table. You define shipping zones, which are groups of countries or regions, and inside each zone you define rates. A rate can be flat, or it can be conditioned on weight, or it can be conditioned on order price. You can have several conditional rates in one zone, so "free over fifty pounds" and "flat rate under fifty pounds" is native and easy. You can set per-product shipping overrides and you can flag products as not requiring shipping at all. For a store selling boxed goods of broadly predictable size, this is genuinely all you need, and it costs nothing and never goes down.
The table breaks when the price stops being a function of weight or order value alone. A pillow weighs almost nothing and fills a large box, and parcel carriers bill it by dimensional weight, the volumetric figure that Shopify's gram-based brackets cannot compute. A pallet of tiles is freight, and freight is priced by dimensions and destination class, not by a weight bracket. A case of aerosols carries a dangerous-goods surcharge that applies per shipment and only to certain services and destinations. A box of chilled food needs an insulated-packaging and cold-chain fee that scales with how long the parcel is in transit. A delivery to the Scottish Highlands or a remote US ZIP carries a carrier surcharge finer-grained than any shipping zone you can draw. In every one of those cases, the native table will quote a number, the number will be wrong, and wrong shipping at checkout means either you absorb the loss on every order or you watch carts abandon. That is the signal that your shipping logic has outgrown a lookup and needs to become a function.
Registering the carrier service
A Carrier Service is created through the Shopify Admin API. You are not configuring it in the admin UI; you are calling an endpoint, usually from the app or backend service that will also host the callback. With the GraphQL Admin API the mutation is carrierServiceCreate, and the equivalent REST resource is carrier_service. The shape is small. You give the service a name, you provide a callback URL, and you decide whether it is active and whether it is in test mode.
{
"carrier_service": {
"name": "WitsCode Freight & Hazmat Rates",
"callback_url": "https://rates.example.com/shopify/carrier",
"service_discovery": true,
"active": true
}
}
The callback_url is the part that matters. It must be a publicly reachable HTTPS endpoint that you own and control, because Shopify will POST to it from its own servers during checkout. The service_discovery flag, when true, tells Shopify that this service returns rates dynamically rather than from a static list, which is what you want for any real calculation. Once the service is created and active, it appears in the store's shipping settings as a rate provider, and the merchant or you can enable it within each shipping zone where those rates should apply.
Two practical notes before you build the endpoint. Set the service to test mode while you are developing, so the callback can be exercised from the shipping settings page without affecting a live checkout. And a store can hold more than one carrier service, so it is fine to register a focused service that only handles, say, hazmat freight, and leave ordinary parcels on native rates. You do not have to take over all shipping just because you took over some of it.
The callback: the request Shopify sends you
When a customer reaches the shipping step at checkout, Shopify sends a POST request to your callback URL with a JSON body. The body has a single top-level rate object, and that object is everything you need to compute a price. It contains the origin address, the destination address, the currency and locale, and an items array with one entry per line in the cart.
{
"rate": {
"origin": {
"country": "GB", "postal_code": "M1 1AA",
"province": "", "city": "Manchester"
},
"destination": {
"country": "GB", "postal_code": "IV27 4XX",
"province": "", "city": "Lairg"
},
"items": [
{
"name": "Insulated Crate, 40L",
"sku": "FRZ-40L",
"quantity": 1,
"grams": 1200,
"price": 8900,
"requires_shipping": true,
"properties": { "hazmat_class": "9", "temp": "frozen" }
}
],
"currency": "GBP",
"locale": "en-GB"
}
}
Each item gives you grams, price in subunits, quantity, and any custom line-item properties, which is where a great deal of niche shipping logic actually lives. If your products are tagged with a hazard class, a temperature requirement, or a packed dimension, you carry that data through to the cart as a line-item property or read it from your own product database by SKU, and now the callback has what it needs. The destination postcode is the other half: for remote-area surcharges you match it against your own surcharge list, and for freight you take the dimensions, compute a dimensional weight, classify the consignment, and price it against the destination region. The callback is a normal HTTP request handler in whatever stack you like, and the only Shopify-specific part is parsing this rate object and returning the right response.
The callback: the rates you send back
Your endpoint must reply with HTTP 200 and a JSON body containing a rates array. Each entry in the array is one shipping option the customer will see at checkout.
{
"rates": [
{
"service_name": "Frozen Freight (Highlands surcharge applied)",
"service_code": "FRZ_FREIGHT_HL",
"total_price": "4750",
"currency": "GBP",
"description": "Insulated, 2-day cold-chain. Includes remote-area surcharge.",
"min_delivery_date": "2026-05-22 09:00:00 +0100",
"max_delivery_date": "2026-05-23 17:00:00 +0100"
}
]
}
The two fields that bite people are total_price and currency. The price is a string, and it is in the smallest unit of the currency, so four thousand seven hundred and fifty here means forty-seven pounds fifty, not four thousand pounds. An off-by-one-hundred error in this field is the single most common Carrier Service bug, and it will either undercharge every customer or quote them an absurd figure that kills the sale. The service_name and description are customer-facing, so use them: a customer who sees "remote-area surcharge applied" understands why the number is what it is, and an unexplained high rate just looks like a mistake. The optional delivery date fields drive the estimated delivery shown at checkout, which is genuinely useful for cold-chain goods where transit time is part of the product promise.
You can return more than one rate, and you usually should, because offering a faster paid option and a slower cheaper one is exactly the kind of choice native tables make hard and a callback makes trivial. You can also return an empty rates array, and that is a valid, meaningful response: it tells Shopify there are no shipping options for this cart and destination. Sometimes that is correct, because you genuinely cannot ship dangerous goods to that country. But an empty array blocks checkout for that customer, so it has to be a decision you made on purpose, never the accidental output of a code path you forgot to handle.
The 10 second budget and the fallback that keeps checkout alive
Here is the part the documentation states quietly and production teaches loudly. Your callback runs inside a strict time budget. Shopify will wait only so long for your endpoint to respond, and the practical ceiling to design against is ten seconds, though you should be aiming to answer in well under one. Shopify also caches your rate responses, keyed on the request, so an identical cart going to an identical address may not hit your endpoint every time. That caching is a mild help and a real hazard, because it means a bug or an outage can be masked in testing and then surface for a customer whose specific cart was never cached.
What happens when your endpoint is slow or down is the thing to plan for before you launch. If the callback times out or returns an error, Shopify falls back to the last set of rates it successfully cached for that scenario, and if there is no cached set, the customer sees no shipping options for that destination and cannot complete the order. A deploy that takes your callback offline for ninety seconds during a sale can therefore quietly stop a slice of your checkout, and you will not get an error in your own logs because the failure is a customer abandoning, not an exception.
The fix is a fallback discipline inside the callback itself. The endpoint should never be allowed to fully fail. Wrap the entire handler so that any unhandled error, any timeout, any malformed input, is caught, and on catch you return a safe, deliberately generous flat rate rather than an empty array or an error status. A returned rate, even a blunt "Standard shipping, 19.99" that is higher than your true cost, keeps the customer moving through checkout, and you would far rather slightly overcharge a handful of orders than lose them. If your callback calls a carrier's live rating API, put a strict internal timeout of three or four seconds on that outbound call, and if the carrier does not answer in time, fall back to your own computed estimate so that your response to Shopify still lands inside the window. The rule is simple: your endpoint always returns at least one usable rate, fast, no matter what went wrong upstream. Treat the callback as a stateless, side-effect-free function that can be hit repeatedly and concurrently, because it will be.
If this is the point where building it yourself starts to feel like more backend than you bargained for, that is the honest reaction, and it is exactly the kind of work WitsCode takes on as a focused build. A Carrier Service is a small service, but it is a real one, with a deploy story, a monitoring story, and a fallback story, and it sits directly in the path of revenue.
The Plus requirement and other honest constraints
There is a plan-level gate to check before you write a line of code. Carrier-calculated and real-time third-party shipping rates, the capability a custom Carrier Service runs on, is not switched on by default for every Shopify plan. It is included on Shopify Plus, it can be added to some plans as a paid carrier-calculated shipping feature, and on certain plans it is included if you pay annually rather than monthly. The detail varies and Shopify changes it, so the practical instruction is this: confirm with the merchant's actual plan and billing settings that third-party calculated rates are available before you build, because a Carrier Service registered against a plan that does not expose the capability will simply not show its rates at checkout.
Two smaller constraints are worth knowing. The caching behaviour means your test coverage has to be deliberate, because a casual click-through may hit a cached response rather than your live code, so vary your test carts to actually exercise the endpoint. And the callback only ever sees the cart and the address, so any logic that depends on a product's dimensions or hazard class needs that data reliably present, either as line-item properties or as something your endpoint looks up by SKU. The callback is only as good as the product data feeding it.
Where WitsCode comes in
WitsCode is a small web development agency, and the through-line of everything we do is the last mile: the part of a build that is genuinely a developer's job, sitting just past where a store owner or an AI-scaffolded project naturally stops. Shopify shipping is a near-perfect example of that line. The native rate tables are easy, the theme is easy, the product upload is easy, and then the store ships something oversized or hazardous or refrigerated, and suddenly the correct shipping price is a backend service with a callback contract, a time budget, and a fallback that has to be right because it stands between a customer and a completed order.
That service is a clean, contained build, and it is one we are glad to do. We will check the plan gate first so there are no surprises, register the carrier service, build and deploy the callback endpoint with your real freight, hazmat, cold-chain or remote-area logic inside it, give it the strict internal timeouts and the always-return-a-rate fallback that keep checkout alive through a slow carrier or a bad deploy, and test it against carts that actually exercise the awkward cases rather than the happy path. If your store quotes the wrong shipping today, or quotes nothing at all to the destinations that matter, that is the conversation to have with us.
This is the hundredth and final article in this series, so it is a fitting one to end on, because it is the whole WitsCode idea in one build. The easy ninety percent gets you a live store. The last ten percent, the carrier service, the security pass, the performance work, the conversion fixes, is where a real developer earns the fee, and it is the ten percent that decides whether the store actually makes money. That last mile is what we do.
Get weekly field notes.
Practical writing on shipping products, straight to your inbox. No spam.
Need help with this?
WordPress Development
We design and build web apps, MVPs, and SaaS products. Talk to us about what you are working on.
Talk to usWant to discuss shopify niche topics (gap-fill) for your business?
Start a project and we'll talk through where you are, what's working, and the highest-leverage moves for the next 90 days.