USPS v3 Migration Guide: Node.js and Python, XML to REST
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:
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
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"]) # 0005import { 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 NWThe 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
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 genuinely diverge, and a mechanical port between languages will get it wrong.
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
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.
- Obtain. POST to
https://apis.usps.com/oauth2/v3/tokenwithgrant_type=client_credentials, form-encoded. Not JSON. This is the number-one failure. - Use. Send
Authorization: Bearer <token>on every call. Valid 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. 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
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 or you will debug a 401 that is really a stale production token.
- 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, 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
- 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 survive 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 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.
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.
10 min readMigrationUSPS 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.
14 min readReplacement Guideusps-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.
9 min read