LealUp Docs
API & Webhooks

Webhooks

Subscribe to LealUp events — delivery, retries, HMAC signatures, and replay protection.

Webhooks let your system receive real-time notifications when things happen in LealUp — without polling.

Typical use cases

  • Sync health to your CRM — when a customer drops to red, flag the deal in Salesforce/HubSpot.
  • Custom alerts — send a message to your incidents channel on a churn signal.
  • Integrate with billing — when a customer changes tier, update your billing system.
  • Data warehouse — replicate LealUp events to Snowflake/BigQuery.

Available events

A subset of the events you can subscribe to:

EventDescription
customer.creatednew customer created
customer.updatedchange in ARR, owner, status, fields
customer.deletedcustomer removed (soft-delete)
customer.status_changedchanged to churned, paused, etc.
health.changedscore crossed a threshold (healthy→neutral, neutral→at-risk, etc.)
health.drop_sharpdrop >15 pts in 7 days
renewal.approachingrenewal in 60/30/15/7 days
renewal.completedcustomer renewed
renewal.churnedcustomer didn't renew
task.createdtask created (by playbook or manual)
task.completedtask completed
task.overduetask overdue
playbook.triggeredplaybook fired
ai.detected_issueAI detected a potential issue on WhatsApp/email
integration.disconnectedan integration stopped working

Full list: GET /v1/webhook-events.

Create a subscription

POST /v1/webhook-subscriptions
Authorization: Bearer ...
Content-Type: application/json
{
  "url": "https://your-system.com/lealup-webhook",
  "events": [
    "health.drop_sharp",
    "renewal.approaching"
  ],
  "secret": "wh_secret_abc123...",
  "description": "Alerts to Slack via Zapier",
  "filters": {
    "segment_id": "01HSEG..."
  }
}

Response:

{
  "id": "01HWEB...",
  "url": "...",
  "events": [...],
  "created_at": "2026-04-15T...",
  "status": "active"
}

Fields

  • url — HTTPS required. Must respond 2xx in <5 sec.
  • events — list of types (or ["*"] for all).
  • secret — to sign requests. Minimum 32 chars. You generate it; LealUp stores it to sign.
  • filters — optional, for more granular subscriptions (only customers from segment X, with ARR > Y, etc.).

Payload format

POST https://your-system.com/lealup-webhook
Content-Type: application/json
X-LealUp-Signature: sha256=abc123def456...
X-LealUp-Timestamp: 1713193200
X-LealUp-Event: health.drop_sharp
X-LealUp-Delivery-Id: 01HDEL...

{
  "event": "health.drop_sharp",
  "delivery_id": "01HDEL...",
  "created_at": "2026-04-15T14:30:00Z",
  "tenant_id": "01HTEN...",
  "data": {
    "customer_id": "01HCUS...",
    "customer_name": "Acme Corp",
    "previous_score": 72,
    "current_score": 54,
    "delta": -18,
    "days": 5,
    "owner_id": "01HUSR...",
    "owner_email": "[email protected]"
  }
}

Verify the signature (required)

Always validate the X-LealUp-Signature header to confirm the request comes from LealUp and wasn't tampered with.

Algorithm

signature = HMAC-SHA256(
  secret,
  timestamp + "." + raw_body
)

Example (Node.js)

import crypto from "crypto";

