USPS v3 OAuth Troubleshooting: Every Error and How to Fix It
USPS v3 authentication fails in two ways that look identical from the outside.
The first is structural: USPS issues two separate tokens, not one. Label calls return 401 while address and tracking calls work fine, and nothing in the error says why.
The second is new. Since August 1, 2026 the Addresses API sits behind a signed license agreement, and the USPS Developer Portal states plainly that “customers who did not complete onboarding no longer have access to the Addresses API.” Correct credentials no longer guarantee a 200 on address calls.
Below: the token lifecycle, then each error code with the fix.
Three portals, three different things
Half of USPS auth debugging is knowing which system owns the thing you are missing. They are separate logins on separate hosts.
| Portal | Host | Issues |
|---|---|---|
| Developer Portal | developers.usps.com |
Your client_id and client_secret, and the API products attached to your app |
| Business Customer Gateway (BCG) | gateway.usps.com |
CRID, Mailer IDs, Enterprise Payment Account |
| Business Portal / Customer Onboarding Portal (COP) | cop.usps.com |
The Addresses API license, and claims linking that pushes CRID, MIDs, and EPA into your token |
Note the expansion: COP is the Customer Onboarding Portal. It is not a payment system and not a certificate of posting, and searching for either will send you somewhere useless.
The two-token lifecycle
| Token | Endpoint | Lifetime | Required for |
|---|---|---|---|
| OAuth Bearer Token | /oauth2/v3/token |
8 hours (28800s) | Every API call |
| Payment Authorization Token | /payments/v3/payment-authorization |
8 hours | Label creation only |
The OAuth Bearer Token is the standard client credentials grant. Every endpoint, including addresses, tracking, rates, locations, and standards, requires it in the Authorization header.
The Payment Authorization Token comes from a different endpoint and travels in a different header. It requires enrollment-specific credentials (CRID, MID, EPS account number) issued through BCG. Without it, any call to /labels/v3/label returns 401 no matter how valid your Bearer Token is.
Most developers meet the second token the hard way, after addresses and tracking have been working in production for weeks.
Getting your OAuth Bearer Token
Your client_id and client_secret come from your app in the Developer Portal at developers.usps.com. This is the credential pair people most often go looking for in BCG, where it does not exist.
curl -X POST "https://apis.usps.com/oauth2/v3/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=client_credentials" \
-d "client_id=YOUR_CLIENT_ID" \
-d "client_secret=YOUR_CLIENT_SECRET"import httpx
response = httpx.post(
"https://apis.usps.com/oauth2/v3/token",
data={
"grant_type": "client_credentials",
"client_id": "YOUR_CLIENT_ID",
"client_secret": "YOUR_CLIENT_SECRET",
},
)
token_data = response.json()
access_token = token_data["access_token"]
expires_in = token_data["expires_in"] # 28800 (8 hours)const params = new URLSearchParams({
grant_type: "client_credentials",
client_id: process.env.USPS_CLIENT_ID,
client_secret: process.env.USPS_CLIENT_SECRET,
});
const res = await fetch("https://apis.usps.com/oauth2/v3/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: params.toString(),
});
const { access_token, expires_in } = await res.json();
// expires_in = 28800<?php
$response = file_get_contents("https://apis.usps.com/oauth2/v3/token", false,
stream_context_create([
"http" => [
"method" => "POST",
"header" => "Content-Type: application/x-www-form-urlencoded",
"content" => http_build_query([
"grant_type" => "client_credentials",
"client_id" => getenv("USPS_CLIENT_ID"),
"client_secret" => getenv("USPS_CLIENT_SECRET"),
]),
],
])
);
$data = json_decode($response, true);
$accessToken = $data["access_token"];
$expiresIn = $data["expires_in"]; // 28800The response looks like this:
{
"access_token": "eyJraWQiOiJNV...",
"token_type": "Bearer",
"issued_at": "1741564800000",
"expires_in": "28800",
"status": "approved",
"scope": "addresses tracking labels prices"
}Use the token in every subsequent request:
curl "https://apis.usps.com/addresses/v3/address?streetAddress=1600+Pennsylvania+Ave+NW&city=Washington&state=DC" \
-H "Authorization: Bearer eyJraWQiOiJNV..."Reading a v3 error at all
Two response shapes, and confusing them wastes an afternoon.
The token endpoint (/oauth2/v3/token) speaks plain OAuth 2.0: a flat body with error and error_description as strings. "error": "invalid_client" here means the credential pair is wrong.
Every resource endpoint returns a nested envelope instead:
{
"apiVersion": "3.0",
"error": {
"code": "401",
"message": "Unauthorized request.",
"errors": [
{ "status": "401", "code": "...", "title": "..." }
]
}
}Read the headers too, because USPS puts the actionable part there. A 401 carries WWW-Authenticate, telling you which security scheme the endpoint wanted. A 429 carries Retry-After in seconds. Both are defined in the USPS Addresses API specification, and most client libraries throw them away.
Error: 401 Unauthorized
Symptoms: A resource endpoint returns 401 with "message": "Unauthorized request." and a WWW-Authenticate header.
Causes:
- Token expired. The lifetime is 28800 seconds (8 hours), but USPS can invalidate a token early. A strict
expires_incountdown is not reliable. - Missing “Bearer” prefix. The header must be
Authorization: Bearer <token>, notAuthorization: <token>. - Wrong token for the endpoint. A Payment Authorization Token where a Bearer Token belongs, or the reverse.
- Re-authenticating every call. Fetching a new token per request invites race conditions and clock skew, which surface as intermittent 401s.
- The token predates a claims change. Claims are baked in at issue. Linking a license or refreshing claims changes nothing until you request a new token.
Fix:
Cache the token with a 30-minute expiry buffer, not 5 minutes. If expires_in is 28800, treat the token as dead at 27000 seconds (7h 30m). Check the cache before every call and re-authenticate only when the buffer is crossed.
import time
class TokenCache:
def __init__(self):
self._token = None
self._expires_at = 0
self._buffer = 1800 # 30 minutes in seconds
def get_token(self, client_id, client_secret):
if self._token and time.time() < self._expires_at - self._buffer:
return self._token
self._token = self._fetch_token(client_id, client_secret)
return self._token
def _fetch_token(self, client_id, client_secret):
import httpx
r = httpx.post(
"https://apis.usps.com/oauth2/v3/token",
data={
"grant_type": "client_credentials",
"client_id": client_id,
"client_secret": client_secret,
},
)
data = r.json()
self._expires_at = time.time() + int(data["expires_in"])
return data["access_token"]Error: 401 or 403 on address calls, since August 1, 2026
This one is new, and it is now the most common address-endpoint failure with nothing wrong in the code.
Symptoms: /addresses/v3/address worked in July and returns 401 or 403 now. Tracking and pricing on the same credentials still work.
Cause: the Addresses API moved behind a signed license agreement effective August 1, 2026. The USPS Developer Portal states it directly: “Customers who did not complete onboarding no longer have access to the Addresses API.” No code change caused this, and no scope string fixes it.
Fix: the license path runs through the Business Portal at cop.usps.com → My Account → API Licenses → Add an Addresses API License. USPS returns an order form and license agreement by DocuSign, then countersigns. Your Enterprise Payment Account has to exist and be funded first, because usage bills against it at $10 flat for up to 2,000 lookups a month.
Then three separate actions that all look automatic and are not: link your credentials to the license, refresh your claims, and request a new OAuth token. Skip the last one and you will keep calling with a token issued before the license existed.
Timeline is the hard part. Portal steps take an afternoon; the countersignature depends on a human at USPS with no published SLA. Full walkthrough in the CRID and MID enrollment guide, fee curve in the pricing guide.
Error: 403 Forbidden on labels or payments
Symptoms: A valid Bearer Token returns 403 with "error": "insufficient_scope", or 401 Insufficient OAuth scope on /labels/v3/label.
Causes:
- Your app is on Public Access I. The Developer Portal grants that bundle by default. It covers OAuth, addresses, pricing, tracking, and other read endpoints, and it excludes Labels and Payments. Asking for
scope=labels paymentson a Public Access I app returns a token without them rather than an error. - Claims never linked. Even after USPS grants the products, your CRID, MIDs, and EPA reach the token only through claims linking at
cop.usps.com. - Test and production mixed up. The test environment is
apis-tem.usps.com; production isapis.usps.com.
Fix:
Check the scope field in the token response and base64-decode the payload. If api_products still reads [Public Access I], no amount of scope-string editing will help.
There is no self-service path to Labels and Payments. Submit a service request at emailus.usps.com/s/web-tools-inquiry, selecting “USPS APIs” then “Customer Access,” and name your app, CRID, MIDs, and EPA account number. USPS answers in roughly 1–4 weeks, often with a questionnaire to complete before the grant moves. After the grant, refresh claims in COP and request a new token.
On environments: TEM at https://apis-tem.usps.com/ takes your production credentials rather than a separate sandbox key pair, and the same product restrictions apply there. A label flow that fails in TEM for lack of the Labels product will fail identically in production.
Error: 429 Too Many Requests
Symptoms: Calls return 429 with a Retry-After header. Usually during a batch run, or right after a deploy that dropped token caching.
Cause:
USPS defaults to 60 requests per hour per application, and the window is shared across every v3 endpoint. Address validation, tracking, pricing, and label creation all draw on the same 60. Token fetches count too.
That last detail is what turns a rate limit into an outage. Re-authenticate on every call and each validation costs two requests, so 100 validations become 200 requests and you are cut off at the 30th.
Fix:
Cache the token. This is not an optimization, it is the difference between 30 and 60 usable calls an hour.
Then honor Retry-After rather than inventing a backoff. USPS returns the wait in seconds on the 429 itself, which is more accurate than any fixed sleep you would guess.
If 60 an hour is genuinely too few, request an increase at emailus.usps.com with your app name, CRID, and a specific monthly volume (“5,000 address validations + 2,000 labels”). Reported grants run from about 300 an hour for small businesses to 5,000-plus for enterprise, answered in 1–5 business days against no published criteria. The rate limit guide covers the queueing and caching architecture that survives the default.
Payment Authorization Token
The Payment Authorization Token is a separate credential for label creation. It is not a scope on your Bearer Token. It is a different token, from a different endpoint, sent in a different header.
What you need before calling this endpoint:
- CRID. Customer Registration ID, assigned during BCG enrollment.
- Master MID. Mailer ID at the master account level.
- Label MID. Mailer ID for label printing. Often the same as the Master MID.
- EPS Account Number. Your Enterprise Payment System account, 10 digits.
All four come from BCG. Without them there are no labels. Getting them into your token is claims linking, done by hand in the Customer Onboarding Portal at cop.usps.com, with no API to automate it.
curl -X POST "https://apis.usps.com/payments/v3/payment-authorization" \
-H "Authorization: Bearer YOUR_BEARER_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"roles": [
{
"roleName": "PAYER",
"CRID": "YOUR_CRID",
"MID": "YOUR_MASTER_MID",
"manifestMID": "YOUR_LABEL_MID",
"accountType": "EPS",
"accountNumber": "YOUR_EPS_ACCOUNT_NUMBER"
}
]
}'{
"paymentAuthorizationToken": "USPS-PO-PAYMENT-...",
"roles": [
{
"roleName": "PAYER",
"CRID": "12345678",
"MID": "900012345",
"accountType": "EPS",
"accountNumber": "XXXXXXXX",
"permit": {
"permitNumber": "XXXXXXXX",
"permitZIP": "XXXXX",
"permitZIP4": "XXXX"
}
}
]
}Where the token actually goes
This is the step that costs people an afternoon. The paymentAuthorizationToken is not a body field. It travels as its own request header, alongside the Bearer token:
curl -X POST "https://apis.usps.com/labels/v3/label" \
-H "Authorization: Bearer YOUR_BEARER_TOKEN" \
-H "X-Payment-Authorization-Token: YOUR_PAYMENT_TOKEN" \
-H "X-Idempotency-Key: 8f14e45f-ceea-467a-9a1b-2c3d4e5f6a7b" \
-H "Content-Type: application/json" \
-d '{ "toAddress": { "...": "..." } }'The USPS Labels API specification marks X-Payment-Authorization-Token required on label creation, reprint, and cancellation. Put the token in the JSON body and the header is simply absent, which USPS reports as an authorization failure rather than a malformed request. That is why the error reads like a credential problem when the credential is fine.
X-Idempotency-Key is worth wiring at the same time. It is a client-generated UUID that keys reprints and cancellations back to the original label. Label creation itself is not idempotent: resubmitting a used UUID mints a second label and bills you for it. Full schemas in the API reference.
Free API key
A key you can test against in 30 seconds
Debugging OAuth is easier with a working baseline. A free RevAddress key runs Census standardization, geocoding, and address extract with no USPS license at all — 1,000 requests a month, no credit card.
By signing up you agree to our Terms and Privacy Policy.
Your free API key
Copy it now — it is shown once here and sent to your email. Saved in this browser for the dashboard.
Token caching best practices
Two-layer caching — in-memory for the current process, file-based for across restarts — eliminates the majority of authentication errors.
import json
import os
import time
import threading
import httpx
CACHE_PATH = "/tmp/.usps_token_cache"
BUFFER_SECS = 1800 # 30 minutes
_lock = threading.Lock()
_memory: dict = {}
def get_bearer_token(client_id: str, client_secret: str) -> str:
with _lock:
now = time.time()
# 1. Check in-memory
if _memory.get("expires_at", 0) - BUFFER_SECS > now:
return _memory["access_token"]
# 2. Check file cache
try:
with open(CACHE_PATH) as f:
cached = json.load(f)
if cached.get("expires_at", 0) - BUFFER_SECS > now:
_memory.update(cached)
return cached["access_token"]
except (FileNotFoundError, json.JSONDecodeError, KeyError):
pass
# 3. Fetch fresh token
r = httpx.post(
"https://apis.usps.com/oauth2/v3/token",
data={
"grant_type": "client_credentials",
"client_id": client_id,
"client_secret": client_secret,
},
)
r.raise_for_status()
data = r.json()
entry = {
"access_token": data["access_token"],
"expires_at": now + int(data["expires_in"]),
}
# Write file with restricted permissions
with open(CACHE_PATH, "w") as f:
json.dump(entry, f)
os.chmod(CACHE_PATH, 0o600)
_memory.update(entry)
return entry["access_token"]<?php
define("TOKEN_CACHE_PATH", sys_get_temp_dir() . "/.usps_token_cache.json");
define("TOKEN_BUFFER_SECS", 1800); // 30 minutes
function get_bearer_token(string $clientId, string $clientSecret): string {
$now = time();
// 1. Check file cache
if (file_exists(TOKEN_CACHE_PATH)) {
$cached = json_decode(file_get_contents(TOKEN_CACHE_PATH), true);
if (isset($cached["expires_at"]) && ($cached["expires_at"] - TOKEN_BUFFER_SECS) > $now) {
return $cached["access_token"];
}
}
// 2. Fetch fresh token
$context = stream_context_create([
"http" => [
"method" => "POST",
"header" => "Content-Type: application/x-www-form-urlencoded",
"content" => http_build_query([
"grant_type" => "client_credentials",
"client_id" => $clientId,
"client_secret" => $clientSecret,
]),
],
]);
$body = file_get_contents("https://apis.usps.com/oauth2/v3/token", false, $context);
$data = json_decode($body, true);
$entry = [
"access_token" => $data["access_token"],
"expires_at" => $now + (int) $data["expires_in"],
];
file_put_contents(TOKEN_CACHE_PATH, json_encode($entry));
chmod(TOKEN_CACHE_PATH, 0600);
return $entry["access_token"];
}Key rules:
- Store the cache file with
0600permissions — it contains a live credential. - Use a 30-minute buffer, not 5 minutes. USPS tokens can be invalidated before their stated expiry.
- Lock around the fetch to prevent stampedes in multi-threaded or multi-process environments.
- The Payment Authorization Token needs the same caching treatment — it also lasts 8 hours and has the same early-revocation behavior.
Common gotchas — quick reference
| Symptom | Likely cause | Fix |
|---|---|---|
| Address calls 401/403 since August 2026 | No signed Addresses API license | Request one at cop.usps.com, then link credentials and take a new token |
| 401 on every call | Re-authenticating per request | Implement token caching |
| 401 on label calls only | Missing Payment Authorization Token | Fetch the second token after COP claims linking |
| Token works, label still 401 | Payment token sent in the JSON body | Send it as the X-Payment-Authorization-Token header |
| 403 on the label endpoint | App is on Public Access I | Service request at emailus.usps.com. No self-service path |
Scope string includes labels, token does not |
Product not granted to the app | Decode the JWT. api_products is the real answer, not the scope you asked for |
| 429 after a deploy | Token cache removed or disabled | Re-enable caching, then honor Retry-After |
invalid_client on the token request |
Wrong client_id or client_secret | Verify in the Developer Portal, not BCG |
| 401 after 7.5 hours | 30-minute buffer not applied | Refresh at expires_in - 1800, not expires_in |
| COP shows zero authorizations | Labels/Payments not granted, or claims not refreshed | Refresh claims. Still zero means the grant is pending |
| TEM call fails the same way production does | Same product restrictions apply | TEM uses your production credentials. Fix the grant, not the URL |
What RevAddress takes off your plate, and what it cannot
The honest split, because the enrollment maze is real and nobody can shortcut a USPS signature for you.
The token machinery, yes. Bring your own USPS credentials and RevAddress runs the OAuth lifecycle against them: the two-token flow, caching with the expiry buffer, the X-Payment-Authorization-Token header, Retry-After handling, and refresh on invalidation. Your code sends one API key. Available on every plan, including Free.
The USPS data itself, on your license. DPV, ZIP+4, rates, service standards, and tracking are USPS data. They run through your own USPS credentials on every plan, never bundled into the monthly fee. What the plan buys is the infrastructure around them: managed OAuth, the encrypted credential vault, retries, and the dashboard.
The paperwork, no. The Addresses API license, the Labels and Payments grant, and COP claims linking are agreements between USPS and your business entity. Postage is paid by the entity that owes it, which means your CRID, your MID, your EPA.
Something that works today, while USPS moves. Standardization, geocoding, and address extract run against the US Census Bureau’s public address inventory. No USPS license, no DocuSign, 1,000 requests a month, no credit card. It puts an address in correct postal form and returns street-level coordinates. It does not return DPV, ZIP+4, or USPS deliverability flags, because those are USPS data and USPS data requires a license.
Start here
- Get a free API key — no credit card, a known-good endpoint to debug against
- CRID and MID enrollment, end to end — the license, the grant, and claims linking
- What the v3 API costs — the fee curve and the license that gates it
- Surviving the 60-per-hour rate limit — caching and queueing patterns
- API reference — request schemas, response fields, live try-it
Questions
- How long does a USPS v3 OAuth token last?
- Eight hours. The token response returns expires_in as 28800 seconds, and the Payment Authorization Token used for label creation has the same eight-hour lifetime. USPS can invalidate a token before its stated expiry, so refresh on a buffer rather than on the exact countdown.
- Why does address validation return 401 when my credentials are correct?
- Since August 1, 2026 the Addresses API requires a signed license agreement. USPS states that customers who did not complete onboarding no longer have access to the Addresses API. Valid client credentials are no longer sufficient on their own, and a token issued before your license was linked will not carry the new claims.
- Where does the Payment Authorization Token go in a label request?
- In the X-Payment-Authorization-Token request header, not in the JSON body. The USPS Labels API specification marks that header required on every label, reprint, and cancellation call, alongside the standard Authorization Bearer header.
- What is the difference between the Developer Portal and the Business Customer Gateway?
- The Developer Portal at developers.usps.com issues your client ID and client secret. The Business Customer Gateway at gateway.usps.com issues your CRID, Mailer IDs, and Enterprise Payment Account. The Customer Onboarding Portal at cop.usps.com links the two so your token carries the right claims.
- Does USPS tell me how long to wait after a 429?
- Yes. A 429 response carries a Retry-After header. Honor that value instead of guessing a backoff interval, and treat the header as the source rather than a fixed sleep in your retry loop.
Read next
How to Get a USPS CRID and MID, and Link Them to the v3 API (2026)
A USPS CRID identifies the business; a MID identifies the mailer. Here is where to find them, connect the EPA, link COP claims, and activate v3 APIs.
16 min readBreaking ChangeUSPS 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 read