The Shopify Carrier Service API lets your app return live shipping rates at checkout. You register a public callback URL with the carrierServiceCreate mutation (scope write_shipping), Shopify POSTs the cart, origin and destination to it, and you answer with a rates array priced in subunits. The store needs third-party carrier-calculated shipping enabled, and every answer has to land inside a 10 second budget or the buyer sees backup rates instead.
The detail is in who can install it, what the payload looks like, and what Shopify does when your endpoint is slow.
Which stores can install a carrier service?
Not all of them, so check before you quote for the work. The CarrierService reference says the create call fails unless the store is on Advanced or higher, is on the Shopify plan with yearly billing (or has paid the monthly add-on for the feature), or is a development store. Shopify’s help page lists the same tiers from the merchant’s side: included on Advanced and Plus, an add-on for Grow.
If the merchant later downgrades, the association is deactivated. Your app keeps working everywhere else; the rates stop appearing. A failed carrierServiceCreate during install should produce a plain message about the plan, because that is the usual cause.
How do you register the callback with carrierServiceCreate?
One mutation, four required fields. This is against Admin API 2026-10:
mutation RegisterCarrier($input: DeliveryCarrierServiceCreateInput!) {
carrierServiceCreate(input: $input) {
carrierService {
id
name
callbackUrl
active
supportsServiceDiscovery
}
userErrors {
field
message
}
}
}
{
"input": {
"name": "Acme Pallet Freight",
"callbackUrl": "https://rates.example.com/shopify/rates/8f3c2a",
"active": true,
"supportsServiceDiscovery": true
}
}
name is what merchants and buyers see as the carrier. active must be true or the service is registered but never asked for rates. supportsServiceDiscovery lets the merchant send dummy data from the admin to preview example rates, which is how they will try your integration without placing an order, so leave it on.
The callback URL is documented as “a public URL” and nothing more. The reference describes no signature header on the rate request, so treat the endpoint as anonymous: put an unguessable per-shop segment in the path when you register it, never let the handler write anything, and store the returned gid://shopify/DeliveryCarrierService/... against the shop so you can call carrierServiceUpdate or carrierServiceDelete later. Only the app that created a carrier service can update it.
If you built this on REST before, the mapping is direct. The REST CarrierService resource has been legacy since 1 October 2024, new public apps have had to be GraphQL-only since 1 April 2025, and carrierServiceCreate, carrierServices, carrierService, carrierServiceUpdate and carrierServiceDelete replace the five endpoints one for one.
What does Shopify send, and what must come back?
Shopify POSTs JSON with a single rate object. Inside it: origin and destination addresses, an items array, currency, locale, and in the current reference an order_totals block (subtotal_price, total_price, discount_amount as strings in subunits) plus a customer block with id and tags. Each item carries name, sku, quantity, grams, price in subunits (the example’s 1999), vendor, requires_shipping, taxable, fulfillment_service, properties, product_id and variant_id.
Two caveats on the address. For API-created carrier services only address1, address2, city, zip, province and country are populated; address3, fax, address_type and company_name stay null. The buyer’s name, phone and email are also null in the documented example, so if a rate depends on who is buying, the customer block’s id and tags are what you have to work with.
The response is a rates array. Five fields are required: service_name, description, service_code, currency and total_price. total_price is in subunits, so 500 for 5.00 GBP and 100000 for 1000 JPY. Optional fields are phone_required, min_delivery_date, max_delivery_date and a metafields array, which the example uses with namespace carrier_service_metadata to attach a tracking URL to a rate.
service_code has to be stable for the same option across requests. The reference is explicit: no session IDs, timestamps or per-request identifiers in it. Anything dynamic goes in metafields.
Here is a handler that bands by total weight. It uses the Fetch API Request and Response types, so it runs on any runtime that has them, Cloudflare Workers included:
interface RateItem {
grams: number;
quantity: number;
requires_shipping: boolean;
}
interface RateRequest {
rate: {
destination: { country: string; zip: string };
items: RateItem[];
currency: string;
};
}
interface Rate {
service_name: string;
service_code: string;
description: string;
currency: string;
total_price: string;
min_delivery_date?: string;
max_delivery_date?: string;
}
/** Prices a UK pallet delivery by total shipped weight. */
export async function handleRates(request: Request): Promise<Response> {
const body = (await request.json()) as RateRequest;
const { destination, items, currency } = body.rate;
// Anything we cannot price: 404 forces Shopify's backup rates.
if (destination.country !== "GB") {
return new Response(null, { status: 404 });
}
const grams = items
.filter((item) => item.requires_shipping)
.reduce((sum, item) => sum + item.grams * item.quantity, 0);
const pencePerBand = grams <= 50_000 ? 4500 : grams <= 250_000 ? 7900 : 12900;
const rates: Rate[] = [
{
service_name: "Pallet Delivery",
service_code: "pallet_standard",
description: "Kerbside, 3 to 5 working days",
currency,
total_price: String(pencePerBand),
},
];
return Response.json({ rates });
}
Returning an empty rates array with a 2xx is the documented way to say this carrier has nothing for this cart, and it does not trigger backup rates. The 404 above is the other path: it asks Shopify to fall back.
What happens when the callback is slow or fails?
Shopify does not try again. The reference states there is no retry mechanism; the response has to succeed first time, within the read timeout. That timeout is per shop and app, and it shrinks as traffic grows: 10 seconds under 1,500 requests a minute, 5 seconds between 1,500 and 3,000, 3 seconds above that. The docs call these upper limits that may change outside the normal versioning cadence. A callback that itself calls a courier API and waits 8 seconds will work in testing and fail on a busy Saturday.
Redirects are followed only within the callback’s own domain. A 30x to a different host counts as a failure.
When your endpoint times out, errors, or returns a 40x or 50x, Shopify shows backup rates. These are on by default for every zone that contains an app or carrier rate, and they appear only when every calculated rate for the shipment has failed and no other eligible rate exists. The buyer sees a Shopify-estimated price the merchant never set. A flat rate in the same zone changes that failure mode, because flat rates always display when eligible: a broken callback then means one option missing instead of an unexpected number.
Caching cuts how often you are called. Shopify stores a successful response for 15 minutes keyed on variant IDs, quantities, item weights and signatures, the default shipping box, the carrier service ID and both addresses. An error is cached for 30 seconds. Separately, checkout reuses computed delivery options until cart lines or the delivery address change, so a buyer editing their email or a discount code does not hit you again. The consequence is that a rate which depends on something outside those keys, say a cut-off time for next-day delivery, can be served stale for up to 15 minutes, so price the whole window rather than the current minute.
Carrier service or delivery customization function?
They solve different problems. A delivery customization function runs inside Shopify with no network call and can hide, rename and reorder the delivery options that already exist. It cannot create an option or change a price; the output schema has exactly those three operations. A carrier service is the only app-side mechanism that produces a new rate with a new price.
So if the merchant’s shipping table already has the right prices and the ask is “hide express for the Highlands” or “put Click and Collect first”, write the function; we covered that in our post on hiding and renaming delivery rates. If the ask is “quote what the pallet firm charges for this postcode and weight”, you need a carrier service and a store that is allowed to have one. The two combine: a function can rename or reorder the rates your carrier service returned, and unlike your callback it runs on every checkout update.
A storefront can also fetch these rates ahead of checkout through the cart’s delivery groups, which is how a cart-page estimator works; see shipping rates before checkout.
What I would do
Check the plan first, in writing, before anything is built. Then answer from your own data or a cache rather than a live courier call, and treat 2 seconds as the budget rather than 10. Keep service_code as a fixed handle per product line, and give every rate a description the merchant would be happy to see printed on a packing slip. Add a flat-rate fallback in the same zone so an outage degrades to a missing option rather than a surprise price.
I would not build a carrier service to implement rules the merchant could express in Shopify’s own weight and price conditions, and I would not build one for a store on the base plan unless the monthly add-on has been costed against what the custom rates save.
Whoooop builds Shopify apps that sit behind checkout, from carrier services and delivery customisation functions to the admin tooling merchants use to manage them. If your shipping rules have outgrown the settings page, our Shopify development work starts with a plan check and a rate model you can read.