USPS v3 SDK for Python and Node.js: Endpoints, Credentials, and the OAuth You Do Not Have to Write
Two open-source clients cover the USPS v3 REST API: usps-v3 on PyPI and usps-v3 on npm. Both are at v1.0.4, both are MIT licensed, and both exist to remove the same boilerplate: the OAuth dance, the USPS-specific parameter casing, and the nested JSON you would otherwise unpack by hand.
They are drop-in replacements for the packages the Web Tools shutdown killed on January 25, 2026: usps-api on the Python side, usps-webtools and usps-webtools-promise on the Node side.
Every signature below is from the shipped v1.0.4 READMEs. Where the two clients differ, they differ in what comes back, not in what they can call.
Install
pip install usps-v3 # Python 3.8+, one dependency (httpx)
npm install usps-v3 # Node 18+, zero dependencies, TypeScript types included
Credentials go in the constructor or in the environment. Both clients read USPS_CLIENT_ID and USPS_CLIENT_SECRET if you leave the constructor empty.
Address validation
from usps_v3 import Client
client = Client(client_id="your-id", client_secret="your-secret")
# Or Client() and let it read USPS_CLIENT_ID / USPS_CLIENT_SECRET
result = client.addresses.validate(
street_address="1600 Pennsylvania Ave NW",
city="Washington",
state="DC",
zip_code="20500",
)
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',
});
console.log(result.address);
// { streetAddress: '1600 PENNSYLVANIA AVE NW', city: 'WASHINGTON', state: 'DC', ZIPCode: '20500' }
Two details that cost people an afternoon:
The Node client uses USPS field casing on the way in. It is ZIPCode, not zipCode. The Python client takes snake_case kwargs and converts. If you are porting a snippet between the two, the address key is the thing that breaks.
Python returns dictionaries, Node returns typed objects. Python gives you result["address"]["ZIPPlus4"] with USPS casing preserved inside the dict. Node gives you result.address.ZIPCode with full TypeScript definitions behind it. Neither is a dataclass; do not reach for attribute access in Python.
Endpoint coverage
Identical across both clients. The method names differ only in casing convention.
| What it does | Python | Node.js | Credentials |
|---|---|---|---|
| Validate and standardize | addresses.validate() |
addresses.validate() |
OAuth |
| City and state from ZIP | addresses.city_state() |
addresses.cityState() |
OAuth |
| Package tracking | tracking.track() |
tracking.track() |
OAuth |
| Delivery estimates | standards.estimates() |
standards.estimates() |
OAuth |
| Drop-off locations | locations.dropoff() |
locations.dropoff() |
OAuth |
| Domestic rates | prices.domestic() |
prices.domestic() |
OAuth |
| International rates | prices.international() |
prices.international() |
OAuth |
| Create a label | labels.create() |
labels.create() |
OAuth + Payment Auth |
| Void a label | labels.void() |
labels.void() |
OAuth |
Only label creation needs more than OAuth. Rate quotes do not, which surprises people who assume pricing is gated the way labels are. Payment Authorization means a CRID, a master MID and a label MID, an Enterprise Payment Account, and COP claims linking. The CRID and MID enrollment guide walks the whole sequence.
A currency note the SDK README does not carry: since August 1, 2026 the USPS Addresses API sits behind a signed license agreement and bills on a consumption curve. The credential requirement in the table above is what the API checks. What it costs is a separate question, and the pricing guide has the fee curve and the licensing sequence.
Tracking, rates, and labels
# Tracking — positional tracking number, dict back
info = client.tracking.track("9400111899223033005282")
print(info["statusCategory"]) # "Delivered"
# Delivery estimates and drop-off locations
standards = client.standards.estimates("10001", "90210")
locations = client.locations.dropoff("20500", mail_class="PRIORITY_MAIL")
# Rates — origin and destination positional
rates = client.prices.domestic("10001", "90210", weight=2.5)
print(rates["rates"]["rateOptions"][0]["totalPrice"])
# Labels — address dicts use USPS key casing, not snake_case
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("9400111899223033005282")
// Tracking — positional string, not an options object
const tracking = await client.tracking.track('9400111899223033005282');
console.log(tracking.statusCategory); // 'Delivered', 'In Transit', etc.
// Rates — ZIP keys are spelled originZIPCode / destinationZIPCode
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,
});
// Delivery estimates
const estimates = await client.standards.estimates('10001', '90210');
// [{ mailClass: 'PRIORITY_MAIL', daysToDelivery: 2 }, ...]
Label creation on Node takes the Payment Authorization values on the client, not the call:
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);
Errors are typed, and the names are shared
Both clients raise the same four classes. Node adds a fifth.
| Class | Raised when |
|---|---|
ValidationError |
Invalid input parameters. Carries the offending field. |
AuthError |
OAuth or Payment Authorization failure. |
RateLimitError |
429 from USPS. Carries the retry-after value. |
APIError |
USPS returned an error response. Carries the status code. |
NetworkError |
Node only. Connection timeout, DNS failure. |
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: {e} (field: {e.field})")
except RateLimitError as e:
print(f"Rate limited — retry after {e.retry_after}s")
except APIError as e:
print(f"USPS error ({e.status_code}): {e}")
Note the naming: it is RateLimitError, not USPSRateLimitError. Python uses retry_after, Node uses retryAfter.
Token handling
Neither client makes you touch a token. Both run the client_credentials flow on the first API call and refresh 30 minutes before expiry.
Where they differ is persistence. The Python client caches tokens in memory and on disk at ~/.usps-v3/tokens.json, and is thread-safe for concurrent use. The Node client caches in memory and gives you handles to inspect and control it:
console.log(client.tokenStatus);
// { hasOAuthToken: true, oauthExpiresIn: 27000, ... }
await client.refreshTokens(); // force
client.close(); // clean up
The disk cache matters more than it sounds for short-lived Python processes. A cron job that runs every five minutes gets a warm token instead of a fresh OAuth round-trip each time.
The rate limit is still yours to solve
USPS defaults to 60 requests per hour per application, shared across every endpoint the app calls. The SDKs do not enforce it and do not queue for you. USPS returns 429 and the client raises RateLimitError with a retry-after value.
That is the right division of labor for a client library, and it means throughput is an architecture problem rather than a configuration flag. Caching validated addresses, smoothing bursts through a queue, and asking USPS for an increase are the three moves that work, and the rate-limit guide has the code for each.
What the SDK removes
Address validation without a client library is an OAuth request, a hand-built query string in USPS’s casing, a bearer header, and your own error handling:
const tokenRes = await fetch('https://apis.usps.com/oauth2/v3/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'client_credentials',
client_id: 'your_client_id',
client_secret: 'your_client_secret',
}),
});
const { access_token } = await tokenRes.json();
const params = new URLSearchParams({
streetAddress: '1600 Pennsylvania Ave NW',
city: 'Washington',
state: 'DC',
ZIPCode: '20500',
});
const res = await fetch('https://apis.usps.com/addresses/v3/address?' + params, {
headers: { Authorization: 'Bearer ' + access_token },
});
const data = await res.json();
// Then: cache the token, refresh before expiry, type the response, classify the errors.
The four lines that follow the comment are the ones that take a week. The SDK version is the six-line block at the top of this post.
Go deeper
- Python quickstart — error handling, retry patterns, rate shopping, DPV reference
- Node.js quickstart — TypeScript usage, label creation, the full error hierarchy
- OAuth troubleshooting — what each failure mode actually means
- CRID and MID enrollment — Business account, EPA, Payment Authorization
- Source on GitHub — MIT licensed, issues and contributions welcome
Sources
- Method signatures, return shapes, error classes, token behavior, and credential requirements come from the published v1.0.4 READMEs on PyPI and npm, read August 23, 2026. Both packages were at v1.0.4 on that date.
- The USPS 60-requests-per-hour default and the 429 behavior are documented in both READMEs and in USPS’s own developer material.
- The August 1, 2026 license requirement and consumption billing come from the USPS Addresses API Tech Sheet on PostalPro, updated 2026-08-13.
Start here
- Get a free API key — 1,000 requests a month, no card, no USPS license needed to start
- API reference · Full pricing
- Rate-limit strategies — what to do about 60 requests an hour
Questions
- Are the USPS v3 SDKs free?
- Yes. Both packages are MIT licensed and free to install from PyPI and npm. What is no longer free is the USPS side. Since August 1, 2026 the USPS Addresses API requires a signed license agreement and bills address lookups on a consumption curve, so the SDK costs nothing and the calls it makes may not.
- What is the difference between the Python and Node.js SDKs?
- Endpoint coverage is the same. The difference is what comes back. Python returns plain dictionaries with USPS field casing, so you read result["address"]["ZIPPlus4"]. Node returns typed objects with full TypeScript definitions, so you read result.address.ZIPCode. Python needs 3.8+ and pulls in httpx; Node needs 18+ and has zero dependencies.
- Do the USPS v3 SDKs handle OAuth automatically?
- Yes. Both run the client_credentials flow on the first call and refresh the token 30 minutes before it expires. The Python client caches tokens in memory and on disk at ~/.usps-v3/tokens.json and is thread-safe; the Node client caches in memory and exposes client.tokenStatus and client.refreshTokens for inspection.
- Which USPS v3 calls need more than OAuth?
- Only label creation. Address validation, city/state lookup, tracking, delivery estimates, drop-off locations, and both domestic and international price quotes work with OAuth credentials alone. labels.create needs Payment Authorization, which means CRID, master and label MIDs, an Enterprise Payment Account, and COP claims linking.
- How do the SDKs handle the 60-requests-per-hour rate limit?
- They surface it, they do not prevent it. USPS returns 429 when you exceed the default of 60 requests an hour per app, and the SDK raises RateLimitError with the retry-after value. Staying under the ceiling is an architecture problem, solved with caching and queueing.
Read next
USPS API Rate Limits in 2026: From 6,000/min to 60/hour
USPS v3 throughput fell from roughly 6,000 requests a minute on Web Tools to 60 per hour. What that does to checkout at peak, and five architecture patterns.
12 min readGuideUSPS API Pricing 2026 — What It Actually Costs
USPS address validation stopped being free on August 1, 2026. The published fee curve, the license that gates it, and what direct vs third-party costs.
8 min readTroubleshootingUSPS v3 OAuth Troubleshooting: Every Error and How to Fix It
401, 403, and 429 from USPS v3, with the exact fix for each, including the Addresses API license that has gated address calls since August 1, 2026.
9 min read