Skip to content
All Posts
Migration Guide

WooCommerce USPS Rates Not Showing: The 2026 Fix

·Updated ·11 min read·By RevAddress·Migration Guide

USPS rates stopped showing in WooCommerce checkouts on January 25, 2026, the day USPS retired the Web Tools XML API. Every plugin that called secure.shippingapis.com or production.shippingapis.com stopped returning rates that morning, and the failure is quiet: an empty shipping block at checkout, sometimes a PHP cURL error 6: Could not resolve host in the log, no admin notice. Stores that never enabled logging found out from customers.

The official extension was updated. REST support landed in USPS Shipping Method 5.2.5, so for most stores this migration is a plugin update and two credential fields rather than a rewrite. What the update does not give you is caching, and the USPS rate limit is the thing that will bite next.

Find your symptom first

Six failures produce an empty shipping block, and they need different fixes. Match the symptom before touching anything.

What you see What it is Where to go
No USPS options at checkout, nothing in the log Extension below 5.2.5, still building XML for a retired endpoint Check the version, below
cURL error 6: Could not resolve host Custom code still calling shippingapis.com Rewrite against OAuth, not reconfigure
REST API Status never reads Authenticated Wrong Consumer Key or Secret, or an app under a different USPS Business Account Configure the extension, below
401 or invalid_client in the log Same cause, confirmed by USPS. A trailing space in the secret is a real one Configure the extension, below
429, and the customer sees nothing The 60-request-per-hour application cap. WooCommerce renders no error for this Cache, below
Rates on some carts and not others Zero-weight products, or a store weight unit that is not what the code assumes Getting the weight right, below

The three rows in the middle look identical from the front end. The log is the only thing that separates them, which is why logging goes on before the next test rather than after it.

Check the version first

Before anything else, find out whether you are running dead code:

Check the installed extension versionbash
wp plugin list --field=name,version --format=table | grep -i usps

# or, if WP-CLI is not available:
# Plugins > Installed Plugins > USPS Shipping Method > version under the name

Below 5.2.5, the plugin is still building XML requests to an endpoint that no longer answers. Update before you debug anything else, because no credential you enter will change the outcome.

Other USPS integrations broke the same day and have their own upgrade paths. Anything custom that passed a USERID query parameter to shippingapis.com needs to be rewritten against OAuth, not reconfigured.

What actually changed

The v3 REST API is an architectural replacement, not a version bump.

Web Tools (retired) USPS v3 REST
Protocol XML over HTTPS JSON REST
Auth USERID query parameter OAuth 2.0 bearer token
Rate lookup Batch XML request Per-call JSON endpoint
Street field Address2 streetAddress
Unit or apartment Address1 secondaryAddress
Endpoint secure.shippingapis.com/ShippingAPI.dll api.usps.com/prices/v3/…
Registration Web Tools portal (closed) developers.usps.com
Rate limit Effectively none 60 requests per hour, per app

The Address1 and Address2 swap is the single most common bug in hand-rolled v3 integrations. Web Tools put the street in Address2 and the apartment in Address1, which was backwards from every other address API in existence. The v3 field names read the way you would expect. Code that ports the old field order by name rather than by meaning will send apartment numbers as street addresses and get back nothing.

The rate limit is the change with no equivalent in the old world. Sixty requests an hour is shared across every endpoint the app calls, so rating, tracking, and validation all draw from the same window. See what the USPS API actually costs for the fee curve that came with it.

Configure the official extension

Register the app first. Sign into a USPS Business Account, create an app at developers.usps.com, and copy its Consumer Key and Consumer Secret.

Then go to WooCommerce → Settings → Shipping → USPS and fill in two fields:

Field Value
REST API Key Your app’s Consumer Key
REST API Secret Your app’s Consumer Secret

Save, then read the REST API Status row on the same screen. It reads Authenticated only when USPS accepted the pair and issued a token. That row is the fastest diagnostic on this page: if it does not authenticate, nothing downstream is worth investigating, and the cause is almost always a key copied from the wrong app or an app that has not finished approval.

Set a fallback flat rate while you are on the screen. It is the difference between a degraded checkout and a broken one.

Read the log when rates go missing

WooCommerce writes shipping calls to its own log once logging is on. Turn it on before you test, not after.

Enable logging and watch USPS callsbash
# WooCommerce > Settings > Advanced > Legacy API is unrelated —
# logging lives under WooCommerce > Status > Logs.

wp option update woocommerce_usps_settings --format=json \
"$(wp option get woocommerce_usps_settings --format=json | sed 's/"debug":"no"/"debug":"yes"/')"

