USPS v3 Migration Guide: Node.js and Python, XML to REST
Updated 15 min read By RevAddress
Topics: Web Tools migration, USPS v3
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 |
USPS returns 429 when you exceed 60 requests an hour, and the SDK raises that as RateLimitError carrying a retry hint. Handling it sits in your architecture; the rate-limit guide has the cache and queue patterns.
Step 1: credentials
Sign into the Customer Onboarding Portal and create an application under My Apps. Its Credentials section holds the Consumer Key (the client ID) and the Consumer Secret. Use a business email for production access.
Verify the pair before you write anything:
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.
Check two causes first for a 401 here: 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
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.
npm install usps-v3
# Exports USPSClient. Zero dependencies.
# Built-in fetch, so Node 18 or later.
# Full TypeScript definitions, ESM and CommonJS.
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
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
import { USPSClient } from 'usps-v3';
const client = new USPSClient({
clientId: process.env.USPS_CLIENT_ID,
clientSecret: process.env.USPS_CLIENT_SECRET,
});
const result = await client.addresses.validate({
streetAddress: '1600 Pennsylvania Ave NW',
city: 'Washington',
state: 'DC',
ZIPCode: '20500', // capital ZIP — zipCode is silently ignored
});
console.log(result.address.streetAddress); // 1600 PENNSYLVANIA AVE NW
Send ZIPCode with a capital ZIP. An unrecognized key is dropped rather than rejected, so zipCode returns 200 with a less precise match.
City and state from a ZIP
info = client.addresses.city_state("10001")
const info = await client.addresses.cityState('10001');
Tracking
info = client.tracking.track("9400111899223033005282")
print(info["statusCategory"]) # "Delivered", "In Transit", ...
const tracking = await client.tracking.track('9400111899223033005282');
console.log(tracking.statusCategory); // 'Delivered', 'In Transit', ...
Rate shopping
rates = client.prices.domestic("10001", "90210", weight=2.5)
print(rates["rates"]["rateOptions"][0]["totalPrice"])
intl = client.prices.international("10001", "CA", weight=3.0)
const rates = await client.prices.domestic({
originZIPCode: '10001',
destinationZIPCode: '90210',
weight: 2.5,
});
const intlRates = await client.prices.international({
originZIPCode: '10001',
destinationCountryCode: 'GB',
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 diverge, so port this call by hand.
Delivery estimates and drop-off locations
standards = client.standards.estimates("10001", "90210")
locations = client.locations.dropoff("20500", mail_class="PRIORITY_MAIL")
const estimates = await client.standards.estimates('10001', '90210');
// [{ mailClass: 'PRIORITY_MAIL', daysToDelivery: 2 }, ...]
const locations = await client.locations.dropoff({
destinationZIP: '20500',
mailClass: 'PRIORITY_MAIL',
});
Labels
Labels need the enrollment credentials from step 1 passed to the constructor.
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"])
const client = new USPSClient({
clientId: process.env.USPS_CLIENT_ID,
clientSecret: process.env.USPS_CLIENT_SECRET,
crid: process.env.USPS_CRID,
masterMid: process.env.USPS_MASTER_MID,
labelMid: process.env.USPS_LABEL_MID,
epaAccount: process.env.USPS_EPA_ACCOUNT,
});
const label = await client.labels.create({
fromAddress: { streetAddress: '228 Park Ave S', city: 'New York',
state: 'NY', ZIPCode: '10003' },
toAddress: { streetAddress: '1600 Pennsylvania Ave NW', city: 'Washington',
state: 'DC', ZIPCode: '20500' },
mailClass: 'PRIORITY_MAIL',
weight: 2.0,
});
console.log(label.trackingNumber);
Note ZIPCode again, inside the nested address objects, in both languages.
Step 4: method-name mapping
If you are porting from a Web Tools integration or an older guide, check your calls against this table.
| 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: credentials are now a value you exchange for a token that expires, rather than one you send on every call.
- Obtain. POST to
https://apis.usps.com/oauth2/v3/tokenwithgrant_type=client_credentials, form-encoded, not JSON. - Use. Send
Authorization: Bearer <token>on every call. The token is valid for 28,800 seconds, which is 8 hours. - Refresh. Request a new token before expiry rather than waiting for a failure. Both SDKs refresh 30 minutes early.
- Recover. On a 401, fetch a new token and retry the original request once. The SDKs raise the 401 as an error instead, which the early refresh makes rare.
If you use the SDK you write none of the above. It matters because token requests count against the same 60-per-hour ceiling as your real traffic. 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
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}")
import { USPSClient, RateLimitError, ValidationError, AuthError } from 'usps-v3';
try {
await client.addresses.validate({ streetAddress: '' });
} catch (err) {
if (err instanceof ValidationError) {
console.log(`Bad field: ${err.field}`);
} else if (err instanceof RateLimitError) {
console.log(`Retry after ${err.retryAfter}s`);
} else if (err instanceof AuthError) {
console.log('Check credentials');
}
}
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; a stale production token returns a 401 on the test host.
- A known-good address returns a
ZIPPlus4and a DPV confirmation. - A known-bad address raises a catchable SDK exception rather than escaping.
- An address with a unit round-trips with the unit intact, which is the check that catches the
Address1andAddress2swap. - A real tracking number populates every field your data model reads.
- A rate quote matches postcalc.usps.com for the same origin, destination, and weight.
- A deliberate burst past 60 requests raises
RateLimitErrorand your code degrades rather than rendering an empty result. - A run longer than 8 hours survives unattended, the only test that proves token refresh works.
Step 8: production cutover
Once your tests pass, cut over in four moves:
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. The flag gives you the error rate on both paths.
Watch for a day. 401s mean credentials, 429s mean the rate limit, 400s mean field mapping. Each shows up separately 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
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. The USPS API pricing guide 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) |
EasyPost wraps USPS behind its own abstraction, prices per label, and covers multiple carriers. The SDK stays on the USPS v3 schema with no per-label fee, covers USPS, and the USPS enrollment runs in your name. EasyPost vs RevAddress compares the pricing directly.
Running the infrastructure through RevAddress
The SDK talks to USPS itself, with token management handled; caching, request smoothing, and retry policy 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. Pricing is a flat monthly fee 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 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
- Dead package replacement guide: the short version if you only need the dependency swapped
- Web Tools to v3 migration guide: endpoint mapping and raw OAuth without an SDK
- USPS rate limits in production: the cache and queue patterns that work within 60 per hour
- Get a free API key: Census-based standardization and geocoding, no USPS license required
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 an integration that moves 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?
- Check whether 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 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.
Read next
-
USPS Web Tools Shut Down on January 25, 2026: What Broke and the Fastest Way to the v3 API
USPS retired Web Tools on January 25, 2026. If ShippingAPI.dll, USERID, or secure.shippingapis.com is failing, map the XML route to v3 REST here.
-
USPS Web Tools Migration Checklist (2026)
A numbered checklist for moving off USPS Web Tools XML: endpoint map, the license now required, OAuth, field mapping, error mapping, and the cutover.
-
usps-api and usps-webtools Are Dead: the 2026 Replacement
The usps-api, usps-webtools, and usps-webtools-promise packages target an API that no longer exists. The errors you are seeing, and the one-command swap.