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 staysin_flightand is not retried automatically. Re-queue a dead-lettered delivery withPOST /api/v1/webhooks/{id}/deliveries/{deliveryId}/retryor from the settings page. - Idempotency: each event is queued once per endpoint. A retry re-sends the same
event_idandX-PropRaven-Delivery-Idwith a higher attempt number, so de-duplicate onevent_id. - Ordering: not guaranteed. Use
occurred_atto 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.0Signature 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/sdk0.3.0):verifyWebhook<T>({ payload, signature, secret }), throwsWebhookVerificationError. TypeScript SDK - Python (
propraven0.2.0):propraven.verify_webhook(payload, signature, secret), raisesWebhookVerificationError. Python SDK - Go (
propraven-gov0.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 204Go:
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 idsstate_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 matchedcanonical_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.soldFires 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_changedFires 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_filedFires 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"anddisabled_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
tieris 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_statusholds its status code andlast_response_bodyis the fixed text[redacted: response body not persisted];last_errorexplains 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.