How to Build Custom Shipping Rates With a ShopsWired Plugin
Before you write anything: flat rate, weight-based, free shipping and local pickup are built in, each with rate tiers by order amount or weight and zone restrictions by country and state. Most shipping rules people reach for a plugin to express are a tier table. When yours genuinely isn't — a live carrier quote that needs the ZIP, a surcharge that depends on what's in the cart, a freight rate you settle after the order — it's one hook: shipping.calculate. You export it, you replace ctx.data.options, and checkout shows your rates instead of the store's.
The hook itself is four lines. What's worth reading is the rest: the array that silently ignores you, the ZIP that isn't there yet, and what happens to checkout when your carrier's API has a bad afternoon.
First, try not to write this plugin
A rate tier inside a built-in method carries a price and the bounds a cart has to fall inside — order from/to, weight from/to. Leave a bound empty and it doesn't constrain. Tiers are checked top to bottom and the first match wins, so a narrow tier above a catch-all reads exactly like the rule you'd say out loud: $0 once the order reaches $100, otherwise $9.95. A cart that matches no tier means the method simply isn't offered — a small-parcel rate capped at $200 drops off a $300 order rather than quoting a fallback, which is how you leave room for a freight method to take it.
Tiers on a free method behave as a condition rather than a price, and the threshold is measured on the merchandise subtotal before any discount, so a coupon never retracts free shipping at the last step. Zones restrict a method to specific countries and states; leave them empty for worldwide.
That configuration has one property your plugin will never have: it cannot time out, and it cannot be down. Every reason to prefer code over tiers should survive that comparison.
What the hook hands you
Hook handlers take ctx as their first argument, always. For shipping.calculate, ctx.data is:
{
cart: [ { product_id, shop_id, name, price, qty, image?, set?, plan? } ],
weight: 12.5, // total cart weight, in lbs
address: { country, state, zip, city }, // the destination
options: [ { id, name, price, type, price_note?, pickup? } ]
}Four things to internalise before you write a number:
- Money is integer cents, everywhere in this API. A $14.95 rate is
1495. Get this wrong and you are off by two orders of magnitude in the direction nobody notices until a refund. weightis in lbs — the same unit as a product'sweightfield, already summed for the cart.optionsarrives pre-filled with whatever the store's own methods rated for this cart and address. You are filtering and extending a list, not starting from nothing.typeis one offlat,weight,free,pickup. A fixed amount you computed is aflatoption.pickupis the one with extra behaviour — it routes into the Ship/Pickup switch at checkout and itspickupdetails decide where the order is taxed.
If the shopper is signed in you also get ctx.customer — a read-only snapshot including price_level, so a trade account can be rated differently without a separate customer lookup. It's absent for guests, so guard it.
The simplest useful version: filter
Half of the real-world cases are subtractive. Here's "our small-parcel carrier won't take anything over 50 lbs outside the lower 48", which is a rule tiers can't express because it mixes weight with destination:
// shipping.js
const REMOTE = ['AK', 'HI', 'PR'];
module.exports['shipping.calculate'] = (ctx) => {
const d = ctx.data;
if (d.weight <= 50) return;
if (d.address.country !== 'US') return;
if (!REMOTE.includes(d.address.state)) return;
const kept = d.options.filter((o) => o.id !== 'small-parcel');
if (kept.length) d.options = kept; // never leave checkout with nothing
};The if (kept.length) guard isn't defensive padding. Replacing options with an empty array does not clear the built-in methods — an empty array is ignored — so a filter that removes everything leaves the shopper looking at the original list, not at your intent. Decide what the last-resort option is and keep it.
The trap: push is silently dropped
For the *.calculate hooks the engine reads back only your modifications to ctx.data — returning a value does nothing. And for options specifically, you must reassign the whole array:
// Dropped without a word:
ctx.data.options.push(myRate);
// Any of these work:
ctx.data.options = ctx.data.options.concat([myRate]);
ctx.data.options = [...ctx.data.options, myRate];
ctx.data.options = ctx.data.options.filter(keep);
// Editing a field of an existing option in place DOES take effect:
ctx.data.options[0].price = 1495;Only appending via push is the trap — and it's the usual reason a shipping plugin "does nothing" while its logs show it ran and computed the right number.
A live carrier rate, done defensively
ctx.data.address carries zip and city, not just the country — the initial checkout render rates the customer's saved address, and the default theme re-POSTs the address as the shopper fills it in, so a ZIP-dependent carrier really can rate here. Two things will still go wrong: the ZIP can be blank early in the flow (or on a custom theme that doesn't send it), and the carrier can be slow or down.
// shipping.js — quote a carrier, cache it, and always leave a way to check out.
const FALLBACK = { id: 'std-flat', name: 'Standard shipping', price: 1495, type: 'flat' };
module.exports['shipping.calculate'] = (ctx) => {
const d = ctx.data;
const zip = (d.address.zip || '').trim();
// No ZIP yet: offer a placeholder so checkout stays possible.
if (!zip) {
d.options = d.options.concat([{
id: 'freight-quote',
name: 'Freight (quoted after order)',
price: 0,
type: 'flat',
price_note: '—' // shown instead of the price, so 0 isn't read as "Free"
}]);
return;
}
const key = `rate:${zip}:${Math.ceil(d.weight)}`;
let rate = sw.cache.get(key);
if (!rate) {
try {
const res = fetch('https://api.example-carrier.com/v1/rates', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
// Write-only secret, expanded at the HTTP boundary.
Authorization: 'Bearer {secret.CARRIER_API_KEY}'
},
body: JSON.stringify({ zip: zip, weight: d.weight })
});
if (!res.ok) throw new Error('carrier ' + res.status);
const body = res.json();
rate = { price: Math.round(body.amount * 100), days: body.transit_days };
sw.cache.set(key, rate, 900); // 15 minutes
} catch (e) {
console.warn('carrier rate failed, using fallback: ' + e.message);
d.options = d.options.concat([FALLBACK]);
return;
}
}
d.options = d.options.concat([{
id: 'carrier-ground',
name: `Ground (${rate.days} business days)`,
price: rate.price, // cents
type: 'flat'
}]);
};Three deliberate choices. The try/catch means a carrier outage degrades to a flat rate instead of a checkout the shopper can't finish — an unhandled throw in this hook is a lost order, and that is the whole risk of moving rates into code. The cache key is ZIP plus rounded weight, which is as specific as the quote actually is, and 15 minutes is long enough that a shopper editing their address doesn't re-bill you for the same quote. And the API key is never read into the script: sw.secrets values are write-only by default and {secret.KEY} is expanded at the fetch boundary, so the plaintext never materialises in your code or your logs.
You have room for exactly this: one external call. Cart and checkout hooks are measured in single-digit seconds, and ctx.timeoutRemaining() tells you what's left if you want to skip the call rather than risk being interrupted. Two sequential carrier lookups in one hook is a design you will regret.
A rate is not a gate
If the rule matters — you cannot ship lithium cells to that state, full stop — do not rely on this hook alone. It's a pricing hook, and on a custom theme it may never have seen a complete address. Enforce authoritatively in checkout.before_create, where ctx.data.order.shipping holds the full address for anything being delivered and a throw blocks the order:
module.exports['checkout.before_create'] = (ctx) => {
const ship = ctx.data.order.shipping;
if (!ship) return; // collected in store: nothing to deliver
if (ship.country === 'US' && ship.state === 'HI') {
throw { error: 'We cannot ship these items to Hawaii.' };
}
};An order collected in store carries no shipping address at all and names its counter on order.shipping_method.pickup instead — hence the early return. And note that shipping.calculate never runs for a cart where every line is digital: such an order has nothing to deliver, is shown "Shipping N/A", and is never billed shipping. Don't write a fallback that assumes your hook always fires.
Working on it
Run shopswired dev from your workspace root and the store executes your local files directly on every save — nothing uploaded, nothing installed, and ending the session puts the store back to its installed copy. Open your own checkout, change the address, and watch the rates change in the browser while console.warn streams into your terminal. Setting the store's Log Level (Settings → Developer) to trace adds automatic timing for every hook and every sw.* call, which is how you find out your carrier call is taking 3 seconds before a shopper does.
For something repeatable, sw.test.hook('shipping.calculate', …) fires the hook from a suite in test/ the way the store fires it, with every subscribed plugin running. Tests run in the same runtime against the real sw.* surface and never ship in a published package.
When this is the wrong tool
If your rule fits a tier table, use the tier table. If it fits a tier table plus a zone, still use those — you get the same outcome with nothing to maintain and nothing to monitor. Reach for shipping.calculate when the rate depends on something the store can't see: a carrier's live quote, your own warehouse inventory, the signed-in customer's contract, a dimensional calculation over what's in the cart. That's a real and common need, and it's a dozen lines here. It is also code on the path between a shopper and a payment, which is the most expensive place in a store to be careless.
The developer docs carry the field-level reference for every hook and entity named above, including the pickup-option shape if you're publishing locations from a plugin. Building your own plugins starts on Pro, which is $99/mo — currently $1/month for the first 3 months — while a store running ready-made themes and plugins starts on Starter at $29/mo. Every plan carries 0% platform transaction fees; you still pay your payment processor's standard rate. Full details on pricing.
← Back to Blog