Webhook Events
Subscribe to events and receive real-time HTTP POST callbacks to your endpoint. Each event includes a signed JSON payload with full context.
HTTP Headers
Every webhook POST request includes these headers:
| Header | Description |
|---|---|
| Content-Type | Always application/json |
| X-Webhook-Signature | HMAC-SHA256 signature: t=timestamp,v1=hash |
| X-Webhook-Event | The event type, e.g. lead.replied |
| User-Agent | Always WeezlyOutreach/1.0 |
Response Requirements
Your endpoint must respond with a 2xx status code within 10 seconds. Non-2xx responses or timeouts are treated as failures. After 10 consecutive failures, the webhook is automatically disabled. You can re-enable it from Settings → API.
Verifying Signatures
Every webhook delivery includes an X-Webhook-Signature header. Verify this signature to ensure the payload was sent by Weezly Outreach and hasn't been tampered with.
const crypto = require('crypto');
function verifyWebhookSignature(payload, signature, secret) {
const parts = {};
signature.split(',').forEach(part => {
const [key, value] = part.split('=');
parts[key] = value;
});
const timestamp = parts['t'];
const expectedSig = crypto
.createHmac('sha256', secret)
.update(`${timestamp}.${payload}`)
.digest('hex');
return parts['v1'] === expectedSig;
}
app.post('/webhook', (req, res) => {
const signature = req.headers['x-webhook-signature'];
const isValid = verifyWebhookSignature(
JSON.stringify(req.body),
signature,
process.env.WEBHOOK_SECRET
);
if (!isValid) {
return res.status(401).json({ error: 'Invalid signature' });
}
const { event, data } = req.body;
console.log(`Received event: ${event}`, data);
res.status(200).json({ received: true });
});import hmac
import hashlib
from flask import Flask, request, jsonify
app = Flask(__name__)
def verify_signature(payload: str, signature: str, secret: str) -> bool:
parts = dict(p.split('=', 1) for p in signature.split(','))
timestamp = parts.get('t', '')
expected = hmac.new(
secret.encode(),
f"{timestamp}.{payload}".encode(),
hashlib.sha256
).hexdigest()
return hmac.compare_digest(parts.get('v1', ''), expected)
@app.route('/webhook', methods=['POST'])
def handle_webhook():
signature = request.headers.get('X-Webhook-Signature', '')
payload = request.get_data(as_text=True)
if not verify_signature(payload, signature, WEBHOOK_SECRET):
return jsonify(error='Invalid signature'), 401
data = request.get_json()
print(f"Event: {data['event']}", data['data'])
return jsonify(received=True), 200Events on this page
Payload Structure
Every webhook payload follows the same top-level structure. The data field contains event-specific fields documented below.
{
"event": "event.type",
"data": {
"...event-specific fields": "..."
},
"timestamp": "2026-08-20T14:32:01.234Z",
"webhookId": "cm3wh789pqr"
}Test deliveries: when you use the test button (or the test API endpoint), the payload carries an extra top-level "test": true field so your handler can tell test deliveries apart from real events. Real deliveries never include it.
Campaign Step Types
The step.type field in campaign.step.completed and campaign.step.failed events will be one of:
| Type | Description |
|---|---|
| CONNECTION_REQUEST | Connection Request |
| MESSAGE | Direct Message |
| VIDEO_MESSAGE | Video Message |
| VOICE_NOTE | Voice Note |
| INMAIL | InMail |
| VIEW_PROFILE | Profile View |
| LIKE_POST | Like Post |
| REACT_TO_POST | React to Post |
| COMMENT_ON_POST | Comment on Post |
| FOLLOW_PROFILE | Follow Profile |
| IF_CONNECTION | Condition branch: checks whether the lead is connected |
| IF_OPEN_PROFILE | Condition branch: checks whether the lead has an Open Profile |
| END | End of sequence (or of a branch) |
Event Reference
lead.repliedCampaignsFired when a lead sends a reply message to your outreach. This event triggers immediately when the reply is detected. The disposition field tells you what happened to the sequence: "stopped" (default), "stopped_negative" (the reply was a rejection), or "continued_ack" (the campaign has smart reply handling on and the reply was a quick acknowledgment, so the sequence keeps running). Deliveries with disposition "continued_ack" carry the same identifiers and message object but may omit the full lead object.
{
"event": "lead.replied",
"data": {
"campaignId": "cm3abc123def456",
"campaignName": "Q1 SaaS Founders Outreach",
"executionId": "cm3exec789ghi",
"leadId": "cm3lead456jkl",
"leadName": "Sarah Chen",
"disposition": "stopped",
"messagePreview": "Hi John, thanks for reaching out! I'd love to chat about this.",
"lead": {
"id": "cm3lead456jkl",
"firstName": "Sarah",
"lastName": "Chen",
"fullName": "Sarah Chen",
"headline": "CEO at TechStartup",
"jobTitle": "CEO",
"company": "TechStartup Inc.",
"email": "sarah@techstartup.com",
"phone": null,
"location": "San Francisco, CA",
"linkedinUrl": "https://linkedin.com/in/sarahchen",
"linkedinPublicId": "sarahchen",
"photoUrl": "https://media.licdn.com/.../sarah.jpg",
"connectionStatus": "CONNECTED",
"source": "SEARCH"
},
"accountId": "cm3acc123mno",
"accountName": "John Smith",
"message": {
"text": "Hi John, thanks for reaching out! I'd love to chat about this.",
"chatId": "chat_abc123def456",
"receivedAt": "2026-08-20T14:32:00.000Z"
}
},
"timestamp": "2026-08-20T14:32:01.234Z",
"webhookId": "cm3wh789pqr"
}// When a lead replies to your outreach
app.post('/webhook', (req, res) => {
const { event, data } = req.body;
if (event === 'lead.replied') {
const { lead, message, campaignName } = data;
console.log(`${lead.fullName} replied to "${campaignName}"`);
console.log(`Message: ${message.text}`);
// Example: Send Slack notification
await slack.chat.postMessage({
channel: '#sales-replies',
text: `🔔 ${lead.fullName} (${lead.company}) replied!\n> ${message.text}`,
});
// Example: Update your CRM
await crm.contacts.update(lead.email, {
status: 'replied',
lastActivity: message.receivedAt,
});
}
res.status(200).json({ received: true });
});lead.interestedCampaignsFired when AI sentiment analysis detects that a reply shows genuine interest, with a confidence of at least 0.7 (70%). Always fired in addition to lead.replied for the same reply. Includes the AI's confidence score, its reasoning, and the reply message itself.
{
"event": "lead.interested",
"data": {
"campaignId": "cm3abc123def456",
"campaignName": "Q1 SaaS Founders Outreach",
"executionId": "cm3exec789ghi",
"leadId": "cm3lead456jkl",
"leadName": "Sarah Chen",
"confidence": 0.92,
"reasoning": "Lead explicitly requested a meeting and showed strong buying intent.",
"messagePreview": "Yes, let's definitely connect. Can you send a calendar link?",
"lead": {
"id": "cm3lead456jkl",
"firstName": "Sarah",
"lastName": "Chen",
"fullName": "Sarah Chen",
"headline": "CEO at TechStartup",
"jobTitle": "CEO",
"company": "TechStartup Inc.",
"email": "sarah@techstartup.com",
"phone": null,
"location": "San Francisco, CA",
"linkedinUrl": "https://linkedin.com/in/sarahchen",
"linkedinPublicId": "sarahchen",
"photoUrl": "https://media.licdn.com/.../sarah.jpg",
"connectionStatus": "CONNECTED",
"source": "SEARCH"
},
"accountId": "cm3acc123mno",
"accountName": "John Smith",
"message": {
"text": "Yes, let's definitely connect. Can you send a calendar link?",
"chatId": "chat_abc123def456",
"receivedAt": "2026-08-20T14:32:00.000Z"
}
},
"timestamp": "2026-08-20T14:32:02.456Z",
"webhookId": "cm3wh789pqr"
}// When AI detects an interested reply
app.post('/webhook', (req, res) => {
const { event, data } = req.body;
if (event === 'lead.interested') {
const { lead, message, confidence, reasoning, campaignName } = data;
console.log(`${lead.fullName} looks interested (${Math.round(confidence * 100)}% confident)`);
console.log(`Reply: ${message.text}`);
console.log(`AI reasoning: ${reasoning}`);
// Example: Alert sales immediately
await slack.chat.postMessage({
channel: '#hot-leads',
text: `🔥 ${lead.fullName} (${lead.company}) is interested in "${campaignName}"!\n> ${message.text}`,
});
// Example: Create a deal in your CRM
await crm.deals.create({
contact: lead.fullName,
company: lead.company,
stage: 'interested',
notes: reasoning,
});
}
res.status(200).json({ received: true });
});meeting.bookedMeetingsFired when a lead books a meeting through a Weezly booking link shared from the inbox. The link carries a per-conversation tracking token, so the booking attributes to the exact sender and conversation even when several senders share the same booking page. Includes the scheduled meeting time and who booked.
{
"event": "meeting.booked",
"data": {
"contactName": "Sarah Chen",
"inviteeName": "Sarah Chen",
"inviteeEmail": "sarah@techstartup.com",
"meetingName": "45 min Product Demo",
"meetingTime": "2026-08-26T14:00:00.000Z",
"duration": "45 mins",
"bookedAt": "2026-08-23T09:12:00.000Z",
"accountName": "John Smith",
"accountId": "cm3acc123mno",
"chatId": "chat_abc123def456"
},
"timestamp": "2026-08-23T09:12:01.234Z",
"webhookId": "cm3wh789pqr"
}app.post('/webhook', (req, res) => {
const { contactName, inviteeName, inviteeEmail, meetingName } = req.body.data;
console.log('Event received:', req.body.event);
res.status(200).json({ received: true });
});connection.acceptedCampaignsFired when a lead accepts your LinkedIn connection request. Includes the lead's profile data and the campaign context.
{
"event": "connection.accepted",
"data": {
"campaignId": "cm3abc123def456",
"campaignName": "Q1 SaaS Founders Outreach",
"executionId": "cm3exec789ghi",
"leadId": "cm3lead456jkl",
"leadName": "Marcus Johnson",
"lead": {
"id": "cm3lead456jkl",
"firstName": "Marcus",
"lastName": "Johnson",
"fullName": "Marcus Johnson",
"headline": "VP Engineering at ScaleUp",
"jobTitle": "VP Engineering",
"company": "ScaleUp Solutions",
"email": "marcus@scaleup.com",
"phone": null,
"location": "Austin, TX",
"linkedinUrl": "https://linkedin.com/in/marcusjohnson",
"linkedinPublicId": "marcusjohnson",
"photoUrl": "https://media.licdn.com/.../marcus.jpg",
"connectionStatus": "CONNECTED",
"source": "SEARCH"
},
"accountId": "cm3acc123mno",
"accountName": "John Smith",
"acceptedAt": "2026-08-20T09:15:00.000Z"
},
"timestamp": "2026-08-20T09:15:02.456Z",
"webhookId": "cm3wh789pqr"
}// When a lead accepts your connection request
app.post('/webhook', (req, res) => {
const { event, data } = req.body;
if (event === 'connection.accepted') {
const { lead, campaignName, accountName } = data;
console.log(`${lead.fullName} accepted connection from ${accountName}`);
// Example: Add to CRM pipeline
await crm.deals.create({
contact: lead.fullName,
company: lead.company,
source: `LinkedIn - ${campaignName}`,
stage: 'connected',
});
}
res.status(200).json({ received: true });
});campaign.completedCampaignsFired when all leads in a campaign have reached a terminal state (completed, replied, or failed). Includes final lead counts.
{
"event": "campaign.completed",
"data": {
"campaignName": "Q1 SaaS Founders Outreach",
"totalLeads": 150,
"leadsCompleted": 120,
"leadsReplied": 18
},
"timestamp": "2026-08-20T16:00:05.789Z",
"webhookId": "cm3wh789pqr"
}// When a campaign finishes all leads
app.post('/webhook', (req, res) => {
const { event, data } = req.body;
if (event === 'campaign.completed') {
const { campaignName, totalLeads, leadsCompleted, leadsReplied } = data;
console.log(`Campaign "${campaignName}" completed!`);
console.log(`Results: ${leadsReplied} replies from ${totalLeads} leads`);
// Example: Send summary email
await email.send({
to: 'team@company.com',
subject: `Campaign "${campaignName}" finished: ${leadsReplied} replies`,
body: `Completed ${leadsCompleted} of ${totalLeads} leads with ${leadsReplied} replies.`,
});
}
res.status(200).json({ received: true });
});campaign.launchedCampaignsFired when a campaign is launched and begins execution. Includes the total number of enrolled leads and the sender count.
{
"event": "campaign.launched",
"data": {
"campaignName": "Q1 SaaS Founders Outreach",
"totalLeads": 150,
"senderCount": 3
},
"timestamp": "2026-08-01T08:00:01.234Z",
"webhookId": "cm3wh789pqr"
}// When a campaign starts executing
app.post('/webhook', (req, res) => {
const { event, data } = req.body;
if (event === 'campaign.launched') {
const { campaignName, totalLeads, senderCount } = data;
console.log(`Campaign "${campaignName}" launched!`);
console.log(`${totalLeads} leads across ${senderCount} senders`);
}
res.status(200).json({ received: true });
});campaign.step.completedCampaignsFired each time a campaign step is successfully executed for a lead. High-volume event, fires once per lead per step.
{
"event": "campaign.step.completed",
"data": {
"campaignId": "cm3abc123def456",
"campaignName": "Q1 SaaS Founders Outreach",
"executionId": "cm3exec789ghi",
"leadId": "cm3lead456jkl",
"leadName": "Sarah Chen",
"stepType": "CONNECTION_REQUEST",
"lead": {
"id": "cm3lead456jkl",
"firstName": "Sarah",
"lastName": "Chen",
"fullName": "Sarah Chen",
"headline": "CEO at TechStartup",
"jobTitle": "CEO",
"company": "TechStartup Inc.",
"email": "sarah@techstartup.com",
"phone": null,
"location": "San Francisco, CA",
"linkedinUrl": "https://linkedin.com/in/sarahchen",
"linkedinPublicId": "sarahchen",
"photoUrl": "https://media.licdn.com/.../sarah.jpg",
"connectionStatus": "PENDING",
"source": "SEARCH"
},
"step": {
"type": "CONNECTION_REQUEST",
"order": 1,
"messageText": "Hi Sarah, I noticed we're both in the SaaS space..."
},
"accountId": "cm3acc123mno",
"accountName": "John Smith",
"executedAt": "2026-08-02T10:30:00.000Z"
},
"timestamp": "2026-08-02T10:30:01.567Z",
"webhookId": "cm3wh789pqr"
}// When a step executes for a lead
app.post('/webhook', (req, res) => {
const { event, data } = req.body;
if (event === 'campaign.step.completed') {
const { lead, step, campaignName } = data;
console.log(`${step.type} completed for ${lead.fullName}`);
// Example: Log to analytics
await analytics.track('outreach_step', {
campaign: campaignName,
stepType: step.type,
lead: lead.fullName,
});
}
res.status(200).json({ received: true });
});campaign.step.failedCampaignsFired when a campaign step fails for a lead after all retry attempts. Includes error details for debugging.
{
"event": "campaign.step.failed",
"data": {
"campaignId": "cm3abc123def456",
"campaignName": "Q1 SaaS Founders Outreach",
"executionId": "cm3exec789ghi",
"leadId": "cm3lead456jkl",
"leadName": "Alex Rivera",
"stepType": "MESSAGE",
"lead": {
"id": "cm3lead456jkl",
"firstName": "Alex",
"lastName": "Rivera",
"fullName": "Alex Rivera",
"headline": "Founder at StartupCo",
"jobTitle": "Founder",
"company": "StartupCo",
"email": null,
"phone": null,
"location": "Denver, CO",
"linkedinUrl": "https://linkedin.com/in/alexrivera",
"linkedinPublicId": "alexrivera",
"photoUrl": null,
"connectionStatus": "CONNECTED",
"source": "CSV"
},
"step": {
"type": "MESSAGE",
"order": 3
},
"error": "Rate limit exceeded, account paused for 2 hours",
"accountId": "cm3acc123mno",
"accountName": "John Smith",
"failedAt": "2026-08-03T14:20:00.000Z"
},
"timestamp": "2026-08-03T14:20:01.890Z",
"webhookId": "cm3wh789pqr"
}// When a step fails for a lead
app.post('/webhook', (req, res) => {
const { event, data } = req.body;
if (event === 'campaign.step.failed') {
const { lead, step, error, campaignName } = data;
console.error(`Step ${step.type} failed for ${lead.fullName}: ${error}`);
// Example: Alert on repeated failures
await pagerduty.createIncident({
title: `Campaign step failed: ${campaignName}`,
body: `${step.type} failed for ${lead.fullName}: ${error}`,
});
}
res.status(200).json({ received: true });
});account.status_changedAccountsFired when a LinkedIn account status changes (paused due to rate limiting, warned by LinkedIn, or restored to active).
{
"event": "account.status_changed",
"data": {
"accountName": "John Smith",
"previousStatus": "ACTIVE",
"newStatus": "WARNED",
"reason": "Unusual activity detected by LinkedIn"
},
"timestamp": "2026-08-04T11:00:02.345Z",
"webhookId": "cm3wh789pqr"
}// When a LinkedIn account status changes
app.post('/webhook', (req, res) => {
const { event, data } = req.body;
if (event === 'account.status_changed') {
const { accountName, previousStatus, newStatus, reason } = data;
console.log(`Account "${accountName}": ${previousStatus} → ${newStatus}`);
if (newStatus === 'WARNED' || newStatus === 'PAUSED') {
// Example: Send urgent Slack alert
await slack.chat.postMessage({
channel: '#linkedin-alerts',
text: `⚠️ Account "${accountName}" is now ${newStatus}!\nReason: ${reason}`,
});
}
}
res.status(200).json({ received: true });
});lead.createdLeadsFired when a brand-new lead record is created in your account. Fires exactly once per lead; the source field tells you how the lead was created.
{
"event": "lead.created",
"data": {
"leadId": "cm3lead456jkl",
"leadName": "Emily Zhang",
"lead": {
"id": "cm3lead456jkl",
"firstName": "Emily",
"lastName": "Zhang",
"fullName": "Emily Zhang",
"headline": "Head of Growth at Acme Corp",
"jobTitle": "Head of Growth",
"company": "Acme Corp",
"email": "emily@acme.com",
"phone": null,
"location": "San Francisco, CA",
"linkedinUrl": "https://linkedin.com/in/emilyzhang",
"linkedinPublicId": "emilyzhang",
"photoUrl": "https://media.licdn.com/.../emily.jpg",
"connectionStatus": null,
"source": "ENRICHMENT"
},
"source": "ENRICHMENT"
},
"timestamp": "2026-08-05T08:45:00.123Z",
"webhookId": "cm3wh789pqr"
}// When a new lead is created
app.post('/webhook', (req, res) => {
const { event, data } = req.body;
if (event === 'lead.created') {
const { lead, source } = data;
console.log(`New lead: ${lead.fullName} created via ${source}`);
// Example: Enrich lead data
if (lead.email) {
await enrichment.lookup(lead.email);
}
// Example: Sync to CRM
await crm.contacts.create({
name: lead.fullName,
company: lead.company,
email: lead.email,
linkedinUrl: lead.linkedinUrl,
source: `Outreach - ${source}`,
});
}
res.status(200).json({ received: true });
});Using with Zapier, Make, or n8n
These automation platforms can receive webhooks directly. Simply use their webhook trigger URL as your endpoint:
Zapier
Create a new Zap → Choose "Webhooks by Zapier" as trigger → Select "Catch Hook" → Copy the webhook URL → Paste it in Settings → API → Webhooks.
Make (Integromat)
Create a new scenario → Add "Webhooks" module → Select "Custom webhook" → Copy the URL → Paste it in Settings → API → Webhooks.
n8n
Add a "Webhook" trigger node → Set method to POST → Copy the production URL → Paste it in Settings → API → Webhooks.
Delivery Behavior
| Timeout | 10 seconds per delivery attempt |
| Success codes | Any 2xx status code (200, 201, 202, 204) |
| Auto-disable | After 10 consecutive failures, webhook is disabled |
| Delivery order | Best-effort chronological, not guaranteed |
| Idempotency | Use webhookId + timestamp to deduplicate |