Multi-carrier shipping rates are quotes, not prices
The shipping amount a customer sees at checkout comes from a carrier API call that happened seconds or minutes earlier, computed from package dimensions and an address that can both change before anyone buys a label. If you store that amount as a price column on the order, every gap between what you quoted and what the carrier actually billed becomes manual work that never ends.
I have spent years on the delivery side of a cross-border shopping platform: rates, labels and tracking from several carriers behind one checkout. The design decision that mattered most was refusing to treat a rate as a number. A rate is a quote — an object with inputs, an issuer, a carrier-side reference and an expiry. Once it is modelled that way, most of the ugly cases stop being ugly.
The rate is a function of inputs you do not fully own#
A carrier price depends on billable weight (which may be volumetric rather than actual), on whether the destination is classified residential or commercial, on surcharges that change without notice, on declared value, and on which service code you asked for. Half of those inputs are decided after checkout, by a warehouse operator choosing a box.
So the first thing to write down is not the price. It is the exact input vector the price was computed from, hashed, so you can later ask a precise question: is this quote still about the same shipment?
import hashlib
import json
from dataclasses import dataclass, asdict
@dataclass(frozen=True)
class QuoteInputs:
origin_zone: str
dest_country: str
dest_postcode: str
dest_kind: str # 'residential' or 'commercial'
billable_weight_grams: int
dims_mm: tuple[int, int, int]
declared_value_minor: int
currency: str
incoterm: str
def fingerprint(inputs: QuoteInputs) -> str:
payload = json.dumps(asdict(inputs), sort_keys=True, separators=(',', ':'))
return hashlib.sha256(payload.encode()).hexdigest()Note what is deliberately absent: cart contents, customer id, promo codes. The fingerprint covers the shipment, not the order. Adding a second identical item changes the weight and must invalidate the quote; changing the billing email must not.
Persist the quote, not the number#
Every offer a carrier returns gets a row. Not the winning one — every one. The alternatives are what let you answer, months later, why a parcel went out with the carrier it did.
create table shipping_quote (
id uuid primary key,
shipment_id uuid not null references shipment(id),
carrier text not null,
service_code text not null,
carrier_quote_ref text,
amount_minor bigint not null,
currency char(3) not null,
input_fingerprint char(64) not null,
raw_response jsonb not null,
fetched_at timestamptz not null,
expires_at timestamptz not null
);
create index shipping_quote_lookup
on shipping_quote (shipment_id, fetched_at desc);Two columns there are load-bearing and easy to skip.
carrier_quote_ref is the identifier the carrier hands back with its rate. Where a carrier honours it at purchase time, buying against that reference is the difference between paying what you quoted and paying whatever the rate engine feels like today.
raw_response is the untouched body. Normalized columns throw away precisely the field you will need when an unfamiliar surcharge shows up on an invoice line and you have to prove whether it was visible at quote time.
Fetching is the other place where multi-carrier integrations rot, because it is tempting to gather everything into one list and move on:
def refresh_quotes(shipment, carriers, ttl=timedelta(minutes=30)):
inputs = quote_inputs(shipment)
fp = fingerprint(inputs)
issued_at = now()
quotes, unavailable = [], []
for carrier in carriers:
try:
offers = carrier.rate(inputs)
except CarrierError as exc:
unavailable.append(CarrierOutage(carrier.code, str(exc)))
continue
for offer in offers:
quotes.append(store_quote(
shipment=shipment,
offer=offer,
input_fingerprint=fp,
fetched_at=issued_at,
expires_at=issued_at + min(ttl, offer.carrier_ttl or ttl),
))
return quotes, unavailableAn empty list from a carrier means it does not serve that destination. An exception means you do not know. Those are different facts and they belong in different variables. Collapse them into 'no options available' and you will quietly stop selling to a country for as long as one integration is broken, and nobody will file a bug, because the checkout looks fine.
Buying the label against a quote, not against a service code#
The purchase step is where the model earns its keep. It has one job: refuse to spend money if the world has moved.
def buy_label(quote_id: UUID) -> Label:
with transaction.atomic():
quote = Quote.objects.select_for_update().get(id=quote_id)
shipment = quote.shipment
if quote.expires_at <= now():
raise QuoteExpired(quote_id)
if fingerprint(quote_inputs(shipment)) != quote.input_fingerprint:
raise ShipmentChanged(quote_id)
label = carriers[quote.carrier].buy(
quote_ref=quote.carrier_quote_ref,
service_code=quote.service_code,
idempotency_key=f'label:{quote.id}',
)
record_charge(shipment, kind='label', amount_minor=label.amount_minor,
currency=label.currency, quote=quote,
carrier_ref=label.tracking_number, source='label_api')
return labelThe idempotency key is derived from the quote id rather than generated per call, because a retried label purchase is not a duplicate row in a report — it is a second parcel and a second charge. Deriving the key from something already persisted means a retry after a timeout, a redeploy, or an operator double-click all land on the same key.
ShipmentChanged is the interesting failure. It fires when the warehouse repacked, when the customer corrected an address, when the classification of the destination flipped. The correct response is to re-quote and show the new price to whoever is standing at the packing bench — not to buy the old one and file the difference under 'shipping variance'.
The carrier reprices after you shipped#
This is the part nobody warns you about. Carriers re-measure parcels in their own hubs. Address corrections, remote-area surcharges and dimensional-weight adjustments arrive days later, on an invoice, keyed by tracking number — not by your order id, and not through the API that sold you the label.
If shipping cost is a column, you now have to overwrite it, and the quote is gone. Make it a ledger instead.
create table shipping_charge (
id uuid primary key,
shipment_id uuid not null references shipment(id),
quote_id uuid references shipping_quote(id),
kind text not null, -- quoted | label | adjustment | refund
amount_minor bigint not null, -- signed
currency char(3) not null,
carrier_ref text, -- tracking number or invoice line id
source text not null, -- checkout | label_api | invoice
occurred_at timestamptz not null,
unique (source, carrier_ref, kind, amount_minor, occurred_at)
);Rows are appended, never updated. What the customer paid is one row. What the label cost is another. Every later adjustment is its own row, attached by carrier_ref because that is the only key the invoice actually contains. Margin on a shipment becomes a sum, reconciliation becomes a query, and 'what did we quote and why' remains answerable forever.
The uniqueness constraint matters because invoice ingestion is a file-parsing job, and file-parsing jobs get run twice.
What I would do differently#
I would introduce the input fingerprint at the same time as the quote table, not later. Without it, quote expiry is the only guard you have, and expiry does not catch the case that actually costs money: a quote that is still fresh but no longer describes the parcel on the bench.
I would model carrier unavailability as a first-class value returned to the caller from day one, rather than a log line. Everything downstream — checkout messaging, alerting, deciding whether a destination is genuinely unserved — needs that distinction, and retrofitting it means touching every call site.
And I would store the raw carrier payload immediately. It costs a jsonb column, and it is the only thing that makes a surcharge dispute a five-minute lookup instead of an argument.
Close#
A price is a fact. A rate is a promise made under assumptions, by a third party, with a shelf life. Almost every recurring problem in multi-carrier shipping — quoted-versus-charged drift, duplicate labels, mystery invoice lines, silently disappearing destinations — comes from storing the second thing in a shape designed for the first.