USPS Python SDK Quickstart: Validate, Track, and Rate with usps-v3
usps-v3 is an MIT-licensed Python client for the USPS v3 REST API. It handles the OAuth 2.0 flow, caches tokens on disk, and returns plain dictionaries instead of XML. This walks installation, authentication, and the four calls most integrations need.
One thing to settle before you write code: the SDK talks to USPS on your credentials. Since August 1, 2026 the Addresses API sits behind a signed license agreement and bills on a consumption curve, so the license and the invoice are yours. The pricing guide has the published fee table and the signature sequence.
Prerequisites
- Python 3.9 or newer. That is the floor the package declares.
- One dependency.
httpx0.24 or newer, installed with the package. - A USPS application. Register in the Business Customer Gateway to get a Client ID and Client Secret.
- An executed Addresses API license if you want address validation. The CRID and MID guide walks the enrollment screens.
pip install usps-v3Authentication
The USPS v3 API uses the OAuth 2.0 client credentials grant, and tokens last 8 hours. The SDK runs the whole flow for you.
from usps_v3 import Client
# Credentials from the USPS Business Customer Gateway
client = Client(client_id="your-id", client_secret="your-secret")
# Or set USPS_CLIENT_ID and USPS_CLIENT_SECRET and pass nothing:
# client = Client()That is the whole setup. Token handling underneath:
- Cached in memory and on disk at
~/.usps-v3/tokens.json, so a restart does not re-authenticate. - Refreshed automatically 30 minutes before expiry.
- Thread-safe, so one client instance serves concurrent workers.
The disk cache matters more than it looks. Every token request counts against your hourly quota, so a process that re-authenticates on each run burns throughput before it validates anything.
USPS also runs a testing host at apis-tem.usps.com with its own credentials. Testing and production credentials are not interchangeable; the OAuth guide covers what fails when they get crossed.
Label creation needs more than OAuth. Pass the payment identifiers to the constructor:
client = Client(
client_id="...",
client_secret="...",
crid="56982563",
master_mid="904128936",
label_mid="904128937",
epa_account="1000405525",
)Those four come from COP claims linking, which is a manual enrollment step with no API path.
Address validation
The most common call. It standardizes the address and returns the USPS delivery-point attributes.
result = client.addresses.validate(
street_address="1600 Pennsylvania Ave NW",
city="Washington",
state="DC",
zip_code="20500",
)
print(result["address"]["ZIPPlus4"]) # "0005"The return value is a dict, not an object. The SDK hands back the USPS JSON shape rather than wrapping it, so you index into it. That is deliberate: no accessor layer to drift out of sync when USPS adds a field.
The parameter rule catches people. street_address is always required. On top of that USPS wants either city and state, or zip_code, or all three. A street line on its own comes back as a ValidationError.
Reading DPV
DPVConfirmation is the field the call exists for. The USPS specification defines exactly four values:
| Code | Meaning | What to do |
|---|---|---|
Y |
Confirmed for the primary number and, if present, the secondary number | Accept it |
D |
Confirmed for the primary number only, secondary information missing | Collect the unit number |
S |
Confirmed for the primary number only, secondary information present but not confirmed | Offer a correction, do not re-prompt from empty |
N |
Neither primary nor secondary confirmed | Reject or route to manual review |
Two things worth knowing. Y is not a delivery guarantee: USPS states in its own specification that a Y does not necessarily imply USPS delivers to that address, and carrierRoute values such as R777 and R779 can mean the recipient collects mail somewhere else. And D and S are different problems, which is why treating both as “prompt for a unit” sends the second group back through a form they already filled in correctly enough to match the building. The address validation quickstart covers the full attribute set.
City and state from a ZIP is a separate call:
info = client.addresses.city_state("10001")Package tracking
The tracking number is positional, and the response is a dict.
info = client.tracking.track("9400111899223033005282")
print(info["statusCategory"]) # "Delivered"Do not cache tracking. It goes stale within minutes. Poll at a sane interval, or register for USPS tracking notifications and let USPS tell you.
Rate shopping and delivery estimates
Domestic rates take origin ZIP, destination ZIP, and weight positionally.
rates = client.prices.domestic("10001", "90210", weight=2.5)
print(rates["rates"]["rateOptions"][0]["totalPrice"])
# International: origin ZIP, destination country code, weight
intl = client.prices.international("10001", "CA", weight=3.0)Two related calls round out the shipping picture:
# Delivery time estimates between two ZIPs
standards = client.standards.estimates("10001", "90210")
# Where to drop the parcel
locations = client.locations.dropoff("20500", mail_class="PRIORITY_MAIL")Labels
Label creation is the one call that needs payment credentials as well as OAuth.
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")Note the address keys inside the label call are USPS camelCase (streetAddress, ZIPCode), not the snake_case the validate method takes. The label endpoint passes your address dicts through to USPS largely as-is.
Error handling
Four exception types, all importable from usps_v3.
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 AuthError as e:
print(f"Auth failed: {e}")
except APIError as e:
print(f"USPS error ({e.status_code}): {e}")| Exception | Raised when | Carries |
|---|---|---|
ValidationError |
Input is missing or malformed | .field |
AuthError |
OAuth or payment authorization failed | |
RateLimitError |
USPS returned 429 | .retry_after |
APIError |
USPS returned any other error | .status_code |
RateLimitError is the one to build around. USPS defaults to 60 requests per hour per application, shared across every endpoint the app touches, and the SDK does not throttle for you: it surfaces the 429 and lets you decide.
import time
from usps_v3 import RateLimitError, AuthError, ValidationError
def validate_with_retry(client, attempts=3, **address):
for attempt in range(attempts):
try:
return client.addresses.validate(**address)
except RateLimitError as e:
# Honour USPS's own number when it sends one.
time.sleep(getattr(e, "retry_after", None) or 2 ** attempt)
except (AuthError, ValidationError):
# Neither clears on a retry. Fail loudly.
raise
raise RuntimeError("USPS validation failed after retries")Retry the 429. Never retry the ValidationError, which will return identically forever, or the AuthError, which needs a credential fix rather than another attempt. The rate limit guide has the caching and queueing patterns that keep you under the ceiling in the first place.
Migrating from usps-api
The usps-api PyPI package targets USPS Web Tools, which stopped answering on January 25, 2026. The mapping is direct:
| Web Tools XML | usps-v3 |
|---|---|
AddressValidateRequest |
client.addresses.validate(...) |
CityStateLookupRequest |
client.addresses.city_state(...) |
TrackFieldRequest |
client.tracking.track(...) |
RateV4Request |
client.prices.domestic(...) |
| USERID in the query string | OAuth 2.0, handled by the client |
| XML parsing | Python dicts |
| No practical rate limit | 60 per hour by default |
The migration checklist runs the whole move phase by phase, including the license step that did not exist under Web Tools.
Running it through RevAddress instead
If you would rather not hold USPS credentials in your own process, the managed route swaps the client for one HTTP header.
from usps_v3 import Client
client = Client(client_id="...", client_secret="...")
result = client.addresses.validate(
street_address="1600 Pennsylvania Ave NW",
city="Washington",
state="DC",
zip_code="20500",
)import httpx
resp = httpx.get(
"https://api.revaddress.com/api/address/validate",
params={
"streetAddress": "1600 Pennsylvania Ave NW",
"city": "Washington",
"state": "DC",
"ZIPCode": "20500",
},
headers={"X-API-Key": "rv_live_your_key_here"},
)
result = resp.json()What the subscription buys is the infrastructure, not the USPS data. Standardization, geocoding, and address extract run on US Census Bureau reference data and are included in every plan, including Free at 1,000 requests a month. DPV verification, rates, service standards, and tracking run on your USPS license (BYOK), so you sign the agreement, USPS bills your Enterprise Payment Account, and the managed layer handles the credential vault, the token lifecycle, caching, retries, and rate-limit budgeting.
Start here
- Get a free API key, 1,000 requests a month, no credit card, no USPS license needed for standardization and geocoding.
- Node.js SDK quickstart is the same walkthrough with the TypeScript client.
- Address validation, start to finish covers the raw HTTP call and every response field.
- Rate limit strategies covers caching, queueing, and the increase request.
- usps-v3 on PyPI and the source on GitHub, MIT licensed.
Questions
- How do I install the USPS Python SDK?
- Run pip install usps-v3. The package requires Python 3.9 or newer and pulls in a single dependency, httpx 0.24 or newer. It is MIT licensed and the source lives at github.com/revereveal/usps-v3.
- What does the usps-v3 Python client return?
- Plain Python dictionaries that mirror the USPS JSON response, not custom objects. Address validation returns a dict you index into, for example result['address']['ZIPPlus4']. There is no XML parsing and no attribute-style accessor layer.
- Does the SDK handle OAuth tokens for me?
- Yes. Tokens are cached in memory and on disk at ~/.usps-v3/tokens.json, refreshed automatically 30 minutes before expiry, and the client is safe to use across threads. You never handle a Bearer token directly.
- Which exceptions does the usps-v3 Python SDK raise?
- AuthError for credential and token failures, ValidationError for bad input (it carries a field attribute), RateLimitError for 429 responses (it carries retry_after), and APIError as the catch-all (it carries status_code). All four import from usps_v3.
- Do I need a USPS license to use the Python SDK?
- For address validation, yes. Since August 1, 2026 the USPS Addresses API requires a signed license agreement and bills on a monthly consumption curve against an Enterprise Payment Account. The SDK talks to USPS on your own credentials, so the license and the bill are yours.
Read next
USPS Node.js SDK Quickstart: TypeScript Client for the v3 API
Install usps-v3, authenticate once, and call address validation, tracking, rates, and labels from Node. Zero dependencies, typed errors, Express route.
12 min readQuickstartUSPS v3 Address Validation: One Request, Start to Finish
The USPS v3 address call in curl, Python and Node: OAuth, the required parameters, every DPV code, and the license USPS has required since August 1, 2026.
9 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