Skip to content
All Posts
Migration Guide

USPS v3 Migration Guide: Node.js and Python, XML to REST

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

The USPS Web Tools XML API was retired on January 25, 2026. If your code still imports usps-api or usps-webtools, or sends XML to production.shippingapis.com, it has been broken since that morning.

This is the per-language version of that migration: every common operation written twice, in Python and in Node, against the usps-v3 SDKs. For the raw HTTP flow without an SDK, the Web Tools to v3 migration guide covers endpoint mapping and the OAuth exchange directly. For a straight package swap with no rewrite, the dead package replacement guide is shorter than this one.

What the SDK takes off your hands

Web Tools (retired) v3 REST via SDK
Format XML in, XML out JSON, parsed for you
Auth Static User ID OAuth 2.0, fetched and refreshed by the SDK
Token lifecycle None 8 hours, auto-refreshed 30 minutes early
Base URL production.shippingapis.com apis.usps.com
Rate limit None enforced 60 per hour, surfaced as a typed error
Errors Parse an XML string Typed exception classes

The SDK does not remove the rate limit, and it does not enforce it either. USPS returns 429 when you exceed 60 requests an hour and the SDK raises that as RateLimitError carrying a retry hint. Handling it is still your architecture problem; the rate-limit guide has the cache and queue patterns.

Step 1: credentials

Register at developer.usps.com and create an application. You get a Consumer Key (the client ID) and a Consumer Secret, shown once. Use a business email; free providers are sometimes rejected for production access.

Verify the pair before you write anything:

Confirm the credentials workbash
curl -X POST https://apis.usps.com/oauth2/v3/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=client_credentials&client_id=YOUR_KEY&client_secret=YOUR_SECRET"

# Success:
# {"access_token":"eyJ...","token_type":"Bearer","expires_in":28800}
# 28800 seconds = 8 hours.

A 401 here is nearly always one of two things: the request was sent as JSON instead of form-encoded, or the app has not finished approval. The OAuth troubleshooting guide covers the rest.

Address validation and tracking work on OAuth alone. Label creation does not. It needs a CRID, a master and label Mailer ID, an Enterprise Payment Account, and COP claims linking, all of which are USPS enrollment steps rather than code. Both SDKs accept those as constructor arguments once you have them; the CRID and MID enrollment guide documents how to get them.

Step 2: install

Install the SDK
pip install usps-v3

# Exports Client from the usps_v3 module.
# Tokens cached in memory and at ~/.usps-v3/tokens.json.
# Calls return plain dictionaries.

Uninstall the dead package in the same commit. usps-api, usps-webtools, and usps-webtools-promise all target the retired host, and leaving one in the lockfile means CI keeps resolving a dependency that can never work.

Step 3: every operation, both languages

The two SDKs mirror each other. Python uses snake_case and returns dictionaries; Node uses camelCase and returns typed objects.

Address validation

Validate and standardize an address
from usps_v3 import Client

# Reads USPS_CLIENT_ID / USPS_CLIENT_SECRET if you omit the arguments.
client = Client(client_id="your_consumer_key", client_secret="your_consumer_secret")

result = client.addresses.validate(
  street_address="1600 Pennsylvania Ave NW",
  city="Washington",
  state="DC",
  zip_code="20500",
)

print(result["address"]["streetAddress"])   # 1600 PENNSYLVANIA AVE NW
print(result["address"]["ZIPPlus4"])        # 0005

The ZIPCode capitalisation is the single most common Node bug in this migration. An unrecognized key is dropped rather than rejected, so the call returns 200 with a less precise match and nothing tells you why.

City and state from a ZIP

City/state lookup
info = client.addresses.city_state("10001")

Tracking

Track a package
info = client.tracking.track("9400111899223033005282")

print(info["statusCategory"])   # "Delivered", "In Transit", ...

Rate shopping

Domestic and international rates
rates = client.prices.domestic("10001", "90210", weight=2.5)

print(rates["rates"]["rateOptions"][0]["totalPrice"])

intl = client.prices.international("10001", "CA", weight=3.0)

