Developer reference

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.

Overview

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.

Read properties
GET
/api/v1/properties/{id}

Parcel detail with assessment history, sales history, and currently-marketed listings.

Read alerts
GET
/api/v1/alerts

Price-drop and status-change notifications, scoped to your active webhook subscriptions.

Manage webhooks
POST
/api/v1/webhooks

Subscribe to events; receive HMAC-signed deliveries at any public HTTPS URL.

Authentication
Two modes for the same three endpoints. Pick API key for server-to-server scripts; pick OAuth2 for end-user integrations.

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):

alerts:read
properties:read
webhooks:write

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_code and refresh_token
  • ·Scopes (space-separated on the wire): alerts:read properties:read webhooks:write
  • ·Access token: send as Authorization: Bearer vp_at_…

Authorize (browser)

Step 1 — open in a 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=S256

Token (server-to-server)

Step 2 — exchange the code
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.

Revoke an access token
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"
GET /api/v1/properties/{id}
One parcel with assessment history, sales history, and currently-marketed listings. Read-only.
properties:read
Auth: API key or OAuth bearer

Path & query parameters

NameInDescription
idpathEither the Veriprop parcel id (e.g. par_abc123) or a market-qualified parcelId when you also supply market.
marketqueryMarket slug (austin-tx, denver-co). Required if id is a raw parcel APN; ignored otherwise.

Response (200) — ParcelDetail

Response shape
{
  "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"
    }
  ]
}

Example

Step 1 — look up a key
# 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]'
Step 2 — fetch the parcel
# 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"
GET /api/v1/alerts
Price-drop and status-change notifications, scoped to the calling key or OAuth client's active webhook subscriptions.
alerts:read
Auth: API key or OAuth bearer

Query parameters

NameDescription
limitPage size. Defaults to 100, capped at 200.
cursorOpaque pagination cursor (the id of the last item from the previous page).
channelOne of Email, Sms.
kindOne of PriceDrop, StatusChange.
listingIdNarrows further to a single listing.

Response (200) — AlertList

Response shape
{
  "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
    }
  ]
}

Example

Step 1 — subscribe to a stream first
# 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 '.'
Step 2 — list alerts
# 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]'
POST /api/v1/webhooks
Subscribe to events. The signing secret is returned ONCE — store it immediately.
webhooks:write
Auth: API key or OAuth bearer

Request

WebhookCreateInput
{
  "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)

WebhookCreateResponse
{
  "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.

Example

Step 1 — create subscription, capture secret
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
Step 2 — verify a delivery
# 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
fi
Webhook payload + verification
Every outbound delivery carries a signed JSON envelope. Verify the HMAC before you trust a payload.

Envelope

WebhookDeliveryEnvelope
{
  "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:

data when eventKind = alert.created
// 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"
}
data when eventKind = opportunity.created
// 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

HeaderDescription
x-vp-timestampUNIX seconds the delivery was generated.
x-vp-signatureLowercase hex HMAC-SHA256 of (timestamp, rawBody) joined as timestamp + "." + rawBody and signed with the subscription secret. No sha256= prefix.
x-vp-eventThe event kind — e.g. alert.created.
x-vp-delivery-idStable 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.

Express middleware sketch
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');
});
Rate limits
Current state of the rate-limiting surface. Numbers below are the planned policy — they are documented for transparency and may land without further notice.
ScopePlanned limitBehavior when exceeded (planned)
properties:read
60 req/minSoft 429 with Retry-After header.
alerts:read
120 req/minSoft 429 with Retry-After header.
webhooks:write
30 req/minSoft 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".

Errors
Two shapes — short message for auth/scope/not-found, full field map for zod validation.

Auth-shape errors

Returned for missing/invalid credentials, missing scope, and unknown resources.

StatusBodyCondition
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.

400 validation response
{
  "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.