Skip to content
API · Webhooks

Webhooks

PropRaven POSTs signed JSON to your endpoint when watched parcels change. 3 event types are live today (parcel.sold, parcel.permit_filed, parcel.owner_changed); a create request may subscribe to any of them. Deliveries are HMAC-signed, retried on failure, and carry a deterministic event_id to de-duplicate on.

Delivery semantics

  • Transport: HTTPS POST, Content-Type: application/json, 10-second timeout. Any 2xx response acks the delivery; the response body is not read.
  • Retries: a non-2xx response is retried up to 8 attempts in all, waiting 1 min, 5 min, 15 min, 30 min, 1 h, 2 h and 6 h (about 10 hours in all) between attempts. After the last attempt the delivery is dead_lettered. When the outcome of a send is unknown (a network error or timeout, after which your endpoint may or may not have received it) the delivery stays in_flight and is not retried automatically. Re-queue a dead-lettered delivery with POST /api/v1/webhooks/{id}/deliveries/{deliveryId}/retry or from the settings page.
  • Idempotency: each event is queued once per endpoint. A retry re-sends the same event_id and X-PropRaven-Delivery-Id with a higher attempt number, so de-duplicate on event_id.
  • Ordering: not guaranteed. Use occurred_at to order events for a given parcel.
  • Quotas: Free: not included · Developer ($99/mo): 50 endpoints · 100K events/day · Team ($1,000/mo): unlimited endpoints · 1M events/day. Only active endpoints count toward the endpoint quota. Legacy API plans keep their existing quotas.

Delivery headers

