Developer API · v1
Search 150M+ U.S. property records (153.9M in the verified July 2026 inventory), build a source-matched owner list, and pull it as JSON or CSV — the same engine that powers the PropContact dashboard. Search and preview are free; you spend one credit per record you pull.
Choose the reference that fits your workflow
Quickstart
Estimate the cost, build the list (it can wait for you), download the CSV.
KEY='<PROPCONTACT_API_KEY>'
# 1 · estimate (free)
curl https://api.propcontact.net/v1/preview -H "Authorization: Bearer $KEY" \
-H 'Content-Type: application/json' \
-d '{"filters":{"areas":[{"state":"NJ","city":"Cresskill"}],"has_wireless_phone":true},"max_results":40}'
# -> {"total_matching":3080,"eligible_for_export":3080,"estimated_credit_cost":40}
# 2 · build + wait (spends credits)
RESP=$(curl "https://api.propcontact.net/v1/lists?wait=55" -H "Authorization: Bearer $KEY" \
-H 'Content-Type: application/json' -H "Idempotency-Key: $(uuidgen)" \
-d '{"name":"Cresskill mobiles","filters":{"areas":[{"state":"NJ","city":"Cresskill"}],"has_wireless_phone":true},"max_results":40}')
# -> {"list_id":"7087fe64-…","status":"ready","credits_held":40}
LIST_ID=$(printf '%s' "$RESP" | jq -r '.list_id // empty')
test -n "$LIST_ID" || { printf '%s
' "$RESP" >&2; exit 1; }
# 3 · download the CSV (free)
curl -sL https://api.propcontact.net/v1/lists/$LIST_ID/download -H "Authorization: Bearer $KEY" -o list.csvAuthentication
Every request carries a Bearer key, created in your dashboard under Developer API. The full key is shown once at creation — store it in a secret manager.
Authorization: Bearer <PROPCONTACT_API_KEY>
pc_live_… — production. Search/preview/detail are free; list builds and billable enrichment results spend credits.pc_test_… — sandbox. Free reads hit real data, but builds return a fake list and never spend credits. Wire up your integration with this first.| Scope | Grants |
|---|---|
| search:read | search, preview, properties, dropdowns |
| lists:read | list status, items, download |
| lists:write | POST /lists (spends credits) |
| enrichment:write | Live async polling; paid sync after separate activation, for exactly entitled partner accounts |
Headers
| Header | Direction | Meaning |
|---|---|---|
| Authorization | request | Required. Bearer <PROPCONTACT_API_KEY> |
| Content-Type | request | application/json on POST bodies |
| Idempotency-Key | request | Required on POST /enrichments and on paid POST /append after activation; recommended on POST /lists. Reuse only for an identical retry |
| RateLimit-Limit | response | Your per-key budget per window (default 120) |
| RateLimit-Remaining | response | Requests left in the current window |
| Retry-After | response | Seconds to wait — sent on 429 |
Credits
Free (rate-limited): me, usage, search, preview, properties, dropdowns.
Spends credits: POST /lists and live async enrichment hold credits and settle their actual billable rows. After its separate activation notice, paid POST /append directly settles one credit only when a phone or email is returned. Nonmatches and contact-free matches cost zero. Reading an already built list or completed batch is free.
curl https://api.propcontact.net/v1/me -H "Authorization: Bearer $KEY"
# {"account_id":"…","credits_balance":123734,"active_plan_id":null,
# "key":{"environment":"live","scopes":["search:read","lists:read","lists:write"],"rate_limit_per_min":120}}When the balance cannot cover a write, POST /lists returns 402 insufficient_credits with detail.meta.{required, available, shortfall}; paid /append returns detail.{required, available}.
Reference
/v1/mefreeAccount id, credit balance, key scopes
/v1/usagefreeRolling 30-day usage summary
/v1/searchfree · search:readCount + property summaries + map pins (pins carry only id + lat/lng)
/v1/previewfree · search:readEstimate matches + credit cost
/v1/listsspends credits · lists:writeBuild a contact list. ?wait=<secs> to block until ready; Idempotency-Key to be safe on retry
/v1/listsfree · lists:readYour lists
/v1/lists/{id}free · lists:readOne list's status: pending·building·ready·failed·cancelled
/v1/lists/{id}/itemsfree · lists:readPaginated rows (owner, Mobile_Phone1, Landline1, Email1, …)
/v1/lists/{id}/downloadfree · lists:readThe whole list as a CSV file
/v1/properties/{id}free · search:readSingle-property detail + contact availability flags (no raw numbers)
/v1/dropdowns/{STATE}free · search:readCity / county / ZIP lists for a 2-letter state
/v1/append0 or 1 credit after activationPending paid cutover: exactly one address; Idempotency-Key required; external_id echoed
/v1/enrichmentsholds credits · enrichment:writeSubmit a durable address batch for polling; Idempotency-Key required
/v1/enrichments/{job_id}free · enrichment:writePoll batch status and settled-credit totals
/v1/enrichments/{job_id}/resultsfree · enrichment:writePage completed results with an opaque cursor
/v1/enrichments/{job_id}free · enrichment:writeRequest early deletion after a supported terminal state
Every search/preview/build takes the same filters object.
| Group | Fields |
|---|---|
| Location | areas:[{state,city?|county?|zip?}] (preferred) · states[] counties[] cities[] zips[] zip_prefix |
| Property | property_types[] bedrooms_min/max bathrooms_min/max sqft_min/max year_built_min/max is_commercial |
| Value / equity | est_value_min/max equity_pct_min/max is_free_clear mortgage_balance_min/max last_sale_date_before/after |
| Owner | owner_corporate is_trust_owned cash_buyer owner_last_name is_senior_owner age_owner1_min/max |
| Contact | has_contact has_wireless_phone has_landline_phone has_email has_verified_phone exclude_dnc |
| Combine | groups:[Filters,…] + groups_op:"or"|"and" |
CLEC does not mean mobile. CLEC is a source carrier classification, reported separately from wireless, landline, and any independently verified line status. It does not prove that a number is mobile or currently connected. For any source-matched number, use has_contact:true.
Partner enrichment
Async batches are live; the paid single-address sync cutover is pending
During key creation, select enrichment:write. PropContact must also entitle the exact account; adding the scope to an ordinary key does not grant product access. Durable asynchronous jobs and separately entitled status-only webhooks are live. The one-address paid sync contract below is a release candidate until PropContact confirms schema 57 deployment and a production zero/one-credit canary. Do not route production sync traffic before that confirmation.
Enrichment accepts structured property addresses and returns one terminal result for every input. Your optional external_id and PropContact's input_index are echoed on every matched, not-found, ambiguous, or invalid row so you can join results without relying on a property ID.
curl https://api.propcontact.net/v1/append -H "Authorization: Bearer $KEY" \
-H 'Content-Type: application/json' \
-H "Idempotency-Key: $(uuidgen)" \
-d '{"records":[{"external_id":"crm-row-42","address":{"street":"101 Main St","unit":"2B","city":"Cresskill","state":"NJ","zip":"07626"}}]}'POST /v1/append accepts exactly one records[] item. A live request requires an active account entitlement and Idempotency-Key. It settles one credit only when at least one phone or email is returned; not-found, ambiguous, invalid, and contact-free matches cost zero. Contact values are released only after settlement. An identical retry never debits twice. Use /v1/enrichments for every multi-row request. Test keys return one deterministic synthetic record, never read production addresses, and never spend credits.
Activation gate: this paid sync shape replaces the completed complimentary benchmark contract only after PropContact sends an explicit activation notice. Until then, use the live paid /v1/enrichments flow for address work.
| Method | Path | Purpose |
|---|---|---|
| POST | /v1/enrichments | Submit 1-10,000 addresses; Idempotency-Key is required |
| GET | /v1/enrichments/{job_id} | Poll queued/processing/complete/failed state and accounting totals |
| GET | /v1/enrichments/{job_id}/results | Read up to 1,000 typed results/page using the opaque next_cursor |
| DELETE | /v1/enrichments/{job_id} | Request early deletion after a complete/failed/cancelled terminal state |
{
"records": [
{
"external_id": "crm-row-42",
"address": {
"street": "101 Main St", "unit": "2B", "city": "Cresskill",
"state": "NJ", "zip": "07626"
}
}
],
"options": {
"email_verification": false,
"line_lookup": false,
"compliance_scrub": false
}
}Send a unique visible-ASCII Idempotency-Key for each logical batch and reuse it only for an identical retry. Poll status until it is complete, then page results untilnext_cursor is null. Inputs/results are scheduled for deletion after 14 days; minimal accounting facts follow the financial retention schedule.
Polling remains authoritative: separately entitled accounts may add webhook: { url, signing_secret? } for a signed status-only notification. Webhooks contain no contacts or input rows; fetch results from the authenticated results endpoint.
| Result | Meaning | Base charge |
|---|---|---|
| matched | One exact normalized situs-address candidate | 1 only if a requested usable contact is returned |
| not_found | No exact candidate | 0 |
| ambiguous | Multiple candidates or a required unit is missing | 0 |
| invalid | Required address elements are malformed/missing | 0 |
An async batch holds at most one base credit per input, then settles only unique matched properties that contain a requested usable contact. Unused holds are restored. Duplicate inputs fan out to every caller row but charge once within that batch. Separate batches may be charged again under the partner freshness agreement; enrichment never silently removes a row because it appeared in an older list.
Phones are returned wireless-first, followed by other source classifications. Each number may include E.164 normalization, source classification, identity-match quality/confidence/score, source DNC status, and connected status. A strong match means the source linked the number to this property's record on surname plus address or name, at the stated confidence. It is not identity verification, and it is not proof that the line is active, connected, reachable, mobile, or safe to contact. CLEC remains a separate source classification. A false or missing source DNC value is unknown, never "clear."
Base emails are unverified. Fresh carrier/LRN, provider-backed email validity, live line status, reassigned-number, DNC, and TCPA-litigator signals are unavailable in the initial release. Requests that enable email_verification, line_lookup, or compliance_scrub fail closed until a contracted provider, metering unit, data-processing terms, and account entitlement are enabled.
A webhook requires a public HTTPS callback on port 443 and separate exact-account entitlement. Supply a 32–256 character signing secret, or store the generated secret returned once on a fresh create. PropContact sends X-PropContact-Event-Id, X-PropContact-Delivery-Attempt, X-PropContact-Timestamp (Unix seconds), and X-PropContact-Signature in the form v1=<lowercase 64-hex HMAC-SHA256>.
signed_bytes = X-PropContact-Timestamp + "." + exact_raw_request_body expected = "v1=" + hex(HMAC-SHA256(signing_secret, signed_bytes))
Verify the signature against the exact raw body before parsing JSON, compare it in constant time, reject a timestamp more than five minutes from your clock, require the body event_id to equal X-PropContact-Event-Id, and atomically deduplicate that event ID. A valid duplicate should return 2xx without doing the work twice. Transport failures, 408, 425, 429, and 5xx retry for about 32.5 hours; redirects are never followed. Always retain polling as the recovery path.
Operating
Every error responds {"detail":{"code","message"}}. Switch on detail.code.
| HTTP | code | Meaning |
|---|---|---|
| 400 | bad_request | malformed request |
| 400 | max_results_too_large | above the per-build cap (50,000) |
| 400 | no_eligible_rows | filters match nothing in that area |
| 401 | unauthorized | missing / invalid / revoked key |
| 402 | insufficient_credits | balance can't cover the write; list details are under meta, sync details are directly under detail |
| 403 | missing_scope | key lacks the required scope |
| 403 | append_not_entitled | account lacks an active paid append entitlement |
| 404 | not_found | property / list not found |
| 409 | all_already_pulled | every match was already pulled by your account |
| 409 | request_in_progress | idempotency key still processing |
| 422 | validation_error | invalid fields — see detail.errors[] |
| 409 / 422 | idempotency_key_reused | same key, different body; status depends on write route |
| 400 | idempotency_key_required | add a 1-200 visible-ASCII key to append/async submission |
| 429 | append_daily_row_quota_exceeded | account UTC-day sync quota exhausted |
| 503 | append entitlement / settlement / invariant failure | no contacts were released; retry the same key |
| 503 | enrichment_result_invariant_failed | result page withheld by a billing/contact safety check |
| 403 | enrichment_webhook_disabled / not_entitled | omit webhook or request separate entitlement |
| 422 | invalid_webhook_url | use a public HTTPS destination on TCP 443 |
| 422 | provider_option_unavailable | keep every optional provider flag false |
| 429 | rate_limited | too many requests — honor Retry-After |
| 503 | enrichment_submission_unavailable | submission failed before acceptance — retry with backoff |
| 503 | webhook_validation / configuration_unavailable | no hold was created — retry or contact support |
| 500 | internal_error | server error — retry with backoff |
Rate limits. Default 120 requests / 60 s per key (higher on request). Over the limit → 429 rate_limited with Retry-After: 60. Sleep, then retry.
Idempotency. Live POST /append and POST /enrichments require Idempotency-Key; POST /lists strongly recommends it. Generate one key per logical write and reuse it only for an identical retry. This prevents duplicate receipts, jobs, and charges.
Sandbox. A pc_test_… key lets you build integrations for free — POST /lists returns a fake list and entitled POST /append returns deterministic synthetic people. Free search reads use real data. Async POST /enrichments accepts live keys only.
For AI agents & tools
Point your AI coding agent at this contract and it can wire the API into your app. The full spec with drop-in Python & TypeScript client classes is in our interactive reference. The core loop: preview (cost) → lists?wait= (build) → items / download.
import os, uuid, requests
BASE, KEY = "https://api.propcontact.net/v1", os.environ["PROPCONTACT_API_KEY"]
H = {"Authorization": f"Bearer {KEY}"}
filters = {"areas": [{"state": "NJ", "city": "Cresskill"}], "has_wireless_phone": True}
r = requests.post(f"{BASE}/lists?wait=55",
headers={**H, "Idempotency-Key": str(uuid.uuid4())},
json={"filters": filters, "max_results": 40}).json()
rows = requests.get(f"{BASE}/lists/{r['list_id']}/items",
params={"page_size": 500}, headers=H).json()["rows"]
print(len(rows), "contact records")