parcelpump API reference
Full reference for the parcelpump REST API at
https://api.parcelpump.io. This document is the canonical source
for parcelpump.dev — a new endpoint or response field is documented
here first, then the docs site renders it.
Quick start
# Get an API key from the parcelpump admin (see "Issuing keys" below).
# Set it once:
export KEY="<your key>"
# Read endpoint:
curl -H "X-Parcelpump-Key: $KEY" https://api.parcelpump.io/v1/sources
# Search (typeahead):
curl -H "X-Parcelpump-Key: $KEY" 'https://api.parcelpump.io/v1/search?q=hayden&limit=10'
# Single parcel:
curl -H "X-Parcelpump-Key: $KEY" \
https://api.parcelpump.io/v1/parcels/franklin-county-wa/123290284
Conventions
- Base URL:
https://api.parcelpump.io. CloudFront fronting an AWS Lambda; TLS 1.3, ARM64 backend, ~50ms latency p50 for warm reads, ~6–10s for synchronous scrapes. - Authentication:
X-Parcelpump-Key: <hex>header on every request except/healthand/tiles/manifest.json. - JSON bodies in / JSON bodies out.
Content-Type: application/jsonon all POST/PATCH/DELETE. - No pagination cursors yet. All list endpoints accept
?limit=<n>(default 100, max 500). Past v0, expect?after=<id>keyset pagination. - No rate limit yet. v0 trusts the small set of issued keys.
- Error format:
Plus an HTTP status code (401, 403, 404, 4xx for client errors; 5xx for server / vendor failures).{ "error": "missing or invalid api key" } - Time fields are ISO-8601 UTC:
"2026-05-04T18:07:39.546Z". - Geometry fields are GeoJSON (
MultiPolygonfor parcel boundaries), EPSG:4326.
Capabilities (authorization model)
Each API key holds zero or more capabilities. The key map lives at
PARCELPUMP_API_KEYS (Lambda env), shaped:
{
"<key>": { "capabilities": ["enqueue", "admin"] },
"<read-only-key>": { "capabilities": [] }
}
| Capability | Grants access to |
|---|---|
| (none) | Read endpoints only (parcels, sources, search, findings, scrape-jobs read) |
enqueue |
The above plus: POST /scrape-jobs, POST /scrape-jobs/enqueue-ag-county, POST /parcels/:source/:id/scrape-now |
admin |
The above plus: PATCH /sources, PATCH /source-refresh-policies, PATCH /admin/concurrency, POST /admin/concurrency/run-tuner, DELETE /scrape-jobs |
Issuing keys: see docs/lambda-deployment.md § "Creating an API
key for a new consumer". Generate openssl rand -hex 32, merge into
infra/.env.deploy's PARCELPUMP_API_KEYS, redeploy.
Revoking a key: delete its entry from the map and redeploy. Next request with that key returns 401.
Endpoints
Public (no auth)
GET /health
Liveness check. Returns { status: "ok", time: <iso> }.
curl https://api.parcelpump.io/health
# => {"status":"ok","time":"2026-05-05T03:16:22.038Z"}
GET /tiles/manifest.json
Pointer to the active PMTiles archive. Public so map clients can
fetch it without an API key. Tile bytes themselves serve via
/tiles/<layer>/<hash>.pmtiles — also public, byte-range cacheable.
Response:
{
"parcels": {
"url": "https://api.parcelpump.io/tiles/parcels/a43955359b44b9e4.pmtiles",
"pmtiles_url": "pmtiles://https://api.parcelpump.io/tiles/parcels/a43955359b44b9e4.pmtiles",
"updated_at": "2026-05-04T18:07:39.546Z",
"size_bytes": 51420430
}
}
Mapbox / Maplibre snippet in docs/tile-deployment.md.
Sources & policies (read)
GET /sources
Every wired (source, vendor, state, county) tuple plus its refresh-policy cadence. 922 rows currently.
Response:
{
"count": 922,
"sources": [
{
"source": "franklin-county-wa",
"state": "WA",
"county_name": "Franklin",
"vendor": "terra-tax-sifter",
"base_url": "https://terra.co.franklin.wa.us/taxsifter/",
"active": true,
"cadence": "weekly",
"next_due_at": "2026-05-11T00:00:00.000Z",
"last_full_scrape_at": null,
"last_known_data_as_of": "2026"
}
]
}
GET /admin/sources/health
Read-only aggregates per source: parcel count, queued / running / errors-24h, last success/error timestamps.
curl -H "X-Parcelpump-Key: $KEY" https://api.parcelpump.io/v1/admin/sources/health
Response row shape:
{
"source": "franklin-county-wa",
"state": "WA",
"county_name": "Franklin",
"vendor": "terra-tax-sifter",
"active": true,
"parcel_count": 87394,
"queued": 0,
"running": 0,
"errors_24h": 0,
"last_success_at": "2026-05-04T17:32:40.000Z",
"last_error_at": null,
"cadence": "weekly",
"next_due_at": "2026-05-11T00:00:00.000Z"
}
Parcels (read)
GET /parcels/:source/:id
One parcel, full record including the latest scrape_data blob.
curl -H "X-Parcelpump-Key: $KEY" \
https://api.parcelpump.io/v1/parcels/franklin-county-wa/123290284
Response (truncated):
{
"id": "69f34cae-e9d4-40df-b66b-0151bfd39f39",
"source": "franklin-county-wa",
"source_parcel_id": "123290284",
"geom_geojson": { "type": "MultiPolygon", "coordinates": [...] },
"acreage": 21.18,
"owner_name": "ROUNDABOUT FARM LLC",
"owner_names_all": ["ROUNDABOUT FARM LLC"],
"owner_mailing_address": "PO BOX 1234, PASCO 99301",
"situs_address": "3750 COTTONWOOD DR",
"situs_city": "PASCO",
"situs_state": "WA",
"situs_zip": "99301",
"land_use_class": "agricultural",
"assessed_value": 542300,
"value_land": 480000,
"value_improvement": 62300,
"scrape_data": { "...": "Scrape jsonb" },
"scraped_at": "2026-04-29T06:00:29.778Z",
"scraped_by": "terra-tax-sifter",
"scrape_data_as_of": "2026",
"scrape_data_as_of_source": "latest-tax-year",
"ingested_at": "2026-05-04T17:30:11.000Z",
"updated_at": "2026-05-04T17:32:40.000Z"
}
scrape_data is the canonical Scrape shape — see
the type definition section.
Errors: 404 { error: "not found" }.
GET /parcels?bbox=west,south,east,north[&source=]&limit=500
Bounding-box query. Lightweight rows only (no scrape_data, no geom) suitable for map sidebars / search results.
curl -H "X-Parcelpump-Key: $KEY" \
'https://api.parcelpump.io/v1/parcels?bbox=-119.5,46.0,-118.5,46.6&source=franklin-county-wa&limit=20'
Response row shape:
{
"id": "...",
"source": "franklin-county-wa",
"source_parcel_id": "123300017",
"owner_name": "HAYDEN FARMS INC",
"situs_address": "3750 COTTONWOOD DR, PASCO 99301",
"assessed_value": 542300,
"land_use_class": "agricultural"
}
Errors: 400 if bbox missing or non-numeric.
Search (read)
GET /search?q=<text>&limit=<n>&types=parcels,entities&state=<XX>
Fuzzy typeahead over parcels and SoS entities. Trigram-similarity based: handles typos and partial matches. Sub-100ms.
| Query param | Required | Default | Notes |
|---|---|---|---|
q |
yes | — | Min 2 chars. <2 returns 400. |
limit |
no | 20 | Max 100. |
types |
no | parcels,entities |
Comma-separated subset. |
state |
no | (none) | 2-letter code. Filters parcels by source state, entities by jurisdiction. |
# Typo-tolerant — "haydn" matches "HAYDEN"
curl -H "X-Parcelpump-Key: $KEY" \
'https://api.parcelpump.io/v1/search?q=haydn+farms&limit=10'
Response:
{
"count": 14,
"results": [
{
"kind": "parcel",
"source": "franklin-county-wa",
"source_parcel_id": "123300017",
"state": "WA",
"county_name": "Franklin",
"owner_name": "HAYDEN FARMS INC",
"situs_address": "3750 COTTONWOOD DR, PASCO 99301",
"situs_city": "PASCO",
"situs_state": "WA",
"assessed_value": 542300,
"score": 1.58
},
{
"kind": "entity",
"id": "...",
"jurisdiction": "WA",
"registry_number": "604123456",
"name": "HAYDEN FARMS, INC.",
"entity_type": "LLC",
"status": "ACTIVE",
"registered_agent_name": "JOHN HAYDEN",
"linked_parcel_count": 8,
"score": 1.55
}
]
}
Relevance scoring (Phase 3):
- Owner / entity name match weighted ×3.0
- Normalized name / situs address ×2.0–2.5
- City / agent / mailing ×1.0–1.5
- Exact-prefix bonus +0.3 to +0.5
Errors: 400 { error: "q must be ≥2 chars" } for short queries.
Findings (read)
GET /findings/:source/:id
Review-engine output for a parcel: severity / category / evidence / suggested_action. Used by tax-appeal evidence workflows.
Response:
{
"count": 3,
"findings": [
{
"id": "...",
"severity": "moderate",
"category": "valuation_jump",
"evidence": { "year": 2025, "delta_pct": 0.31 },
"suggested_action": "Petition for reassessment review",
"generated_at": "2026-05-04T17:32:40.000Z"
}
]
}
(Empty for now — review engines haven't been wired into the per-parcel scrape path yet. Schema is in place.)
Scrape jobs (read + write)
GET /scrape-jobs?status=&source=&limit=
List/filter the work queue. Useful for admin dashboards.
| Query param | Notes |
|---|---|
status |
queued / running / done / error |
source |
source slug, e.g. franklin-county-wa |
limit |
default 100, max 500 |
GET /scrape-jobs/:id
Single job detail.
POST /scrape-jobs (capability=enqueue)
Enqueue per-parcel scrapes. Body: array or single object.
curl -X POST -H "X-Parcelpump-Key: $KEY" -H "Content-Type: application/json" \
-d '[
{ "source": "franklin-county-wa", "source_parcel_id": "113110106" },
{ "source": "linn-county-or", "source_parcel_id": "11S03W10 01700" }
]' \
https://api.parcelpump.io/v1/scrape-jobs
Response:
{ "count": 2, "jobs": [
{ "id": "...", "source": "franklin-county-wa", "source_parcel_id": "113110106" },
{ "id": "...", "source": "linn-county-or", "source_parcel_id": "11S03W10 01700" }
] }
Idempotent — ON CONFLICT (source, source_parcel_id, status) upserts
the timestamp instead of inserting a duplicate.
POST /scrape-jobs/enqueue-ag-county (capability=enqueue)
Bulk-enqueue every is_agricultural parcel in a source. Avoids
round-tripping the parcel list through the client.
Body:
{
"source": "franklin-county-wa",
"limit": 5000,
"skip_existing": true
}
skip_existing=true (default) honors the source's cadence — only
parcels whose last scrape is older than the cadence interval get
enqueued. false enqueues every is_agricultural parcel up to limit.
POST /parcels/:source/:id/scrape-now (capability=enqueue)
Synchronous scrape: dispatches the vendor adapter immediately, waits
for the result (typically 5–15s), upserts to parcels, returns the
full record. The path the farminggame "user clicked an unknown
parcel" UX uses.
curl -X POST -H "X-Parcelpump-Key: $KEY" -H "Content-Type: application/json" \
-d '{}' \
https://api.parcelpump.io/v1/parcels/franklin-county-wa/123300017/scrape-now
Optional body:
{
"vendorKey": "<override>",
"pacsPropId": 12345,
"ownerName": "HAYDEN FARMS",
"situsAddress": "3750 COTTONWOOD DR"
}
(Most adapters derive the vendor key from source_parcel_id
automatically. Overrides are only needed for vendors whose lookup key
is a raw.attributes.<field> value or a separate pacs_prop_id.)
Response: same as GET /parcels/:source/:id.
Errors:
404— unknown source502— vendor / network error503 { enqueued: true }— vendor requires Playwright (currentlyhelion-psoonly). Job is auto-enqueued; client should pollGET /parcels/:source/:iduntilscrape_datapopulates.
DELETE /scrape-jobs/:id (capability=admin)
Cancel a queued or running job. Sets status to error with a marker
in error_message. Returns 404 if the job is in a terminal state
(done / error).
Sources & policies (write — capability=admin)
PATCH /sources/:source
Toggle active flag, edit vendor_config. Body fields optional.
curl -X PATCH -H "X-Parcelpump-Key: $KEY" -H "Content-Type: application/json" \
-d '{ "active": false }' \
https://api.parcelpump.io/v1/sources/grant-county-wa
PATCH /source-refresh-policies/:source
Adjust cadence / next_due_at / agricultural_only flag.
curl -X PATCH -H "X-Parcelpump-Key: $KEY" -H "Content-Type: application/json" \
-d '{ "cadence": "monthly", "agricultural_only": true }' \
https://api.parcelpump.io/v1/source-refresh-policies/whitman-county-wa
Cadence values: daily, weekly, monthly, quarterly, annual,
tax-statement-window.
Concurrency (read + write)
The auto-tuner watches recent scrape throughput and adjusts a target
concurrency per vendor every cycle. Read endpoints are open to any
key; write endpoints require admin.
GET /admin/concurrency
Per-vendor config. 24 vendors seeded.
Response row:
{
"vendor": "terra-tax-sifter",
"min_concurrency": 2,
"max_concurrency": 8,
"target_concurrency": 4,
"mode": "auto",
"slow_threshold_ms": 12000,
"fast_threshold_ms": 5000,
"step_size": 1,
"signal_window_minutes": 10,
"last_signal": "idle (depth=0, recent=0)",
"last_tuned_at": "2026-05-05T02:42:44.474Z",
"updated_at": "2026-05-05T02:44:23.114Z",
"updated_by": "5b6932ec1682..."
}
GET /admin/tuning-log?vendor=&limit=
Recent tuning decisions. Each row records the prev/new target, a human-readable reason, and the signals that drove it.
PATCH /admin/concurrency/:vendor (capability=admin)
Update bounds / mode / target / thresholds. All fields optional.
# Manually pin Aumentum to target=2 (freezes the auto-tuner)
curl -X PATCH -H "X-Parcelpump-Key: $KEY" -H "Content-Type: application/json" \
-d '{ "mode": "manual", "target_concurrency": 2 }' \
https://api.parcelpump.io/v1/admin/concurrency/aumentum-ascend
POST /admin/concurrency/run-tuner (capability=admin)
Run one tuner cycle synchronously. Returns the per-vendor decisions.
Type definitions
Scrape (the canonical scrape result shape)
interface Scrape {
parcel_number: string;
owner_name?: string;
owners?: { name: string; ownership_pct?: number; type?: string }[];
statements?: StatementYear[]; // tax statements per year
valuation_history?: ValuationHistoryYear[]; // RMV/AV per year
sale_history?: SaleHistoryEntry[]; // recorded sales
exemptions?: ExemptionEntry[];
improvements?: ImprovementEntry[]; // building / barn / shed details
fetched_at: string; // ISO when the scrape ran
// Freshness telemetry — populated by withFreshness wrapper
data_as_of?: string; // "2026" or ISO date
data_as_of_source?: "header" | "page-marker" | "feature-server"
| "latest-tax-year" | "page-footer" | "etag";
http_etag?: string; // mostly null; ArcGIS sources only
http_last_modified?: string; // mostly null; ArcGIS sources only
sale_history_observed_at?: string;
// Review-engine output (per-vendor; usually empty in v0)
review_findings?: { severity: string; category: string;
evidence: unknown; suggested_action?: string }[];
}
Full source: parcelpump/src/types.ts.
ParcelpumpSearchHit (typeahead result shape)
Discriminated union by kind:
type ParcelpumpSearchHit =
| {
kind: "parcel";
source: string; source_parcel_id: string;
state: string; county_name: string;
owner_name: string | null;
situs_address: string | null; situs_city: string | null; situs_state: string | null;
assessed_value: number | null;
score: number;
}
| {
kind: "entity";
id: string;
jurisdiction: string; registry_number: string;
name: string;
entity_type: string | null; status: string | null;
registered_agent_name: string | null;
linked_parcel_count: number;
score: number;
};
Tile rendering
The /tiles/manifest.json endpoint plus /tiles/<layer>/<hash>.pmtiles
power the consumer map layer. PMTiles archives are content-hashed and
immutable; the manifest's hash flips when a new build lands. See
docs/tile-deployment.md for the Mapbox / Maplibre integration.
How farminggame uses it (real examples)
farminggame is parcelpump's first commercial consumer. The
client wrapper at farminggame/src/lib/parcelpump-client.ts exposes
typed functions over every endpoint. Selected examples below.
1. Detect whether parcelpump is wired
import { parcelpumpConfigured } from "@/lib/parcelpump-client";
// Gate any feature on the env vars being set.
if (!parcelpumpConfigured()) {
return <p>parcelpump not yet configured for this environment.</p>;
}
The client reads PARCELPUMP_API_URL + PARCELPUMP_API_KEY from
process env on each call.
2. Get a parcel by source + ID
import { getParcel } from "@/lib/parcelpump-client";
const parcel = await getParcel("franklin-county-wa", "123290284");
if (parcel) {
console.log(parcel.owner_name); // "ROUNDABOUT FARM LLC"
console.log(parcel.scrape_data?.statements?.[0]?.tax_year);
}
3. Bbox query for the parcel-overlay map layer
import { listParcelsInBbox } from "@/lib/parcelpump-client";
// In a Next.js Mapbox component:
map.on("moveend", async () => {
const b = map.getBounds();
const { parcels } = await listParcelsInBbox(
{ west: b.getWest(), south: b.getSouth(), east: b.getEast(), north: b.getNorth() },
{ source: "franklin-county-wa", limit: 200 },
);
// ...render lightweight rows in a sidebar
});
(For full polygon rendering, use the PMTiles manifest path instead of the bbox API — orders of magnitude cheaper.)
4. Fetch-or-load (the "user clicked an unknown parcel" flow)
import { getOrLoadParcel } from "@/lib/parcelpump-client";
// User entered "123300017" in the add-by-id form:
const result = await getOrLoadParcel("franklin-county-wa", "123300017");
if (result.ok) {
if (result.loaded) {
// First sighting — parcelpump just scraped + persisted it.
toast(`Loaded ${result.parcel.owner_name}`);
} else {
// Already cached.
toast(`Found ${result.parcel.owner_name}`);
}
router.push(`/parcels/${result.parcel.source}/${result.parcel.source_parcel_id}`);
} else if (result.enqueued) {
// Vendor needs Playwright (currently only Linn OR via helion-pso).
// Job is queued; check back later.
toast("Loading in background — refresh in a minute.");
} else {
toast(`Couldn't load: ${result.error}`);
}
This composes getParcel (cache hit) and scrapeNow (sync scrape)
under one helper.
5. Typeahead search via a server-side proxy
The browser must NOT see the API key. farminggame's pattern:
// farminggame/src/app/api/parcelpump/search/route.ts (server-side proxy)
import { search } from "@/lib/parcelpump-client";
import { createClient } from "@/lib/supabase/server";
export async function GET(req: Request) {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const { searchParams } = new URL(req.url);
const result = await search(searchParams.get("q") ?? "", { limit: 12 });
return NextResponse.json(result);
}
// farminggame/src/components/parcels/parcel-search-input.tsx (browser)
const res = await fetch(`/api/parcelpump/search?q=${encodeURIComponent(q)}`);
const { results } = await res.json() as { results: ParcelpumpSearchHit[] };
The component handles 250ms debounce, AbortController on each keystroke, keyboard navigation (arrow / enter / esc), and grouped rendering for parcel vs. entity hits.
6. Enqueue a bulk ag-only scrape
import { fetchPP } from "@/lib/parcelpump-client"; // hypothetical helper
// Trigger a county-wide refresh of every is_agricultural parcel:
await fetch(`${PARCELPUMP_API_URL}/scrape-jobs/enqueue-ag-county`, {
method: "POST",
headers: {
"X-Parcelpump-Key": PARCELPUMP_API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({ source: "whitman-county-wa", skip_existing: true }),
});
Or via the client method (when added): enqueueAgCounty(...).
7. Admin dashboard — read concurrency state
farminggame's /admin/concurrency page (when migrated to call
parcelpump instead of its own DB) does:
const concurrencyRes = await fetch(`${API_URL}/admin/concurrency`, {
headers: { "X-Parcelpump-Key": ADMIN_KEY },
});
const { vendors } = await concurrencyRes.json();
// Render cards: per-vendor target / mode / last_tuned_at / last_signal
And to nudge a target manually:
await fetch(`${API_URL}/admin/concurrency/aumentum-ascend`, {
method: "PATCH",
headers: { "X-Parcelpump-Key": ADMIN_KEY, "Content-Type": "application/json" },
body: JSON.stringify({ target_concurrency: 4, mode: "manual" }),
});
The audit row in scrape_tuning_log records the truncated key prefix
so admin nudges show up in the tuning history with author attribution.
Versioning
Today: unversioned. The API is on its first major shape and we
break-and-redeploy when needed. Once a third-party consumer is in
production, semantic-versioned routes (/v1/parcels/...) will land
with a 6-month deprecation window for the unversioned paths.
Status & uptime
No formal SLA in v0. Component checks:
https://api.parcelpump.io/health— 200 = the Lambda + RDS path is up.https://api.parcelpump.io/tiles/manifest.json— 200 = the S3 + CloudFront path is up.
Outage signals (subscribe in the AWS Console for now; a proper status page will live at parcelpump.dev/status):
- CloudWatch alarms on 5xx from CloudFront
- RDS CPU + connection-count alarms
Changelog
A versioned changelog will land at parcelpump.dev/changelog once
the docs site is built. Until then, git log --oneline parcelpump
in the source repo is the source of truth.