Shipping Integrations
This guide covers building carrier shipping integrations using the shipping_carrier backbone module — the shipping equivalent of Payment Integrations.
Architecture
Section titled “Architecture”Carrier shipping is provider-agnostic. The shipping_carrier module handles:
- The
ShippingIntegrationhook contract on the sharedIntegrationmodel - Live rating on sales orders (
SaleOrder.apply_shipping_methodfor a “Live Carrier Rate” method) - Label purchase, tracking, and void on the outbound delivery (
Transfer) - Address validation
Carrier modules (DHL, FedEx, UPS, Royal Mail) implement the interface. Each ships as a normal module_category_integrations module with integration_category: Shipping, so installing it auto-creates one shared Integration record whose credentials are company_scoped (each company plugs in its own keys against the same record).
┌──────────────────────────────────────────────────────────────────┐│ CONSUMING SURFACES ││ ┌───────────────┐ ┌───────────────┐ ┌────────────────────┐ ││ │ Sales order │ │ Checkout │ │ Outbound Transfer │ ││ │ (live rate + │ │ (live rates) │ │ (buy label, track, │ ││ │ validate) │ │ │ │ void) │ ││ └───────┬───────┘ └───────┬───────┘ └─────────┬──────────┘ ││ └───────────────────┼─────────────────────┘ ││ ▼ ││ ┌──────────────────────────────────────┐ ││ │ shipping_carrier (backbone) │ ││ │ - ShippingIntegration hook contract │ ││ │ - ShippingMethod "Live Carrier Rate" │ ││ │ - Transfer carrier/tracking/label │ ││ └──────────────────┬───────────────────┘ ││ │ ││ ┌───────────┬──────────┼───────────┬──────────────┐ ││ ▼ ▼ ▼ ▼ ▼ ││ shipping_dhl shipping_fedex shipping_ups shipping_royal_mail │└──────────────────────────────────────────────────────────────────┘Integration Interface
Section titled “Integration Interface”The ShippingIntegration model in shipping_carrier defines the interface. All hooks are cooperative overrides: a provider guards each with its own _is_<carrier>() check and defers to super() when the integration isn’t its own — exactly like the payment providers.
Mode and Sandbox URL are not carrier-specific. Integration declares integration_mode (Test/Live), sandbox_url, and the api_base_url(test_url, live_url) seam that resolves them; shipping_carrier only opts the category in, by overriding calc_supports_test_mode to return True for its own records. See Integrations.
class ShippingIntegration(Model): __inherit__ = "Integration"
# --- Talking to the carrier --- async def carrier_request(self, method: str, url: str, **kwargs): """Call the carrier and return parsed JSON, raising UserError with a message the user can act on when it refuses. Route every request through this."""
# --- Capability probes (safe base defaults) --- async def supports_shipping(self) -> bool: return False async def carrier_services(self) -> list: return [] # [{"code","name"}] async def supports_address_validation(self) -> bool: return False
# --- Core hooks (base raises NotImplementedError) --- async def get_rates(self, shipment: dict) -> list: """Live rate shopping → [{"service", "service_name", "price", "currency", "transit_days"}]."""
async def create_label(self, shipment: dict, service: str) -> dict: """Buy a label → {"tracking_number", "label_data" (base64), "label_format", "cost", "currency", "carrier_shipment_id", "tracking_url"}."""
async def track_shipment(self, tracking_number: str) -> dict: """→ {"status", "checkpoints": [...], "estimated_delivery"}."""
async def cancel_shipment(self, carrier_shipment_id: str) -> dict: """Void a label → {"success": bool, "message": str}."""
async def validate_address(self, address: dict) -> dict: """→ {"valid": bool, "normalized": {...}|None, "messages": [...]}."""The shipment payload
Section titled “The shipment payload”Hooks receive a provider-agnostic shipment dict — the backbone builds it from the order or the transfer, and the provider maps it to the carrier’s request shape:
{ "ship_from": <address dict>, "ship_to": <address dict>, "packages": [{"weight", "length", "width", "height", "value"}], # kg / cm "total_value": float, "currency": str, "reference": str, # the delivery / order name}# address dict: {name, company, street, street2, city, state, zip,# country_code, phone, email}Weights are in kilograms and dimensions in centimetres; convert to the carrier’s units inside the provider. A capability a carrier doesn’t offer (e.g. Royal Mail has no address-validation endpoint) simply returns False from its probe and inherits the base NotImplementedError for the hook — the consuming code only calls hooks whose probe is True.
How the backbone uses the hooks
Section titled “How the backbone uses the hooks”| Surface | Backbone entry point | Hook called |
|---|---|---|
| Sales order pricing | SaleOrder.apply_shipping_method (method delivery_type == "Live Carrier Rate") | get_rates |
| Store checkout | SaleOrder.ecom_shipping_options (bridge ecommerce_shipping_carrier) | get_rates |
| Address check | SaleOrder.action_validate_delivery_address | validate_address |
| Fulfilment | Transfer.action_buy_shipping_label | create_label |
| Fulfilment | Transfer.action_track_shipment | track_shipment |
| Fulfilment | Transfer.action_void_label | cancel_shipment |
A ShippingMethod opts into live rating by setting delivery_type = "Live Carrier Rate", an enabled shipping_integration, and an optional carrier_service (blank = the cheapest quoted service is chosen). The purchased label, tracking number, cost, and tracking URL are stored on the outbound Transfer (the delivery record).
carrier_service is a relation to a CarrierService record, not a typed code: enabling an integration calls your carrier_services() and syncs what it returns into records, so the user picks “FedEx Priority Overnight” from a list while your hooks still receive the carrier’s own code. Re-syncing (the Refresh Services button) renames in place and archives what you drop, so a method already pointing at a service never breaks.
Building a Carrier Provider Module
Section titled “Building a Carrier Provider Module”Step 1: Module structure
Section titled “Step 1: Module structure”fullfinity/enterprise/shipping_mycarrier/├── __init__.py├── manifest.yaml├── models/│ ├── __init__.py # from . import mycarrier_provider│ └── mycarrier_provider.py├── views/│ └── mycarrier_provider_views.yaml└── static/img/mycarrier_logo.pngStep 2: Manifest
Section titled “Step 2: Manifest”name: MyCarrieridentifier: shipping_mycarrierversion: '1.0'category: module_category_integrationsintegration_category: Shippingdescription: Ship with MyCarrier — live rates, labels, tracking, address validation.dependencies:- shipping_carriericon: Truckimage: /static/img/mycarrier_logo.png # REQUIRED for integration modulesstatic_paths:- imgStep 3: Provider model
Section titled “Step 3: Provider model”from fullfinity.engine.base import *
MYCARRIER_BASE_TEST = "https://sandbox.mycarrier.com"MYCARRIER_BASE_LIVE = "https://api.mycarrier.com"
class MyCarrierIntegration(Model): __inherit__ = "Integration"
# Secrets use the Encrypted field (Fernet at rest); non-secret ids stay Char. mycarrier_client_id = Char(max_length=128, company_scoped=True, description="Client ID") mycarrier_client_secret = Encrypted(company_scoped=True, description="Client Secret")
async def _is_mycarrier(self) -> bool: await self.fetch_related("module") return self.module and self.module.identifier == "shipping_mycarrier"
def _base_url(self) -> str: # `api_base_url` is declared on Integration (every category shares it): it resolves # Test vs Live and honours the Sandbox URL override in Test. A carrier serving both # environments from one host passes the same URL twice. return self.api_base_url(MYCARRIER_BASE_TEST, MYCARRIER_BASE_LIVE)
async def _request(self, method, path, **kwargs): # Always go through carrier_request: it turns a carrier's refusal into a message # naming what to fix, instead of an unhandled traceback in the user's face. return await self.carrier_request(method, f"{self._base_url()}{path}", **kwargs)
async def enable_integration(self): if await self._is_mycarrier() and not (self.mycarrier_client_id and self.mycarrier_client_secret): raise UserError("Enter your MyCarrier client ID and secret before enabling.") await super().enable_integration()
async def supports_shipping(self) -> bool: return True if await self._is_mycarrier() else await super().supports_shipping()
async def get_rates(self, shipment: dict) -> list: if not await self._is_mycarrier(): return await super().get_rates(shipment) data = await self._request("POST", "/rates", json=self._to_rate_body(shipment)) return [self._to_rate(r) for r in data.get("rates", [])]
# ... create_label / track_shipment / cancel_shipment / validate_address, each # guarded by `_is_mycarrier()` and deferring to super() otherwise.Cache an OAuth token per company in the key/value store rather than re-authenticating each call — use env.valstore("shipping_mycarrier"), never the raw Valkey client:
async def _access_token(self) -> str: store = env_ctx.get().valstore("shipping_mycarrier") key = f"token:{env_ctx.get().company_id}:{self.integration_mode}" token = await store.get(key) if token: return token # ... POST the client-credentials grant ... await store.set(key, token, ttl=max(expires_in - 60, 60)) return tokenStep 4: Surface credentials on the Integration form
Section titled “Step 4: Surface credentials on the Integration form”Extend integration_form_view, gating each field on the record’s own identifier Char (an M2O module__identifier never reaches the client, so it can’t be used in a visible clause):
- data_type: UiView identifier: mycarrier_integration_form_extension type: Form model: Integration inherited_view: integration_form_view arch: operations: - action: add target: {field: description} position: after value: type: field name: mycarrier_client_id properties: widget: TextInput visible: Q(identifier='shipping_mycarrier') & Q(state__neq='Not Installed')Use widget: PasswordInput for secret fields.
Step 5: Enterprise gating (if licensed)
Section titled “Step 5: Enterprise gating (if licensed)”The bundled carriers are enterprise-gated. A licensed carrier module lives under fullfinity/enterprise/ and has its identifier in _ENTERPRISE_MODULES (fullfinity/engine/licensing/__init__.py) — the test test_enterprise_gating.py fails CI if an enterprise/ module is missing from the list. A free carrier goes in fullfinity/modules/ and is never listed.
Testing
Section titled “Testing”- Install and enable the carrier with sandbox credentials (Mode = Test).
- Create a
ShippingMethodwithdelivery_type = "Live Carrier Rate", pointing at the integration. - Pick a Carrier Service on it, or leave it blank to take the cheapest rate quoted.
- On a sales order, Add Shipping with that method — the dialog quotes the carrier and shows the price and the service before you apply it.
- Confirm the order, open the outbound delivery, and Buy Shipping Label — tracking, label and cost are stored on the transfer; Track and Void Label exercise the remaining hooks.
Unit-test the request/response mapping with a mocked HTTP client (no live carrier needed) — assert the body your provider builds and that a documented carrier response maps to the hook’s return dict.
When you can’t get into the carrier’s sandbox
Section titled “When you can’t get into the carrier’s sandbox”Most carriers gate sandbox access behind being their customer, which leaves you unable to call anything at all while building a provider. Set Sandbox URL on the integration (the field appears on the form in Test mode) and every request goes there instead:
python3 tests/carrier_mock.py # serves the bundled carriers' shapes on :8787Then set http://localhost:8787 as the Sandbox URL and drive the app normally — quote, label, track, void. The mock takes ?mock_status=401 / ?mock_delay=5 on any route so you can exercise the failure paths too. Add your own carrier’s routes to _ROUTES in that file.
A live integration can never be redirected: saving a Sandbox URL while Mode is Live is refused, and api_base_url returns the live host regardless of what is stored.
Build your mock’s responses from the carrier’s published schema, not from what your provider happens to read. A mock that mirrors the provider’s assumptions confirms the provider agrees with itself — it cannot catch a field you invented. (Every response shape in
carrier_mock.pycame from the carriers’ own specs, which is how three field-name bugs surfaced.)
Next Steps
Section titled “Next Steps”- Payment Integrations — the sibling provider framework
- Integrations — general integration framework