WooCommerce’s shipping zone system lets you define geographic areas and assign shipping methods to each zone — but real-world requirements often demand dynamic rates based on weight, dimensions, or a live carrier API. Implementing a custom shipping method as a WC_Shipping_Method subclass integrates cleanly with the zone system while giving you full control over rate calculation logic.
Problem: WooCommerce's built-in shipping zones cover basic flat rate and free shipping scenarios, but complex logistics — weight-based tiers, carrier-calculated rates, rules that differ by product category — require a custom shipping method.
Solution: Create a custom shipping method by extending WC_Shipping_Method — override calculate_shipping() to add rates with $this->add_rate(), define admin settings fields in init_form_fields(), and register the method with the woocommerce_shipping_methods filter. Access package weight and destination from the $package argument to implement rule-based rate logic.
The code below registers a custom shipping method class that calculates rates from a live carrier API (with transient caching), integrates with WooCommerce shipping zones, and adds an admin settings panel for the API credentials.
id = 'my_carrier';
$this->instance_id = absint( $instance_id );
$this->method_title = __( 'My Carrier', 'my-plugin' );
$this->method_description = __( 'Live rates from My Carrier API.', 'my-plugin' );
$this->supports = [ 'shipping-zones', 'instance-settings' ];
$this->title = $this->get_option( 'title', __( 'My Carrier', 'my-plugin' ) );
$this->init();
}
public function init(): void {
$this->init_form_fields();
$this->init_settings();
add_action( 'woocommerce_update_options_shipping_' . $this->id, [ $this, 'process_admin_options' ] );
}
public function init_form_fields(): void {
$this->instance_form_fields = [
'title' => [
'title' => __( 'Method title', 'my-plugin' ),
'type' => 'text',
'default' => __( 'My Carrier', 'my-plugin' ),
],
'api_key' => [
'title' => __( 'API Key', 'my-plugin' ),
'type' => 'password',
],
'markup' => [
'title' => __( 'Markup (%)', 'my-plugin' ),
'type' => 'number',
'default' => '0',
'description' => __( 'Add a percentage markup to carrier rates.', 'my-plugin' ),
],
];
}
public function calculate_shipping( array $package = [] ): void {
$api_key = $this->get_option( 'api_key' );
if ( ! $api_key ) {
return;
}
$cache_key = 'carrier_rates_' . md5( serialize( $package ) );
$rates = get_transient( $cache_key );
if ( false === $rates ) {
$rates = $this->fetch_carrier_rates( $package, $api_key );
if ( ! is_wp_error( $rates ) ) {
set_transient( $cache_key, $rates, 10 * MINUTE_IN_SECONDS );
} else {
return;
}
}
$markup = ( 1 + ( (float) $this->get_option( 'markup', 0 ) / 100 ) );
foreach ( $rates as $service ) {
$this->add_rate( [
'id' => $this->id . '_' . sanitize_key( $service['code'] ),
'label' => esc_html( $service['name'] ),
'cost' => round( (float) $service['price'] * $markup, 2 ),
'meta_data' => [ 'estimated_days' => $service['days'] ],
] );
}
}
private function fetch_carrier_rates( array $package, string $api_key ): array|\WP_Error {
$response = wp_remote_post( 'https://api.mycarrier.example.com/rates', [
'headers' => [ 'X-API-Key' => $api_key, 'Content-Type' => 'application/json' ],
'body' => wp_json_encode( [
'destination' => $package['destination'],
'weight_kg' => array_reduce( $package['contents'], fn( $w, $i ) => $w + (float) $i['data']->get_weight() * $i['quantity'], 0 ),
] ),
'timeout' => 8,
] );
if ( is_wp_error( $response ) ) {
return $response;
}
return json_decode( wp_remote_retrieve_body( $response ), true )['rates'] ?? [];
}
}
NOTE: The transient cache key uses md5(serialize($package)) — the $package array includes the cart contents, destination, and user data, so cache hits only occur when all three are identical; for shared-hosting environments where many customers share the same destination, consider a coarser cache key based only on destination postcode and total weight to improve cache hit rates.