tail -f wp-content/uploads/wc-logs/*.log

Three responses account for nearly every empty rate block:

401 or invalid_client. The credential pair is wrong, or the app was registered under a different USPS Business Account than the one you think. Re-copy both values; a trailing space in the secret is a real and common cause.

429. You hit the hourly cap. USPS answers with a quota message pointing at its web-tools inquiry form. WooCommerce shows the customer an empty shipping section, not an error, so this is invisible unless you are reading the log.

Timeout with no response body. USPS was slow rather than unavailable. Raise the client timeout to around 8 seconds and make sure your fallback rate is configured, because a 30-second timeout is worse than a wrong rate.

Cache, or the cap will find you

This is the part the extension update does not solve, and it is worth understanding before your next busy day.

WooCommerce calls calculate_shipping() on every cart change and every address-field change, not once per order. A customer who edits their ZIP twice and adds an item has generated four rate requests. A store doing 50 orders a day can easily send several hundred requests an hour at peak, against a ceiling of 60.

The fix is a cache keyed on the only three things that change the answer: origin ZIP, destination ZIP, and weight rounded to a bucket. Sixty seconds is enough to collapse the repeated calls inside a single checkout session without ever serving a stale price.

Transient-cached rate lookup in a WC_Shipping_Methodphp
public function calculate_shipping( $package = [] ) {
  $weight = round( WC()->cart->get_cart_contents_weight(), 1 );

  $cache_key = 'usps_rates_' . md5( implode( '|', [
      $this->origin_zip,
      $package['destination']['postcode'] ?? '',
      (string) $weight,
  ] ) );

  $rates = get_transient( $cache_key );

  if ( false === $rates ) {
      $rates = $this->fetch_rates( $package, $weight );

      // Cache the empty result too, briefly. Without this, a rate-limited
      // store re-requests on every keystroke and stays rate-limited.
      set_transient( $cache_key, $rates, empty( $rates ) ? 15 : 60 );
  }

  if ( empty( $rates ) ) {
      $fallback = (float) $this->get_option( 'fallback_rate', '0' );
      if ( $fallback > 0 ) {
          $this->add_rate( [
              'id'    => $this->id . '_fallback',
              'label' => __( 'Standard Shipping', 'yourco-usps' ),
              'cost'  => $fallback,
          ] );
      }
      return;
  }

  foreach ( $rates as $rate ) {
      $this->add_rate( $rate );
  }
}

The short negative cache is the line most implementations leave out. Caching only successful responses means a store that hits 429 keeps hammering USPS on every keystroke and never recovers inside the hour.

Getting the weight right

USPS Domestic Prices takes weight in pounds as a decimal. WooCommerce stores it in whatever unit the store owner picked, and that setting is a store-wide option nobody remembers changing.

WooCommerce weight unit to USPS poundsphp
private function cart_weight_to_pounds(): float {
  $weight = (float) WC()->cart->get_cart_contents_weight();

  switch ( get_option( 'woocommerce_weight_unit' ) ) {
      case 'g':   return round( $weight / 453.59237, 2 );
      case 'kg':  return round( $weight * 2.20462, 2 );
      case 'oz':  return round( $weight / 16, 2 );
      case 'lbs':
      default:    return round( $weight, 2 );
  }
}

Two failure modes come out of this. A cart weight of zero, because products were imported without weights, produces either a rejected request or a rate for a one-ounce envelope. And a store set to grams that sends the raw number asks USPS to price a package a thousand times heavier than it is. Both look like “the API is wrong” in a support ticket.

Check the products, not just the code:

Find products with no weight setbash
wp db query "SELECT p.ID, p.post_title
FROM wp_posts p
LEFT JOIN wp_postmeta m ON m.post_id = p.ID AND m.meta_key = '_weight'
WHERE p.post_type = 'product'
  AND p.post_status = 'publish'
  AND (m.meta_value IS NULL OR m.meta_value = '')
LIMIT 25;"

When to write your own shipping method

Stay on the official extension if you need rates and nothing more. Write your own WC_Shipping_Method when you need something it does not do: tracking detail rendered in the customer account, per-store credential isolation across a multisite network, or a rate cache you control.

Two honest paths from there.

Call USPS directly. composer require revaddress/usps-v3-php gives you an MIT-licensed, zero-dependency PHP client for the v3 REST API on PHP 8.0 or later. It handles the OAuth token lifecycle and the multipart label parsing, and it holds your credentials in your own code:

Calling USPS directly with the PHP clientphp
use RevAddress\USPSv3\Client;

$usps = new Client( YOURCO_USPS_KEY, YOURCO_USPS_SECRET );

$result = $usps->validateAddress( [
  'streetAddress' => '1600 Pennsylvania Ave NW',
  'city'          => 'Washington',
  'state'         => 'DC',
  'ZIPCode'       => '20500',
] );

$result['address']['DPVConfirmation']; // "Y"

You own the OAuth app, the license, the fee, and the 60-per-hour budget. That is the right trade when you want no third party in the path.

Route through a managed API. RevAddress sits between your store and USPS: you connect your own USPS license once from the dashboard, and the platform manages the OAuth lifecycle, caches identical quotes, and budgets against the hourly window. POST /api/rates returns the USPS Domestic Prices body under a rates key with serviceStandards alongside it, and GET /api/tracking/{trackingNumber} returns scan events.

Rate fetch against the managed APIphp
private function fetch_rates( array $package, float $weight_lbs ): array {
  $response = wp_remote_post( 'https://api.revaddress.com/api/rates', [
      'headers' => [
          'X-API-Key'    => $this->get_option( 'api_key' ),
          'Content-Type' => 'application/json',
      ],
      'body'    => wp_json_encode( [
          'originZIPCode'      => $this->origin_zip,
          'destinationZIPCode' => $package['destination']['postcode'],
          'weight'             => $weight_lbs,
          'length'             => 12,
          'width'              => 9,
          'height'             => 3,
      ] ),
      'timeout' => 8,
  ] );

  if ( is_wp_error( $response ) ) {
      return [];
  }

  // A paid plan with no USPS license connected answers with this header
  // instead of rates. Log it once — it is a setup problem, not an outage.
  if ( wp_remote_retrieve_header( $response, 'x-revaddress-byok' ) === 'required' ) {
      wc_get_logger()->warning( 'USPS license not connected', [ 'source' => 'yourco-usps' ] );
      return [];
  }

  $data = json_decode( wp_remote_retrieve_body( $response ), true );

  return $data['rates']['rateOptions'] ?? [];
}

Each option in rateOptions carries a mailClass enum, a totalBasePrice, and a zone. There is no human-readable service name in the payload, so map the enum to a label before you show it, or customers read USPS_GROUND_ADVANTAGE at checkout.

What is free and what is not

Address standardization, geocoding, and address extract run against US Census Bureau data. No USPS license, no per-lookup fee, and they are enough to get a shipping address into correct postal form and return street-level coordinates. They do not return DPV, ZIP+4, or any USPS deliverability flag, because those are USPS data and USPS data now requires a signed license.

Rating, tracking, and USPS-verified deliverability run on the USPS license you connect. Connecting it takes about three minutes from the dashboard, and from then on the OAuth lifecycle, the token refreshes, the caching, the retries, and the budgeting against the 60-per-hour window are handled for you. The USPS relationship stays yours, which is also what keeps your own reporting and compliance posture intact.

Before you call it done

  1. REST API Status reads Authenticated after a save and a page reload.
  2. A test cart to a known-deliverable ZIP returns at least two mail classes.
  3. The log shows no 401, 429, or timeout across a dozen quotes.
  4. A cart with a zero-weight product still returns something, because your fallback rate is set.
  5. Editing the destination ZIP twice inside one session fires one USPS call, not three. That is the cache doing its job.

Start here

  • What the USPS API actually costs — the fee curve and the license sequence that now gates rating
  • Get a free API key — standardization, geocoding, and address extract on Census Bureau data, no USPS license, no per-lookup fee
  • Plans and BYOK — rates, tracking, and DPV run on the USPS license you connect; label creation and voids sit on the Pro tier, also on your license
  • Magento AC-15210 fix — the same migration for a Magento 2 store

Questions

Why are USPS rates not showing in my WooCommerce checkout?
Six causes produce the same empty shipping block. An extension below 5.2.5 is still calling the retired Web Tools endpoint. Custom code still pointed at shippingapis.com throws cURL error 6. Wrong REST credentials return 401. The 60-per-hour application cap returns 429, which WooCommerce never surfaces to the customer. Zero-weight products and a mismatched store weight unit break some carts and not others. Turn on WooCommerce logging first, because the front end shows the same nothing for all six.
Do I have to replace the official WooCommerce USPS extension?
No. USPS Shipping Method 5.2.5 and later speaks the REST API. Update the extension, paste a Consumer Key and Consumer Secret from a USPS developer-portal app into the REST API Key and REST API Secret fields, and rates come back. Replacement only makes sense if you need caching, tracking detail, or multi-store credential isolation the extension does not offer.
Why do rates still not appear after updating to 5.2.5?
Check the REST API Status row on the settings screen first. It reads Authenticated only when USPS accepted the credential pair. If it authenticates and rates are still missing, you are almost certainly hitting the 60-requests-per-hour application cap, which USPS answers with a 429 that the extension does not surface as a customer-visible error.
What replaced the USERID parameter?
OAuth 2.0. Web Tools authenticated with a USERID in the query string. The v3 REST API issues a bearer token from a Consumer Key and Consumer Secret pair tied to an app you register at developers.usps.com. Tokens expire, so anything you write yourself has to cache and refresh them.
Is the USPS API still free in 2026?
No. Since August 1, 2026, the Addresses API bills on a consumption-tier curve and requires a signed license agreement. Rating and tracking run against your own USPS account, so the license and the fee are yours. Address standardization and geocoding against Census Bureau data stay free and need no USPS license.
How do I stop the 60-per-hour cap from emptying my checkout?
Cache. WooCommerce fires a rate request on every cart and address change, not on every order, so the request count tracks sessions rather than sales. Cache quotes in a transient keyed on origin ZIP, destination ZIP, and a rounded weight, and always configure a fallback flat rate so an empty USPS response never leaves the customer with no shipping option.