parcelpump
Sign in

Display

DOCS / API

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 /health and /tiles/manifest.json.
  • JSON bodies in / JSON bodies out. Content-Type: application/json on 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:
    { "error": "missing or invalid api key" }
    
    Plus an HTTP status code (401, 403, 404, 4xx for client errors; 5xx for server / vendor failures).
  • Time fields are ISO-8601 UTC: "2026-05-04T18:07:39.546Z".
  • Geometry fields are GeoJSON (MultiPolygon for 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 source
  • 502 — vendor / network error
  • 503 { enqueued: true } — vendor requires Playwright (currently helion-pso only). Job is auto-enqueued; client should poll GET /parcels/:source/:id until scrape_data populates.

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.