Acceptance Spec — Realtor Outbound Webhook Emitter (MVP)
- Status: Proposed (MVP) — 2026-07-03
- Product: WiseAI Realtor System → Integrations
- Owner surface:
/realtor/app/integrations(add/manage endpoints) + a server-side event emitter - Priority: NOW #3 (connective tissue — one build makes Zapier/Make/IXACT/Mailchimp/BoldTrail-push-in reachable without N connectors —
AUDIT_realtor_integrations_strategy_2026-07-03.md§2 Automation, §5) - Related:
ai-front-desk,realtor-calendar-booking-mvp,realtor-sms-messaging-mvp
1. Why (problem)
The founder's list wants IXACT (no real public API), BoldTrail (brokerage-gated API), Mailchimp (marketing), and Zapier/Make. Building a bespoke connector for each is wasteful. A single outbound webhook emitter — "on a domain event, POST a signed payload to a per-tenant URL" — lets an agent wire Aria into their own stack (Zapier/Make reach ~8,000 apps including IXACT and Mailchimp; BoldTrail accepts lead push-in). Highest leverage-per-build-hour on the roadmap. Today the Integrations hub honestly says "Outbound webhooks and a Zapier app aren't built yet."
2. Scope
In: per-tenant webhook endpoints (URL + secret + subscribed event types); a server-side emitter that fires on RE domain events; HMAC-signed JSON payloads; retry with backoff; a delivery log; a test-ping; and an SSRF-guarded URL validator. Manage from the Integrations screen.
Out (explicit non-goals): inbound/receiving webhooks; a full public REST API + API keys (LATER); a branded/published Zapier app (NEXT tier); customer-defined payload field mapping; delivering church/outreach events (RE spine only — the three-lead-worlds rule); emitting suppressed/withdrawn-consent contact PII.
3. Events (v1 catalog)
Minimal, high-value set (all RE-spine):
lead.created— a newre_contactslead captured (voice/chat/QR/form).lead.stage_changed—re_contacts.pipeline_statustransition.showing.booked— atype='showing'task booked to a calendar (pairs with the calendar MVP).review.requested— a Reviews Engine request created.
Payload (per event): { id, type, occurred_at, business_id, data: {...} } where
data carries a minimal, non-sensitive projection (contact id, display name,
source, language, pipeline stage, consent state) — never AI-summary intent notes,
never financial/commission fields, never a withdrawn-consent contact's PII.
4. Data shape
re_webhook_endpoints
id uuid PK
business_id uuid NOT NULL FK
url text NOT NULL -- https only, SSRF-validated (§5)
secret_enc bytea NOT NULL -- per-endpoint signing secret, encrypted; shown to owner ONCE
events text[] NOT NULL -- subscribed event types
active boolean NOT NULL DEFAULT true
created_by text
created_at timestamptz NOT NULL DEFAULT now()
last_delivery_at timestamptz
UNIQUE (business_id, url)
re_webhook_deliveries
id uuid PK
endpoint_id uuid NOT NULL FK → re_webhook_endpoints(id) ON DELETE CASCADE
business_id uuid NOT NULL FK
event_type text NOT NULL
payload jsonb NOT NULL
attempts integer NOT NULL DEFAULT 0
status text NOT NULL DEFAULT 'pending' -- 'pending'|'delivered'|'failed'|'exhausted'
response_status integer
next_retry_at timestamptz
delivered_at timestamptz
created_at timestamptz NOT NULL DEFAULT now()
5. Security model (load-bearing — SSRF is the sharp edge)
- SSRF guard on every endpoint URL (add AND each delivery): reject non-
https; resolve the host and block private/loopback/link-local/metadata ranges (127.0.0.0/8,10/8,172.16/12,192.168/16,169.254/16incl. the cloud metadata IP169.254.169.254,::1, ULA), block internal hostnames, and re-check on redirect (do not blindly follow redirects to internal hosts). This is non-negotiable — the platform already has an openhttp*SSRF finding flagged in the security backlog; a user-supplied webhook URL is a classic SSRF vector. - HMAC-SHA256 signature: every POST carries
X-WiseAI-Signature: sha256=<hmac(secret, raw_body)>+X-WiseAI-Event+ a timestamp; the receiver verifies with the per-endpoint secret. The secret is generated server-side, stored encrypted, and shown to the owner exactly once. - Delivery hardening: short timeout (e.g. 5s), capped retries with exponential
backoff (e.g. 5 attempts →
exhausted), no unbounded fan-out, per-tenant rate cap. - Consent/PII minimization: never emit a contact whose consent is withdrawn beyond
the bare
lead.stage_changedid; never emit AI intent notes or financial fields (§3). - Per memory: authed client calls carry
?account_id=(feedback_realtor_client_calls_need_account_id_query); endpoint config writes UPSERT (feedback_per_account_config_writes_must_upsert).
6. Flow (states)
- Add endpoint: owner pastes an https URL + picks event types →
SSRF+https validation → generate secret (shown once) → a signed test-ping
(
type:'ping') must return 2xx before the endpoint is marked verified/active (honest-status-hub rule). A URL that fails SSRF/validation is rejected inline and never stored. - Emit: on a domain event, enqueue a
re_webhook_deliveriesrow per subscribed active endpoint; a worker POSTs the signed payload. - Retry: non-2xx/timeout → increment
attempts, setnext_retry_at(backoff); after the cap →status='exhausted'and surface it in the delivery log. - Manage: owner sees a delivery log (recent events, response codes, retries), can re-send, rotate the secret, disable, or delete an endpoint.
7. Honest status hub
The Integrations "Outbound webhooks / Zapier" card flips from "Not available yet" to active only when the tenant has ≥1 endpoint that passed the signed test-ping — never on a saved-but-unverified URL.
8. Acceptance criteria
- SSRF blocked: adding an endpoint pointing at
http://,localhost,127.0.0.1,169.254.169.254, or a private-range host is rejected and never stored; a redirect to an internal host during delivery is not followed. - Signed + verifiable: each POST carries
X-WiseAI-Signaturecomputed as HMAC-SHA256 of the raw body with the endpoint secret; a test receiver validates it. - Test-ping gates activation: a new endpoint is only marked verified/active after
a signed
pingreturns 2xx; a failing URL stays inactive. - Emits on events:
lead.created,lead.stage_changed,showing.booked, andreview.requestedeach produce a delivery to every subscribed active endpoint, logged inre_webhook_deliveries. - Retry + exhaust: a receiver returning 500 causes capped retries with backoff,
ending in
status='exhausted', visible in the log. - PII minimization: payloads carry no AI intent notes, no financial/commission fields, and no withdrawn-consent contact PII (§3).
- Zapier reachability (proof of the whole point): a real Zapier "Catch Hook" (or
Make) receives a
lead.createdevent end-to-end and can route it onward (e.g. to a Google Sheet / IXACT), demonstrating the connector-free integration path. - account_id + UPSERT: authed client calls carry
?account_id=; endpoint writes UPSERT.
9. Verification (deployed URL)
- On the real host with the TeamMoelker showcase account: add an endpoint pointing at
a Zapier Catch Hook (or a
webhook.sitereceiver); confirm the signed test-ping activates it; trigger alead.created(seed a lead) and assert the receiver got a correctly signed payload and are_webhook_deliveries(delivered)row. Sample the delivery status at ≥2 timepoints (pending → delivered). - SSRF negatives: attempt to add
http://169.254.169.254/…andhttp://localhost→ assert both are rejected and unstored. - Retry: point an endpoint at a 500-returning receiver → assert backoff retries
and a final
exhaustedstate in the log.