function verifyWebhook(
  rawBody: string,
  signature: string,
  timestamp: string,
  secret: string,
): boolean {
  // 1. Verify timestamp is recent (anti-replay).
  const age = Date.now() / 1000 - parseInt(timestamp);
  if (age > 300) return false; // >5 min → reject

  // 2. Compute expected signature.
  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${timestamp}.${rawBody}`)
    .digest("hex");

  // 3. Compare with time-safe equals.
  const received = signature.replace("sha256=", "");
  return crypto.timingSafeEqual(
    Buffer.from(expected),
    Buffer.from(received),
  );
}

Example (Python)

import hmac
import hashlib
import time

def verify_webhook(raw_body: bytes, signature: str, timestamp: str, secret: str) -> bool:
    if abs(time.time() - int(timestamp)) > 300:
        return False
    expected = hmac.new(
        secret.encode(),
        f"{timestamp}.".encode() + raw_body,
        hashlib.sha256,
    ).hexdigest()
    received = signature.replace("sha256=", "")
    return hmac.compare_digest(expected, received)

Delivery and retries

  • Timeout: your endpoint must respond in 5 seconds.
  • Success: any 2xx status counts as delivered.
  • Retry: if it fails (timeout, 5xx, broken connection), we retry with exponential backoff:
    • Attempt 1: immediate.
    • Attempt 2: +1 min.
    • Attempt 3: +5 min.
    • Attempt 4: +30 min.
    • Attempt 5: +2 h.
    • Attempt 6: +12 h.
    • Attempt 7 (last): +24 h.
  • Dead letter: after 7 failed attempts, the delivery is marked failed and logged. Your subscription stays active; it will keep receiving new events.

Idempotency and ordering

Idempotency

Each delivery has a unique X-LealUp-Delivery-Id. Store the ones you've processed — on retry, LealUp resends with the same delivery_id.

if (await redis.exists(`processed:${deliveryId}`)) {
  return { status: 200 };  // already processed, OK
}

await processWebhook(payload);
await redis.set(`processed:${deliveryId}`, "1", { ex: 86400 });

Ordering

LealUp does not guarantee strict ordering (at-least-once delivery). If you receive customer.updated then customer.created out of order, use the payload's created_at to resolve.

Filters

You can limit when a subscription fires:

{
  "url": "...",
  "events": ["health.changed"],
  "filters": {
    "segment_id": "01HSEG...",
    "min_arr": 50000,
    "from_status": ["healthy", "neutral"],
    "to_status": "at_risk"
  }
}

Supported filters depend on the event. See GET /v1/webhook-events/{event}/filters.

Manage subscriptions

GET /v1/webhook-subscriptions           # list
GET /v1/webhook-subscriptions/{id}      # detail + stats (deliveries last 7d)
PATCH /v1/webhook-subscriptions/{id}    # update URL, events, secret
DELETE /v1/webhook-subscriptions/{id}   # delete

See deliveries

GET /v1/webhook-subscriptions/{id}/deliveries

Query: status (success | failed | pending), from, to.

Response includes request headers, body, response status, latency — useful for debugging.

Replay

If your endpoint was down and you want to reprocess:

POST /v1/webhook-subscriptions/{id}/deliveries/{delivery_id}/replay

UI

You can also manage subscriptions from Admin → API → Webhooks:

  • Create / edit / delete.
  • See recent deliveries with response.
  • Test endpoint (LealUp sends a test event).

Security

  • HTTPS required — HTTP endpoints are rejected.
  • HMAC signature — always verify (prevents spoofing).
  • Timestamp check — reject requests >5 min (prevents replay).
  • Secret rotationPATCH .../secret — LealUp signs with the new secret immediately; you have to update your endpoint to accept the new one.
  • IP allowlist — optional, ask support for LealUp IP ranges ([email protected]).

Limits

  • Max 50 subscriptions per account.
  • Max 100 req/sec inbound to your endpoint (burst).
  • Delivery log retention: 30 days (90 on Scale+).

Common issues

"I'm receiving duplicate events"

  • Normal — at-least-once delivery. Use delivery_id for idempotency.

"I'm not receiving anything"

  • Verify URL at GET /v1/webhook-subscriptions/{id}.
  • Check deliveries: GET .../deliveries?status=failed to see if they're failing.
  • Manual test: Admin → API → Webhooks → [sub] → Test.
  • Firewalls/VPN — LealUp connects from fixed IPs (request an allowlist from support).

"Signature doesn't validate"

  • Timestamp skew — your server and LealUp must be synced (<5 min).
  • You're not using the raw body — many frameworks auto-parse JSON. Use the original body as bytes.
  • Wrong secret — verify the stored secret matches what LealUp has.

"Arriving too late"

  • If your endpoint takes >2 sec, throughput drops (the worker waits). Respond 202 Accepted quickly and process async.

Complete example (Python + FastAPI)

from fastapi import FastAPI, Request, HTTPException
import hmac, hashlib, time

app = FastAPI()
SECRET = os.environ["LEALUP_WEBHOOK_SECRET"]

@app.post("/lealup-webhook")
async def webhook(request: Request):
    body = await request.body()
    sig = request.headers.get("x-lealup-signature", "")
    ts = request.headers.get("x-lealup-timestamp", "0")

    if abs(time.time() - int(ts)) > 300:
        raise HTTPException(401, "Stale timestamp")

    expected = hmac.new(
        SECRET.encode(),
        f"{ts}.".encode() + body,
        hashlib.sha256,
    ).hexdigest()

    if not hmac.compare_digest(expected, sig.replace("sha256=", "")):
        raise HTTPException(401, "Invalid signature")

    payload = await request.json()
    delivery_id = payload["delivery_id"]

    if await already_processed(delivery_id):
        return {"status": "already_processed"}

    await process_event(payload["event"], payload["data"])
    await mark_processed(delivery_id)
    return {"status": "ok"}

On this page