Skip to content
All Posts
Tutorial

USPS Node.js SDK Quickstart: TypeScript Client for the v3 API

·Updated ·12 min read·By RevAddress·Tutorial

usps-v3 is an MIT-licensed TypeScript client for the USPS v3 REST API. Zero runtime dependencies, built on native fetch, with type declarations bundled. This walks installation, authentication, and the six operations most Node integrations need.

Settle one thing before you write code: the SDK runs on your USPS 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

  • Node.js 18 or newer. The SDK uses the built-in fetch, so there is no polyfill to install.
  • Zero dependencies. Nothing else lands in your lockfile.
  • A USPS application. Register at developer.usps.com to get a Client ID and Client Secret.
  • An executed Addresses API license for address validation. The CRID and MID guide walks the enrollment screens.
Installbash
npm install usps-v3

Authentication

OAuth 2.0 client credentials, 8-hour tokens, all of it handled inside the client.

Create a clienttypescript
import { USPSClient } from 'usps-v3';

const client = new USPSClient({
clientId: process.env.USPS_CLIENT_ID,
clientSecret: process.env.USPS_CLIENT_SECRET,
});

Both values fall back to those environment variables if you omit them. The full constructor surface:

Constructor optionstypescript
const client = new USPSClient({
clientId: 'your-client-id',         // or USPS_CLIENT_ID
clientSecret: 'your-client-secret', // or USPS_CLIENT_SECRET
baseUrl: 'https://apis.usps.com',   // default
timeout: 30000,                     // ms, default

// Label creation only:
crid: '...',        // or USPS_CRID
masterMid: '...',   // or USPS_MASTER_MID
labelMid: '...',    // or USPS_LABEL_MID
epaAccount: '...',  // or USPS_EPA_ACCOUNT
});

baseUrl is how you reach the testing host. USPS runs one at apis-tem.usps.com with separate credentials; point baseUrl there and keep the two credential sets apart. Crossing them is the most common cause of a 403 that looks like an entitlement problem. The OAuth guide covers the rest of the failure modes.

Token handling is automatic, and the client exposes it:

Token lifecycletypescript
// Inspect without forcing a call
console.log(client.tokenStatus);
// { hasOAuthToken: true, oauthExpiresIn: 27000, ... }

// Force a refresh
await client.refreshTokens();

// Release resources on shutdown
client.close();

Tokens are cached in memory and refreshed 30 minutes before expiry. Instantiate the client once, at startup, not per request: every token fetch counts against your hourly quota, and a per-request client re-authenticates constantly.

Address validation

Validate an addresstypescript
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' }

ZIPCode is capitalised, not zipCode. The SDK mirrors the USPS field names rather than normalizing them to camelCase, and a lowercase zipCode is silently ignored rather than rejected, which turns into a validation that quietly ran without the ZIP you thought you sent.

The parameter rule catches people. streetAddress is always required, and USPS wants either city and state, or ZIPCode, or all three. A street line alone throws a ValidationError.

City and state from a ZIP is its own call:

City and state lookuptypescript
const info = await client.addresses.cityState('10001');

Reading DPV

DPVConfirmation is what the call is for. The USPS specification defines 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

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 elsewhere. D and S are also different problems: one is a field you never collected, the other is a field the customer filled in wrong. The address validation quickstart covers the full attribute set.

Package tracking

Track a packagetypescript
const tracking = await client.tracking.track('9400111899223033005282');

console.log(tracking.statusCategory);  // 'Delivered', 'In Transit', ...

The tracking number is a plain string argument. Do not cache the response: tracking goes stale within minutes. Poll at a sane interval or register for USPS tracking notifications.

Rates, estimates, and drop-off

Rate shoppingtypescript
// Domestic
const rates = await client.prices.domestic({
originZIPCode: '10001',
destinationZIPCode: '90210',
weight: 2.5,
});

// Outbound international
const intlRates = await client.prices.international({
originZIPCode: '10001',
destinationCountryCode: 'GB',
weight: 3.0,
});
Delivery estimates and drop-off locationstypescript
const estimates = await client.standards.estimates('10001', '90210');
// [{ mailClass: 'PRIORITY_MAIL', daysToDelivery: 2 }, ...]

const locations = await client.locations.dropoff({
destinationZIP: '20500',
mailClass: 'PRIORITY_MAIL',
});

Note the shape difference: standards.estimates takes two positional strings, while locations.dropoff takes an object with destinationZIP. They are not consistent with each other, and reading the signature beats guessing.

Label creation

Labels need payment authorization on top of OAuth, which means the four enrollment identifiers on the constructor.

Create a labeltypescript
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);

The CRID, MIDs, and EPA account come from COP claims linking, a manual enrollment step with no API path. Postage is drawn from your own payment account, so label creation is always on your own USPS license.

Error handling

Five error classes, all exported from the package root.

Typed errorstypescript
import {
USPSClient,
ValidationError,
AuthError,
RateLimitError,
} from 'usps-v3';

