WooCommerce: custom shipping providers (Fulfillments)
For plugins that integrate a carrier with WooCommerce — UPS / FedEx / DHL / a national post / a private courier. A "shipping provider" in WC 10.1+ is a carrier identity tied into the Fulfillments system: tracking URL generation, tracking-number pattern detection, country-pair support, and admin filtering on the orders list. Different from a shipping method, which is a checkout-time rate calculator.
WC 10.8 ships ~70 built-in providers (DHL, FedEx, UPS, USPS, Royal Mail, La Poste, Deutsche Post, Australia Post, Canada Post, dozens of national posts, courier services). Adding a new one is a small abstract-class extension + one filter callback.
Misconception this skill corrects
"A shipping provider is a shipping method. I'll extend
WC_Shipping_Method."
These are two different concepts:
| Concept | Class | When | Purpose |
|---|---|---|---|
| Shipping method | WC_Shipping_Method (sibling skill wc-shipping-method) |
Checkout time | Calculate the rate the customer pays for shipping. |
| Shipping provider | AbstractShippingProvider (this skill) |
Post-purchase / fulfillment time | Generate tracking URLs, parse tracking numbers, identify carriers on the admin orders list. |
The same store can use Free Shipping (method) at checkout and DHL (provider) for the actual fulfillment. They don't overlap or compete — they sit at different lifecycle stages.
When to use this skill
Trigger when ANY of the following is true:
- Integrating a courier / carrier with WC Fulfillments — even a small national one not in the built-in list.
- Writing a plugin that needs to generate carrier-specific tracking URLs from tracking numbers.
- Building a "tracking number autocomplete" feature (parse the entered number → infer the carrier).
- The diff or file contains:
AbstractShippingProvider,woocommerce_fulfillment_shipping_providers,FulfillmentUtils::get_shipping_providers,Automattic\WooCommerce\Admin\Features\Fulfillments\Providers. - The user says "shipping provider" in a WC context — confirm whether they actually mean shipping method (checkout rate) or shipping provider (fulfillment carrier identity).
API surface — the abstract class
Automattic\WooCommerce\Admin\Features\Fulfillments\Providers\AbstractShippingProvider (src/Admin/Features/Fulfillments/Providers/AbstractShippingProvider.php):
Required (abstract):
abstract public function get_key(): string;
abstract public function get_name(): string;
abstract public function get_icon(): string;
abstract public function get_tracking_url( string $tracking_number ): string;
Optional (default implementations exist):
public function get_shipping_from_countries(): array { return array(); }
public function get_shipping_to_countries(): array { return array(); }
public function can_ship_from( string $country_code ): bool;
public function can_ship_to( string $country_code ): bool;
public function can_ship_from_to( string $shipping_from, string $shipping_to ): bool;
public function try_parse_tracking_number( string $tracking_number, string $shipping_from, string $shipping_to ): ?array;
try_parse_tracking_number is the carrier-detection hook: given a tracking number string and a country pair, return either null (this isn't our format) or an array describing the parsed result (used by WC for autocompletion and ambiguity scoring).
Minimal scaffold — register a custom provider
namespace MyPlugin\Shipping;
use Automattic\WooCommerce\Admin\Features\Fulfillments\Providers\AbstractShippingProvider;
class MyCourierShippingProvider extends AbstractShippingProvider {
public function get_key(): string {
return 'mycourier';
}
public function get_name(): string {
return 'My Courier';
}
public function get_icon(): string {
// Absolute URL to a small carrier logo (typical: 64x64 PNG).
return plugins_url( 'assets/images/mycourier.png', MYPLUGIN_PLUGIN_FILE );
}
public function get_tracking_url( string $tracking_number ): string {
return sprintf(
'https://track.mycourier.example/?tn=%s',
rawurlencode( $tracking_number )
);
}
// Optional — restrict to specific country pairs
public function get_shipping_from_countries(): array {
return array( 'HU' );
}
public function get_shipping_to_countries(): array {
return array( 'HU', 'SK', 'AT', 'RO' );
}
// Optional — pattern-detect "is this a MyCourier tracking number?"
//
// Verified return shape (FulfillmentsManager.php lines 510-527):
// - 'url' — string, the tracking URL. NOTE: it's 'url', not 'tracking_url'.
// The Manager reads $result['url'] and exposes it as the
// outer 'tracking_url' field on the public response.
// - 'ambiguity_score' — int, HIGHER means more confident match. Built-in providers
// use values like 70 (loose match), 85-90 (typical strong
// match), 92-98 (very specific format). Manager picks the
// provider with the HIGHEST score when multiple match.
public function try_parse_tracking_number(
string $tracking_number,
string $shipping_from,
string $shipping_to
): ?array {
if ( ! preg_match( '/^MC[0-9]{10}HU$/', $tracking_number ) ) {
return null; // not our format
}
return array(
'url' => $this->get_tracking_url( $tracking_number ),
'ambiguity_score' => 90, // strong match — exact regex with country anchor
);
}
}
Then register it via the filter:
add_filter( 'woocommerce_fulfillment_shipping_providers', static function ( array $providers ): array {
// Either pass a class name string (resolved via WC's DI container)…
$providers[] = MyCourierShippingProvider::class;
// …or pass a pre-built instance.
// $providers[] = new MyCourierShippingProvider();
return $providers;
} );
That's the entire surface. The provider now appears in the Fulfillments admin (provider dropdown when adding a tracking number to an order) and in the orders-list "Shipping provider" filter.
How get_shipping_providers() resolves the filter
FulfillmentUtils::get_shipping_providers() (src/Admin/Features/Fulfillments/FulfillmentUtils.php:417) iterates the filter result and accepts either:
- An
AbstractShippingProviderinstance — used directly. - A class name string — resolved via
wc_get_container()->get( $class )(WC's DI container). Failure is silent (continueon Throwable).
It rejects entries that are neither — strings that aren't class names, scalars, anonymous arrays, etc. The final array is keyed by get_key(). Two providers with the same key collide silently — last-registered wins.
Because of the DI resolution, classes can have constructor dependencies (e.g. an HTTP client for live tracking lookups) that the container will inject — useful when the provider needs more than just the four abstract methods.
Registering in the right place
The woocommerce_fulfillment_shipping_providers filter fires every time FulfillmentUtils::get_shipping_providers() is called — admin order detail render, orders-list filter, REST endpoint. Add the filter on plugins_loaded or your Plugin class's runtime registration phase.
add_action( 'plugins_loaded', static function () {
require_once __DIR__ . '/includes/MyCourierShippingProvider.php';
add_filter( 'woocommerce_fulfillment_shipping_providers', /* ... */ );
}, 20 );
WC 10.8 REST note
The v4 Fulfillments REST surface is flat: /wc/v4/fulfillments?order_id=<id> and /wc/v4/fulfillments/<fulfillment_id>, plus /wc/v4/fulfillments/providers. It is not nested under /wc/v4/orders/<id>/.... WC 10.8 also tightened unauthenticated access to guest order fulfillments, so do not expose these responses to customer-facing code unless you perform explicit ownership checks.
Critical rules
AbstractShippingProvider≠WC_Shipping_Method. Different concept, different lifecycle stage, different class hierarchy. If the goal is "show a rate at checkout", you needWC_Shipping_Method(sibling skill).get_key()is the unique identifier — keep it stable. Renaming it is a breaking change for orders that already store the key on their fulfillment records.get_tracking_urlMUST URL-encode the tracking number to handle special characters and prevent URL injection.try_parse_tracking_numberreturnsnullon no-match, NOT an empty array. WC's detection logic distinguishes "didn't match" (null) from "matched with empty data" (array).ambiguity_score: HIGHER = MORE CONFIDENT. Verified inFulfillmentsManager::get_best_parsing_result()(line 561) —$result['ambiguity_score'] > $best_scorepicks the highest. Built-in carriers use 70 (loose / fallback patterns), 85-90 (typical strong matches), 92-98 (very specific formats). Use a high score for exact-regex matches, low for permissive heuristics that could match multiple carriers.- The result's
'url'key is the tracking URL — NOT'tracking_url'. TheFulfillmentsManagerreads$result['url'](line 522) and re-exposes it on the outer response astracking_url. If you key it astracking_urlinside your provider's parse result, the carrier is recognised but the outertracking_urlfield stays empty. - Two providers with the same
get_key()collide silently. Pick a slug-style key prefixed with your plugin / company name to avoid collisions with the built-ins (e.g.mycompany-courier, not justmycourier). - Country lists in
get_shipping_from_countries()/get_shipping_to_countries()are ISO-3166-1 alpha-2 codes —'HU','DE','US'. Same convention aswc_get_country_locale(). - The icon should be 64x64 or similar small PNG/SVG, served over HTTPS. Used in admin UI carrier selection.
- Use the DI container or a pre-built instance — both work. For providers with no constructor dependencies, the class-string form is simplest.
Common mistakes
// WRONG — extending the wrong base class
class MyCourier extends WC_Shipping_Method { /* ... */ }
// This is a checkout-time rate calculator, not a fulfillment provider.
// WRONG — bare key collision with a built-in
public function get_key(): string {
return 'dhl'; // already used by built-in DHLShippingProvider
}
// RIGHT — prefixed
public function get_key(): string {
return 'mycompany-internal-courier';
}
// WRONG — returning empty array from try_parse_tracking_number when no match
public function try_parse_tracking_number( $tn, $from, $to ): ?array {
if ( /* no match */ ) {
return array(); // BUG — WC treats this as "matched with empty data"
}
}
// RIGHT — return null on no-match
public function try_parse_tracking_number( $tn, $from, $to ): ?array {
if ( /* no match */ ) {
return null;
}
return array( 'url' => $this->get_tracking_url( $tn ), 'ambiguity_score' => 90 );
}
// WRONG — wrong key name; carrier is detected but tracking URL stays empty
return array(
'tracking_url' => $this->get_tracking_url( $tn ), // BUG — should be 'url'
'ambiguity_score' => 90,
);
// WRONG — score inverted (treating 0 as "highest confidence")
return array(
'url' => $this->get_tracking_url( $tn ),
'ambiguity_score' => 0, // BUG — 0 means LOWEST; this provider loses to any provider that returns >=1
);
// RIGHT — high score for confident match
return array(
'url' => $this->get_tracking_url( $tn ),
'ambiguity_score' => 90,
);
// WRONG — registering on rest_api_init or admin_init (too late for some uses)
add_action( 'rest_api_init', function () {
add_filter( 'woocommerce_fulfillment_shipping_providers', /* ... */ );
} );
// RIGHT — registering on plugins_loaded so it's ready for any caller
add_action( 'plugins_loaded', function () {
add_filter( 'woocommerce_fulfillment_shipping_providers', /* ... */ );
}, 20 );
// WRONG — non-URL-safe tracking number in URL
return 'https://track.example/?tn=' . $tracking_number;
// RIGHT — URL-encode user input
return 'https://track.example/?tn=' . rawurlencode( $tracking_number );
// WRONG — returning provider instances from get_icon
public function get_icon(): string {
return MYPLUGIN_PLUGIN_PATH . 'assets/icon.png'; // server filesystem path, not a URL
}
// RIGHT — public URL
public function get_icon(): string {
return plugins_url( 'assets/icon.png', MYPLUGIN_PLUGIN_FILE );
}
Reading provider data at runtime
use Automattic\WooCommerce\Admin\Features\Fulfillments\FulfillmentUtils;
$providers = FulfillmentUtils::get_shipping_providers();
// array<string, AbstractShippingProvider> keyed by provider get_key()
$dhl = $providers['dhl'] ?? null;
if ( $dhl ) {
$url = $dhl->get_tracking_url( $tracking_number );
}
// Or programmatically iterate to find a carrier that matches a tracking number:
foreach ( $providers as $provider ) {
$parsed = $provider->try_parse_tracking_number( $tn, 'HU', 'DE' );
if ( $parsed !== null ) {
// Found a match
break;
}
}
The returned providers are fully resolved instances — no further wc_get_container() calls needed.
Cross-references
- Run
wc-shipping-methodwhen the goal is a checkout-time rate calculator (the typical "I want to add a custom shipping option" request). - Run
wc-hpos-compatibilityif your provider stores per-order metadata — order writes go throughWC_Order::update_meta_data, not direct postmeta. - Run
wp-plugin-bootstrapfor theRequires Plugins: woocommercedeclaration and the surrounding plugin file.
What this skill does NOT cover
- The Fulfillments REST API endpoints (
/wc/v4/fulfillments?order_id=<id>,/wc/v4/fulfillments/<fulfillment_id>) — adjacent topic; covered by the REST v4 skill if your plugin reads/writes fulfillments programmatically. - Label generation, address printing, postage purchase flows — those are carrier-specific integrations the provider class doesn't standardize.
- The Fulfillments meta-box in the order edit screen — admin UI, separate concern.
- Live rate-fetching from the carrier API (used by some plugins to show real-time shipping rates) — that's a
WC_Shipping_Methodconcern, not a provider concern. - Webhook receivers from carriers (status updates, delivery confirmations) — also outside the provider class; usually a custom REST route on your end.
References
AbstractShippingProvider: wp-content/plugins/woocommerce/src/Admin/Features/Fulfillments/Providers/AbstractShippingProvider.php.- Provider registry & filter: wp-content/plugins/woocommerce/src/Admin/Features/Fulfillments/FulfillmentUtils.php:417 —
get_shipping_providers()+ thewoocommerce_fulfillment_shipping_providersfilter (@since 10.1.0). - v4 Fulfillments REST controller: wp-content/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Fulfillments/Controller.php — flat
fulfillmentsrest_base and providers sub-route. - Static class-name → instance map of built-in providers: wp-content/plugins/woocommerce/src/Admin/Features/Fulfillments/ShippingProviders.php — ~70 entries, useful as a reference for naming conventions and key style.
- Reference implementation (DHL): wp-content/plugins/woocommerce/src/Admin/Features/Fulfillments/Providers/DHLShippingProvider.php.