Note the shape difference: Python takes the two ZIPs positionally, Node takes a single options object with originZIPCode and destinationZIPCode. This is the one place the two SDKs genuinely diverge, and a mechanical port between languages will get it wrong.

Delivery estimates and drop-off locations

Service standards and locations
standards = client.standards.estimates("10001", "90210")

locations = client.locations.dropoff("20500", mail_class="PRIORITY_MAIL")

Labels

Labels need the enrollment credentials from step 1 passed to the constructor.

Create a label
client = Client(
  client_id="...",
  client_secret="...",
  crid="56982563",
  master_mid="904128936",
  label_mid="904128937",
  epa_account="1000405525",
)

label = client.labels.create(
  from_address={"streetAddress": "123 Sender St", "city": "New York",
                "state": "NY", "ZIPCode": "10001"},
  to_address={"streetAddress": "456 Recipient Ave", "city": "LA",
              "state": "CA", "ZIPCode": "90001"},
  mail_class="PRIORITY_MAIL",
  weight=2.0,
)

print(label["trackingNumber"])

client.labels.void(label["trackingNumber"])

Note ZIPCode again, inside the nested address objects, in both languages.

Step 4: method-name mapping

Porting from a Web Tools integration, or from an older guide, this is the table to check your calls against.

Operation Web Tools XML Python Node.js
Validate address AddressValidateRequest addresses.validate() addresses.validate()
City/state lookup CityStateLookupRequest addresses.city_state() addresses.cityState()
Track package TrackFieldRequest tracking.track() tracking.track()
Domestic rates RateV4Request prices.domestic() prices.domestic()
International rates prices.international() prices.international()
Delivery estimates standards.estimates() standards.estimates()
Drop-off locations locations.dropoff() locations.dropoff()
Create label eVSRequest labels.create() labels.create()
Void label labels.void()

Step 5: authentication, and what you no longer write

The conceptual change is that credentials are no longer a value you send. They are a value you exchange for a token that expires.

  1. Obtain. POST to https://apis.usps.com/oauth2/v3/token with grant_type=client_credentials, form-encoded. Not JSON. This is the number-one failure.
  2. Use. Send Authorization: Bearer <token> on every call. Valid 28,800 seconds, which is 8 hours.
  3. Refresh. Request a new token before expiry rather than waiting for a failure. Both SDKs refresh 30 minutes early.
  4. Recover. On a 401, fetch a new token and retry the original request once. Both SDKs do this.

If you use the SDK you write none of the above. The reason to understand it anyway is that token requests count against the same 60-per-hour ceiling as your real traffic, so an integration that fetches a token per call can rate-limit itself on authentication alone. Python’s on-disk token cache at ~/.usps-v3/tokens.json exists for exactly that reason: short-lived processes such as cron jobs reuse one token instead of burning a request each run.

Step 6: error handling

Typed exceptions
from usps_v3 import Client, AuthError, ValidationError, RateLimitError, APIError

try:
  result = client.addresses.validate(street_address="123 Main St")
except ValidationError as e:
  print(f"Bad input on {e.field}: {e}")
except RateLimitError as e:
  print(f"Rate limited — retry after {e.retry_after}s")
except AuthError as e:
  print(f"Auth failed: {e}")
except APIError as e:
  print(f"USPS error {e.status_code}: {e}")

Node adds NetworkError for timeouts and DNS failures. Both raise APIError as the catch-all for a USPS error response.

Step 7: testing checklist

Point the client at the USPS test host before production. On Node that is the baseUrl option; tokens are not portable between environments, so clear any cached token when you switch hosts or you will debug a 401 that is really a stale production token.

  1. A known-good address returns a ZIPPlus4 and a DPV confirmation.
  2. A known-bad address raises a catchable SDK exception rather than escaping.
  3. An address with a unit round-trips with the unit intact, which is the check that catches the Address1 and Address2 swap.
  4. A real tracking number populates every field your data model reads.
  5. A rate quote matches postcalc.usps.com for the same origin, destination, and weight.
  6. A deliberate burst past 60 requests raises RateLimitError and your code degrades rather than rendering an empty result.
  7. A run longer than 8 hours survives unattended, which is the only real proof that token refresh works.

Step 8: production cutover

