USPS v3 API with PHP: Complete Quickstart Guide
The revaddress/usps-v3-php Composer package is a zero-dependency PHP client for the USPS v3 REST API. This walks through installation, authentication, and the operations most integrations need first: address validation, tracking, rate shopping, label creation, error handling, and framework wiring for Magento, WooCommerce, and Laravel.
One thing to settle before you write code. Since August 1, 2026 the Addresses API sits behind a signed license agreement, and USPS states that “customers who did not complete onboarding no longer have access to the Addresses API.” The SDK will authenticate cleanly and your address calls will still fail until that license is signed. Start it now, in parallel with the code: the enrollment guide has the path.
Prerequisites
- A USPS Developer Portal app. Register at developers.usps.com and create an application for your Client ID and Client Secret. This is the Developer Portal, not the Business Customer Gateway.
- PHP 8.0 or newer with
ext-jsonandext-openssl. The package’s Composer constraint isphp >=8.0, and CI runs it against 8.0 through 8.4. - Zero dependencies. No Guzzle, no PSR-7. The SDK uses PHP’s built-in stream contexts.
composer require revaddress/usps-v3-phpThe package is MIT licensed, published on Packagist, and carries 59 tests with 102 assertions across client validation, the token cache lifecycle, multipart parsing, and the exception hierarchy.
Authentication
The v3 API uses the OAuth 2.0 client credentials grant. Tokens last 8 hours. The SDK runs the whole flow, and caches the token to disk so it survives across PHP-FPM workers rather than being refetched per request.
use RevAddress\USPSv3\Client;
// Client ID and Client Secret from the USPS Developer Portal
// https://developers.usps.com
$usps = new Client('your-client-id', 'your-client-secret');
// That is the whole setup. Tokens are managed for you:
// - fetched on the first API call
// - cached to disk (8-hour lifetime)
// - refreshed on a 30-minute buffer before expiry
// - custom cache dir: new Client('id', 'secret', cacheDir: '/var/cache/usps')Caching matters more than it looks. A token request spends one call against the same 60-per-hour budget as an address lookup, so refetching per request halves your usable throughput.
$status = $usps->tokenStatus();
// ['oauth' => ['valid' => true, 'ttl_seconds' => 25200], 'payment' => [...]]
$usps->refreshTokens(); // force a refresh on both tokensTokens land in sys_get_temp_dir() unless you pass cacheDir. On shared hosting, point it somewhere your process owns.
Address validation
The most common call. It standardizes the address, confirms deliverability through DPV, and flags vacancy.
use RevAddress\USPSv3\Client;
$usps = new Client('your-client-id', 'your-client-secret');
$result = $usps->validateAddress([
'streetAddress' => '1600 Pennsylvania Ave NW',
'city' => 'Washington',
'state' => 'DC',
'ZIPCode' => '20500',
]);
// Standardized fields
echo $result['address']['streetAddress']; // 1600 PENNSYLVANIA AVE NW
echo $result['address']['city']; // WASHINGTON
echo $result['address']['state']; // DC
echo $result['address']['ZIPCode']; // 20500
echo $result['address']['ZIPPlus4']; // 0005
// Delivery Point Validation
echo $result['address']['DPVConfirmation']; // YDPV codes, in the USPS definitions
| Code | Meaning | What to do |
|---|---|---|
Y |
Primary and, if present, secondary both confirmed | Accept the address |
D |
Primary confirmed, secondary information missing | Prompt for the unit number |
S |
Primary confirmed, secondary present but not confirmed | Ask the customer to verify the unit |
N |
Neither primary nor secondary confirmed | Reject or route to manual review |
D and S get published backwards constantly, including in earlier versions of this page. The definitions above are the ones in the USPS Addresses API specification: D is the missing-secondary case, S is the present-but-unconfirmed case. Getting them the wrong way round means prompting the wrong customers for apartment numbers.
A Y also does not promise USPS delivers there. It means the address matches a known USPS record. Carrier routes like R777 and R779 are the classic case where the physical address confirms but mail actually goes to a PO Box.
Migration note. In the retired Web Tools XML API, Address2 was the street and Address1 was the apartment or suite, the reverse of every other address API. The v3 API uses streetAddress and secondaryAddress. Copying the old field order across corrupts addresses silently rather than raising an error.
Package tracking
$tracking = $usps->trackPackage('9400111899223456789012');
echo $tracking['trackingNumber']; // "9400111899223456789012"
echo $tracking['statusCategory']; // "Delivered"
echo $tracking['status']; // "Delivered, In/At Mailbox"
// Full event history, most recent first
foreach ($tracking['trackingEvents'] as $event) {
printf(
"%s %s | %s, %s | %s\n",
$event['eventDate'],
$event['eventTime'],
$event['eventCity'],
$event['eventState'],
$event['eventDescription']
);
}
// 2026-03-08 10:15 | Washington, DC | Delivered, In/At Mailbox
// 2026-03-08 06:30 | Washington, DC | Out for Delivery
// 2026-03-07 22:10 | Washington, DC | Arrived at HubTracking is real-time. Poll every 30 to 60 minutes rather than continuously, because every poll spends the same rate-limit budget as a checkout-blocking validation. The old TrackID XML body is gone; the tracking number is a path parameter and the SDK builds the URL.
Rate shopping
The v3 API takes one mailClass per request, unlike the old RateV4 which returned every class at once. Rating an order across three services is three calls.
$rates = $usps->getDomesticPrices([
'originZIPCode' => '10001', // New York, NY
'destinationZIPCode' => '90210', // Beverly Hills, CA
'weight' => 2.5, // pounds
'mailClass' => 'PRIORITY_MAIL',
'processingCategory' => 'MACHINABLE',
'rateIndicator' => 'DR', // Dimensional Rectangular
'priceType' => 'RETAIL',
]);
echo $rates['totalBasePrice']; // "12.10"
$intlRates = $usps->getInternationalPrices([
'originZIPCode' => '10001',
'destinationCountryCode' => 'CA', // ISO alpha-2, not "Canada"
'weight' => 2.5,
'mailClass' => 'PRIORITY_MAIL_EXPRESS',
]);| Mail class constant | Service |
|---|---|
PRIORITY_MAIL_EXPRESS |
1–2 day guaranteed |
PRIORITY_MAIL |
1–3 day |
USPS_GROUND_ADVANTAGE |
2–5 day ground |
FIRST-CLASS_PACKAGE_SERVICE |
1–5 day, under 1 lb |
PARCEL_SELECT |
2–8 day economy |
MEDIA_MAIL |
2–8 day, books and media only |
Label creation
Labels need more than a client ID and secret. They need a CRID, a master MID, a label MID, and an EPS account number, all issued through the Business Customer Gateway and linked through COP claims. Postage is paid by the entity that owes it, which means these are yours.
$usps = new Client(
'client-id',
'client-secret',
crid: '12345678',
masterMid: '900000001',
labelMid: '900000000',
epaAccount: 'your-eps-account',
);
$label = $usps->createLabel(
fromAddress: [
'firstName' => 'RevAddress',
'streetAddress' => '228 Park Ave S',
'city' => 'New York',
'state' => 'NY',
'ZIPCode' => '10003',
],
toAddress: [
'firstName' => 'Jane',
'lastName' => 'Doe',
'streetAddress' => '1600 Pennsylvania Ave NW',
'city' => 'Washington',
'state' => 'DC',
'ZIPCode' => '20500',
],
mailClass: 'PRIORITY_MAIL',
weight: 2.5,
);
echo $label['trackingNumber'];
file_put_contents('label.pdf', $label['labelData']); // PDF bytesUnderneath, a label carries two tokens: the standard OAuth Bearer token and a Payment Authorization token, the second one in an X-Payment-Authorization-Token request header rather than the JSON body. The SDK handles both, which is most of why it exists. If you are debugging this by hand, the OAuth troubleshooting guide covers the failure modes. To get the credentials in the first place, work through the CRID and MID enrollment guide.
Error handling
The SDK throws a typed exception per error category. RateLimitException is the one to handle first, because USPS caps direct access at 60 requests an hour across every endpoint.
use RevAddress\USPSv3\Client;
use RevAddress\USPSv3\Exception\USPSException;
use RevAddress\USPSv3\Exception\AuthException;
use RevAddress\USPSv3\Exception\RateLimitException;
use RevAddress\USPSv3\Exception\ValidationException;
$usps = new Client('your-client-id', 'your-client-secret');
try {
$result = $usps->validateAddress([
'streetAddress' => '1600 Pennsylvania Ave NW',
'city' => 'Washington',
'state' => 'DC',
]);
} catch (RateLimitException $e) {
// 429 — USPS returns Retry-After; honor it rather than guessing
$retryAfter = $e->getRetryAfter() ?? 60;
sleep($retryAfter);
} catch (AuthException $e) {
// OAuth or Payment Auth failure
$usps->refreshTokens();
} catch (ValidationException $e) {
// Bad input — which field failed
echo $e->getField(); // "ZIPCode"
} catch (USPSException $e) {
// Everything else
if ($e->isRetryable()) {
// 500, 502, 503, 504 — transient
}
$body = $e->getResponseBody(); // raw USPS error
}| Exception | HTTP | Cause |
|---|---|---|
AuthException |
401 | Bad credentials, expired token, or a missing Addresses API license |
ValidationException |
400 | Missing or invalid request parameters |
RateLimitException |
429 | Over the 60-per-hour limit |
USPSException |
5xx | USPS server error, retry-safe when isRetryable() is true |
getRetryAfter() reads the Retry-After header USPS sends on a 429. Use it. It is more accurate than any fixed sleep you would pick, and the rate limit guide covers the caching and queueing that keep you off the ceiling.
Since August 2026 a 401 on an address call is more often a license problem than a credential problem. If tracking works on the same client and addresses do not, check the license before you touch the code.
The rest of the surface
Beyond the four operations above, the client also exposes:
$usps->cityStateLookup('20500');
$usps->getServiceStandards('10001', '90210', 'PRIORITY_MAIL');
$usps->getLocations('10001', radius: 5);
$usps->schedulePickup([...]);
$usps->cancelPickup('confirmation-number');Magento 2 integration
If you are fixing the AC-15210 USPS shipping breakage, a custom carrier calling v3 directly is the fastest path: no XML parsing, no USERID query parameter, proper OAuth caching.
// app/code/RevAddress/Shipping/Model/Carrier/RevAddress.php
namespace RevAddress\Shipping\Model\Carrier;
use Magento\Quote\Model\Quote\Address\RateRequest;
use Magento\Shipping\Model\Carrier\AbstractCarrier;
use RevAddress\USPSv3\Client;
class RevAddress extends AbstractCarrier
{
protected $_code = 'revaddress';
private Client $usps;
public function __construct(/* ... */)
{
parent::__construct(/* ... */);
$this->usps = new Client(
$this->getConfigData('client_id'),
$this->getConfigData('client_secret'),
);
}
public function collectRates(RateRequest $request)
{
$rates = $this->usps->getDomesticPrices([
'originZIPCode' => $request->getPostcode(),
'destinationZIPCode' => $request->getDestPostcode(),
'weight' => $request->getPackageWeight(),
'mailClass' => 'USPS_GROUND_ADVANTAGE',
'processingCategory' => 'MACHINABLE',
'rateIndicator' => 'DR',
'priceType' => 'RETAIL',
]);
// ... build RateResult from $rates
}
}Rate shopping at checkout is exactly the workload the 60-per-hour limit punishes, so cache aggressively. The full Magento fix guide has the module structure, etc/config.xml, admin fields, and deployment.
Laravel integration
Register the client as a singleton. It manages its own token lifecycle and is safe to reuse, so instantiate once and inject everywhere.
// app/Providers/USPSServiceProvider.php
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
use RevAddress\USPSv3\Client;
class USPSServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->singleton(Client::class, fn() =>
new Client(
config('services.usps.client_id'),
config('services.usps.client_secret'),
cacheDir: storage_path('framework/cache/usps'),
)
);
}
}
// In any controller or service:
use RevAddress\USPSv3\Client;
class AddressController
{
public function validate(Request $request, Client $usps)
{
$result = $usps->validateAddress([
'streetAddress' => $request->street,
'city' => $request->city,
'state' => $request->state,
'ZIPCode' => $request->zip,
]);
return response()->json([
'valid' => $result['address']['DPVConfirmation'] === 'Y',
'standardized' => $result['address'],
]);
}
}Add usps.client_id and usps.client_secret to config/services.php. Pointing cacheDir at Laravel’s cache directory is what makes the token survive across FPM workers instead of being refetched by each one.
WooCommerce
Same client, different seam: a shipping method class extending WC_Shipping_Method, calling getDomesticPrices() from calculate_shipping(). The WooCommerce migration guide has the full plugin.
Start here
- Packagist — install, changelog, version history
- GitHub — source, issues, CI across PHP 8.0–8.4, MIT licensed
- CRID and MID enrollment — the license, the EPA, and COP claims linking
- Magento AC-15210 fix · WooCommerce migration
- Rate limit strategies — caching and queueing past 60 requests an hour
- Get a free API key — Census standardization, geocoding, and extract with no USPS license, 1,000 requests a month
Using a different language? The same walkthrough exists for Python and Node, both on the usps-v3 package name.
Questions
- What PHP version does the USPS v3 PHP SDK require?
- PHP 8.0 or newer with the json and openssl extensions. The Composer constraint is php >=8.0 and CI runs the package against PHP 8.0 through 8.4. The package has zero external dependencies, so no Guzzle and no PSR-7.
- Do I still need a USPS license to validate addresses from PHP?
- Yes, since August 1, 2026. The Addresses API requires a signed license agreement, and USPS states that customers who did not complete onboarding no longer have access to it. The SDK will authenticate fine and the address call will still fail until the license is signed and your claims are refreshed.
- What do the DPVConfirmation codes mean?
- Y means both primary and secondary numbers were confirmed. D means the primary was confirmed and the secondary was missing. S means the primary was confirmed and the secondary was present but not confirmed. N means neither confirmed. D and S are frequently documented backwards, so check against the USPS specification.
- How does the SDK handle OAuth tokens?
- Automatically. The token is fetched on the first API call, cached to disk so it survives across PHP-FPM requests, and refreshed on a 30-minute buffer against its 8-hour lifetime. Call tokenStatus() to inspect TTL and validity, or refreshTokens() to force a refresh.
- Can I create shipping labels with just a client ID and secret?
- No. Label creation additionally needs a CRID, a master MID, a label MID, and an EPS account number, all issued through the Business Customer Gateway and linked through COP claims. Pass them as named arguments to the Client constructor.
Read next
USPS Web Tools Shut Down on January 25, 2026: What Broke and the Fastest Way to the v3 API
USPS retired Web Tools on January 25, 2026. If ShippingAPI.dll, USERID, or secure.shippingapis.com is failing, map the XML route to v3 REST here.
10 min readMigration GuideMagento USPS AC-15210: Apply the Patch, Then Fix the Three Things It Leaves Broken
AC-15210 is Adobe's official USPS REST patch, not an unfixed bug. How to apply it, the config.xml error it ships with, and the gaps it leaves behind.
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