Shipping
This page shows a store administrator how to charge for delivery, using either one flat rate or shipping zones that offer methods based on the customer's address.
The two shipping engines
Shipping works the same way tax does. KahunaCart picks the engine for you.
| Zones configured | Engine | What it does |
|---|---|---|
| None | Flat rate | One amount on any order containing a physical item. |
| One or more | Zone | Address matching, method selection, per-class surcharges, free-shipping thresholds. |
Both engines write identical adjustment rows, so nothing downstream changes when you switch.
Note
Digital-only carts are skipped by both engines. If nothing in the cart is physical, no shipping is charged, no method is asked for, and checkout does not mention shipping.
Set a flat shipping rate
- Open KahunaCart → Settings and the Commerce tab.
- Under Shipping, set Flat Shipping Rate to a decimal amount in the base currency (
shipping.flat_rate). On a store that sells in more than one currency the config file also takes a map of currency to amount here; see Shipping in another currency. - Set Flat Shipping Label to the name that appears on receipts (
shipping.label). - Click Save.
shipping:
flat_rate: '5.99' # decimal string in the base currency
label: 'Shipping'
The rate now applies to any order with at least one physical line, regardless of destination, item count or weight.
| Config key | Default | What it does |
|---|---|---|
shipping.flat_rate |
'0' |
The flat amount. 0 emits nothing. |
shipping.label |
'Shipping' |
The label on the receipt. |
Shipping zones
Shipping zones use the same geography matcher as tax zones: the same criteria, the same wildcard and range syntax, and the same "an empty criterion means any" rule. See Tax → Zones for the full matching semantics.
Zones are matched in the sort order you set in the admin, and the first matching zone wins. tax.basis decides which address is matched, for both engines.
Create a shipping zone
- In the admin sidebar, click KahunaCart, then Shipping.
- Click New zone.
- Enter a Name, then set Priority to place this zone in the matching order.
- Fill in Countries, Regions and Postcodes. Values are comma separated, and an empty field matches anything.
- Click Add method, then enter a Label, choose a Type, and set Cost.
- Click Create zone.
Customers whose address matches the zone are now offered its methods at checkout.
Methods
A zone offers one or more methods, in three types.
| Type | Admin label | Behaviour |
|---|---|---|
flat |
Flat rate | Charges cost_minor, plus any per-class surcharges the cart triggers. |
free |
Free shipping | Always zero, whatever else is configured. Surcharges do not apply. |
pickup |
Local pickup | Charges cost_minor, usually zero, with no surcharges. |
| Field | Admin label | Notes |
|---|---|---|
label |
Label | What the customer sees, and what appears on the receipt. |
enabled |
Enabled | Disabled methods are hidden from customers but stay visible in the admin. |
cost_minor |
Cost | The base cost. |
per_class_json |
Per shipping class | A map of shipping class to surcharge in minor units. |
min_total_minor |
Free over | The free-shipping threshold. Blank means always offered. |
free_minimum_shipped_only |
Count only items that ship | The threshold adds up only the lines that need a parcel, leaving downloads out. Off by default. |
free_minimum_charges_below |
Charge under the minimum | Under the threshold the method charges its own cost, and from the threshold it's free. Off by default, which hides the method until the threshold is met. |
weight_rules_json |
Price by weight | Optional weight bands that replace cost_minor. |
per_item_minor, per_kg_minor, percent_rate |
Per item, Per kg, Percentage | The three rate types that price by what is in the parcel. See Shipping rates. |
min_total_cost_minor, max_total_cost_minor |
Minimum, Maximum | The floor and the ceiling, applied after everything else and in that order. |
cost_expression |
Cost expression | A formula that replaces the tabulated figure. See Cost expressions. |
class_policy, class_policy_classes_json |
Class policy | Which shipping classes this method will carry. See The three-valued shipping-class policy. |
carrier_code, carrier_service_code |
Carrier, Service | The live carrier that prices this method, when one does. KahunaCart ships no carrier. See The carrier rate contract. |
position |
Position | The order methods appear in. |
A method that sets none of the extra fields charges exactly what it always did. Shipping rates covers all of them in the order they are charged.
Free-shipping thresholds
min_total_minor is the threshold a cart must reach before a method is offered at all. Give a free method named "Free shipping over $50" a threshold of 5000, and it stays out of the list until the cart clears it.
The threshold is tested against the items subtotal, not the total, so a discount that drops the subtotal below it removes free shipping.
By default the threshold counts everything in the cart, downloads included, so "free shipping on $10 or more" is met by a $5 download and two $4 prints. Turn on Count only items that ship on the method and only the lines that need a parcel count: that cart counts as $8 and pays for postage, and a third print makes it free. Downloads and services are left out, every line that ships counts even when shipping profiles split them into separate parcels, and the "spend more for free shipping" message names the amount that was counted.
$5 shipping, free from $20
Two methods side by side, a $5 flat rate and a free one with a $20 threshold, both show once the cart reaches $20, because a threshold only decides when its own method appears. Nothing takes the $5 one away. For "$5, free from $20", use one method instead: set the $5 flat rate's free-shipping requirement to a $20 minimum and turn on Charge under the minimum. Under $20 it costs $5 with "Free from $20.00" under its price at checkout, and from $20 the same method costs nothing. Delete the separate free method, or turn it off.
The minimum still follows Compare before discounts and Count only items that ship, and the order's shipping line records the rate it would have cost and the minimum that made it free.
Weight-based rates
A method can price by weight instead of charging one flat amount. Give it weight_rules, a list of bands:
weight_rules:
- { min_grams: 0, max_grams: 500, amount: '3.00' }
- { min_grams: 501, max_grams: 2000, amount: '6.50' }
- { min_grams: 2001, max_grams: ~, amount: '12.00' }
| Key | Admin label | Meaning |
|---|---|---|
min_grams |
From (grams) | Lower bound, inclusive. |
max_grams |
Up to (grams) | Upper bound, inclusive. Null or blank means open-ended. |
amount |
Cost | A decimal string in the base currency. |
Add a weight band
- In the admin sidebar, click KahunaCart, then Shipping.
- Click Edit on the zone, and find the method you want to price by weight.
- Under Price by weight, click Add weight band.
- Enter From (grams), Up to (grams) and Cost. Leave Up to (grams) blank for an open-ended top band.
- Repeat for each band, then click Save zone.
The method is now priced by weight, and cost_minor is no longer consulted.
How weight bands are resolved
- The order's weight is the sum of
qty × weight_gramsover its physical lines. Digital lines never contribute, and a line with no recorded weight contributes zero. - Weights come from each line's snapshot, never the live variant row, so correcting a product's weight does not change what a completed order was charged.
- The first matching band wins, in your order. Overlapping bands are resolved by position rather than refused.
- Bands replace the flat amount. Once a band matches,
cost_minoris not consulted. - A method whose bands do not cover the order is not offered. It disappears from the customer's list of options.
- A malformed band is dropped. A negative bound, a
max_gramsbelow itsmin_grams, or an amount that is not a plain decimal takes that band out of service. If every band is unusable, the method goes back to chargingcost_minor.
Everything else about the method still applies, in this order:
- The free-shipping threshold decides whether the method is offered at all.
- Weight bands decide whether it is offered, and what the base amount is.
- The rest of the rate types, per item, per kilogram and percentage, are added to that base, then the per-class figures on top, then the two clamps. See Shipping rates for the full order.
- Tax is applied to the result if
apply_to_shippingis on for the matching rate.
A free method is the exception to step 2: a band cannot put a price back on it. Its bands still govern availability, so you can offer free delivery on anything you can post.
Worked example
A UK zone with one method, "Royal Mail", carrying the three bands above and a fragile surcharge of 2.50:
| Cart | Weight | Result |
|---|---|---|
| 1 × 300 g mug | 300 g | Band 1 → 3.00. |
| 2 × 300 g mugs | 600 g | Band 2 → 6.50. |
2 × 300 g mugs, both fragile |
600 g | Band 2 → 6.50, plus the fragile surcharge once → 9.00. |
| 1 × 4 kg crate | 4000 g | Band 3 is open-ended → 12.00. |
| 1 × 300 g mug + 1 downloadable guide | 300 g | The download weighs nothing. Band 1 → 3.00. |
Add a second method, "Letter post", with one band 0–100 g at 1.20, and the 300 g cart never sees it. Add a third of type free with min_total_minor: 5000 and one band 0–2000 g, and it appears only once the subtotal reaches $50 and the parcel is under 2 kg.
Shipping classes and surcharges
Assign a shipping_class to a product, such as oversized, fragile or hazmat. The strings are yours. Give a method a surcharge for that class under Per shipping class.
- Surcharges apply per distinct class present in the cart, not per line and not per unit. A cart with two oversized items pays the oversized surcharge once.
- Classes come from each line's snapshot, never the live product row, so reclassifying a product does not change what a completed order was charged.
The method's total is cost_minor plus every triggered surcharge, and the adjustment records the breakdown so a receipt can show it.
Method selection at checkout
The customer's choice is recorded on the cart as a hint. The selector re-checks it against what is currently available on every recalculation, and falls back to the first offered method if the store no longer offers the chosen one.
Once zones exist, checkout is a two-step flow for physical orders:
- The customer fills in their address and submits. No method is chosen yet, so checkout redirects back.
- Checkout re-renders, resolves the zone and shows the real options at their real prices.
- The customer picks one and submits again.
Checkout blocks with a specific message for each case.
| Situation | Message |
|---|---|
| No country on the address | "Please enter your delivery address, including the country, so we can work out shipping." |
| Address is known but matches no zone | "We are unable to ship to that address. Please check it, or contact us for help." |
| Methods exist but none is chosen | "Please choose a shipping method." |
Omitting shipping_method_id from the POST means "not asked yet", and leaves whatever the cart already carries alone. Posting it empty clears the choice.
No service to an address
When a zone matches but offers this cart nothing, the calculation emits nothing at all. That happens when every method has a threshold the cart does not clear, or every method is disabled. It is not the same as free shipping: checkout blocks the order instead.
A method that is resolved always emits a row, including a zero-cost one, so "Free shipping — 0.00" appears as a line the customer can see.
The same applies when the address matches no zone: nothing is emitted, and checkout stops the order with the "unable to ship to that address" message. A method whose weight bands do not cover the order is unavailable the same way, so if it was the zone's only method the customer gets that message rather than a free parcel.
Why a method is offered but disabled
When a shipping method is unavailable for a reason that may not be obvious to the customer, checkout displays it as disabled with an explanation. Each reason has a predefined code that maps to a translatable language key.
| Code | Means |
|---|---|
below_minimum |
The cart has not reached the method's free-shipping threshold. |
weight_unmatched |
The parcel's weight falls outside every band the method carries. |
coupon_required |
The mode needs a free-delivery coupon the cart does not carry. |
currency_unpriced |
The order is in a currency the method has no amounts for. See Shipping in another currency. |
item_excluded |
Something in the basket cannot travel this way. Reserved for an add-on, and KahunaCart never produces it. |
pickup_only |
A pickup method in a store that has collection points configured. Collection is chosen by naming a counter, not by picking this method. |
calculator_declined |
A rate calculator was asked and quoted nothing about this method. |
class_disallowed |
The parcel holds a shipping class this method will not carry. See the class policy. |
class_required |
The parcel holds nothing of a class this method requires. |
carrier_unavailable |
The carrier that prices this method timed out, failed, is not installed, or carrier rates are switched off. Checkout continues on the fallback method. |
carrier_excluded |
The carrier rejected this service. The driver supplies the explanation as a language key, never as English prose. |
An unknown code fails where it is written rather than printing nothing later.
Live carrier rates
A method can also be bound to a carrier rate driver, which prices it from a courier's own API. carrier_code and carrier_service_code on the method name the driver and the service, and the driver answers with a price or with a reason. It enters the offer list the same way everything else does.
KahunaCart ships no carrier. The drivers are the KahunaCart Shipping add-on; core ships the interface, the registry, the cache, the timeout and the documented failure mode. See The carrier rate contract, including what happens when a courier does not answer.
Shipping in another currency
A store that sells in more than one currency prices each shipping method in each currency on purpose. The method's ordinary fields are its rates in the base currency, and each other currency has its own amounts beside them, entered on the method in the admin or sent as currency_amounts through the API. See Shipping in another currency for how to set them up.
A method is offered in a currency only when its price can be worked out entirely from amounts in that currency. A method with a base rate of 5.00 and no euro rate is not offered to a euro order. It is not offered at 5.00 euro, and it is not free. It is listed as refused with currency_unpriced ("This option is not available for orders in this currency."), the same way a method that is too light or too heavy is, and a store that sets shipping.show_unavailable: false does not list it at all. That is the rule for a currency that types its prices. A currency set to convert works the method's missing amounts out from the base ones instead, to that currency's own decimal places. If no method is offered in the order's currency, checkout stops the order with the "unable to ship to that address" message.
The free-shipping minimum follows the same rule. A method that is free over 50.00 in the base currency and has no minimum in euro has no way to be free in euro, so a minimum method is refused, and a minimum_or_coupon method is offered to a cart carrying the coupon and to no one else. It is never free from zero. See what a missing amount does for every rate type.
A store that sells in one currency is not changed by any of this: nothing is looked up and nothing is added to the page.
Split an order into parcels
A product carries a shipping profile. An order is packed into one package per profile its lines mention, and each package resolves its own method and gets its own shipping line. The order's shipping total is the sum of its packages.
Two profiles ship with the plugin: default, which ships, and digital, which does not. A product with no profile falls back to whichever suits its type, so a store that never opens the profile editor packs every order into exactly one parcel. See Shipping profiles and packages.
Carriers and tracking links
Everything above is about what a customer is charged for postage. Who carries the parcel is recorded afterwards, on a shipment.
A shipment stores a carrier code and a tracking number. The name shown to a human and the link the customer follows are built from those two.
KahunaCart knows sixteen carriers out of the box, each with a tracking-URL template: ups, usps, fedex, dhl-express, dhl-ecommerce, royal-mail, parcelforce, australia-post, canada-post, deutsche-post, la-poste, postnl, an-post, evri, dpd, gls. There is also other, which has no template and pairs with a free-text name in the admin.
Note
Nothing here is an integration. A template is a URL with {tracking} in it, and the plugin never calls any of these companies.
Add or override a carrier
shipping.carriers adds to that list. Reuse a built-in code to replace its tracking URL, or use a new code to add a carrier.
shipping:
carriers:
- { code: 'tuffnells', label: 'Tuffnells', tracking_url: 'https://tuffnells.example/track/{tracking}' }
A map keyed by code works too, which is shorter when all you are changing is a URL:
shipping:
carriers:
ups: 'https://www.ups.com/track?tracknum={tracking}'
The rules:
{tracking}is where the number goes, URL-encoded. A template without it is never used.- A code is normalised to lowercase letters, digits and dashes.
Royal Mailbecomesroyal-mailand finds the built-in rather than creating a second entry beside it. - Overriding a URL does not rename the company. A row that names only
tracking_urlkeeps the built-in label. - A carrier with no
tracking_urlis valid. Local couriers often have no online tracking, and the shipment then shows the number as plain text. othersorts last in the admin's list.
How a tracking URL is decided
- If the tracking URL field was filled in, that is the URL.
- Otherwise, if the carrier has a template and there is a number, the URL is derived from them.
- Otherwise there is no link, and the tracking number is printed as text.
The result is stored on the shipment rather than recomputed on read, so a link already sent to a customer never changes because a template did.
API reference
All shipping endpoints require the kahunacart.settings permission.
GET /kahunacart/shipping/zones
POST /kahunacart/shipping/zones
PATCH /kahunacart/shipping/zones/{id}
DELETE /kahunacart/shipping/zones/{id}
Zone fields are name, countries, regions, postcodes and position. Methods are managed as part of their zone. The criteria accept PHP arrays or JSON arrays and are always stored as JSON.
On a store that sells in more than one currency each method also carries currency_amounts (what is set in other currencies), priced_in (the currencies it is offered in, the base first) and missing_in (for each currency it is not offered in, the fields that are missing). A method written with currency_amounts follows the rules in Shipping rates. A single-currency store's methods have none of these keys.
Rates, cost expressions, shipping profiles, carrier drivers and an order's parcels have their own routes. They are listed in Shipping rates → API reference, and the full bodies are in Shipping in the REST API.
Related
- Shipping rates - the six rate types in application order, the clamps, cost expressions, the class policy, parcels and the carrier contract
- Click and collect - named collection points, their hours, and the tax rule that comes with them
- Tax
- Coupons
- Money conventions
- Orders and the admin
- Currencies - selling in more than one currency