This page is for the app that uses BWH Shipping, for example your storefront. It covers the four things you wire up: services, checkout quotes, shipments, and status changes.
1. Set up the Shipping Services
A Shipping Service is a delivery option a shopper can pick, such as "Express, 1–2 days". Create one per option, in Shipping Service → New:
| Field | Purpose |
|---|---|
| Title | What the shopper sees. It is also the service's name, so it must be unique. |
| Provider | The Shipping Provider Profile that carries it, for example Shiprocket |
| Service Code | The carrier's id for this service. Each carrier page says what it means. |
| Markup %, Handling Fee | Added on top of the live carrier rate |
| Shipping Rule | Optional. When one of its bands covers the cart, the band sets the price instead of the live rate. |
| Backup Charge | Used when there is no band and no live rate, so the option still shows |
An enabled service needs a Service Code and an enabled provider.
2. Quote at checkout
quote_services prices every enabled Shipping Service for a cart. It isn't a public API, so wrap it in a whitelisted method of your own:
# your_app/api.py
import frappe
from bwh_shipping.bwh_shipping.pricing import quote_services
from bwh_shipping.bwh_shipping.utils import get_address_payload
from bwh_shipping.units import normalise_parcels
@frappe.whitelist(methods=["POST"])
def get_delivery_options(address: str) -> list[dict]:
cart = get_current_cart() # your code: the shopper's cart
return quote_services(
origin=None, # each carrier ships from its own Pickup Address
destination=get_address_payload(address),
parcels=normalise_parcels([{"length": 30, "width": 20, "height": 10, "weight": cart.weight}]),
cart={
"declared_value": cart.grand_total,
"weight": cart.weight,
"base_net_total": cart.base_net_total,
"conversion_rate": cart.conversion_rate,
"currency": cart.currency,
},
)
Each row it returns looks like this:
{
"title": "Express",
"description": "1–2 days",
"provider": "Shiprocket",
"service_code": "10",
"amount": 99.0,
"is_free": False,
"is_live_rate": True,
}
- Carrier errors never break checkout. A carrier that fails is logged as
<provider> checkout rates failedin the Error Log, and its services fall back to their Shipping Rule or Backup Charge. - A service with no price is dropped, rather than shown as free.
- Store the chosen row on the server when the shopper picks it. When the order is placed, bill
get_charge_amount(title, cart, quoted_amount=stored_amount)from the same module. Never trust an amount the browser sends back. Without aquoted_amount, a live-rate service is billed its Backup Charge, or refused if it has none.
3. Create and book a shipment
From the desk: open a submitted Delivery Note and click Create → Shipment. That creates a draft Shipping Request, which staff book with Book Shipment.
The Delivery Note route picks:
- the carrier and service from the Sales Order's custom fields
custom_shipping_provider,custom_shipping_service_codeandcustom_delivery_option(the Shipping Service title), when you've added them. Otherwise it uses the most recently created enabled provider, with no service, so staff pick one before Book Shipment. - the origin from the carrier's Pickup Address, or the company address.
- one 30 × 20 × 10 cm parcel, weighted from the items' total weight. Items with no weight count as 0.5 kg per unit.
From code:
shipping_request = frappe.get_doc(
{
"doctype": "Shipping Request",
"provider": "Shiprocket",
"service_code": "10",
"ref_doctype": "Sales Order",
"ref_docname": sales_order.name,
"origin_address": warehouse_address,
"destination_address": sales_order.shipping_address_name,
"customer_name": sales_order.customer_name,
"declared_value": sales_order.grand_total,
"currency": sales_order.currency,
"parcels": [{"length": 30, "width": 20, "height": 10, "weight": 1.2, "count": 1}],
}
).insert(ignore_permissions=True)
shipping_request.book()
book() buys the label and sets the status to Ready To Ship. The request then holds the awb, label_url, carrier and cost_amount.
4. React to status changes
Statuses arrive from carrier webhooks, or when someone clicks Sync Status (shipping_request.sync_status()). BWH Shipping doesn't poll carriers on a schedule. If you need that, for a carrier without webhooks, run your own scheduled job that calls sync_status() on shipments that are still in flight.
To act on a change, for example to email the customer when a parcel is out for delivery, hook on_update:
# your_app/hooks.py
doc_events = {
"Shipping Request": {"on_update": "your_app.shipping.on_shipping_request_update"},
}
# your_app/shipping.py
import frappe
def on_shipping_request_update(doc, method=None):
if doc.has_value_changed("status") and doc.status == "Out For Delivery":
frappe.enqueue(send_out_for_delivery_email, shipping_request=doc.name, enqueue_after_commit=True)
Check has_value_changed, because the hook fires on every save. Enqueue slow work. During a webhook the hook runs inside the carrier's request, as Administrator.
Next steps
- Webhooks and security: set up tracking updates
- Shiprocket and AfterShip: connect a carrier