Your tests pass. The sequence that keeps a bad cutover cheap:

Deploy behind a flag. Route a small share of traffic through the v3 path and leave the rest on the existing one, even if the existing one is failing. Knowing the error rate on both is worth more than moving fast.

Watch for a day. 401s mean credentials, 429s mean the rate limit, 400s mean field mapping. Those three cover nearly every cutover failure, and they are distinguishable in the logs.

Ramp and delete. Once the error rate holds, go to 100 percent and remove the XML path. Swap USPS_USER_ID out of your environment for USPS_CLIENT_ID and USPS_CLIENT_SECRET, and remove the old variable so nothing can silently fall back.

Then ask for headroom. Email USPS through emailus.usps.com with your CRID, app name, and real usage figures. Asking after cutover is easier than asking before, because you are quoting measurements rather than estimates.

What this costs now

One thing has changed since the shutdown that most migration material still gets wrong.

Tracking, pricing, and service standards sit outside the USPS fee curve. Address validation does not. Since August 1, 2026 the Addresses API requires a signed license agreement and bills on a consumption-tier curve, so an addresses-heavy integration has a DocuSign round trip and a funded Enterprise Payment Account in front of it. Start that before your launch date. What the USPS API actually costs has the published curve.

Coming from EasyPost

If you are leaving EasyPost after its March 17 plan enforcement, the operations map cleanly:

EasyPost usps-v3 SDK
client.address.create(verify=["delivery"]) addresses.validate()
client.Shipment.create() then .buy() labels.create()
client.Tracker.create() tracking.track()
shipment.lowestRate() prices.domestic()
EasyPostClient("API_KEY") Client(client_id, client_secret)

The tradeoff is honest in both directions. EasyPost wraps USPS behind its own abstraction and prices per label, and it gives you multi-carrier in exchange. The SDK stays on the USPS v3 schema with no per-label fee, and gives you USPS only, plus the enrollment work EasyPost was absorbing. EasyPost vs RevAddress compares the pricing directly.

If you would rather not run the infrastructure

The SDK talks to USPS itself, with token management handled. What it does not give you is caching, request smoothing, or retry policy; those stay in your application.

RevAddress runs that layer instead. You connect your own USPS license once from the dashboard, and the OAuth lifecycle, quote caching, retries, and budgeting against the 60-per-hour window happen on your credentials and your quota. Flat monthly pricing rather than per-label fees; the current tiers are on the pricing page. DPV, rates, and tracking all run on the license you connect, never as a flat-rate inclusion.

Standardization, geocoding, and address extract run against US Census Bureau data with no USPS license and no per-lookup fee, which is enough when you need an address in correct postal form rather than a USPS deliverability verdict.

Start here

Questions

Is there a Python SDK for the USPS v3 API?
Yes. The usps-v3 package on PyPI installs with pip install usps-v3 and exports Client from the usps_v3 module. It handles the OAuth token lifecycle, caches tokens on disk, and covers addresses, tracking, prices, service standards, drop-off locations, and labels. Calls return plain dictionaries.
Is there a Node.js SDK for the USPS v3 API?
Yes. The usps-v3 package on npm installs with npm install usps-v3 and exports USPSClient. It has zero dependencies, uses built-in fetch on Node 18 or later, ships full TypeScript definitions, and handles OAuth token caching and refresh.
How long does a migration from Web Tools take?
A weekend for a typical integration, if you are only moving addresses, tracking, and rates. Labels are the exception. Label creation needs a CRID, Mailer IDs, an Enterprise Payment Account, and COP claims linking, which is an enrollment process measured in weeks rather than an afternoon of code.
Why does my Node address validation return nothing for the ZIP?
Almost certainly because you passed zipCode. The Node SDK takes ZIPCode with a capital ZIP, matching the USPS schema rather than the camelCase convention around it. An unrecognized key is dropped rather than rejected, so the call succeeds and the field is simply absent.
Does the Python SDK return objects or dictionaries?
Dictionaries. A validated address is read as result['address']['ZIPPlus4'], not as an attribute. Guides that show attribute access such as result.address.zip_plus_4 predate the current package and will raise a TypeError.