Skip to content
All posts

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

Updated 12 min read By RevAddress

Topics: Quickstart, Address validation, USPS v3

usps-v3 is an MIT-licensed TypeScript client for the USPS v3 REST API. It has zero runtime dependencies, runs on native fetch and bundles its type declarations.

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. Create one under My Apps at cop.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.
Install Bash
npm install usps-v3

Authentication

The client handles the OAuth 2.0 client credentials grant and its 8-hour tokens internally.

Create a client TypeScript
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 options TypeScript
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, and it takes the same credentials as production. Tokens do not cross hosts, and the SDK mints its token from baseUrl, so pointing baseUrl there is the whole switch. A token minted on the other host returns a 403 that reads like an entitlement problem. The OAuth guide covers the other 401 and 403 causes.

Token handling is automatic, and the client exposes it:

Token lifecycle TypeScript
// 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 address TypeScript
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 capitalized, not zipCode. The SDK mirrors the USPS field names rather than normalizing them to camelCase, and a lowercase zipCode is ignored rather than rejected, so the validation runs without the ZIP.

Send city and state, or ZIPCode. 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 lookup TypeScript
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 package TypeScript
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 shopping TypeScript
// 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 locations TypeScript
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.

Label creation

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

Create a label TypeScript
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

The SDK has five error classes, all exported from the package root.

Typed errors TypeScript
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 retryAfter TypeScript
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

Create one client at startup and reuse it across every request.

Address validation endpoint TypeScript
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 matter in production. 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

The managed route is one header, and the USPS credentials stay out of your own process.

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 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.