# Postal Code Area Lookup for Agent Data Enrichment (`grit-77/postal-area-lookup`) Actor

Postal-area lookup from Zippopotam.us for country and postal-code pairs. Export attributed place names, state codes and approximate place centroids as JSON; does not validate street addresses.

- **URL**: https://apify.com/grit-77/postal-area-lookup.md
- **Developed by:** [Grit](https://apify.com/grit-77) (community)
- **Categories:** Business, AI
- **Stats:** 2 total users, 1 monthly users, 100.0% runs succeeded, 0 bookmarks
- **User rating**: No ratings yet

## Pricing

$0.10 / 1,000 postal areas

This Actor is paid per event. You are not charged for the Apify platform usage, but only a fixed price for specific events.

Learn more: https://docs.apify.com/actors/running/actors-in-store.md#pay-per-event

## What's an Apify Actor?

An Actor is a serverless cloud program that runs on the Apify platform. It has two run modes.
In Batch mode, an Actor accepts a well-defined JSON input, performs an action which can take anything from a few seconds to a few hours,
and optionally produces a well-defined JSON output, datasets with results, or files in key-value store.
In Standby mode, an Actor provides a web server which can be used as a website, API, or an MCP server.

Apify vocabulary and the platform model are defined once, in the agent quickstart at https://apify.com/agents.md.

## How to integrate an Actor?

If asked about integration, you help developers integrate Actors into their projects.
You adapt to their stack and deliver integrations that are safe, well-documented, and production-ready.

Do not guess an integration path. Every one of them is in the agent quickstart at https://apify.com/agents.md: the Apify MCP server, Agent Skills with the Apify CLI, the JavaScript and Python clients, the REST API, and the account-free path for an agent with no human to sign in. It also carries the rule on stating cost before the first paid run.

For examples already wired to this Actor's own input schema, see the [API](#api) section below.

Each client library has reference documentation the quickstart does not restate: [JavaScript/TypeScript](https://docs.apify.com/api/client/js/docs.md) (`npm install apify-client`) and [Python](https://docs.apify.com/api/client/python/docs.md) (`pip install apify-client`).

# README

## Postal Code Area Lookup for Agent Data Enrichment

Enrich country/postal-code pairs with attributed postal place names, state codes and approximate place centroids from the anonymous Zippopotam.us API.

Use this actor for postal-area labels and coarse geographic enrichment. A postal code may map to several places: each source place becomes its own row. Postal codes stay strings, including leading zeros. This is separate from OSM place or business discovery and does not validate street addresses or delivery eligibility.

### Input

The prefilled example is:

```json
{
  "lookups": [{"country": "US", "postalCode": "90210"}],
  "maxItems": 1
}
```

For a mixed-country batch:

```json
{
  "lookups": [
    {"country": "US", "postalCode": "01001"},
    {"country": "DE", "postalCode": "01067"}
  ],
  "maxItems": 20,
  "concurrency": 3,
  "maxRetries": 2,
  "timeoutSecs": 15
}
```

| Input | Meaning |
|---|---|
| `lookups` | Required array of 1–10,000 objects containing only `country` and `postalCode`. |
| `country` | Two-letter country code. Outer whitespace is trimmed and country letters are uppercased. Actual coverage belongs to the source. |
| `postalCode` | Required string of 1–20 letters, digits, spaces or hyphens. Outer whitespace is trimmed; leading zeros remain. Numbers are rejected. |
| `maxItems` | Total output-row cap, including errors: 1–100,000, default 1,000. The Store prefill is 1. |
| `concurrency` | Simultaneous lookups: 1–10, default 3. |
| `maxRetries` | Extra attempts for network errors, HTTP 429 or 5xx: 0–3, default 2. |
| `timeoutSecs` | Timeout per HTTP operation: 1–60 seconds, default 15. |

Street/address fields, residents, households, reverse-person searches, contact fields and POI/business queries are unsupported. Unknown input fields are rejected. Malformed pairs produce `input_error` rows; an invalid overall input fails the run with a clear message. `lookupIndex` identifies the original pair even when its values cannot be used.

### Returned fields

| Field | Meaning |
|---|---|
| `countryCode`, `postalCode` | Normalized input country and string postal code; null for an unusable input pair. |
| `placeName` | Postal place name reported by the source. |
| `state`, `stateCode` | Source state/region name and abbreviation; null when unavailable. |
| `latitude`, `longitude` | Numeric approximate place centroid, or both null if the source pair is unusable. These are not rooftop coordinates or postal boundaries. |
| `coordinateAccuracy` | `approximate_place_centroid` or `unavailable`. |
| `coordinateWarnings` | Reasons source coordinates were omitted. |
| `lookupIndex` | Zero-based input index. Duplicate pairs remain separate inputs. |
| `matchIndex` | Zero-based source place index; null for a lookup-level error. |
| `matchedPlaceCount` | Number of source places before the output cap. |
| `matchesTruncated` | True when the cap or charge budget omitted places from this lookup. |
| `status` | `ok`, `coverage_missing`, `input_error` or `source_error`. |
| `errorCode`, `error` | Machine-readable error category and explanatory text; null on success. |
| `httpStatus` | Source HTTP status, or null if no response/request was available. |
| `source`, `sourceUrl` | Zippopotam.us and the exact country/postal-code API URL. |
| `sourceAttribution`, `sourceLicenseUrl` | Attribution to Zippopotam.us/GeoNames and source database license link. |
| `retrievedAt` | UTC timestamp for the lookup result. |

`SUMMARY` in the default key-value store records row totals and whether the run stopped at the output cap, charge limit or end of input. The default dataset is available through the actor's Results output link and Apify dataset export.

### Real output example

This row comes from the local run saved in [sample_output.json](sample_output.json):

```json
{
  "lookupIndex": 0,
  "countryCode": "US",
  "postalCode": "90210",
  "placeName": "Beverly Hills",
  "state": "California",
  "stateCode": "CA",
  "latitude": 34.0901,
  "longitude": -118.4065,
  "coordinateAccuracy": "approximate_place_centroid",
  "coordinateWarnings": [],
  "matchIndex": 0,
  "matchedPlaceCount": 1,
  "matchesTruncated": false,
  "status": "ok",
  "errorCode": null,
  "error": null,
  "httpStatus": 200,
  "source": "Zippopotam.us",
  "sourceUrl": "https://api.zippopotam.us/us/90210",
  "sourceAttribution": "Zippopotam.us; postal reference data from GeoNames (https://www.geonames.org/).",
  "sourceLicenseUrl": "https://opendatacommons.org/licenses/odbl/1-0/",
  "retrievedAt": "2026-10-06T09:08:17.749118+00:00"
}
```

### Pricing proposal

The proposed `postal_area` event is **$0.0001 per delivered `ok` place row**, or $0.10 for 1,000 successful rows. A code with three saved places incurs three events. A successful postal place with unavailable coordinates is still billed because its place/state data was returned. Coverage gaps, invalid inputs, source errors and discarded rows do not incur this event. Duplicate successful inputs incur their own events.

See [PRICING.md](PRICING.md) for the current Store comparison, the free competitor caveat and the proposed event configuration. This local build does not activate Store pricing. Apify's separate platform costs or automatic start events depend on the final account configuration.

### Limits and source quality

- The actor requests only `https://api.zippopotam.us/{country}/{postalCode}` without login, API key, proxy, reverse search or additional enrichment sources.
- **HTTP 404 means missing source coverage. It does not establish that a delivery address or postal code is invalid.**
- Raise `maxItems` to preserve all source matches. Truncation is explicit. Input order and source place order are retained; a small batch may already be in flight when the row cap is reached.
- Retry waits grow as 1, 2, then 4 seconds. `Retry-After` can extend a wait to a maximum of 30 seconds. Other HTTP errors and malformed responses become item-level errors.
- Missing, nonnumeric, nonfinite or out-of-range coordinate pairs become null with a warning. Values within the numeric ranges can still be wrong; the actor cannot independently certify source accuracy. No guessed correction is made.
- In the captured DE/01067 response, all three places had source latitude `14612`; their names were preserved and both coordinates were omitted. This case is included in the real response fixtures and sample.
- Coverage and precision vary by country. Some countries expose postal prefixes or incomplete datasets. Consult the [source coverage notes](https://docs.zippopotam.us/docs/getting-started/) and [endpoint documentation](https://docs.zippopotam.us/docs/v1/).
- Zippopotam.us is a public third-party service with data supplied as-is. Keep concurrency low; availability and source changes can affect runs.

### Attribution

Data is supplied by **Zippopotam.us**, derived from **[GeoNames](https://www.geonames.org/)**. The [Zippopotam.us source page](https://api.zippopotam.us/) identifies the [Open Database License](https://opendatacommons.org/licenses/odbl/1-0/) and [Database Contents License](https://opendatacommons.org/licenses/dbcl/1-0/). Attribution and a source-license link accompany each row; retain them when reusing or exporting data. Source terms apply to derived data.

### FAQ

**Can this verify an address for shipping?** No. It provides a postal-area reference match, not a delivery-address verdict.

**Why does my country or full postal code return no match?** The public source may not cover it or may expose only a prefix. A 404 is saved as `coverage_missing`; no alternate source is queried.

**Why are there multiple rows for one code?** The source can associate a code with several places. Join on `lookupIndex` and retain `matchIndex` rather than silently picking a single city.

**Why are coordinates missing on an `ok` row?** A usable place name was returned, but the source coordinate pair failed numeric/range checks. Read `coordinateWarnings`.

**Are repeated pairs deduplicated?** No. Each input retains its own index and successful output events.

### Use with AI agents / MCP

For an Apify-connected agent or MCP client, supply the JSON input above to this actor and read the Results dataset. Keep `postalCode` a JSON string. Handle `coverage_missing` as an unknown coverage state, retain all place matches, and check `coordinateAccuracy` before using coordinates. `maxItems` is an output-row budget, so choose a larger value than the pair count when several places may match. This actor does not implement its own MCP server; the connection is provided by the client's Apify integration.

### Local development

Use the existing virtual environment; do not install packages into it:

```powershell
& C:/GritWork/earn/.venv/Scripts/python.exe evidence/run_tests.py
& C:/GritWork/earn/.venv/Scripts/python.exe evidence/run_tests.py live
apify validate-schema
```

Put the JSON input in `storage/key_value_stores/default/INPUT.json`, then run:

```powershell
$env:APIFY_LOCAL_STORAGE_DIR = "$PWD/storage"
& C:/GritWork/earn/.venv/Scripts/python.exe -m src
```

The scripts in `evidence/` retain the exact local-run commands, build digests, observations and test results. Real response fixtures and their capture metadata are under `tests/fixtures/`. Cloud builds and real billing require a separate owner-run publication/configuration step.

# Actor input Schema

## `lookups` (type: `array`):

JSON array of country/postalCode pairs. Use two-letter country codes and quote postal codes to preserve leading zeros. No street/address, personal, contact or POI fields are accepted. Source coverage varies by country.

## `maxItems` (type: `integer`):

Hard output cap including error rows. One postal code can return multiple place rows. Truncated matches are marked; discarded rows are not charged.

## `concurrency` (type: `integer`):

Maximum simultaneous requests to the anonymous source. Keep this low for a public service.

## `maxRetries` (type: `integer`):

Additional attempts for network failures, HTTP 429 and HTTP 5xx. Backoff waits 1, 2, then 4 seconds, honors Retry-After up to 30 seconds. A 404 is not retried.

## `timeoutSecs` (type: `integer`):

Timeout for each HTTP operation, including connection and reading the response.

## Actor input object example

```json
{
  "lookups": [
    {
      "country": "US",
      "postalCode": "90210"
    }
  ],
  "maxItems": 1,
  "concurrency": 3,
  "maxRetries": 2,
  "timeoutSecs": 15
}
```

# Actor output Schema

## `dataset` (type: `string`):

Dataset containing one row per matched place, or an explicit error/coverage row for an unsuccessful lookup.

# API

You can run this Actor programmatically using our API. Below are code examples in JavaScript, Python, and CLI, as well as the OpenAPI specification and MCP server setup.

## JavaScript example

```javascript
import { ApifyClient } from 'apify-client';

// Initialize the ApifyClient with your Apify API token
// Replace the '<YOUR_API_TOKEN>' with your token
const client = new ApifyClient({
    token: '<YOUR_API_TOKEN>',
});

// Prepare Actor input
const input = {
    "lookups": [
        {
            "country": "US",
            "postalCode": "90210"
        }
    ],
    "maxItems": 1
};

// Run the Actor and wait for it to finish
const run = await client.actor("grit-77/postal-area-lookup").call(input);

// Fetch and print Actor results from the run's dataset (if any)
console.log('Results from dataset');
console.log(`💾 Check your data here: https://console.apify.com/storage/datasets/${run.defaultDatasetId}`);
const { items } = await client.dataset(run.defaultDatasetId).listItems();
items.forEach((item) => {
    console.dir(item);
});

// 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/js/docs

```

## Python example

```python
from apify_client import ApifyClient

# Initialize the ApifyClient with your Apify API token
# Replace '<YOUR_API_TOKEN>' with your token.
client = ApifyClient("<YOUR_API_TOKEN>")

# Prepare the Actor input
run_input = {
    "lookups": [{
            "country": "US",
            "postalCode": "90210",
        }],
    "maxItems": 1,
}

# Run the Actor and wait for it to finish
run = client.actor("grit-77/postal-area-lookup").call(run_input=run_input)

# Fetch and print Actor results from the run's dataset (if there are any)
print(f"💾 Check your data here: https://console.apify.com/storage/datasets/{run.default_dataset_id}")
for item in client.dataset(run.default_dataset_id).iterate_items():
    print(item)

# 📚 Want to learn more 📖? Go to → https://docs.apify.com/api/client/python/docs/quick-start

```

## CLI example

```bash
echo '{
  "lookups": [
    {
      "country": "US",
      "postalCode": "90210"
    }
  ],
  "maxItems": 1
}' |
apify call grit-77/postal-area-lookup --silent --output-dataset

```

## MCP server setup

```json
{
    "mcpServers": {
        "apify": {
            "type": "http",
            "url": "https://mcp.apify.com/?tools=fetch-actor-details,grit-77/postal-area-lookup"
        }
    }
}
```

The hosted server signs you in with OAuth on first connect, so no API token belongs in this config. Clients without OAuth support can send an `Authorization: Bearer <APIFY_API_TOKEN>` header instead, using a token from API & Integrations in Apify Console (https://console.apify.com/settings/integrations).

## OpenAPI specification

Download the OpenAPI definition: https://api.apify.com/v2/actors/aX88CF5OI0EYMT6fa/builds/6bb0GODNMrwmjOU2d/openapi.json
