Sources studied
Decisions
D1. Shipment status and tracking status are separate fields. Alternative: one lifecycle enum (Karrio:draft, purchased, in_transit, delivered, cancelled…). Why: they change for different reasons, are owned by different systems, and can disagree legitimately (voided but scanned, purchased but lost). This is the “two clocks” argument from the Shipping Internals lifecycle issue.
D2. Rates are a persisted, expiring rate_quote. Alternative: stateless rates (Karrio /proxy/rates) with the client re-sending the whole shipment on purchase. Why: purchase-by-ID removes a whole class of “rated one thing, bought another” bugs, gives auditability, and lets carriers with rate tokens (Amazon) be implemented faithfully.
D3. Partial rating failure returns 200. Alternative: 207 or an error. Why: checkout must not fail because one carrier is slow. 207 Multi-Status is poorly supported by HTTP clients.
D4. purchasing is a real status. Alternative: fail on a carrier timeout. Why: a timeout is not a failure. The label may exist. Reporting failure invites a retry, which double-bills. The implementer reconciles and then tells the client.
D5. Void is an action, not DELETE. It is the Stripe refund model. The voided record must remain for reconciliation against the carrier invoice.
D6. Integer minor units for money, explicit units for weight and dimensions. Floats in money are a known bug class. Implicit units are a known support-ticket class.
D7. Ten canonical tracking statuses, open status_detail, lossless carrier_code. Alternative: adopt one carrier’s vocabulary, or a large flat enum (Karrio has 12 statuses and 35 reasons in one layer). Why: clients need a small set they can switch on, support needs the raw truth, and the mapping layer is where carrier disagreement is allowed to be lossy. Status can move backwards because carriers correct themselves.
D8. Credentials and config are separated, and carriers publish JSON Schemas for both. Why: it allows dynamic UIs and agent onboarding with no per-carrier client code, secrets stay write-only while config stays readable, and the same mechanism works for spec-native carriers.
D9. Carrier OAuth is modelled as app + connect_session + connection. Alternative: store tokens on the connection with no app object. Why: platforms register one app and let thousands of merchants connect through it. Apps rotate secrets independently of connections. OIDC sub deduplicates carrier accounts.
D10. Inbound is part of the spec. Carrier push receivers and canonical injection share one object (inbound_event). This makes “carriers with no API” and “your own fleet” first-class, rather than leaving them to a workaround.
D11. Unknown request fields are rejected, unknown response fields are ignored. Strict on input, because a typo in an option silently buys the wrong label. Lenient on output, so the spec can evolve additively.
D12. shipping.md sets agent policy, not just endpoints. Confirmation before spend, test mode first, idempotency, and treating carrier text as data. An API that agents will call needs its guardrails published alongside its reference.
D13. Contacts nest their address. Alternative: a flat address with name, phone and street fields side by side (Karrio, and draft 0.1). Why: parties and places are different things. A party can exist without a place (an EU responsible person), and a place without a party (a manifest origin). Tax IDs belong to the party. Stripe and , so developers already expect it.
D14. ship_from / ship_to, not from / to. from is a reserved word in Python, so it cannot be a keyword argument in generated SDKs.
D15. Items live in handling_units, not in customs. Alternative: a flat customs.commodities[] (Karrio and most carriers). Why: items are physical facts about a box, and they are needed outside customs: packing slips, dangerous goods on domestic shipments, per-piece customs for carriers that declare per parcel, and partial returns. The declaration says how goods are declared. The parcels say what goods exist. Implementers aggregate items for carriers that want one list.
D16. Order and customs declaration are separate, optional objects. Alternative: one customs object holding invoice, order total, parties and items. Why: the order is a commercial fact (what was sold, for how much, with which invoice), and it is useful for DDP pricing even when nothing is declared. The declaration is a legal act (who is liable, under which incoterm, certified by whom). Folding them together forced domestic shipments to carry an empty customs object to hold an invoice number. importer_of_record and exporter_of_record are full contacts, because a broker or 3PL needs a name, phone, address and tax IDs, not just a number.
D17. Two HS codes per item. export_hs_code and import_hs_code. Only the first 6 digits are harmonized, and export filings and import duty calculation need different national extensions. One field forced a guess.
D18. Dangerous goods sit on the item. The hazard is a property of the goods. Putting it on the parcel or shipment loses which item is hazardous, which matters for mixed boxes and per-item declarations.
D19. carrier_configs is the product-and-feature configuration: standardized features plus carrier-specific ones, with the service inside. It succeeds Karrio’s shipment.options, carriers’ additionalServices flags and Amazon’s value-added services, and also absorbs service selection, like a shipping method configuration in an ERP or WMS. Alternative: separate service and options fields, or only carrier-native settings. Why: standardized features (signature, insurance, COD, weekend delivery…) let a shipper state intent once, with * applying it to every carrier, while the implementer maps it to each carrier, as Karrio does today. Carrier-specific features keep each carrier’s full surface, typed by its published schema. To stop mapping from becoming silent substitution, unsupported standardized features exclude a service rather than being dropped, and rate.features shows the native code and price used for each one.
D20. bill_to is a contact, and billing is part of the quote. Alternative: a billing account number with its country and postal code inline (Karrio’s payment, draft 0.1’s billing.account). Why: third-party billing names a real party, which needs a name, address and tax IDs for the carrier invoice and for validating the account (the billTo party in carrier APIs). Giving it the same Contact type as the other three party slots keeps the model uniform. Billing is in the rate request because collect and third-party accounts change the price. Duties keep separate billing, because freight-prepaid with duties-collect is common.
D21. street_number instead of line3. Alternative: three free-text lines (Stripe’s two lines, plus Amazon’s addressLine3). Why: the third line is rarely used, and when it is, its meaning is undefined. A separate street number solves a real problem: a large share of European and Latin American carriers reject addresses without it, and parsing it from line1 fails on inputs like Straße des 17. Juni 135. The field is optional, so US and UK integrations that send line1 alone keep working. Karrio’s street_number has carried this in production across 30+ carriers.
D22. Three metadata namespaces, one writer each. user_metadata (client), carrier_metadata (carrier) and system_metadata (implementer). Alternative: Stripe’s single metadata plus Karrio’s meta. Why: a single bag mixes data with different owners, trust levels and stability. A client cannot tell its own order_id from a carrier’s routing code, a carrier key can collide with a client key, and nobody knows which keys survive a version bump. Splitting by writer makes ownership obvious: only user_metadata is writable, only carrier_metadata is untrusted and unversioned, and only system_metadata records provenance (source, principal, request, batch) for audit.
D23. End of day is a workflow, not a single call. Alternative: POST /v1/manifests with a shipment list (Karrio, draft 0.1). Why: Karrio’s connectors show the single call hiding most of the real behavior. Canada Post and SEKO return several manifests per request. USPS scopes a SCAN form by entry ZIP and mailing date. Australia Post locks shipments into an “order”. Asendia and other cross-border carriers finish asynchronously, and Asendia rejects individual handling_units. The spec models what operations teams actually do: preview, fix blockers, close all or part of the day (close_group), receive one or several carrier manifests, and get reminded or auto-closed when they forget. Carrier-run auto-closes are recorded as manifests, so the API never reports a shipment as voidable when the carrier has already closed it.
D24. Modes are profiles over shared modules, not separate APIs. Alternative: separate specs for aggregators, carriers, 3PLs and fleets. Why: they share most of their surface (a shipment, a rate, a tracker), and the valuable part of a standard is that a client written for one implementer works with another. Modes only decide which modules are required. A carrier’s catalog becomes a universal implementer’s /v1/carriers entry through federation, so the modes compose rather than compete.
D25. Plans and executions are separate objects throughout. pickup_schedule → pickup, route → route_run, rate_sheet → rate, service (catalog) → rate.service. Why: timetables and tariffs are edited by planners, and executions are recorded by operations. Mixing them makes it impossible to change next week’s schedule without rewriting yesterday’s history, or to explain an old price after a tariff change. Every execution records the plan version it came from.
D26. Operations write tracking. Route-run stop and leg actions emit canonical tracking events on the same trackers that universal implementers fill from carrier APIs. Why: a carrier, 3PL or fleet should not need a second integration to make its own scans visible. Clients see one tracker API regardless of who moved the parcel.
D27. The catalog generates the config schema. In carrier-type modes, shipment_config_schema is derived from option objects, both standardized and carrier-specific. Why: hand-maintained schemas drift from what the carrier actually accepts. Generating the schema makes the catalog the single source, and makes the standardized feature registry enforceable at publish time.
D28. Pricing is computed, explainable, and testable. Rate sheets follow Karrio’s proven structure (shared zones, shared surcharges, service × zone weight bands with price and cost), with a normative calculation order, pricing_details on every computed rate, and simulate before publish. Why: the support question in pricing is always “why was I charged this?”, and the operational risk is always “what will this tariff change do?”. Both need answers before anyone asks.
D29. Lean roots, grouped by concern. A shipment’s root holds only what describes the shipment and what a client sends. Outcomes and relationships are grouped: purchase (what was bought: rate, service, amount, charges, void), tracking (status, number, tracker, journey), handover (close group, pickup, manifest), returns (outbound and return links). Every document, labels included, lives in documents[]. Why: a flat object with 40 root fields reads as noise, and puts a void refund next to a ship-from address. Grouping makes the request shape mirror the object, makes nullability obvious (no purchase means not bought), and gives each future field an obvious home. The input purchase instruction mirrors the output purchase object, replacing a purchase: true flag plus loose label, rate and max_increase fields.
D30. One spelling for carrier settings. carrier_configs is a map keyed by carrier everywhere: requests, rates, purchased shipments, pickups, schedules and manifests. Where only one carrier is involved, the map has one key. Why: carrier_config and carrier_configs side by side forced every client to remember which one each object used. A single shape means one parser and one validator.
D31. handling_units, not parcels. The freight and GS1 term covers parcels, pallets, crates and cartons, so LTL and parcel shipments share one model. Why: “parcel” excludes pallets, “package” reads as parcel-only in freight, and “pieces” means loose cartons on a bill of lading.
D32. Injection is a group on the shipment, plus ASNs. Alternative: a separate inbound_shipment resource. Why: once received, an injected parcel is a shipment in the receiving network, with the same rating, routes, tracking and webhooks. A separate type would fork every downstream API. The injection group adds what differs (partner, upstream identifiers, label mode, receiving outcome), and the ASN models the physical handover that data alone cannot. Upsert by (partner, upstream_reference) makes platform order syncs idempotent without client bookkeeping.
D33. Identity, tenancy and authorization are three systems. Identity stays in the customer’s OIDC provider, tenancy is account, and authorization is membership + role + api_key, sharing the OAuth scope vocabulary. Why: coupling them is how platforms end up storing passwords, breaking when a customer moves IdPs, or granting a platform’s app more than the person using it. Token exchange (RFC 8693) gives platform users single sign-on into merchant accounts, with the narrowest of three scope sets and per-person audit.
D34. Products are referenced, then snapshotted. Items may reference a product, and resolved values are written onto the item and frozen at purchase. Why: entering trade data once is the point of a product master, but a customs declaration is a legal record that must not change when the catalog does. Field ownership on upsert lets several sync sources share a product without overwriting each other.
D35. A network is accounts, not adapters. Members are carrier accounts that author the same catalog, pricing and routes objects a Carrier-mode implementer does. Why: the network’s value is breadth, and breadth dies if each member needs custom onboarding. Composed rates and interline handoffs reuse journeys and injection. Settlement is a ledger of who owes whom, not a payment system.
D36. Devices are participants in tracking, not a side channel. Scans, sensors, positions and geofences arrive through one ingestion endpoint and become the same tracking events, route-run actions and consolidation changes a person would make. Why: most real events start on a device. A separate IoT API forks the source of truth.
D37. The data network stores the spec’s own objects, signed and anchored, never on-chain. Alternative: a blockchain-native data model, or plain replication. Why: a new model would split the industry again, and plain replication cannot prove who said what. Content addressing, producer signatures and Merkle anchoring give tamper-evidence on any ledger. Per-record encryption plus crypto-shredding keeps personal data erasable. Access is derived from each party’s role on the shipment, at field level, because “share the record or not” is why logistics data is not shared today.
D38. Consolidation is first-class, with one aggregation rule. Bags, gaylords, pallets, ULDs and containers are consolidations, and flights, voyages and trips are conveyances. An event on a container applies to everything inside it. Why: without the hierarchy, one scan of a gaylord either means nothing to 700 shippers or has to be fanned out by hand. The model follows the EPCIS aggregation semantics the industry already uses.
D39. Templates are written against the spec, not the implementer. The render context is the canonical object, and the language (Liquid, ZPL) is portable, so a packing slip template works on any implementer. Why: document templates are among the stickiest lock-ins in shipping software, and validating them against the object schema catches breakage before it prints.
D40. Every external provider is an integration with typed interfaces. Carriers, 3PLs, address validators and insurers share one catalog, one OAuth app wrapper and one connection object. Each interface is a subset of this spec. Why: platforms otherwise build the same credential, OAuth and health-check machinery once per provider category. Making interfaces spec subsets means a provider that implements the spec federates exactly like a carrier does.
D41. One envelope for background work. Every asynchronous operation is a job with the same status, progress, cancel and retry semantics, and the implementer’s own automations are visible as system job schedules. Why: operations teams debug “what happened at 17:30” across a dozen async features. One envelope gives one place to look, and lets agents supervise long-running work generically.
D42. Billing is three flows ending in one invoice, and payments are an interface. Transport invoices from carriers, usage invoices from platforms, and network settlements all become invoices. Reconciliation classifies every carrier line against what was purchased. Payment providers sit behind the payments interface, and implementers never touch card numbers. Why: carrier invoice audit (reweighs, duplicates, billed voids) is one of the largest hidden costs in shipping and is still done in spreadsheets. Coupling billing to one PSP is the lock-in platforms regret most. Standard import formats (EDI 210, INVOIC, UBL) and a PSP-neutral model address both.
D43. Facilities declare functions, and inventory is a ledger. A facility states what it is for (sortation, storage, fulfillment, cross-dock, returns, customs, hazmat) with a spec per function. Stock is a projection of append-only movements. Why: “warehouse” means a sort hub to a carrier and a storage building to a merchant, and routing and allocation need to know which. Inventory systems that mutate quantities in place cannot explain a discrepancy. A movement ledger can, in the same way the tracking event log explains a status.
D44. Network members declare mile segments. Couriers, forwarders, airlines and last-mile fleets declare which segments (first_mile, middle_mile, last_mile, end_to_end) they serve, on which lanes or zones, and where they hand off. Why: most cross-border journeys are relays, and composing them needs to know who can pick up where the last one put down. Segments plus handoff points turn that into a graph search instead of a sales negotiation.
D45. The data network assembles end-to-end timelines from distributed scans. Identifier links (same_as, contains with validity windows) build an identity graph per shipment. Events recorded against any linked identifier are merged, deduplicated without rewriting, and consolidated into standard milestones, each with provenance (via) and a confidence status (confirmed, corroborated, attested, inferred, disputed). Why: no single party can see a cross-border journey, because each knows it by a different identifier. Without the graph, an airline’s ULD scan can’t reach the shopper. Keeping conflicting records rather than overwriting turns disagreements into evidence.
Open questions for 0.2
- Rate shopping with carrier accounts the caller does not own. Should
ownership: systemrates expose the implementer’s margin, or only the price? - Freight (LTL/FTL). Pallets, freight classes, accessorials and BOLs. These belong in an extension or a separate resource (
freight_shipment), and the right boundary is not yet settled. - Address validation as its own resource (
POST /v1/address_validations) versus the inlinevalidateflag only. - Duties and taxes calculation as a standalone endpoint (as some cross-border carriers offer) versus only
rate.landed_cost. - Orders as a resource. The order is an embedded value on the shipment, not a resource with its own ID and lifecycle. The shipment stays the unit of this spec, and order management belongs to the OMS. Should
GET /v1/shipments?order.id=…be enough for split shipments, or do platforms need an order resource? - Standardized feature governance. What evidence promotes a carrier-specific feature into the standardized registry, and who decides when two carriers’ semantics are close enough to share a name?
- Carrier ID registry governance. Who adds
fed_*carriers to the shared registry, and how do collisions get resolved?