ddtcorex

02 Oct 2026 · 5 min read

Custom shipping carriers and rate negotiation at checkout

Build a custom Magento CarrierInterface with collectRates, caching and carrier timeout fallbacks for stable checkout

Part 6 of the Checkout & Payments series — the rate the customer sees must be the rate you can ship at.

The carrier that broke checkout

A merchant with three warehouses and two regional carriers called us after every carrier outage became a checkout outage. When the rate API timed out, the shipping step spun, then went blank, and customers abandoned carts they had already decided to buy. The carrier was slow; the checkout made it fatal by treating a live rate call as a hard dependency with no timeout, no cache, and no fallback. This post shows how to build a carrier integration that negotiates rates defensively so checkout stays fast even when carriers do not.

Why this matters

Shipping rates sit on the critical path of conversion. Every shipping method rendered at checkout comes from collectRates() calls that run synchronously while the customer waits. A carrier API with a two-second p99 turns the shipping step into a gamble: fast when carriers are healthy, broken exactly during the peak season when they degrade. The business cost is asymmetric. An approximate rate with a clear fallback still converts; a blank shipping step converts nothing.

Magento's carrier abstraction anticipates this. CarrierInterface separates rate collection from method presentation, and the checkout totals flow tolerates carriers that return errors gracefully. The work is in using that seam properly: bounded calls, cached answers, and honest degradation.

How it works

Implement Magento\Shipping\Model\Carrier\AbstractCarrierInterface via AbstractCarrierOnline. The only method that matters at checkout is collectRates(RateRequest $request):

public function collectRates(RateRequest $request)
{
    $result = $this->rateFactory->create();
    $rates = $this->getRatesWithFallback($request);
    foreach ($rates as $rate) {
        $method = $this->methodFactory->create();
        $method->setCarrier($this->getCarrierCode());
        $method->setCarrierTitle($this->getConfigData('title'));
        $method->setMethod($rate['code']);
        $method->setMethodTitle($rate['label']);
        $method->setPrice($rate['price']);
        $method->setCost($rate['cost']);
        $result->append($method);
    }
    return $result;
}

Bound every external call. Set an explicit HTTP timeout of two to three seconds and a retry budget of one, not the HTTP client's default, which can block a PHP worker for thirty seconds. Wrap the call so any exception returns a RateResult\Error or falls through to the fallback path instead of bubbling up and killing the whole shipping step. One slow carrier must never take down the other carriers' methods.

Cache aggressively by the inputs that actually change the price: destination postcode, country, weight bucket, and subtotal band. Store the carrier response in cache for five to fifteen minutes keyed on that tuple. At checkout volume this turns hundreds of identical rate calls during a session into one upstream request. Invalidate on config change, never on every request.

Negotiate rather than proxy. Real rate logic blends sources: contracted base tables in shipping_tablerate for the default answer, live carrier quotes as an override when they arrive in time, and free-shipping or flat fallbacks when they do not. A practical ordering is live rate if fresh, cached live rate if the carrier is slow, table rate if the cache is stale, and a clear error method only when nothing applies. Log which tier served each quote in the carrier debug log so rate disputes become answerable: carrier_code, request_hash, tier=live|cached|table, latency_ms.

Configuration that matters lives in Stores > Configuration > Sales > Shipping Methods: carrier title, handling fee behavior, sort order, ship-to applicable countries, and debug logging. Keep showspecific scoped to the countries the carrier actually serves; offering a method everywhere and failing at placement is worse than not offering it. Enable the carrier's debug log in staging and keep it sampled in production.

Test with the carrier's sandbox plus fault injection. Cover the matrix that production will hit: valid rate, overweight parcel, unsupported postcode, API timeout, HTTP 500, and malformed response. Each case must produce either priced methods or a graceful fallback, never a blank step or a fatal.

Pitfalls

  • No timeout on the rate HTTP client. The default socket timeout holds a PHP worker hostage and queues every checkout behind one slow carrier. Always set connect and request timeouts explicitly.
  • Throwing from collectRates. An uncaught exception aborts the entire shipping method list, hiding healthy carriers alongside the broken one. Return an error result and let checkout render the rest.
  • Caching by too little or too much. Caching one rate globally misprices every destination; caching by full cart snapshot never hits. Key on postcode, country, weight bucket, and subtotal band.
  • Trusting live rates without a table fallback. The week the carrier API changes its schema is the week checkout needs table rates most. Keep shipping_tablerate current as the standing fallback even when live rates work.
  • Logging the full request with credentials. Carrier debug logs easily capture API keys and customer addresses. Log the request hash, response code, and latency; keep credentials and PII out.

Field check on a live store

Enable carrier debug logging and sample one busy hour. For each carrier, compute timeout rate, p95 latency, and fallback tier share. If timeouts exceed one percent or p95 exceeds your configured timeout, the timeout is either missing or too generous. Replay the top failing destination postcodes against staging with the carrier sandbox forced to time out and confirm the customer still sees priced methods from cache or table rates. Then check the abandonment funnel for sessions that reached the shipping step but saw zero methods: that count should be near zero, and every remaining one should have a log line explaining which tier failed and why.

Closing

Carriers fail; checkout must not. Bounded calls, a cache keyed on what changes price, and a table-rate floor turn rate negotiation from a live dependency into a best-effort upgrade over a standing answer.

Next in this series:

magento2-post-purchase-ops

magento2checkoutshipping