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:
| Event | Description |
|---|---|
customer.created | new customer created |
customer.updated | change in ARR, owner, status, fields |
customer.deleted | customer removed (soft-delete) |
customer.status_changed | changed to churned, paused, etc. |
health.changed | score crossed a threshold (healthy→neutral, neutral→at-risk, etc.) |
health.drop_sharp | drop >15 pts in 7 days |
renewal.approaching | renewal in 60/30/15/7 days |
renewal.completed | customer renewed |
renewal.churned | customer didn't renew |
task.created | task created (by playbook or manual) |
task.completed | task completed |
task.overdue | task overdue |
playbook.triggered | playbook fired |
ai.detected_issue | AI detected a potential issue on WhatsApp/email |
integration.disconnected | an 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 respond2xxin <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
2xxstatus 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
failedand 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} # deleteSee deliveries
GET /v1/webhook-subscriptions/{id}/deliveriesQuery: 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}/replayUI
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 rotation —
PATCH .../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_idfor idempotency.
"I'm not receiving anything"
- Verify URL at
GET /v1/webhook-subscriptions/{id}. - Check deliveries:
GET .../deliveries?status=failedto 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 Acceptedquickly 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"}