try {
await client.addresses.validate({ streetAddress: '' });
} catch (err) {
if (err instanceof ValidationError) {
  console.log('Bad field: ' + err.field);
} else if (err instanceof RateLimitError) {
  console.log('Retry after ' + err.retryAfter + 's');
} else if (err instanceof AuthError) {
  console.log('Check credentials');
} else {
  throw err;
}
}
Error class Raised when
ValidationError Invalid input parameters (carries field)
AuthError OAuth or payment authorization failure
RateLimitError 429 from USPS (carries retryAfter)
APIError USPS returned an error response
NetworkError Connection timeout or DNS failure

Import them from 'usps-v3', not a subpath. The package declares a single export entry point, so 'usps-v3/errors' and 'usps-v3/types' do not resolve. Types come through the root import as well; there is no @types/ package to add.

RateLimitError is the one to design around. USPS defaults to 60 requests per hour per application, shared across every endpoint your app calls, and the SDK surfaces the 429 rather than throttling for you.

Retry that respects retryAftertypescript
import { RateLimitError, AuthError, ValidationError } from 'usps-v3';

const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

async function validateWithRetry(client, address, attempts = 3) {
for (let attempt = 0; attempt < attempts; attempt++) {
  try {
    return await client.addresses.validate(address);
  } catch (err) {
    if (err instanceof RateLimitError) {
      // Honour USPS's own number when it sends one.
      await sleep((err.retryAfter ?? 2 ** attempt) * 1000);
      continue;
    }
    // Neither of these clears on a retry.
    if (err instanceof AuthError || err instanceof ValidationError) throw err;
    throw err;
  }
}
throw new Error('USPS validation failed after retries');
}

Retry the 429. Never retry a ValidationError, which returns identically forever, or an AuthError, which needs a credential fix. The rate limit guide covers the caching and queueing that keep you under the ceiling to begin with.

Express route

One client at startup, reused across every request.

Address validation endpointtypescript
import express from 'express';
import { USPSClient, ValidationError, RateLimitError } from 'usps-v3';

const app = express();
app.use(express.json());

// Once, at startup. The token is cached and shared across requests.
const usps = new USPSClient({
clientId: process.env.USPS_CLIENT_ID,
clientSecret: process.env.USPS_CLIENT_SECRET,
});

app.post('/api/validate-address', async (req, res) => {
try {
  const result = await usps.addresses.validate({
    streetAddress: req.body.street,
    city: req.body.city,
    state: req.body.state,
    ZIPCode: req.body.zip,
  });
  res.json({ address: result.address });
} catch (err) {
  if (err instanceof ValidationError) {
    return res.status(400).json({ error: err.message, field: err.field });
  }
  if (err instanceof RateLimitError) {
    res.set('Retry-After', String(err.retryAfter ?? 60));
    return res.status(429).json({ error: 'usps_rate_limited' });
  }
  return res.status(502).json({ error: 'usps_upstream_error' });
}
});

app.listen(3000);

process.on('SIGTERM', () => usps.close());

Three things that matter in production and are easy to skip. Map RateLimitError to a real 429 with a Retry-After header so your own callers can back off correctly instead of hammering you. Return 502 rather than 500 for an upstream USPS failure, because the distinction is what tells your on-call whether the bug is yours. And call client.close() on shutdown.

Migrating from usps-webtools

usps-webtools and usps-webtools-promise target USPS Web Tools, which stopped answering on January 25, 2026.

usps-webtools usps-v3
verify(address, callback) client.addresses.validate(address)
zipCodeLookup(address, callback) client.addresses.cityState(zip)
track(trackingNumber, callback) client.tracking.track(trackingNumber)
rates(params, callback) client.prices.domestic(params)
USERID string auth OAuth 2.0, handled by the client
XML responses JSON responses

Field names moved too: Address2 was the street line and becomes streetAddress; Address1 was the unit and becomes secondaryAddress. Callbacks become promises throughout. 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 is one header.

Same validation, two routes
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',
});

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, tracking, and labels 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

Questions

How do I install the USPS Node.js SDK?
Run npm install usps-v3. The package requires Node.js 18 or newer, ships zero runtime dependencies, and uses the built-in fetch. TypeScript declarations are bundled, so no @types package is needed.
Where do the error classes come from in usps-v3?
The package root. Import ValidationError, AuthError, RateLimitError, APIError, and NetworkError directly from 'usps-v3' alongside USPSClient. The package exports map defines a single entry point, so subpath imports do not resolve.
How do I point the Node SDK at the USPS testing host?
Pass baseUrl to the constructor. It defaults to https://apis.usps.com; set it to the testing host to run against USPS test credentials. Testing and production credentials are not interchangeable.
Does the SDK manage OAuth tokens?
Yes. Tokens are cached in memory and refreshed automatically 30 minutes before expiry. You can inspect client.tokenStatus, force a refresh with client.refreshTokens(), and release resources with client.close().
Do I need a USPS license to use the Node 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 runs on your own credentials, so the license and the invoice are yours.