Skip to content
All Posts
SDK Reference

USPS v3 SDK for Python and Node.js: Endpoints, Credentials, and the OAuth You Do Not Have to Write

·Updated ·9 min read·By RevAddress·SDK Reference

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

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

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.