Skip to content
All Posts
Tutorial

USPS Batch Address Validation: 50 Addresses per Call

·Updated ·10 min read·By RevAddress·Tutorial

USPS does not publish a batch endpoint. The Addresses API takes one address per request, and it caps a whole application at 60 requests an hour by default. At that ceiling, a 10,000-row list is a seven-day job.

Batch validation is what a client builds on top of that: fan the addresses out concurrently, hold the failures apart from the successes, and return one result set. RevAddress exposes it as a single call that takes up to 50 addresses, resolves them in parallel, and reports which ones came from cache.

The endpoint

POST /api/batch/validate, Growth plan and above, JSON in and JSON out.

Batch requestbash
curl -X POST "https://api.revaddress.com/api/batch/validate" \
-H "X-API-Key: rv_live_your_key_here" \
-H "Content-Type: application/json" \
-d '{
  "addresses": [
    {
      "streetAddress": "1600 Pennsylvania Ave NW",
      "city": "Washington",
      "state": "DC",
      "ZIPCode": "20500"
    },
    {
      "streetAddress": "350 Fifth Avenue",
      "city": "New York",
      "state": "NY",
      "ZIPCode": "10118"
    },
    {
      "streetAddress": "1 Infinite Loop",
      "city": "Cupertino",
      "state": "CA"
    }
  ]
}'

What each entry has to carry

The request check is loose and the upstream check is strict, and the gap between them is where most partial failures come from.

RevAddress requires only a streetAddress per entry, and it accepts the usual aliases: street_address, address, address1 and line1 all map to it, and zip, zipcode and postal_code all map to ZIPCode. An entry missing all of those fails the whole request up front, with the offending index named.

USPS is stricter. Its Addresses endpoint requires a street address plus either city and state, or ZIP code, or all three. So an entry carrying only streetAddress passes the request check, reaches USPS, and comes back as a per-address error in the results array. If your source file has rows with a street and nothing else, drop them or fill them before the call rather than paying for the round trip.

The response

Batch responsejson
{
"total": 3,
"successful": 3,
"failed": 0,
"results": [
  {
    "index": 0,
    "status": "success",
    "address": {
      "streetAddress": "1600 PENNSYLVANIA AVE NW",
      "city": "WASHINGTON",
      "state": "DC",
      "ZIPCode": "20500",
      "ZIPPlus4": "0005"
    },
    "additionalInfo": {
      "DPVConfirmation": "Y",
      "DPVCMRA": "N",
      "carrierRoute": "C000",
      "deliveryPoint": "00",
      "business": "Y",
      "vacant": "N"
    },
    "corrections": [],
    "matches": [],
    "cached": false
  }
],
"usage": {
  "cached_hits": 0,
  "fresh_lookups": 3
}
}

results is index-aligned with the array you sent, so results[i].index maps straight back to row i of your file. That matters more than it looks: entries resolve concurrently and finish out of order internally, and the index is what lets you write results back to the right row.

usage reports how the batch was served. cached_hits counts addresses answered from the 24-hour cache; fresh_lookups counts the ones that went upstream. Each entry also carries its own cached boolean. Read those numbers rather than assuming: the cache is what makes a second pass over the same list cheap, and usage is how you prove it happened.

For what the DPV codes mean, including why a Y is not a delivery guarantee and why D and S need different handling, see the single-address quickstart.

Partial failure is the normal case

One bad address never kills the batch. Each entry resolves on its own, and a failure becomes a result object rather than an exception:

Mixed resultsjson
{
"total": 3,
"successful": 2,
"failed": 1,
"results": [
  { "index": 0, "status": "success", "address": { "...": "..." }, "cached": false },
  {
    "index": 1,
    "status": "error",
    "error": "Address Not Found.",
    "address": null,
    "additionalInfo": null,
    "corrections": null,
    "matches": null,
    "cached": false
  },
  { "index": 2, "status": "success", "address": { "...": "..." }, "cached": true }
],
"usage": { "cached_hits": 1, "fresh_lookups": 2 }
}

A 200 does not mean every address validated. The HTTP status describes the batch, not its contents. Branch on results[i].status for each entry, and use failed as the count to log. Code that checks resp.ok and moves on will silently write nulls into a customer table.

The four error responses

These are request-level failures, returned instead of a results array.

403: plan does not include batch.

403 tier_requiredjson
{
"error": "tier_required",
"message": "Batch validation requires Growth tier or above.",
"upgrade": "/pricing"
}

400: more than 50 addresses. The body reports the cap and what you sent, so the chunker can correct itself without a lookup table.

400 batch_too_largejson
{
"error": "batch_too_large",
"message": "Maximum 50 addresses per batch.",
"max": 50,
"received": 73
}

400: an entry with no usable street address. The index is named, which is the difference between fixing one row and re-reading the file.

400 validation_errorjson
{
"error": "validation_error",
"message": "addresses[7].streetAddress is required (also accepts: street_address, address, address1, line1)",
"index": 7
}

429: per-minute rate limit. retryAfter is seconds until the current minute bucket rolls over, so honouring it is strictly better than a fixed sleep.

429 rate_limit_exceededjson
{
"error": "rate_limit_exceeded",
"message": "Rate limit of 300 requests/minute exceeded",
"retryAfter": 37
}

Every authenticated response also carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset. Read X-RateLimit-Limit rather than hardcoding a number: the ceiling is set per key, and the defaults differ by plan.

Running a 10,000-row list

Chunk into 50s, honour the rate-limit headers, and retry the transient failures rather than the permanent ones.

