Dynamic DDP shipping in WooCommerce checkout: real-time duties and taxes from an API
Disclosure: some links in this guide are affiliate links. If you sign up through them, I may earn a commission at no extra cost to you. How this site makes money.
WooCommerce's tax rules model your own sales tax or VAT, not the destination's customs: import duty depends on each product's HS code, its country of origin, the destination's thresholds and the courier's DDP fee, and WooCommerce stores none of that. Quote it at checkout instead. Easyship's Rates API (v2024-09) returns DDP rates whose total_charge already includes import duty, import tax and the DDP handling fee when you send incoterms DDP with calculate_tax_and_duties true. Use that endpoint, not the separate Taxes and Duties endpoint, which is limited to 100 calls a day. Only call it once the address is complete and the postcode is valid, cache each quote for 30 minutes, cap the wait at 3 seconds, and stop calling for a minute after a failure. When there's no DDP quote, show a rate that says plainly that duties are due on delivery, never a guessed DDP price. In our tests a cached quote added nothing, an 0.8-second API added about 0.8 seconds once, and an outage cost the first shopper 3 seconds and everyone after them nothing. Easyship is our pick; DDP rates need a paid plan.
Picture a customer in London who orders a $60 candle set from your US store, pays $18 for shipping, and a week later is asked by the courier for duties and VAT before the parcel is handed over. Some customers pay and never come back. Others refuse the parcel, and you pay to ship it back. Delivered Duty Paid (DDP) fixes this: you charge duties and import taxes at checkout and the courier settles them at the border. The hard part is knowing the amount while the customer is still on the checkout page. This guide calls Easyship's Rates API from a WooCommerce shipping method to get it, and makes sure a slow or failing API never breaks the checkout.
We read Easyship's API reference and OpenAPI definition for version 2024-09, its guides on rates at checkout, taxes and duties, HS codes and rate limits, its plans page and its December 2025 post on API pricing (all October 2026). The shipping method was tested on a real WordPress 7.1.3 and WooCommerce 11.2.1 store (US based, prices in USD, weights in pounds) through WooCommerce's Store API, which is what the block checkout uses, against a local mock of the Rates API built from Easyship's published response examples. All 27 checks pass. We didn't have an Easyship account, so the duty amounts in this guide come from the mock, not from Easyship. This is technical guidance, not customs or tax advice.
1. The short answer
| Question | Answer |
|---|---|
| Can WooCommerce tax rules do this? | No. They have no HS codes, origins or customs thresholds |
| Which Easyship endpoint? | POST /2024-09/rates with incoterms: "DDP" and calculate_tax_and_duties: true |
| Not this one | The Taxes and Duties endpoint: 100 calls a day, 3,000 a month |
| What to charge | The rate's total_charge: shipping, surcharges, import duty, import tax and the DDP handling fee |
| When to call | Only when the address is complete and the postcode is valid; then cache the quote for 30 minutes |
| If Easyship is slow or down | 3-second cap, a 1-minute circuit breaker, an earlier quote for the same cart, or a rate labelled "duties due on delivery" |
| Plan | DDP rates aren't available on Easyship's free plan; tax and duty at checkout is listed from Premier |
2. Why WooCommerce's tax settings can't do it
WooCommerce tax rates are percentages by country, state and postcode, applied to product prices and optionally shipping. That models the tax you collect as a seller. Import duty works differently:
- It depends on what the product is. Customs classifies goods by Harmonized System (HS) code. A candle and a candle holder in the same parcel can carry different duty rates. WooCommerce has no HS code field.
- It depends on where it was made, not where you ship from. A product made in China and shipped from Chicago can carry different duty than one made in the US.
- It has thresholds. Below a destination's de minimis value, duty and sometimes tax are zero. Easyship returns
is_above_thresholdfor exactly this. - Import tax is charged on a different base: often the goods plus shipping plus duty, not the product price you see in the cart.
- The courier adds a fee for paying all this on your behalf. Easyship reports it as
ddp_handling_fee.
So the number has to come from a service that knows the tariffs, with the HS code and origin of every line in the cart.
3. What Easyship's API gives you, and its limits
The Rates API returns a list of courier services for a prospective shipment. When the request says incoterms: "DDP" and calculate_tax_and_duties: true, each rate carries import_duty_charge, import_tax_charge and ddp_handling_fee, and its total_charge includes them. The documented examples also include a per-line duty breakdown with the HS code Easyship applied and the calculation method (CIF, for example).
The limits shape the code more than the features do:
- The dedicated Taxes and Duties endpoint is limited to 100 requests a day and 3,000 a month. That's fine for a back-office calculator and useless for a checkout. The Rates API with taxes and duties turned on is the one to call per cart.
- The Rates API allows 60 requests a minute and 10 a second per account (6 a minute in the sandbox). A busy store that calls it on every address change will hit that.
- Calls count toward your plan's API allowance. Easyship's December 2025 post lists Rates allowances from 2,000 calls a month on Plus up to 40,000 on Scale 3, then a fraction of a cent per extra successful call. Caching is a cost decision as well as a speed one.
- Plan gating shows up in the response. The API reference documents a 402 with "The DDP RATES feature is not available for free subscription plan", and another example where a plan without tax and duty data gets rates whose tax and duty fields are
null. The plugin treats both as "no DDP quote". - Some tariffs aren't covered. Easyship says non-percentage duties, such as Switzerland's one-off import duty, aren't supported, and that frequent rule changes in countries like Saudi Arabia may take time to reach its data.
- HS codes go in Easyship's 8-digit form: the six internationally shared digits followed by
00. The plugin converts3406.00.10to34060000.
4. Keeping the checkout fast
WooCommerce calculates shipping whenever the cart or the address changes. The block checkout sends the address to the server as the shopper fills it in, so a naive integration calls the API for "S", "SW1A" and "SW1A 2" before the postcode is even finished. The plugin uses five rules:
- No call until the address can be quoted: street, city, state where the country has states, and a postcode that passes WooCommerce's own validation for that country. In the test, typing a UK postcode in three steps made no call; the complete address made one.
- Cache by the exact request. The cache key is a hash of the whole request: address, items, HS codes, values. Another shopper with the same cart and address gets the same quote without a call, and placing the order made no extra call either.
- A 3-second cap. If Easyship hasn't answered, the shopper gets the fallback rather than a spinner.
- A circuit breaker. After a timeout, a 5xx or a 429, no calls for a minute; after a 402 (plan or balance), an hour. During our simulated outage, three shoppers produced one API call between them.
- Stale beats wrong. While the API is down, a quote for the same cart from the last 24 hours is still offered. Otherwise the fallback says plainly that duties are due on delivery.
cart/update-customer on our local test server, including WordPress's own 0.6 s on this machine. The API delay and outage were simulated with the mock.5. The shipping method
Drop this into wp-content/mu-plugins/, add the two constants, give your products an _hs_code (and _origin_country where it differs from your store's country), and add "Easyship DDP" to your international shipping zones.
<?php
/**
* Plugin Name: Easyship DDP rates
* Description: International shipping rates with import duties and taxes included (DDP), quoted by Easyship's Rates API, cached, and replaced by a clearly labelled duties-due-on-delivery rate when Easyship is slow or down.
*
* wp-config.php:
* define( 'EASYSHIP_TOKEN', 'prod_...' ); // Easyship: Connect > API Integration (needs the advanced rate scope)
* define( 'EASYSHIP_ORIGIN', array( 'line_1' => '...', 'city' => '...', 'state' => '...', 'postal_code' => '...', 'country_alpha2' => 'US' ) );
* Products: an "_hs_code" meta (6+ digits) and optionally "_origin_country" (ISO-2) on every product you ship abroad.
* Then add "Easyship DDP" to your international shipping zone(s).
*/
defined( 'ABSPATH' ) || exit;
defined( 'EASYSHIP_API' ) || define( 'EASYSHIP_API', 'https://public-api.easyship.com/2024-09' );
const ES_TTL = 30 * MINUTE_IN_SECONDS; // same cart + address within this window: no new API call
const ES_STALE = DAY_IN_SECONDS; // an older quote for the same cart may stand in while the API is down
const ES_TIMEOUT = 3; // seconds the shopper may wait for a quote
const ES_FALLBACK = 25.0; // flat price of the duties-due-on-delivery fallback rate, in store currency
add_action( 'woocommerce_shipping_init', function () {
class ES_DDP_Method extends WC_Shipping_Method {
public function __construct( $instance_id = 0 ) {
$this->id = 'easyship_ddp';
$this->instance_id = absint( $instance_id );
$this->method_title = 'Easyship DDP';
$this->method_description = 'International rates with duties and taxes included, from Easyship.';
$this->supports = array( 'shipping-zones' );
$this->enabled = 'yes';
}
public function calculate_shipping( $package = array() ) {
foreach ( es_rates( $package ) as $rate ) {
$this->add_rate( $rate + array( 'package' => $package, 'taxes' => false ) ); // duties and import VAT are in the price, not WooCommerce tax
}
}
}
} );
add_filter( 'woocommerce_shipping_methods', fn( $m ) => $m + array( 'easyship_ddp' => 'ES_DDP_Method' ) );
function es_rates( $package ) {
$d = $package['destination'];
if ( WC()->countries->get_base_country() === $d['country'] ) {
return array(); // domestic: your other methods
}
// No quote until the address is complete and valid: the block checkout re-sends the address as the shopper types
$needs_state = (bool) WC()->countries->get_states( $d['country'] );
if ( ! $d['address_1'] || ! $d['city'] || ( $needs_state && ! $d['state'] ) || ! WC_Validation::is_postcode( $d['postcode'], $d['country'] ) ) {
return array();
}
$items = array();
foreach ( $package['contents'] as $line ) {
$p = $line['data'];
$hs = preg_replace( '/\D/', '', (string) $p->get_meta( '_hs_code' ) );
if ( strlen( $hs ) < 6 ) {
return array( es_fallback( 'no HS code on ' . $p->get_sku() ) ); // a guessed duty is worse than an honest DDU rate
}
$items[] = array(
'description' => $p->get_name(),
'sku' => $p->get_sku(),
'hs_code' => substr( $hs, 0, 6 ) . '00', // Easyship's 8-digit form: the six international digits, then 00
'origin_country_alpha2' => $p->get_meta( '_origin_country' ) ?: WC()->countries->get_base_country(),
'quantity' => (int) $line['quantity'],
'actual_weight' => max( 0.01, (float) wc_get_weight( (float) $p->get_weight(), 'kg' ) ),
'dimensions' => array(
'length' => (float) wc_get_dimension( (float) $p->get_length(), 'cm' ),
'width' => (float) wc_get_dimension( (float) $p->get_width(), 'cm' ),
'height' => (float) wc_get_dimension( (float) $p->get_height(), 'cm' ),
),
'declared_currency' => get_woocommerce_currency(),
'declared_customs_value' => round( $line['line_total'] / $line['quantity'], 2 ), // per unit, after discounts: what the buyer pays
);
}
$req = array(
'origin_address' => EASYSHIP_ORIGIN,
'destination_address' => array(
'line_1' => $d['address_1'],
'line_2' => $d['address_2'],
'city' => $d['city'],
'state' => $d['state'],
'postal_code' => $d['postcode'],
'country_alpha2' => $d['country'],
),
'incoterms' => 'DDP',
'calculate_tax_and_duties' => true,
'courier_settings' => array( 'apply_shipping_rules' => true ),
'shipping_settings' => array( 'output_currency' => get_woocommerce_currency() ),
'parcels' => array( array( 'items' => $items ) ),
);
$key = md5( wp_json_encode( $req ) );
$cached = get_transient( "es_q_$key" );
if ( false !== $cached ) {
return $cached;
}
$down = get_transient( 'es_down' );
if ( ! $down ) {
$rates = es_quote( $req );
if ( is_array( $rates ) ) {
set_transient( "es_q_$key", $rates, ES_TTL );
set_transient( "es_s_$key", $rates, ES_STALE );
return $rates;
}
$down = $rates;
}
$stale = get_transient( "es_s_$key" );
return $stale ?: array( es_fallback( $down ) );
}
/** One Rates API call. Returns WooCommerce rates, or a string saying why there are none. */
function es_quote( $req ) {
$res = wp_remote_post( EASYSHIP_API . '/rates', array(
'timeout' => ES_TIMEOUT,
'headers' => array( 'Authorization' => 'Bearer ' . EASYSHIP_TOKEN, 'Content-Type' => 'application/json' ),
'body' => wp_json_encode( $req ),
) );
$code = is_wp_error( $res ) ? 0 : wp_remote_retrieve_response_code( $res );
$json = json_decode( wp_remote_retrieve_body( $res ), true );
if ( 200 !== $code ) {
$why = $code ? "HTTP $code " . ( $json['error']['code'] ?? '' ) : $res->get_error_message();
error_log( "Easyship rates: $why" );
// Circuit breaker: one slow or failed call, not one per cart update. 402 (plan or balance) won't fix itself in a minute.
if ( 0 === $code || 402 === $code || 429 === $code || $code >= 500 ) {
set_transient( 'es_down', $why, 402 === $code ? HOUR_IN_SECONDS : MINUTE_IN_SECONDS );
}
return $why;
}
$ddp = array_filter( $json['rates'] ?? array(), fn( $r ) =>
'DDP' === $r['incoterms']
&& get_woocommerce_currency() === $r['currency'] // never show a foreign-currency number as ours
&& null !== $r['import_tax_charge'] && null !== $r['import_duty_charge'] ); // null = plan without tax and duty data
if ( ! $ddp ) {
return 'no DDP rate in the response';
}
$best = fn( $k ) => array_reduce( $ddp, fn( $b, $r ) => ! $b || $r[ $k ] < $b[ $k ] ? $r : $b );
$pick = array( $best( 'cost_rank' ), $best( 'delivery_time_rank' ) ); // cheapest and fastest; one rate if they're the same courier
$out = array();
foreach ( $pick as $r ) {
$sid = $r['courier_service']['id'];
$out[ $sid ] = array(
'id' => 'easyship_ddp:' . $sid,
'label' => sprintf( '%s, duties and taxes included (%d-%d days)', $r['courier_service']['name'], $r['min_delivery_time'], $r['max_delivery_time'] ),
'cost' => $r['total_charge'], // shipping, surcharges, import duty, import tax and the DDP handling fee
'meta_data' => array(
'easyship_courier_service_id' => $sid, // create the shipment with this courier, or the duties you charged won't match
'Import duty' => wc_format_decimal( $r['import_duty_charge'], 2 ),
'Import tax' => wc_format_decimal( $r['import_tax_charge'], 2 ),
'DDP handling fee' => wc_format_decimal( $r['ddp_handling_fee'], 2 ),
'Incoterms' => 'DDP',
),
);
}
return array_values( $out );
}
function es_fallback( $why ) {
return array(
'id' => 'easyship_ddp:ddu',
'label' => 'International shipping (import duties and taxes due on delivery)',
'cost' => ES_FALLBACK,
'meta_data' => array( 'Incoterms' => 'DDU', 'Why not DDP' => $why ), // ship this one DDU: the buyer was told they'd pay at the door
);
}
Choices worth knowing about:
- Two options at most: the cheapest and the fastest DDP service, by Easyship's own
cost_rankanddelivery_time_rank. Services that only quote DDU are left out, so every option the shopper sees includes duties. - The price is
total_charge, and the rate is marked as not taxable in WooCommerce, because the import tax is already inside it. The breakdown (duty, import tax, DDP fee) is saved as rate meta and ends up on the order's shipping line. - The Easyship courier service ID is saved on the order. Create the shipment with that courier. The duties you charged belong to that quote, and another courier may calculate them differently.
- Customs value is per unit and after discounts, as Easyship's schema asks: the price the buyer actually paid for one item.
- Currency is checked. The request asks for the store currency, and a response in any other currency is refused, so a euro amount can never be shown as dollars.
- No HS code, no DDP. If any line lacks a code, the plugin doesn't call the API and shows the duties-on-delivery rate. A guessed duty is worse than an honest one.
6. Setting it up
- Create an API token in Easyship with the rate scope. Rates is an "advanced scope" endpoint, so enable advanced scopes on the API connection. Tokens start with
prod_orsand_. - Check your plan with one real request to a DDP destination. A 402 or
nulltax fields mean the plan doesn't include DDP rates or tax and duty data. - Add HS codes and origins to products. Easyship's HS code lookup (dashboard or API, 1,000 lookups a day) helps classify them; for anything uncertain, ask a customs broker.
- Set the fallback price (
ES_FALLBACK) to a flat international rate you're happy to ship DDU. - Test in the sandbox with care: 6 requests a minute, and Easyship says it doesn't show the full set of couriers there.
- If you sell into the EU under IOSS, you collect the import VAT on orders up to €150 yourself, through WooCommerce taxes. Easyship reports that VAT as
import_tax_non_chargeableand doesn't charge it at the border. Check a few EU quotes before going live so the VAT isn't charged twice.
7. Test results
| Check | Result |
|---|---|
| Partial UK postcodes (S, SW1A, SW1A 2): no call; complete address: one call with the Bearer token | Pass |
| Request: DDP, taxes and duties on, output currency USD; HS code 3406.00.10 sent as 34060000; origin CN | Pass |
| Customs value per unit (30.00 for 2 x 30.00); 1.1 lb sent as 0.5 kg; inches sent as cm | Pass |
Two DDP options (cheapest, fastest); DDU-only courier not offered; price = total_charge | Pass |
| Labels say duties and taxes are included, with delivery days; breakdown and courier ID kept | Pass |
| Same cart and address from another shopper: no call; different quantity: new quote | Pass |
| Order placed: DDP and courier ID on the shipping line, total = items + rate with no extra tax, no extra API call | Pass |
| Easyship 503: one "duties and taxes due on delivery" rate; 3 shoppers during the outage = 1 call | Pass |
| Outage with an earlier quote for the same cart: that DDP quote still offered | Pass |
| Easyship hanging: first shopper waits about 3 s, the next one doesn't wait | Pass |
402 (free plan), EUR response for a USD store, null tax fields: fallback in each case; 402 pauses calls for an hour | Pass |
| Product without an HS code: no call, fallback | Pass |
| Domestic US address: method stays out, no call | Pass |
Not tested: Easyship's live API (so no real duty amounts), the classic shortcode checkout (it uses the same shipping method code), multi-package carts, and whether Easyship's quote still matches when the label is bought later.
8. Our opinion: trade-offs
- Offer DDP where duty-free thresholds are low. That's where buyers most often face a bill at the door, and where a refused parcel comes back to you. For orders under a destination's threshold, DDP and DDU cost the buyer the same.
- Show the breakdown, or at least say what's included. A $66 shipping line looks expensive until it says "duties and taxes included". The labels here do that; a line under the rate showing the duty amount does it better.
- Quotes are estimates until the label exists. Easyship's documentation calls DDU amounts "estimated". Keep a small margin, or absorb the occasional difference, rather than adding a surcharge after the sale.
- HS codes are the real work. The API is a few hours of integration. Classifying a catalogue correctly takes longer, and a wrong code means a wrong duty, which you pay under DDP.
- Price the plan against your volume. DDP at checkout needs a paid plan, and every uncached quote is an API call against your allowance. With the cache and the address rules above, you pay for roughly one call per new cart and destination, not one per keystroke.
Related: checking your prices, currency and VAT the way foreign shoppers see them and integrating a 3PL with WooCommerce.
9. Tools
Top pick
| Tool | Role | Why it fits |
|---|---|---|
| Multi-carrier shipping with DDP rates, duties and taxes | One Rates API returns courier options with import duty, import tax and DDP fees already in the price, plus HS code lookup, labels and tracking. Free plan for core shipping; DDP rates need a paid plan, and tax and duty at checkout is listed from Premier. Paid plans start from $29 a month (plans page and API pricing post, October 2026), with a 14-day free trial. |
Alternatives: free tools and references
We don't earn anything from these links.
- Zonos: landed-cost calculation and a cross-border checkout, if you want duties handled outside your shipping software.
- World Customs Organization: the Harmonized System: how HS codes are structured.
- EU VAT One Stop Shop portal, including the Import OSS (IOSS): collecting EU import VAT at checkout for consignments up to €150.
- Easyship developer documentation: the Rates API, rate limits and the taxes and duties guide quoted here.
10. Pitfalls
- Calling the Taxes and Duties endpoint per cart, and running out of its 100 daily calls before lunch.
- Calling on every address change, before the postcode is complete.
- Letting a slow API block the checkout, or retrying it on every request during an outage.
- Showing a DDP label on a price that has no duties in it, which happens when the plan returns
nulltax fields or a courier only quotes DDU. - Sending total line value as the customs value, when Easyship expects the value of one unit.
- Charging WooCommerce tax on top of a DDP rate, or charging EU VAT twice under IOSS.
- Buying the label with a different courier from the one the buyer was quoted.
- Missing or guessed HS codes, which turn into wrong duties that you pay.
Ask Easyship's Rates API for DDP rates once the address is complete, charge the total_charge, and label it "duties and taxes included". Cache each quote, cap the wait at 3 seconds and stop calling for a while when the API fails. When there's no real DDP quote, say "duties due on delivery" instead of guessing. The checkout stays fast, the price the buyer sees is the price they pay, and parcels stop waiting at customs for someone to settle a bill.