X-PropRaven-Signaturet=<unix_ms>,v1=<hex HMAC-SHA256 of `${t}.${rawBody}`>; verify it before trusting the body.
X-PropRaven-Event-IdThe payload's event_id (deterministic; the same on every retry).
X-PropRaven-Event-TypeThe payload's event_type, e.g. parcel.sold.
X-PropRaven-Delivery-IdThis delivery's id (the id in GET /webhooks/{id}/deliveries); the same on every retry.
X-PropRaven-AttemptThe attempt number, starting at 1 (equals the payload's delivery_attempt).
User-AgentPropRaven-Webhooks/1.0

Signature format

Every delivery carries an X-PropRaven-Signature header in Stripe-style format:

X-PropRaven-Signature: t=1715882400000,v1=abc123…

t is the unix millisecond timestamp PropRaven generated the signature. v1 is the hex-encoded HMAC-SHA256 of <t>.<rawBody> using the secret returned at endpoint-creation time.

Reject any request where the timestamp is more than 5 minutes from your clock — this prevents signature replay. Compare signatures in constant time.

Verify in Node.js (no SDK needed):

import { createHmac, timingSafeEqual } from "node:crypto";

// header:  the X-PropRaven-Signature value, e.g. "t=1715882400000,v1=ab12..."
// rawBody: the exact request body string, before any JSON parsing
// secret:  the whsec_... value returned when you created the endpoint
function verifyPropRavenSignature(header, rawBody, secret, toleranceMs = 300000) {
  const parts = {};
  for (const kv of String(header).split(",")) {
    const i = kv.indexOf("=");
    if (i > 0) parts[kv.slice(0, i).trim()] = kv.slice(i + 1).trim();
  }
  const t = Number(parts.t);
  if (!Number.isFinite(t) || !parts.v1) return false;
  if (Math.abs(Date.now() - t) > toleranceMs) return false; // replay window
  const expected = createHmac("sha256", secret).update(parts.t + "." + rawBody).digest();
  const given = Buffer.from(parts.v1, "hex");
  return given.length === expected.length && timingSafeEqual(given, expected);
}

Verify in Python (standard library only):

import hashlib, hmac, time

def verify_propraven_signature(header: str, raw_body: bytes, secret: str,
                               tolerance_ms: int = 300_000) -> bool:
    """header: the X-PropRaven-Signature value; raw_body: the exact request bytes."""
    parts = dict(p.strip().split("=", 1) for p in header.split(",") if "=" in p)
    t, v1 = parts.get("t", ""), parts.get("v1", "")
    if not t.isdigit() or not v1:
        return False
    if abs(time.time() * 1000 - int(t)) > tolerance_ms:  # replay window
        return False
    expected = hmac.new(secret.encode(), t.encode() + b"." + raw_body,
                        hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, v1)

Always verify against the raw request body: re-serialised JSON will not match.

SDK helpers

Each SDK ships a verifier that checks the header, the signature and the 5-minute window and returns the parsed event:

  • TypeScript (@propraven/sdk 0.3.0): verifyWebhook<T>({ payload, signature, secret }), throws WebhookVerificationError. TypeScript SDK
  • Python (propraven 0.2.0): propraven.verify_webhook(payload, signature, secret), raises WebhookVerificationError. Python SDK
  • Go (propraven-go v0.3.0): propraven.VerifyWebhook(payload []byte, signature, secret string, opts ...WebhookOption) (json.RawMessage, error). Go SDK

TypeScript:

import { verifyWebhook, WebhookVerificationError } from "@propraven/sdk";

// verifyWebhook returns `unknown` unless you name the type you expect.
interface PropRavenEvent {
  event_id: string;
  event_type: "parcel.sold" | "parcel.owner_changed" | "parcel.permit_filed";
  parcel_id: string;
  [field: string]: unknown;
}

// rawBody: the exact request body string; signature: the X-PropRaven-Signature header.
export function handleDelivery(rawBody: string, signature: string | null, secret: string): number {
  let event: PropRavenEvent;
  try {
    event = verifyWebhook<PropRavenEvent>({ payload: rawBody, signature, secret });
  } catch (err) {
    if (err instanceof WebhookVerificationError) return 401;
    throw err;
  }
  console.log(event.event_type, event.event_id, event.parcel_id);
  return 204;
}

Python:

from propraven import verify_webhook, WebhookVerificationError

def handle_delivery(raw_body: bytes, signature: str, secret: str) -> int:
    """raw_body: the exact request bytes; signature: the X-PropRaven-Signature header."""
    try:
        event = verify_webhook(raw_body, signature, secret)
    except WebhookVerificationError:
        return 401
    print(event["event_type"], event["event_id"], event["parcel_id"])
    return 204

Go:

import (
    "io"
    "net/http"
    "os"

    propraven "github.com/jdw2111/propraven-go"
)

func handler(w http.ResponseWriter, r *http.Request) {
    rawBody, _ := io.ReadAll(r.Body) // the exact request body, before any JSON parsing
    event, err := propraven.VerifyWebhook(rawBody,
        r.Header.Get("X-PropRaven-Signature"), os.Getenv("PROPRAVEN_WEBHOOK_SECRET"))
    if err != nil {
        http.Error(w, "invalid signature", http.StatusUnauthorized)
        return
    }
    _ = event // json.RawMessage: unmarshal into your own struct; the name is in event_type
    w.WriteHeader(http.StatusNoContent)
}

Register an endpoint

Create a webhook via POST /api/v1/webhooks:

curl -X POST https://propraven.com/api/v1/webhooks \
  -H "Authorization: Bearer $PROPRAVEN_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/hooks/propraven",
    "event_types": ["parcel.sold", "parcel.permit_filed"],
    "filter_kind": "state_fips",
    "filter_value": { "state_fips": "06" }
  }'

Response includes a secret shown once — persist it before discarding the response. It's the signing key for verification.

Filter kinds:

  • parcel_ids — array of up to 1,000 specific parcels, as canonical ids state_fips:county_fips:parcel_id (a bare parcel number is accepted only when it names exactly one parcel; otherwise the request is refused with 422). Most precise; lowest event volume. Deliveries carry the matched canonical_id.
  • state_fips — every event matching a given state.
  • county_fips — state + county pair.

Event types

Every payload includes the common envelope below plus the event-specific fields shown on each card.

Common envelope

event_idstringDeterministic (sha256 of source, record id and event type). A retry re-sends the same event_id: de-duplicate on it.
event_typestringThe event name (constant per card below). The field is event_type, not type.
occurred_atdate-timeISO-8601 UTC. The record's own date when it has one; see occurred_at_basis.
occurred_at_basisstringWhich column dated the event: recording_date or sale_date (deeds), filed_date or issued_date (permits), or ingested_at / curated_at when the record carries no date of its own.
delivery_attemptintegerStarts at 1; increments on every retry (same as the X-PropRaven-Attempt header).
parcel_idstringThe county assessor's parcel number the record was matched to (for example 12104406). It is county-scoped, not unique nationally: identify the parcel with state_fips + county_fips + parcel_id, or with canonical_id.
state_fipsstring?2-digit state FIPS of the record. May be null when the publisher gave none.
county_fipsstring?3-digit county FIPS of the record as published. May be null; a placeholder such as 000 is passed through, not repaired.
canonical_idstring?Parcel-scoped endpoints only: the watched parcel this event matched, as state_fips:county_fips:parcel_id (the canonical id the rest of the API returns). Absent on state / county endpoints.
match_basisstring?Parcel-scoped endpoints only: state_county_parcel_id (the record's own county matched) or state_unique_parcel_id (the record's county was unknown and the parcel number is unique in the state).
source_run_idstring?Reserved for the PropRaven ingest run; null today.
parcel.sold

