Developer docs

Outly AI Webhooks

Push Outly workspace events — hot leads, replies, meetings, campaign activity — to your own HTTPS endpoint. Works with a custom backend, Zapier, Make, n8n, Pipedream, or anything else that accepts a signed HTTPS POST.

1. Create a webhook

In Outly, go to Settings → Integrations → Webhooks → Create Webhook. Give it a name and your endpoint URL. The URL must be HTTPS in production.

2. Choose events

Select exactly the event types you want — lead.created, lead.hot, lead.updated, reply.received, reply.positive, reply.meeting_request, meeting.booked, meeting.cancelled, campaign.started, campaign.paused, campaign.completed, campaign.failed, opportunity.created, opportunity.updated. Each webhook only receives the events it's subscribed to.

3. Copy your endpoint and secret

After creating the webhook, Outly shows a signing secret (whsec_...) exactly once. Save it in your own secrets manager — Outly never shows it again. If you lose it, rotate it from the webhook's detail page (this invalidates the old one immediately).

4. Verify every request's signature

Every delivery includes four headers:

X-Outly-Event: lead.hot
X-Outly-Delivery: whdel_...
X-Outly-Timestamp: 1720000000
X-Outly-Signature: sha256=...

The signature is HMAC-SHA256(secret, timestamp + "." + raw_body) — computed over the timestamp and the exact raw request body bytes, never a re-parsed/re-serialized copy of the JSON (key order and whitespace aren't guaranteed to round-trip identically, which would break the comparison). Compare using a constant-time comparison, never === on the two hex strings directly.

5. Reject stale requests (replay protection)

Reject any request whose X-Outly-Timestamp is more than 5 minutes old (or from the future). This bounds how long a captured request could be replayed even if it were somehow intercepted.

6. Process the event, keyed by event.id

Use event.id (e.g. evt_5f1c2e0a...) as your idempotency key. Outly retries a delivery on a timeout or a 5xx/429/408/425 response, so the same event can legitimately arrive more than once. If you've already processed that event.id, return 200 without repeating the side effect.

7. Return a 2xx quickly

Any 2xx status counts as delivered — no specific response body required. Do your real processing asynchronously if it's slow; just acknowledge receipt fast. Outly's own request timeout is 10 seconds.

8. Understand the retry schedule

Non-2xx responses (and network failures) retry up to 5 attempts total, with backoff:

Attempt 1: immediate
Attempt 2: ~10 seconds later
Attempt 3: ~1 minute later
Attempt 4: ~10 minutes later
Attempt 5: ~1 hour later

Retried: 408, 425, 429, 500, 502, 503, 504, timeouts, connection failures. NOT retried: 400, 401, 403, 404, 410, 422, and other permanent 4xx — fix the underlying issue and use the manual Retry button in Outly once it's resolved.

9. Sample payload

{
  "id": "evt_5f1c2e0a9b3d4c7e8f01a2b3c4d5e6f7",
  "type": "lead.hot",
  "version": 1,
  "created_at": "2026-08-24T10:00:00.000Z",
  "workspace_id": "5b3e...",
  "data": {
    "lead": {
      "id": "lead_123",
      "name": "John Smith",
      "company": "Acme AI",
      "title": "CEO",
      "ai_fit_score": 94,
      "signal": "Hiring sales representatives"
    }
  }
}

version lets Outly evolve the schema without breaking existing consumers — a future breaking change ships as version: 2 on a new opt-in field or event, not a silent change to version 1's shape.

Verification code

Node.js

const crypto = require("crypto");

function verifyOutlyWebhook(secret, timestamp, rawBody, signatureHeader) {
  const expected = "sha256=" + crypto
    .createHmac("sha256", secret)
    .update(`${timestamp}.${rawBody}`, "utf8")
    .digest("hex");

  const expectedBuf = Buffer.from(expected);
  const actualBuf = Buffer.from(signatureHeader);
  if (expectedBuf.length !== actualBuf.length) return false;
  return crypto.timingSafeEqual(expectedBuf, actualBuf);
}

// In your route handler — use the RAW body string, before any JSON.parse:
app.post("/webhooks/outly", express.text({ type: "*/*" }), (req, res) => {
  const timestamp = req.header("X-Outly-Timestamp");
  const signature = req.header("X-Outly-Signature");

  const age = Math.abs(Date.now() / 1000 - Number(timestamp));
  if (age > 300) return res.status(401).send("stale timestamp");

  if (!verifyOutlyWebhook(process.env.OUTLY_WEBHOOK_SECRET, timestamp, req.body, signature)) {
    return res.status(401).send("invalid signature");
  }

  const event = JSON.parse(req.body);
  // ... idempotency check on event.id, then handle event.type ...

  res.status(200).send("ok");
});

Python

import hmac
import hashlib
import time

def verify_outly_webhook(secret: str, timestamp: str, raw_body: str, signature_header: str) -> bool:
    signed_content = f"{timestamp}.{raw_body}"
    expected = "sha256=" + hmac.new(
        secret.encode("utf-8"), signed_content.encode("utf-8"), hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, signature_header)

# Flask example — use request.get_data() for the RAW body, never request.json first
@app.route("/webhooks/outly", methods=["POST"])
def outly_webhook():
    timestamp = request.headers.get("X-Outly-Timestamp")
    signature = request.headers.get("X-Outly-Signature")

    if abs(time.time() - float(timestamp)) > 300:
        return "stale timestamp", 401

    raw_body = request.get_data(as_text=True)
    if not verify_outly_webhook(OUTLY_WEBHOOK_SECRET, timestamp, raw_body, signature):
        return "invalid signature", 401

    event = request.get_json()
    # ... idempotency check on event["id"], then handle event["type"] ...

    return "ok", 200

curl (manual signature check while developing)

# Quick manual check against a payload you've already captured —
# not something you run against real Outly traffic, just useful while
# building your own verification code.
TIMESTAMP=1720000000
BODY='{"id":"evt_test","type":"test.webhook"}'
SECRET="whsec_..."

echo -n "${TIMESTAMP}.${BODY}" | openssl dgst -sha256 -hmac "$SECRET"

Security

  • Outly only delivers to HTTPS endpoints in production, and validates + re-validates your URL server-side (including on every redirect hop) to block requests into private/internal networks.
  • Deliveries never include OAuth tokens, API keys, or other credentials — only the minimal event data described above.
  • Only POST is used. Outly never sends any other HTTP method to your endpoint.
  • Workspace-level rate limiting protects against event bursts (e.g. a campaign generating thousands of events at once) — deliveries are capped and queued, never fired unbounded.

Troubleshooting

  • Signature never matches: confirm you're signing the raw body string, not a re-serialized JSON.parse/JSON.stringify round-trip of it.
  • Webhook marked "Unhealthy": your endpoint has failed 20 deliveries in a row. Check your logs, fix the issue, then use Send Test in Outly to confirm — a single success clears the unhealthy state.
  • Not receiving events you expect: check the webhook's subscribed events list on its detail page, and confirm it's enabled, not disabled.