Skip to content

Alerts and signed webhooks

Create a strategy profile (via the create_strategy_profile MCP tool or POST /api/me/profiles) with match criteria and delivery channels. After each listings run the matcher scores new/changed inventory against every active profile and delivers deduped alerts.

{
"name": "BRRRR 85+ under 400k",
"strategy": "brrrr",
"min_percentile": 85,
"max_price": 400000,
"min_confidence": 60,
"bbox": [-87.75, 41.70, -87.50, 41.85],
"channels": {
"telegram": true,
"webhook": { "url": "https://your-agent.example.com/pm-alerts", "secret": "whsec_…" }
}
}

Matching is deduped on profile + listing + price-bucket (one alert per meaningful price move), digested to at most one push per profile per day carrying the top matches. Every alert row and payload carries provenance: the score snapshot and model_versions that triggered it (5.4.4).

{
"profile": { "id": "prof_…", "name": "BRRRR 85+ under 400k" },
"strategy": "brrrr",
"as_of": "2026-07-10T13:00:00.000Z",
"model_versions": { "model": "p3s-2026.07", "underwrite": "p3s-2026.07", "serving": "p5s8-2026.07" },
"matches": [
{ "listing_id": "rc123", "address": "", "zip": "60637", "price": 315000, "url": "",
"score_snapshot": { "brrrr": 88, "flip": 61, "hold": 54, "arv": 402000, "arv_method": "renovated_comps", "arv_confidence": 71 } }
]
}

Each POST carries two headers:

  • X-PM-Timestamp — unix seconds when the request was signed.
  • X-PM-Signaturesha256=<hex> where <hex> = HMAC-SHA256(secret, timestamp + "." + rawBody).

Verify with a constant-time compare over the raw request body:

import { createHmac, timingSafeEqual } from "node:crypto";
function verifyPlantedWebhook(req, rawBody, secret) {
const ts = req.headers["x-pm-timestamp"];
const sig = (req.headers["x-pm-signature"] || "").replace(/^sha256=/, "");
// replay protection: reject anything older/newer than 5 minutes
if (!ts || Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
const expected = createHmac("sha256", secret).update(ts + "." + rawBody).digest("hex");
const a = Buffer.from(sig, "hex"), b = Buffer.from(expected, "hex");
return a.length === b.length && timingSafeEqual(a, b);
}
  • Timeout 5s per attempt. Retries ×3 with exponential backoff (200ms, 400ms, 800ms) inline.
  • After 3 failures the alert is dead-lettered (dead_letter column holds the last diagnostic); it is not silently dropped.
  • Replay protection is your responsibility: reject timestamps outside ±5 minutes and treat (listing_id, price) as idempotent.

Delivery runs inline in the Worker’s scheduled run today (Workers + KV + D1, no Durable Objects binding required). The durable upgrade path is Cloudflare Queues for ret/backoff fan-out — the payload and signature contract above do not change when that lands.