API Reference
The Veriprop REST API is live at https://veriprop.polsia.app. Three endpoints — GET /api/v1/properties/{id}, GET /api/v1/alerts, and POST /api/v1/webhooks — gated by API key or OAuth2 access tokens. This page is the canonical shape reference: auth setup, request and response contracts, webhook delivery and HMAC verification, and two working curl examples per endpoint.
Every request targets the base URL https://veriprop.polsia.app over HTTPS. All responses are JSON with UTF-8 encoding. Timestamps are ISO-8601 strings; monetary values are integer cents (USD) — divide by 100 for dollars.
/api/v1/properties/{id}Parcel detail with assessment history, sales history, and currently-marketed listings.
/api/v1/alertsPrice-drop and status-change notifications, scoped to your active webhook subscriptions.
/api/v1/webhooksSubscribe to events; receive HMAC-signed deliveries at any public HTTPS URL.
API key
Generate a key from Dashboard → Settings → API keys. Keys are prefixed vp_live_… and shown once on creation. Use them as a Bearer token or via the x-api-key header — both are accepted.
- ·Header form (canonical):
Authorization: Bearer vp_live_… - ·Alt header:
x-api-key: vp_live_…(use when your HTTP client cannot easily set Authorization)
Available scopes (chose one or more when minting the key):
Each endpoint requires exactly one of the scopes above. Mismatched scopes return 403 Insufficient scope.
OAuth 2.0 (Authorization Code + PKCE)
Register your integration at Dashboard → Settings → OAuth clients. PKCE is mandatory for all clients (RFC 7636, S256). The classic client_secret is also issued for confidential clients but is not required when you use PKCE.
- ·Grant types:
authorization_codeandrefresh_token - ·Scopes (space-separated on the wire):
alerts:read properties:read webhooks:write - ·Access token: send as
Authorization: Bearer vp_at_…
Authorize (browser)
GET https://veriprop.polsia.app/api/v1/oauth/authorize
?response_type=code
&client_id=<your-client-id>
&redirect_uri=https://app.example.com/oauth/callback
&scope=alerts:read+properties:read
&state=<random-csrf-token>
&code_challenge=<base64url(sha256(verifier))>
&code_challenge_method=S256Token (server-to-server)
curl -X POST https://veriprop.polsia.app/api/v1/oauth/token \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "grant_type=authorization_code" \
--data-urlencode "code=<code from step 1>" \
--data-urlencode "redirect_uri=https://app.example.com/oauth/callback" \
--data-urlencode "client_id=<your-client-id>" \
--data-urlencode "code_verifier=<verifier you generated>"
# Response (200)
# {
# "access_token": "vp_at_…",
# "token_type": "Bearer",
# "expires_in": 3600,
# "scope": "alerts:read properties:read",
# "refresh_token": "vp_rt_…"
# }Revoke (optional)
Drop a token server-side. RFC 7009 — token_type_hint is optional but recommended.
curl -X POST https://veriprop.polsia.app/api/v1/oauth/revoke \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "token=<access-token-or-refresh-token>" \
--data-urlencode "token_type_hint=access_token"Path & query parameters
| Name | In | Description |
|---|---|---|
id | path | Either the Veriprop parcel id (e.g. par_abc123) or a market-qualified parcelId when you also supply market. |
market | query | Market slug (austin-tx, denver-co). Required if id is a raw parcel APN; ignored otherwise. |
Response (200) — ParcelDetail
{
"id": "par_abc123",
"market": "austin-tx",
"parcelId": "R-12-3456",
"addressKey": "123 MAIN ST, AUSTIN, TX 78701",
"addressRaw": "123 Main St, Austin, TX 78701",
"sqft": 1840,
"yearBuilt": 1998,
"lotSize": 6500,
"ownerName": "DOE JOHN & JANE",
"createdAt": "2026-01-15T18:22:11.000Z",
"updatedAt": "2026-04-03T14:09:42.000Z",
"assessments": [
{
"id": "asm_001",
"parcelId": "par_abc123",
"period": "2025",
"assessedValueCents": 42500000, // $425,000.00
"asOf": "2025-01-01T00:00:00.000Z"
}
],
"sales": [
{
"id": "sale_001",
"parcelId": "par_abc123",
"saleDate": "2019-06-12T00:00:00.000Z",
"salePriceCents": 36000000, // $360,000.00
"grantor": "SMITH JANE",
"grantee": "DOE JOHN & JANE"
}
],
"relatedListings": [
{
"id": "lst_001",
"externalId": "MLS987654",
"market": "austin-tx",
"address": "123 Main St, Austin, TX 78701",
"status": "Active",
"listPriceCents": 47500000, // $475,000.00
"updatedAt": "2026-04-03T14:09:42.000Z"
}
]
}All prices are integer cents
assessedValueCents, salePriceCents, and listPriceCents are non-negative integers (USD). Divide by 100 to display dollars.Example
# You need either the Veriprop id (returned from /api/properties)
# OR (market, parcelId). Easiest: list parcels first.
curl -sS "https://veriprop.polsia.app/api/properties?market=austin-tx&limit=1" \
-H "Authorization: Bearer vp_live_REPLACE_ME" | jq '.items[0]'# Use the Veriprop id, OR fetch via market + parcelId.
curl -sS "https://veriprop.polsia.app/api/v1/properties/par_abc123" \
-H "Authorization: Bearer vp_live_REPLACE_ME"
# …or with the market + parcelId form:
curl -sS "https://veriprop.polsia.app/api/v1/properties/R-12-3456?market=austin-tx" \
-H "Authorization: Bearer vp_live_REPLACE_ME"Cross-developer isolation
market, parcelId, or listingId filter) to opt into the whole feed for your key.Query parameters
| Name | Description |
|---|---|
limit | Page size. Defaults to 100, capped at 200. |
cursor | Opaque pagination cursor (the id of the last item from the previous page). |
channel | One of Email, Sms. |
kind | One of PriceDrop, StatusChange. |
listingId | Narrows further to a single listing. |
Response (200) — AlertList
{
"items": [
{
"id": "alt_001",
"listingId": "lst_001",
"listing": {
"address": "123 Main St, Austin, TX 78701",
"market": "austin-tx",
"bedrooms": 3
},
"channel": "Email",
"kind": "PriceDrop", // or "StatusChange"
"oldPriceCents": 49000000, // null when kind=StatusChange
"newPriceCents": 47500000, // null when kind=StatusChange
"deltaPct": -3.06, // null when kind=StatusChange
"fromStatus": null, // set when kind=StatusChange
"toStatus": null, // set when kind=StatusChange
"createdAt": "2026-04-03T14:09:42.000Z",
"deliveredAt": "2026-04-03T14:09:44.000Z", // null when dispatch failed
"recipient": "jane@example.com" // null when dispatch failed
}
]
}Nullable fields per kind
kind: "PriceDrop" the price fields are populated and status fields are null; for kind: "StatusChange" the inverse holds. Treat all scalar fields after createdAt as nullable.Example
# Alerts are scoped to your webhook subscriptions.
# Mint a wildcard subscription so the query below returns data
# for the live feed (otherwise: empty list, by design).
curl -sS -X POST "https://veriprop.polsia.app/api/v1/webhooks" \
-H "Authorization: Bearer vp_live_REPLACE_ME" \
-H "Content-Type: application/json" \
-d '{
"url": "https://app.example.com/hooks/veriprop",
"eventKinds": ["alert.created"]
}' | jq '.'# Latest 50 PriceDrop notifications.
curl -sS "https://veriprop.polsia.app/api/v1/alerts?kind=PriceDrop&limit=50" \
-H "Authorization: Bearer vp_live_REPLACE_ME" | jq '.items[0]'Request
{
"url": "https://app.example.com/hooks/veriprop",
"eventKinds": ["alert.created", "opportunity.created"],
"filter": { // optional narrowing
"market": "austin-tx", // one of these keys
"parcelId": "R-12-3456", // narrows delivery
"listingId": "lst_001"
}
}Response (201)
{
"subscription": {
"id": "whs_001",
"url": "https://app.example.com/hooks/veriprop",
"eventKinds": ["alert.created", "opportunity.created"],
"filter": {
"market": "austin-tx",
"parcelId": "R-12-3456",
"listingId": "lst_001"
},
"status": "active",
"createdAt": "2026-04-03T14:09:42.000Z",
"lastDeliveredAt": null,
"revokedAt": null
},
"secret": "<one-time-signing-secret>" // shown ONCE; not retrievable later
}Other methods on the same path
- GET— list the caller's active subscriptions.
- DELETE — revoke a subscription by id.
{ "id": "whs_001" }in the JSON body.
URL guard
https (no http), and cannot resolve to a loopback or private (RFC1918) address. The worker rejects bad URLs with 400 before persisting the subscription.Example
SECRET=$(curl -sS -X POST "https://veriprop.polsia.app/api/v1/webhooks" \
-H "Authorization: Bearer vp_live_REPLACE_ME" \
-H "Content-Type: application/json" \
-d '{
"url": "https://app.example.com/hooks/veriprop",
"eventKinds": ["alert.created"]
}' | jq -r '.secret')
echo "$SECRET" # store this in your secret manager NOW# The worker POSTs a JSON envelope (see "Webhook payload + verification").
# On your server, recompute the signature:
#
# ts = request.headers["x-vp-timestamp"] # unix seconds
# sig = request.headers["x-vp-signature"] # lowercase hex
# rawBody = request.rawBody # exact bytes
#
# expected = HMAC_SHA256(secret, ts + "." + rawBody)
# if not in [expected] reject 401.
# Tolerate ±5 minutes of clock skew on ts.
# Example with openssl, given the raw body in $BODY and ts in $TS:
expected=$(printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')
if [ "$expected" != "$(echo -n "$RECEIVED_SIG" | tr 'A-Z' 'a-z')" ]; then
echo "bad signature" >&2; exit 1
fiEnvelope
{
"id": "dly_001",
"eventKind": "alert.created", // or "opportunity.created"
"createdAt": "2026-04-03T14:09:42.000Z",
"data": { /* see shapes below */ }
}Concrete data shapes
The envelope's data field is opaque at the framework level — the actual shape depends on eventKind:
// mirrors AlertItem (see "GET /api/v1/alerts")
{
"id": "alt_001",
"listingId": "lst_001",
"listing": { "address": "...", "market": "...", "bedrooms": 3 },
"channel": "Email",
"kind": "PriceDrop",
"oldPriceCents": 49000000,
"newPriceCents": 47500000,
"deltaPct": -3.06,
"fromStatus": null,
"toStatus": null,
"createdAt": "2026-04-03T14:09:42.000Z",
"deliveredAt": "2026-04-03T14:09:44.000Z",
"recipient": "jane@example.com"
}// mirrors OpportunityItem (see dashboard Opportunity panel)
{
"id": "opp_001",
"listingId": "lst_001",
"kind": "BelowMarket", // BelowMarket | RapidPriceCuts | StatusFlip | Relist | WithdrawnReflux
"listingPriceCents": 42500000,
"baselineCents": 47500000,
"deltaPct": -10.53,
"windowDays": 14,
"detail": { /* kind-specific */ },
"createdAt": "2026-04-03T14:09:42.000Z",
"listing": { "address": "...", "market": "...", "bedrooms": 3 }
}Delivery headers
| Header | Description |
|---|---|
x-vp-timestamp | UNIX seconds the delivery was generated. |
x-vp-signature | Lowercase hex HMAC-SHA256 of (timestamp, rawBody) joined as timestamp + "." + rawBody and signed with the subscription secret. No sha256= prefix. |
x-vp-event | The event kind — e.g. alert.created. |
x-vp-delivery-id | Stable per-delivery id; safe to use as an idempotency key. |
Verification (Node.js)
Compute the HMAC over timestamp + "." + rawBodywith the subscription's signing secret, hex-encode in lowercase, and compare constant-time. Tolerate at most ±5 minutes of skew on the timestamp to absorb clock drift.
import crypto from 'node:crypto';
import express from 'express';
const app = express();
// Webhook deliveries POST raw JSON — capture the bytes verbatim
// before any JSON parser runs so the hash matches the wire.
app.use('/hooks/veriprop', express.raw({ type: 'application/json' }));
app.post('/hooks/veriprop', (req, res) => {
const secret = process.env.VERIPROP_WEBHOOK_SECRET!;
const ts = req.header('x-vp-timestamp') ?? '';
const sigReceived = req.header('x-vp-signature')?.toLowerCase() ?? '';
const sigExpected = crypto
.createHmac('sha256', secret)
.update(`${ts}.${req.body.toString('utf8')}`)
.digest('hex');
// constant-time compare + 5-minute clock-skew window
const skew = Math.abs(Date.now() / 1000 - Number(ts));
if (skew > 300 || sigReceived.length !== sigExpected.length
|| !crypto.timingSafeEqual(Buffer.from(sigReceived), Buffer.from(sigExpected))) {
return res.status(401).send('bad signature');
}
const event = JSON.parse(req.body.toString('utf8'));
// event.eventKind in { 'alert.created', 'opportunity.created' }
res.status(200).send('ok');
});Not enforced today
429 Too Many Requests. The middleware chain reserves a slot for a rate limiter but it's empty. Calling any endpoint above its published ceiling is the developer's responsibility until enforcement is turned on.| Scope | Planned limit | Behavior when exceeded (planned) |
|---|---|---|
properties:read | 60 req/min | Soft 429 with Retry-After header. |
alerts:read | 120 req/min | Soft 429 with Retry-After header. |
webhooks:write | 30 req/min | Soft 429 with Retry-After header. |
We won't publish concrete numbers we can't honor — when the limit arrives the header-based soft-429 contract above is what we'll ship, and this section will move from "planned" to "enforced".
Auth-shape errors
Returned for missing/invalid credentials, missing scope, and unknown resources.
| Status | Body | Condition |
|---|---|---|
401 | { "error": "Unauthorized" } | No API key / OAuth token; invalid signature. |
403 | { "error": "Insufficient scope" } | Authenticated, but the calling key/client does not have the required scope. |
404 | { "error": "Not found" } | Resource does not exist for this caller. |
Validation-shape errors
zod-detected bad input. POST /api/v1/webhooks and DELETE /api/v1/webhooks use this shape.
{
"errors": {
"url": ["Invalid url"],
"eventKinds": ["Required"]
}
}The OAuth /token and /revoke endpoints return the RFC-shaped { "error": "...", "error_description": "..." } body per RFC 6749 §5.2.