Fires when a deed is recorded against a watched parcel AND the transfer is a real sale — sale_price > 0 and arm's-length. Sub-case of parcel.owner_changed.

Payload fields

sale_datedate?Date of sale, YYYY-MM-DD.
sale_price_usdnumber?May be null in non-disclosure states (KS, MS, TX, UT, WY, etc.).
grantorstring?Seller name as recorded.
granteestring?Buyer name as recorded.
recorded_datedate?Date the deed was recorded with the county.
is_arm_lengthboolean?True when the transaction is arm's-length per upstream classification.
document_typestring?Deed classification — Warranty Deed, Quitclaim, Trust Transfer, etc.
parcel.owner_changed

Fires when ANY deed is recorded against a watched parcel — sales, quitclaims, gift deeds, trust transfers. Superset of parcel.sold.

Payload fields

recorded_datedate?Date the deed was recorded with the county.
document_typestring?Deed classification.
document_numberstring?Recorder document number.
prior_ownerstring?Grantor on the recorded deed.
new_ownerstring?Grantee on the recorded deed.
is_saleboolean?True when this transfer is also a sale (parcel.sold also fires).
parcel.permit_filed

Fires when a new permit is matched to a watched parcel. Fires once per permit_id per parcel; jurisdiction re-postings de-dupe.

Payload fields

permit_idstringPropRaven internal permit id; stable across re-ingests.
permit_numberstring?Jurisdiction-issued permit number.
permit_typestring?Building, electrical, roofing, demolition, etc.
permit_statusstring?Filed, issued, in_review, final, expired, withdrawn.
filed_datedate?Date of filing.
issued_datedate?Date the permit was issued (if applicable).
descriptionstring?Free-form description of the work.
estimated_costnumber?Declared job cost in USD.
contractor_namestring?Contractor as recorded.
contractor_licensestring?Contractor license number.
applicant_namestring?Applicant as recorded.
jurisdiction_idstring?PropRaven jurisdiction id.

Managing endpoints

# List your endpoints (with your quota and API tier)
curl https://propraven.com/api/v1/webhooks -H "Authorization: Bearer $PROPRAVEN_API_KEY"

# One endpoint: the same snake_case record as in the list
curl https://propraven.com/api/v1/webhooks/<id> -H "Authorization: Bearer $PROPRAVEN_API_KEY"

# Disable an endpoint (soft disable, see below)
curl -X DELETE https://propraven.com/api/v1/webhooks/<id> -H "Authorization: Bearer $PROPRAVEN_API_KEY"

# The last 100 deliveries for an endpoint
curl https://propraven.com/api/v1/webhooks/<id>/deliveries -H "Authorization: Bearer $PROPRAVEN_API_KEY"
  • DELETE is a soft disable. The endpoint gets is_active: false, disabled_reason: "user" and disabled_at, and stays in the list with its delivery history. Nothing more is sent to it, including deliveries already queued. There is no hard delete and no re-enable or update route; create a new endpoint instead. Disabled endpoints do not count toward your quota.
  • Tier: the list's tier is the API tier key that sets your quota, not the plan name: free = Free; pro = Developer; scale = Team; starter and api_100k = legacy API plans.
  • Delivery status: pending (queued, or waiting for its next retry at next_attempt_at); in_flight (being sent, or the outcome of a send is unknown and it is held for reconciliation); delivered (your endpoint answered 2xx); dead_lettered (every attempt failed, or the send was refused before dispatch; re-queue it with the retry route); failed (legacy value, not set by the current dispatcher; still accepted by the retry route).
  • Response bodies are not stored. When your endpoint answered, last_response_status holds its status code and last_response_body is the fixed text [redacted: response body not persisted]; last_error explains a send that did not complete.

Or use the webhook settings page for a UI view of endpoints, recent deliveries, and one-click retry.

Full reference

Every field above is also documented in the OpenAPI 3.1 spec under the webhooks: block at propraven.com/openapi.json.