Every carrier is a Single DocType whose controller extends both Document and ShippingProviderBase:
from frappe.model.document import Document
from bwh_shipping.base_class import ShippingProviderBase
class AcmeCourierShippingSettings(Document, ShippingProviderBase): ...
| Method | Required | Called when |
|---|---|---|
get_rates |
✅ | Checkout asks for delivery options |
create_shipment |
✅ | A Shipping Request is booked |
cancel_shipment |
✅ | Staff click Cancel With Carrier |
get_tracking |
✅ | Someone clicks Sync Status |
handle_webhook |
✅ | The carrier posts to the webhook endpoint |
schedule_pickup |
Staff click Schedule Pickup | |
generate_manifest |
Staff click Generate Manifest | |
resume_booking |
Staff click Resume Booking after a partial booking | |
get_service_choices |
Your code lists the carrier's services |
Rules for every method
- Weights are in kilograms, dimensions in centimetres, and money in major units of the currency named alongside it. Convert to whatever the carrier wants with
bwh_shipping.units. - Statuses are ladder statuses, already mapped from the carrier's wording:
Draft,Ready To Ship,Pickup Scheduled,In Transit,Out For Delivery,Undelivered,RTO,Delivered,Cancelled,Lost. - To report failure, throw with
frappe.throw.get_ratesis the exception: return[]there.
get_rates
def get_rates(
self,
origin: dict,
destination: dict,
parcels: list[dict],
cod: bool = False,
declared_value: float = 0.0,
) -> list[dict]
Quotes every service that can carry the consignment. It's called once per carrier at checkout, and only when the carrier has an origin (its Pickup Address, unless the checkout passes one).
Parameters
| Name | Type | Description |
|---|---|---|
origin, destination |
dict |
See Address below |
parcels |
list[dict] |
See Parcel below. Passed as the checkout gave them, so normalise them first. |
cod |
bool |
Whether the shopper chose cash on delivery |
declared_value |
float |
The cart's value, in major units |
Returns a list of:
| Key | Description |
|---|---|
service_code |
The carrier's id for the service, as a string. It must match a Shipping Service's Service Code, and it's passed back unchanged at booking. |
amount |
The price, in major units |
currency |
The price's currency |
service_name, carrier, transit_days, cod_available |
For display |
Return [] when you can't quote, for example when the carrier doesn't serve the route. Checkout then prices the service from its Shipping Rule or Backup Charge. If you raise instead, the error is logged and checkout still renders.
create_shipment
def create_shipment(self, shipment: dict) -> dict
Books one consignment and buys its label. Called by ShippingRequest.book().
The shipment dict
| Key | Description |
|---|---|
reference |
The Shipping Request name, for example SHIP-26-00012 |
order_reference |
The document the shipment is for (ref_docname) |
service_code |
From the Shipping Service |
origin, destination |
Address dicts |
parcels |
Parcel dicts |
declared_value, currency |
Declared value, in major units |
cod, cod_amount |
Cash on delivery |
customer |
{name, phone, email} |
Returns
| Key | Required | Description |
|---|---|---|
awb |
✅ | The tracking number. Webhooks find the shipment by it. |
order_ref, shipment_ref |
✅ | Your handles for cancel, pickup and manifest. They're stored as they are. |
label_url |
Opened by Print Label | |
carrier |
The actual courier's name | |
cost_amount, cost_currency |
What the carrier charged you | |
status |
The carrier's own status, stored as provider_status |
|
tracking_ref |
A separate tracking handle, if the carrier has one |
After it returns, the request's status becomes Ready To Ship.
Throw when the carrier refuses the booking. If part of the booking already exists at the carrier, raise PartialBookingError instead (see Helpers).
cancel_shipment
def cancel_shipment(self, order_ref: str, shipment_ref: str | None = None, awb: str | None = None) -> dict
Cancels a booking. It's only offered while the request is Draft, Ready To Ship or Pickup Scheduled.
Returns {"status", "message"}. status is the carrier's own wording. The request becomes Cancelled either way, so throw if the carrier refuses.
get_tracking
def get_tracking(self, awb: str, shipment_ref: str | None = None, tracking_ref: str | None = None) -> dict
Reads the current status from the carrier. Called by ShippingRequest.sync_status().
Returns
| Key | Description |
|---|---|
status |
A ladder status, or None to leave it unchanged (scans are still added) |
provider_status |
The carrier's own wording |
events |
Scans: {timestamp, status, location, message}, plus an optional event_id |
The status only changes if the new one is higher on the ladder. Scans are added whether or not the status moves. Repeats are skipped by event_id, or by timestamp, status and message.
handle_webhook
def handle_webhook(self, payload: bytes, headers: dict) -> dict
Verifies a delivery to the webhook endpoint and says what it means.
Returns
| Return | Meaning | HTTP |
|---|---|---|
| (throws) | Verification failed | 400 |
{} |
Genuine, but nothing to act on | 200 |
{"awb", "status", "event_id", "events", "provider_status"} |
Apply to the Shipping Request with this AWB | 200 |
Optional methods
Implement only what the carrier really supports. supports(capability) returns True when your class overrides the method. Desk actions for a capability you don't support fail with a clear message.
| Method | Capability | Returns |
|---|---|---|
schedule_pickup(shipment_ref, pickup_date=None) |
pickup |
{pickup_ref, scheduled_date, message}. The status moves to Pickup Scheduled. |
generate_manifest(shipment_refs: list[str]) |
manifest |
{manifest_url} |
resume_booking(order_ref, shipment_ref, service_code=None) |
resume |
The same shape as create_shipment |
get_service_choices() |
service_choices |
{provider, accounts: [{carrier, description, services: [{service_code, service_name}]}]} |
get_provider_name() is optional too. It returns a display name, and defaults to the class name.
Address
{
"contact_name": "Ada Lovelace",
"company_name": "Lovelace Labs", # the Address title
"line1": "1 MG Road",
"line2": None,
"city": "Bengaluru",
"state": "Karnataka",
"pincode": "560001",
"country": "India", # the Frappe Country name, not an ISO code
"phone": "+919800000000",
"email": "ada@example.com",
}
Convert country to an ISO code yourself if the carrier needs one. Build this dict from an ERPNext Address with get_address_payload(address_name) from bwh_shipping.bwh_shipping.utils.
Parcel
{"length": 30, "width": 20, "height": 10, "weight": 1.2, "count": 1} # cm, cm, cm, kg
Helpers
bwh_shipping.units
| Helper | Example |
|---|---|
normalise_parcels(parcels) |
Rounds and validates a list of parcels. Throws on an empty list. |
billable_weight(parcels, divisor=5000) |
A 50 × 40 × 30 cm, 2 kg parcel → 12.0 |
volumetric_weight(parcel, divisor=5000) |
The same parcel → 12.0 |
total_weight(parcels) |
The actual weight of every parcel × count |
enclosing_dimensions(parcels) |
One box that holds them all: lengths added, widest width and height |
to_kg(value, unit), to_cm(value, unit) |
to_kg(500, "g") → 0.5 |
to_system_datetime(value) |
Converts a timestamp with a timezone offset for storage |
bwh_shipping.status: SHIPMENT_STATUSES, can_advance(current, new), is_terminal(status).
bwh_shipping.exceptions.PartialBookingError(message, booking=None): raise it when a staged booking fails part-way, with booking={"order_ref": ..., "shipment_ref": ...}. BWH Shipping saves those references, and the Book Shipment button becomes Resume Booking, which calls your resume_booking.