USPS API OAuth 401 Errors:
Complete Troubleshooting Guide
The USPS v3 REST API uses OAuth 2.0 for authentication. When it breaks, you get a 401 Unauthorized with zero useful context. This guide covers every cause we have seen in production and how to fix each one.
The Problem
You register on the USPS Developer Portal, grab your Client ID and Client Secret, send a token request, and get back:
HTTP/1.1 401 Unauthorized
{
"apiVersion": "v3",
"error": "UNAUTHORIZED",
"message": "Invalid or missing credentials"
} The error message is the same whether your credentials are wrong, your Content-Type is wrong, your token is expired, or you are hitting the wrong endpoint entirely. Here is how to tell the difference.
OAuth 2.0 Flow for USPS v3
The USPS v3 API uses the OAuth 2.0 client_credentials grant. No user interaction, no redirect URIs, no authorization codes. It is a machine-to-machine flow with three steps:
- POST to the token endpoint with your Client ID, Client Secret, and
grant_type=client_credentials - Receive an access token valid for 8 hours (28,800 seconds)
- Pass the token as a
Bearertoken in theAuthorizationheader of every API request
Token endpoint: https://apis.usps.com/oauth2/v3/token
Grant type: client_credentials
Content-Type: application/x-www-form-urlencoded (NOT JSON)
Token lifetime: 8 hours (28,800 seconds)
Auto-refresh: None — you must refresh before expiry
Scopes: addresses tracking prices labels international-prices The Correct OAuth Flow
Before debugging, confirm your baseline is correct. Here is the working token request in both Python and Node.js.
import httpx
# USPS v3 OAuth 2.0 — client_credentials grant
# Credentials from https://developer.usps.com → Your Apps
TOKEN_URL = "https://apis.usps.com/oauth2/v3/token"
response = httpx.post(
TOKEN_URL,
data={ # NOT json= !
"grant_type": "client_credentials",
"client_id": "your_client_id",
"client_secret": "your_client_secret",
"scope": "addresses tracking prices labels",
},
headers={
"Content-Type": "application/x-www-form-urlencoded",
},
)
token_data = response.json()
access_token = token_data["access_token"]
expires_in = token_data["expires_in"] # 28800 seconds (8 hours)
print(f"Token: {access_token[:20]}...")
print(f"Expires in: {expires_in // 3600} hours") // USPS v3 OAuth 2.0 — client_credentials grant
// Credentials from https://developer.usps.com → Your Apps
const TOKEN_URL = "https://apis.usps.com/oauth2/v3/token";
const body = new URLSearchParams({
grant_type: "client_credentials",
client_id: "your_client_id",
client_secret: "your_client_secret",
scope: "addresses tracking prices labels",
});
const response = await fetch(TOKEN_URL, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body, // NOT JSON.stringify!
});
if (!response.ok) {
throw new Error(`OAuth failed: ${response.status}`);
}
const { access_token, expires_in } = await response.json();
console.log(`Token: ${access_token.slice(0, 20)}...`);
console.log(`Expires in: ${expires_in / 3600} hours`); Common 401 Error Causes and Fixes
Six root causes cover roughly 95% of USPS 401 errors in the wild. Work through them in order because the first three account for most cases.
1. Wrong Content-Type (the most common mistake)
The USPS token endpoint requires application/x-www-form-urlencoded. If you send application/json (the default for most HTTP libraries), the server receives an empty form body and returns 401.
# WRONG — sends JSON body, USPS returns 401
response = httpx.post(TOKEN_URL, json={
"grant_type": "client_credentials",
"client_id": "your_client_id",
"client_secret": "your_client_secret",
})
# RIGHT — sends form-encoded body
response = httpx.post(TOKEN_URL, data={
"grant_type": "client_credentials",
"client_id": "your_client_id",
"client_secret": "your_client_secret",
}) In Python: use data= (form-encoded), not json=.
In Node.js: use new URLSearchParams(), not JSON.stringify().
In curl: use -d (which defaults to form-encoded), not -H "Content-Type: application/json".
2. Using Web Tools API key instead of v3 OAuth credentials
The legacy USPS Web Tools API used a plain API key (USERID) passed as a query parameter. The v3 REST API uses an entirely separate credential pair: Client ID + Client Secret, obtained from the USPS Developer Portal. They are not interchangeable. If you are passing your old Web Tools user ID as the client_id, it will always return 401.
3. Token expired (8-hour lifetime, no auto-refresh)
USPS tokens last exactly 8 hours. There is no refresh token mechanism. When the token expires, every API call returns 401 until you request a new one. The fix: track the expires_in value from the token response and proactively refresh 15-30 minutes before expiry. Never cache tokens to disk without also storing the expiry timestamp.
4. Missing grant_type parameter
The grant_type=client_credentials parameter is required. Some developers omit it, assuming the endpoint infers the grant type. It does not. Missing or misspelled grant_type returns 401.
5. Wrong token endpoint URL
USPS has multiple endpoint versions and environments. Using the wrong one is an instant 401.
WRONG: https://apis.usps.com/oauth2/v1/token # Deprecated
WRONG: https://api.usps.com/oauth2/v3/token # Wrong subdomain (api vs apis)
WRONG: https://secure.shippingapis.com/... # Web Tools (XML, not REST)
RIGHT (production): https://apis.usps.com/oauth2/v3/token
RIGHT (testing): https://apis-tem.usps.com/oauth2/v3/token 6. Scope issues for label creation
Some API endpoints require specific OAuth scopes. If your token was issued without the labels scope (or international-prices for international rate queries), requests to those endpoints return 401 even though the token itself is valid. Always request all scopes you need upfront: addresses tracking prices labels international-prices.
Payment Authorization (Label Creation)
Creating shipping labels requires a separate second token on top of the standard OAuth token. This is the Payment Authorization flow, and it trips up most developers because USPS documentation buries it.
The flow is: (1) get a standard OAuth access token, (2) use that token to call the Payment Authorization endpoint, (3) pass both tokens when creating a label. If you skip step 2, your label creation request returns 401.
# Step 1: Get standard OAuth token (as above)
access_token = get_oauth_token()
# Step 2: Get Payment Authorization token
# Required for label creation (POST /labels)
# Your CRID must have COP (Centralized Online Payment) claims
pa_response = httpx.post(
"https://apis.usps.com/payments/v3/payment-authorization",
json={
"roles": [
{
"roleName": "PAYER",
"CRID": "your_crid",
"MID": "your_mid",
"manifestMID": "your_manifest_mid",
"accountType": "EPS",
"accountNumber": "your_eps_account",
},
{
"roleName": "LABEL_OWNER",
"CRID": "your_crid",
"MID": "your_mid",
},
],
},
headers={
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/json",
},
)
payment_token = pa_response.json()["paymentAuthorizationToken"]
# Step 3: Use BOTH tokens for label creation
label_response = httpx.post(
"https://apis.usps.com/labels/v3/label",
json=label_payload,
headers={
"Authorization": f"Bearer {access_token}",
"X-Payment-Authorization-Token": payment_token,
},
) Prerequisites: Your USPS developer account must have COP (Centralized Online Payment) claims linked to your CRID. This requires a USPS Business Customer Gateway account with an active EPS (Enterprise Payment System) permit. If your Payment Authorization call returns 401 or 403, your COP enrollment is likely incomplete. Contact USPS API support with your CRID to verify.
Rate Limit Interaction
Failed 401 requests count against your rate limit. At the default 60 requests/hour, a token refresh loop that retries on 401 can exhaust your quota in minutes. This creates a cascading failure: expired token causes 401, retry loop burns rate limit, 429 rate limit response triggers more retries.
The fix: build your token manager with proactive refresh (refresh before expiry, not after failure) and exponential backoff. Here is a production-grade pattern:
import httpx, time
from datetime import datetime, timedelta
class USPSAuth:
def __init__(self, client_id, client_secret):
self.client_id = client_id
self.client_secret = client_secret
self._token = None
self._expires_at = datetime.min
def get_token(self):
# Refresh 30 min before expiry
if datetime.now() >= self._expires_at - timedelta(minutes=30):
self._refresh()
return self._token
def _refresh(self):
for attempt in range(3):
resp = httpx.post(
"https://apis.usps.com/oauth2/v3/token",
data={
"grant_type": "client_credentials",
"client_id": self.client_id,
"client_secret": self.client_secret,
},
)
if resp.status_code == 200:
data = resp.json()
self._token = data["access_token"]
self._expires_at = datetime.now() + timedelta(
seconds=data["expires_in"]
)
return
elif resp.status_code == 429:
time.sleep(2 ** attempt) # Backoff
else:
raise Exception(f"OAuth failed: {resp.status_code}")
raise Exception("OAuth: max retries exceeded") Debugging Checklist
Quick-reference table. Work top to bottom. The first match is usually the problem.
| Symptom | Cause | Fix |
|---|---|---|
| 401 on token request | JSON Content-Type | Use data= not json= |
| 401 on token request | Wrong credentials | Use v3 Developer Portal creds, not Web Tools key |
| 401 on token request | Missing grant_type | Add grant_type=client_credentials |
| 401 on token request | Wrong endpoint | Use apis.usps.com/oauth2/v3/token |
| Token works, then 401 after hours | Token expired | Refresh proactively (30 min before expiry) |
401 on /labels only | Missing Payment Auth token | Add X-Payment-Authorization-Token header |
| 401 on specific endpoints | Missing OAuth scope | Request all needed scopes in token call |
| 403 on Payment Auth | COP claims not linked | Contact USPS support, verify CRID enrollment |
| 401 + 429 cascade | Retry loop burning rate limit | Proactive refresh + exponential backoff |
Quick Debug with curl
When in doubt, strip away your application code and test directly with curl. This isolates whether the problem is your credentials or your code.
# Test your credentials directly
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_ID&client_secret=YOUR_SECRET"
# Expected success response:
# {"access_token":"eyJ...","token_type":"Bearer","expires_in":28800,"issued_at":"..."}
# Test the token against an endpoint
curl -X GET "https://apis.usps.com/addresses/v3/address?streetAddress=1600+Pennsylvania+Ave+NW&city=Washington&state=DC&ZIPCode=20500" \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" If curl works but your code does not, the problem is in your HTTP client configuration (Content-Type, body encoding, or header formatting). If curl also fails, the problem is your credentials or endpoint URL.
Testing vs Production Environments
USPS provides a separate testing environment at apis-tem.usps.com. Same credentials, same OAuth flow, different base URL. Common pitfalls:
- Mixing environments: A token from
apis.usps.comdoes not work againstapis-tem.usps.com, and vice versa. Match token endpoint to API endpoint. - Testing environment quirks: The sandbox returns synthetic data and may have different rate limits. Do not use it for integration testing with real shipments.
- Environment variables: Use an env var for the base URL (
USPS_BASE_URL) rather than hardcoding. Switch betweenapis.usps.comandapis-tem.usps.comwithout code changes.
Or skip OAuth entirely
RevAddress manages OAuth tokens, Payment Authorization, proactive refresh, and retry logic for you. Send REST requests, get responses. No token management code, no 401 debugging sessions, no rate limit cascades.