Webhooks
Webhooks are how WALDO tells your other software — your CRM, your team's Slack, your Google Sheet, an n8n / Zapier flow, your custom dashboard — that something just happened on a market analysis. You give us a URL. We send you a signed JSON message every time the events you care about occur. No polling. No scraping. No copy-paste.
TL;DR for CRE professionals: Add a webhook so when an analysis finishes, you can automatically post a summary into Slack, drop a note onto a AcquisitionPRO® or your CRM contact, append a row to a Google Sheet, or pop a card into Notion. Your integrator (or Claude / ChatGPT) takes the URL and signing secret you generate in the WALDO dashboard and wires up the receiver. You'll never need to touch the code.
What you get from this
- Real-time notifications. The moment WALDO finishes a Cuyahoga County employment analysis, your Slack channel pings, the broker on the deal gets an email, and the row appears in your tracking sheet — automatically.
- No manual handoffs. Stop asking your analyst to "let me know when that's done." Stop screenshotting metrics into a deck.
- Auditable delivery log. Every message we send is recorded. If your receiver ever drops one, you can see exactly when and replay it from the dashboard.
- Fits any tool. If your system can accept an HTTPS POST request, it can accept WALDO webhooks. That includes every modern CRM, every automation tool (Zapier, Make, n8n, Pipedream), Slack, Discord, Microsoft Teams, and any custom code your developer writes.
Real-world workflows we've seen
| Trigger | What happens |
|---|---|
| Analysis completes on a market a broker is pursuing | A summary card is posted in Slack #deals; a note is added to the contact in AcquisitionPRO® or your CRM; a row is appended to the team's pipeline sheet. |
| An export (PDF / Excel / PPTX) is ready | The file URL is emailed to the requesting client; a Notion page is created in the deal database. |
| A phase of an analysis errors out | Your ops channel gets a text |
| Each analysis phase completes | A live progress bar in your custom dashboard updates in real time. |
What you'll hand to your integrator (or AI agent)
To wire up a webhook, the person doing the integration work needs three things:
- Your endpoint URL. This is wherever the messages should be sent — your Slack incoming webhook URL, your n8n trigger URL, your custom server URL, etc. It must be .text
https:// - A signing secret that WALDO generates when you create the endpoint. The receiver uses this to verify each message really came from WALDO (and not a malicious actor pretending to be us). WALDO shows this secret exactly once at creation — copy it immediately and paste it into your integrator's environment variables.
- The list of events the receiver should react to (see the table below).
You generate items 1 and 2 by going to Settings → Webhooks in the WALDO dashboard and clicking "New endpoint."
Step-by-step: creating your first webhook
- Sign in to WALDO as a Professional or Enterprise user. (Webhooks aren't included on Basic — upgrade in Settings → Billing if needed.)
- Go to Settings → Webhooks in the left sidebar.
- Click "New endpoint." Give it a friendly name (e.g. Slack production, n8n analysis flow, Pipeline sheet).
- Paste the URL from your receiver — your Slack incoming webhook, your Zapier "Catch Hook" URL, your n8n webhook node, your custom server, etc.
- Check the events you want to subscribe to. Most teams start with just .text
analysis.completed - Click "Create endpoint." A signing secret will be revealed — save it now, it won't be shown again. (If you lose it, click "Rotate signing secret" later to generate a new one.)
- Click "Send test event." Within a few seconds the deliveries table will show whether your receiver acknowledged the test message. If it shows (green), you're done. If it showstext
DeliveredortextFailed(red), check that your URL is correct and that your receiver is verifying the signing secret properly (see the technical section below).textDead
Available events
These are the things WALDO can notify you about. You can subscribe to any combination per endpoint.
| Event name | When it fires | Useful for |
|---|---|---|
text | All phases of an analysis finished cleanly. | Posting summaries, kicking off downstream automations, alerting clients. |
text | A phase fatal-errored and the run was aborted. | Ops alerts so someone can investigate before the requester notices. |
text | Each individual phase finished (high volume — fires multiple times per analysis). | Live progress dashboards. Most teams skip this one. |
text | An asynchronous export (PDF / Excel / PowerPoint) finished generating. | Emailing the file URL to a client; uploading the export to a CRM. |
The signing secret — why it matters
Every message WALDO sends to your URL includes an HMAC-SHA256 signature in the
Waldo-SignatureThe signing secret is the shared key that proves the message came from WALDO. Treat it like a password.
If you suspect the secret has leaked, click Rotate signing secret in the dashboard. The previous secret stays valid for 7 days so you have time to update your receiver without dropping any messages. After 7 days the old secret is automatically cleared.
When delivery fails
If your receiver returns an error (anything other than HTTP 200–299) or doesn't respond within 15 seconds, WALDO retries on this schedule:
| Attempt | Wait before retry |
|---|---|
| 1st failure | 1 minute |
| 2nd failure | 5 minutes |
| 3rd failure | 30 minutes |
| 4th failure | 2 hours |
| 5th failure | 6 hours |
| 6th failure | 24 hours |
After 6 failed attempts the delivery is marked Dead. You can manually replay any failed or dead delivery from the WALDO dashboard's Webhooks settings page — useful when you've fixed your receiver and want to reprocess what was missed.
Security posture you should know about
We block webhook URLs that point to internal / private addresses to prevent accidental misuse:
- HTTP (non-https) is rejected.
- Any URL whose hostname resolves to a private IP range (RFC 1918, loopback, link-local incl. cloud metadata addresses, multicast) is rejected at registration AND immediately before each delivery.
This means if your receiver is behind a corporate firewall, you'll need to expose it through a public HTTPS URL (a cloud server, an ngrok tunnel, your CRM's incoming-webhook URL, etc.). It can't be a
192.168.x.x10.x.x.xFor your integrator (or AI agent)
Everything below is the technical reference. You can paste a link to this page into Claude / ChatGPT / Cursor and ask the AI to implement the receiver — it has all the information it needs.
Authentication
Webhook delivery is one-way: WALDO sends messages, your receiver acknowledges with a 2xx response. The receiver itself doesn't need a WALDO API key. Authentication is by signature verification (see below).
Managing webhooks (creating, listing, rotating, deleting) IS authenticated. Two ways to do it:
- Dashboard UI at — uses the signed-in Supabase session.text
/settings/webhooks - Public API at — uses a personal API key (text
/api/v1/webhooks/*) with thetextAuthorization: Bearer waldo_live_...and/ortextread:webhooksscopes. See the API Keys docs.textwrite:webhooks
Register an endpoint via API
textPOST /api/v1/webhooks Authorization: Bearer waldo_live_... Content-Type: application/json { "name": "Production receiver", "url": "https://hooks.example.com/waldo", "events": ["analysis.completed", "analysis.failed"] }
Response (the
signing_secretjson{ "id": "5fa9c0a2-...", "name": "Production receiver", "url": "https://hooks.example.com/waldo", "events": ["analysis.completed", "analysis.failed"], "active": true, "signing_secret_prefix": "whsec_a3f1c2", "signing_secret": "whsec_a3f1c2c5b8d9...full...", "created_at": "...", "updated_at": "..." }
Delivery contract
Every POST WALDO sends to your URL has the following shape:
textPOST /your-handler Content-Type: application/json User-Agent: Waldo-Webhooks/1.0 Waldo-Event: analysis.completed Waldo-Delivery-Id: 8c91... Waldo-Signature: t=1730342400,v1=8a5fbe... { "id": "8c91...", "event": "analysis.completed", "created_at": "2026-04-29T19:00:00Z", "data": { "analysis_id": "..." } }
Receiver behavior:
- Verify the signature.
- Acknowledge with any 2xx within 15 seconds.
- Process asynchronously — long work belongs behind your own queue.
Signature verification
The signature is HMAC-SHA256 over
${timestamp}.${raw_body}Node.js
jsimport { createHmac, timingSafeEqual } from 'node:crypto' export function verifyWaldoSignature(rawBody, header, secret) { const parts = Object.fromEntries( header.split(',').map((p) => p.trim().split('=')) ) const t = parseInt(parts.t, 10) if (Math.abs(Date.now() / 1000 - t) > 300) return false const expected = createHmac('sha256', secret) .update(`${t}.${rawBody}`) .digest('hex') const a = Buffer.from(expected, 'hex') const b = Buffer.from(parts.v1, 'hex') return a.length === b.length && timingSafeEqual(a, b) }
In an Express handler, capture the raw body:
jsimport express from 'express' const app = express() app.post('/waldo', express.raw({ type: 'application/json' }), (req, res) => { const ok = verifyWaldoSignature( req.body.toString(), req.headers['waldo-signature'], process.env.WALDO_WEBHOOK_SECRET ) if (!ok) return res.status(400).send('bad signature') const event = JSON.parse(req.body.toString()) // ... handle event ... res.status(200).send('ok') } )
Python
pythonimport hmac, hashlib, time def verify_waldo_signature(raw_body: bytes, header: str, secret: str) -> bool: parts = dict(p.strip().split('=', 1) for p in header.split(',')) t = int(parts.get('t', '0')) if abs(time.time() - t) > 300: return False expected = hmac.new( secret.encode(), f"{t}.{raw_body.decode()}".encode(), hashlib.sha256, ).hexdigest() return hmac.compare_digest(expected, parts.get('v1', ''))
In FastAPI:
pythonfrom fastapi import FastAPI, Request, HTTPException import os app = FastAPI() @app.post("/waldo") async def waldo_hook(request: Request): raw = await request.body() if not verify_waldo_signature( raw, request.headers.get("waldo-signature", ""), os.environ["WALDO_WEBHOOK_SECRET"], ): raise HTTPException(status_code=400, detail="bad signature") event = await request.json() # ... handle event ... return {"ok": True}
Rotating the signing secret
textPOST /api/v1/webhooks/{id}/rotate
Returns a fresh
signing_secretsigning_secret_previousA daily cron clears
signing_secret_previousURL validation (SSRF guard)
Webhook URLs are validated at registration AND immediately before each delivery:
- HTTPS only. is rejected.text
http:// - Private / reserved IP ranges blocked: RFC 1918 (,text
10/8,text172.16/12), CGNAT (text192.168/16), loopback (text100.64/10,text127/8), link-local (text::1including AWS/GCP/Azure metadata, IPv6text169.254/16), unique-local (textfe80::/10), multicast (textfc00::/7,text224/4), and reserved test ranges. Hostname-based blocks:textff00::/8,textlocalhost,text*.local,text*.internal.text*.localdomain - Re-validation at delivery. Even if a hostname's DNS A-record changes after registration to point at a private address, the delivery worker re-resolves and rejects before the POST. Such a delivery is marked immediately (no retry — the URL itself is the problem).text
dead
Examples that will be rejected:
https://169.254.169.254/...https://10.0.0.5/...https://localhost:3000/hookshttps://printer.local/...Test events
From the WALDO settings UI (
/settings/webhookswebhook.testjson{ "id": "<delivery_id>", "event": "webhook.test", "created_at": "...", "data": { "message": "Test event from <your org name>", "source": "<your org name>", "sent_at": "...", "user_id": "...", "endpoint_id": "..." } }
Use this to confirm signature verification and reachability before relying on the endpoint for production traffic.
Inspecting deliveries via API
textGET /api/v1/webhooks/{id}/deliveries?limit=50&status=failed
Returns the recent delivery rows for an endpoint. Status filter values:
pendingin_flightdeliveredfaileddeadReplaying a delivery
textPOST /api/v1/webhook-deliveries/{delivery_id}/replay
Re-attempts a delivery. Resets status to
pendingdeadRetry schedule (technical detail)
Backoff in seconds between attempts:
60, 300, 1800, 7200, 21600, 86400deadError-shape reference
API errors follow RFC 9457 problem-details:
json{ "type": "https://api.waldocre.io/errors/invalid-request", "title": "Invalid request", "status": 400, "detail": "Webhook URL resolves to reserved address 169.254.169.254 (link-local)", "instance": "/api/v1/webhooks", "request_id": "..." }
Always include the
request_id