Webhooks
Subscribe an HTTPS endpoint to a watchlist and we push new matches as they land — HMAC-signed, retried with exponential back-off, deduped by delivery id. Faster than polling GET /v1/leads on a cron.
Overview
Every webhook is attached to a watchlist. Subscribe an endpoint via POST /v1/watchlists/{id}/webhooks — the response includes a per-endpoint signing secret you use to verify every delivery:
curl -X POST https://api.terminal.house/v1/watchlists/01H9.../webhooks \ -H "Authorization: Bearer th_live_..." \ -H "Content-Type: application/json" \ -d '{ "url": "https://hooks.yourdomain.com/webhook", "events": ["watchlist.match"] }' # Response includes the signing secret — shown ONCE:# {# "id": "01H9X...",# "url": "https://hooks.yourdomain.com/webhook",# "events": ["watchlist.match"],# "secret": "whsec_8c41a3f7..."# }
From that point on, whenever the watchlist's recipe matches a freshly-ingested parcel, we POST the event to your URL within seconds. Delivery semantics are at-least-once — dedupe on X-FLPF-Webhook-Id on your side.
Event types
v1 ships one event type. Per the versioning policy we ship new event types additively — existing event payload shapes won't break inside v1.
watchlist.match
Fired when a freshly-ingested parcel matches a watchlist's recipe. One payload per parcel — a single ingestion cycle that produces ten matches results in ten deliveries.
Each delivery consumes one lead-view against your monthly quota (the parcel data inside the payload counts as a read). See Rate limits & quotas.
{ "event_id": "01H9X3K2Y5MQXW8...", "event_type": "watchlist.match", "occurred_at": "2026-05-28T10:14:32Z", "tenant_id": "01H8...", "watchlist": { "id": "01H9...", "name": "out-of-state owners, high equity" }, "match": { "folio": "30-3122-008-0440", "address": "4567 NW 22nd Av, Miami FL 33125", "score": 0.78, "signals_fired": ["out_of_state_owner", "tenure_years_long", "no_homestead", "code_violation"], "owner": { "name": "SUNNY HOLDINGS LLC", "mailing_state": "NY" }, "valuation": { "just_value": 412000, "est_equity": 298000 }, "observed_at": "2026-05-28T10:14:00Z" }}
HMAC signing
Every delivery carries a header that lets you verify the payload came from us and hasn't been replayed:
POST /your-webhook HTTP/1.1Host: hooks.yourdomain.comContent-Type: application/jsonX-FLPF-Signature: t=1748462072,v1=8a3f7c1e9b2d4f6a8c1e3b5d7f9a2c4e6b8d0f1a3c5e7b9d1f3a5c7e9b1d3f5aX-FLPF-Event-Type: watchlist.matchX-FLPF-Webhook-Id: 01H9X3K2Y5MQXW8...User-Agent: terminal.house-webhooks/1.0
The scheme
- Header:
X-FLPF-Signaturewith the formatt=<unix_seconds>,v1=<hex_hmac_sha256>. - Algorithm: HMAC-SHA-256 over the byte string
f"{timestamp}.{raw_body}". The body must be the raw request bytes — JSON-parsing first and re-serializing breaks the signature. - Secret: per-endpoint, returned once at subscribe time, rotatable via the API. Each watchlist endpoint has its own secret.
- Replay window: reject signatures with a timestamp more than 5 minutes off the current time. Catches stale captures + ngrok-tunnel replays.
- Constant-time compare: use
hmac.compare_digest()in Python orcrypto.timingSafeEqual()in Node. Don't use==— it's short-circuit and leaks signature bits via timing.
Rotating the secret
Suspect a secret leaked? Re-issue via POST /v1/watchlists/{id}/webhooks/{endpoint_id}:rotate (returns the new secret, retires the old one). For a clean cut-over without dropped deliveries, accept both secrets briefly on the receiver while you swap.
Retries & idempotency
Every delivery is attempted up to 5 times with exponential back-off. Your endpoint should respond fast (under ~10 seconds) — long-running work belongs in your own queue, not in the webhook handler.
Status interpretation
- 2xx: success. Delivery marked complete. No retries.
- 4xx (except 408 / 429): permanent failure. No retries — your endpoint is telling us the request is bad, retrying won't help. Delivery moves to dead-letter immediately.
- 408, 429, 5xx, network error / timeout: transient. Retry per the back-off schedule below.
Back-off schedule
Delays between attempts (cumulative wait shown):
| Attempt | Delay | Cumulative wait |
|---|---|---|
| 1 | — | 0 |
| 2 | 1s | 1s |
| 3 | 5s | 6s |
| 4 | 30s | 36s |
| 5 | 5m | 5m 36s |
| DLQ | 30m later | 35m 36s |
After the 5th attempt fails, the delivery sits in the dead-letter queue for 30 days. You can replay deliveries from the webhook dashboard or via POST /v1/watchlists/{id}/webhooks/{endpoint_id}/deliveries/{delivery_id}:replay.
Idempotency on your side
Because retries happen on any transient failure, your endpoint will sometimes receive the same delivery twice. Dedupe on X-FLPF-Webhook-Id — it's a stable UUID per delivery attempt-chain. Store seen IDs for at least the 36-minute retry window plus the 30-day DLQ replay window.
Sample receivers
Drop-in handlers that verify the signature, dedupe on delivery id, and ack with 200. Adapt the "do useful work" comment to your pipeline.
Python · Flask
import hmacimport hashlibimport time from flask import Flask, request, abort WEBHOOK_SECRET = b"whsec_..." # from terminal.house dashboardTOLERANCE_SECONDS = 300 # reject signatures older than 5 min app = Flask(__name__) def _parse_signature_header(header: str) -> tuple[int, str]: """Parse 'X-FLPF-Signature: t=<ts>,v1=<hex>' into (timestamp, hex).""" parts = dict(p.split("=", 1) for p in header.split(",")) return int(parts["t"]), parts["v1"] @app.post("/webhook")def receive(): sig_header = request.headers.get("X-FLPF-Signature", "") if not sig_header: abort(400, "missing signature") timestamp, supplied_sig = _parse_signature_header(sig_header) if abs(time.time() - timestamp) > TOLERANCE_SECONDS: abort(400, "stale signature") msg = f"{timestamp}.{request.get_data(as_text=True)}".encode() expected = hmac.new(WEBHOOK_SECRET, msg, hashlib.sha256).hexdigest() if not hmac.compare_digest(expected, supplied_sig): abort(401, "invalid signature") delivery_id = request.headers["X-FLPF-Webhook-Id"] # ...dedupe on delivery_id, then do useful work... return "", 200
Node · Express
import express from "express";import crypto from "node:crypto"; const WEBHOOK_SECRET = Buffer.from(process.env.WEBHOOK_SECRET!, "utf8");const TOLERANCE_SECONDS = 300; const app = express(); // Important: use the raw body for HMAC. JSON-parsing first would// re-serialize and break the signature.app.post( "/webhook", express.raw({ type: "application/json" }), (req, res) => { const sigHeader = req.header("x-flpf-signature") ?? ""; if (!sigHeader) return res.status(400).send("missing signature"); const parts = Object.fromEntries( sigHeader.split(",").map((p) => p.split("=", 2) as [string, string]), ); const timestamp = parseInt(parts.t, 10); const supplied = parts.v1; if (Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) { return res.status(400).send("stale signature"); } const msg = Buffer.concat([ Buffer.from(`${timestamp}.`, "utf8"), req.body as Buffer, ]); const expected = crypto .createHmac("sha256", WEBHOOK_SECRET) .update(msg) .digest("hex"); const expectedBuf = Buffer.from(expected, "hex"); const suppliedBuf = Buffer.from(supplied, "hex"); if ( expectedBuf.length !== suppliedBuf.length || !crypto.timingSafeEqual(expectedBuf, suppliedBuf) ) { return res.status(401).send("invalid signature"); } const deliveryId = req.header("x-flpf-webhook-id")!; // ...dedupe on deliveryId, then do useful work... res.status(200).send(); },); app.listen(3000);
POST /v1/watchlists/{id}/run to confirm the signature verifies + the parsed payload is shaped how you expect.