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

TriggerWhat happens
Analysis completes on a market a broker is pursuingA 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 readyThe file URL is emailed to the requesting client; a Notion page is created in the deal database.
A phase of an analysis errors outYour ops channel gets a
text
#alerts
ping with the analysis ID and the failed phase, so someone can intervene before the broker notices.
Each analysis phase completesA 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:

  1. 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://
    .
  2. 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.
  3. 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

  1. Sign in to WALDO as a Professional or Enterprise user. (Webhooks aren't included on Basic — upgrade in Settings → Billing if needed.)
  2. Go to Settings → Webhooks in the left sidebar.
  3. Click "New endpoint." Give it a friendly name (e.g. Slack production, n8n analysis flow, Pipeline sheet).
  4. Paste the URL from your receiver — your Slack incoming webhook, your Zapier "Catch Hook" URL, your n8n webhook node, your custom server, etc.
  5. Check the events you want to subscribe to. Most teams start with just
    text
    analysis.completed
    .
  6. 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.)
  7. Click "Send test event." Within a few seconds the deliveries table will show whether your receiver acknowledged the test message. If it shows
    text
    Delivered
    (green), you're done. If it shows
    text
    Failed
    or
    text
    Dead
    (red), check that your URL is correct and that your receiver is verifying the signing secret properly (see the technical section below).

Available events

These are the things WALDO can notify you about. You can subscribe to any combination per endpoint.

Event nameWhen it firesUseful for
text
analysis.completed
All phases of an analysis finished cleanly.Posting summaries, kicking off downstream automations, alerting clients.
text
analysis.failed
A phase fatal-errored and the run was aborted.Ops alerts so someone can investigate before the requester notices.
text
analysis.phase.completed
Each individual phase finished (high volume — fires multiple times per analysis).Live progress dashboards. Most teams skip this one.
text
export.ready
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

text
Waldo-Signature
header. Your receiver should verify this signature on every message. Without verification, anyone who guesses your URL could send fake "analysis completed" messages to your system — your CRM might create real notes for analyses that never happened.

The 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:

AttemptWait before retry
1st failure1 minute
2nd failure5 minutes
3rd failure30 minutes
4th failure2 hours
5th failure6 hours
6th failure24 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

text
192.168.x.x
or
text
10.x.x.x
address.


For 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
    text
    /settings/webhooks
    — uses the signed-in Supabase session.
  • Public API at
    text
    /api/v1/webhooks/*
    — uses a personal API key (
    text
    Authorization: Bearer waldo_live_...
    ) with the
    text
    read:webhooks
    and/or
    text
    write:webhooks
    scopes. See the API Keys docs.

Register an endpoint via API

text
POST /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

text
signing_secret
is shown exactly once):

json
{
  "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:

text
POST /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:

  1. Verify the signature.
  2. Acknowledge with any 2xx within 15 seconds.
  3. Process asynchronously — long work belongs behind your own queue.

Signature verification

The signature is HMAC-SHA256 over

text
${timestamp}.${raw_body}
using the endpoint's signing secret. Reject any timestamp older than 5 minutes (replay protection).

Node.js

js
import { 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:

js
import 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

python
import 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:

python
from 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

text
POST /api/v1/webhooks/{id}/rotate

Returns a fresh

text
signing_secret
. The previous secret is preserved on the endpoint row in
text
signing_secret_previous
so receivers running with both configured don't drop deliveries during the cutover. WALDO always SIGNS with the current secret. Receivers should accept either secret during the overlap; once you've confirmed the new one verifies cleanly, drop the old.

A daily cron clears

text
signing_secret_previous
7 days after rotation, so the previous secret becomes inert automatically.

URL validation (SSRF guard)

Webhook URLs are validated at registration AND immediately before each delivery:

  • HTTPS only.
    text
    http://
    is rejected.
  • Private / reserved IP ranges blocked: RFC 1918 (
    text
    10/8
    ,
    text
    172.16/12
    ,
    text
    192.168/16
    ), CGNAT (
    text
    100.64/10
    ), loopback (
    text
    127/8
    ,
    text
    ::1
    ), link-local (
    text
    169.254/16
    including AWS/GCP/Azure metadata, IPv6
    text
    fe80::/10
    ), unique-local (
    text
    fc00::/7
    ), multicast (
    text
    224/4
    ,
    text
    ff00::/8
    ), and reserved test ranges. Hostname-based blocks:
    text
    localhost
    ,
    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
    text
    dead
    immediately (no retry — the URL itself is the problem).

Examples that will be rejected:

text
https://169.254.169.254/...
,
text
https://10.0.0.5/...
,
text
https://localhost:3000/hooks
,
text
https://printer.local/...
.

Test events

From the WALDO settings UI (

text
/settings/webhooks
) you can fire a synthetic
text
webhook.test
event at any active endpoint. The payload mirrors the real delivery envelope:

json
{
  "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

text
GET /api/v1/webhooks/{id}/deliveries?limit=50&status=failed

Returns the recent delivery rows for an endpoint. Status filter values:

text
pending
,
text
in_flight
,
text
delivered
,
text
failed
,
text
dead
.

Replaying a delivery

text
POST /api/v1/webhook-deliveries/{delivery_id}/replay

Re-attempts a delivery. Resets status to

text
pending
, clears retry timer, and enqueues a fresh delivery. Useful for
text
dead
deliveries that should be retried after fixing the receiver.

Retry schedule (technical detail)

Backoff in seconds between attempts:

text
60, 300, 1800, 7200, 21600, 86400
(1m → 24h). After the 6th failed attempt, status is
text
dead
and no further automatic retries occur. Manual replay is always available.

Error-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

text
request_id
when contacting WALDO support — it's how we locate the audit log row for your call.