Chunked batch with backoff
import time
import httpx

API_KEY = "rv_live_your_key_here"
URL = "https://api.revaddress.com/api/batch/validate"
BATCH_SIZE = 50

def send_chunk(client, chunk, attempts=4):
  for attempt in range(attempts):
      resp = client.post(
          URL,
          headers={"X-API-Key": API_KEY},
          json={"addresses": chunk},
          timeout=60.0,
      )

      if resp.status_code == 200:
          # Slow down before we are told to. Remaining is per-minute headroom.
          remaining = int(resp.headers.get("X-RateLimit-Remaining", 999))
          if remaining < 5:
              time.sleep(2)
          return resp.json()

      if resp.status_code == 429:
          time.sleep(int(resp.json().get("retryAfter", 2 ** attempt)))
          continue

      # 400 and 403 are our bug, not a blip. Do not burn retries on them.
      resp.raise_for_status()

  raise RuntimeError("batch failed after retries")

def validate_all(addresses):
  results = []
  with httpx.Client() as client:
      for start in range(0, len(addresses), BATCH_SIZE):
          chunk = addresses[start:start + BATCH_SIZE]
          data = send_chunk(client, chunk)

          # Re-base the per-chunk index onto the position in the full list.
          for r in data["results"]:
              r["row"] = start + r["index"]
          results.extend(data["results"])

          done = min(start + BATCH_SIZE, len(addresses))
          print(f"{done}/{len(addresses)}  cache hits: {data['usage']['cached_hits']}")

  return results

rows = load_from_csv("customers.csv")   # your loader
results = validate_all(rows)

deliverable = [
  r for r in results
  if r["status"] == "success" and r["additionalInfo"]["DPVConfirmation"] == "Y"
]
needs_unit = [
  r for r in results
  if r["status"] == "success" and r["additionalInfo"]["DPVConfirmation"] in ("D", "S")
]
print(f"deliverable {len(deliverable)}  needs unit {len(needs_unit)}  errors "
    f"{sum(1 for r in results if r['status'] == 'error')}")

Three details in that code that are not decoration.

Re-base the index. results[i].index is the position inside its chunk, not inside your file. Adding the chunk offset is what keeps row 4,317 writing back to row 4,317.

Retry 429, never 400 or 403. A 429 clears on its own. A 400 will return the identical 400 on every attempt, and retrying it burns quota and wall-clock for nothing.

Slow down on headroom, not on failure. Watching X-RateLimit-Remaining and pausing before it hits zero avoids the retry loop entirely. Reacting only to 429s means every run spends part of its time in backoff.

What it costs and what it needs

There are two bills here, and conflating them is the budgeting mistake.

The RevAddress subscription unlocks the endpoint and the infrastructure around it. Batch is a Growth-plan feature at $79 a month, with a platform allowance of 25,000 requests/mo. Each address counts as one request against that allowance; cached addresses do not.

The USPS verification runs on your USPS license (BYOK), and USPS bills you separately. Batch validation is a BYOK capability: you sign the Addresses API agreement, USPS debits its consumption fee from your Enterprise Payment Account, and we manage the OAuth lifecycle, the caching and the retries around your credentials. The subscription never includes USPS-verified results at a flat rate.

Constraint Value
Maximum addresses per request 50
Minimum plan Growth, per the pricing page
Result cache 24 hours, reported per entry and in usage
Per-minute ceiling Set per key. Read X-RateLimit-Limit
Verified DPV Runs on your USPS license. USPS bills you directly

USPS has required a signed license agreement for the Addresses API since August 1, 2026, and it bills on a monthly consumption curve. Connect your executed license once and the whole USPS surface runs through the same key: DPV and ZIP+4 on batch and single validation, plus rates, service standards and tracking. The pricing post has the published USPS fee table and the comparison against per-label platforms.

When batch is the wrong tool

Batch validation is for a system that keeps validating: a nightly job, a CRM sync, an import pipeline that runs every week.

If you have one file and one deadline, an API key is overhead you do not need. List Clean takes a CSV and returns it standardized to postal format, deduplicated, geocoded and enriched with neighborhood income and vacancy, with a free diagnosis of the file before you pay. Standardization there runs against US Census Bureau reference data, with no USPS license involved. Address File Audit is the larger version for lists up to 25,000 rows, with the duplicate clusters, county concentration and a cost-of-bad-rows figure written out.

Start here

Questions

Does the USPS v3 API have a batch address validation endpoint?
No. The USPS Addresses API exposes GET /addresses/v3/address, which validates one address per request. Batch validation is something a client or a platform builds on top of it by fanning out concurrent single-address calls.
How many addresses can I send in one RevAddress batch call?
50. A request with more than 50 entries returns a 400 with error batch_too_large, and the response body reports both the maximum and the count you sent.
What happens if one address in the batch fails?
Nothing else in the batch is affected. Each entry resolves independently, so a failed address comes back as its own result object with status error and a null address, while every other entry returns normally. Check per-entry status rather than trusting the HTTP code.
Which plan unlocks batch validation?
Growth and above unlocks the endpoint. A key on Free or Starter gets a 403 with error tier_required, and the response points at the pricing page. The subscription buys the endpoint and the platform allowance, not the USPS data.
Does batch validation need a USPS license?
Yes. Batch validation is a BYOK capability: verified results run on your own USPS Addresses API license, which has been required since August 1, 2026, and USPS bills your Enterprise Payment Account for the lookups. RevAddress manages the OAuth lifecycle, the caching, the retries and the rate-limit budgeting around your credentials.