API + MCP reference

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.

Operator tier and up. Webhooks are included on Operator, Team, and Custom. Starter is REST-only.

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:

subscribe an endpoint
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.

watchlist.match payload
{  "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:

request headers
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-Signature with the format t=<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 or crypto.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):

AttemptDelayCumulative wait
10
21s1s
35s6s
430s36s
55m5m 36s
DLQ30m later35m 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

receiver.py
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

receiver.ts
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);
Test before pointing prod traffic at it. Subscribe a staging endpoint first and trigger a manual run with POST /v1/watchlists/{id}/run to confirm the signature verifies + the parsed payload is shaped how you expect.