Skip to main content

Overview

Webhooks let SmartLead push real-time event notifications to your server whenever something happens in a campaign — a lead replies, an email bounces, someone unsubscribes, or a message is sent. Instead of polling the API for updates, you receive instant HTTP POST requests to your endpoint. This guide covers how to set up webhooks, handle events, verify payloads, and integrate with common tools like CRMs and Slack.

Setting Up a Webhook

Register a webhook URL using the Create Webhook endpoint:
curl -X POST "https://server.smartlead.ai/api/v1/webhook/create?api_key=YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Campaign Notifications",
    "webhook_url": "https://yourapp.com/webhooks/smartlead",
    "association_type": "campaign",
    "email_campaign_id": 123,
    "event_type_map": {
      "EMAIL_SENT": true,
      "EMAIL_OPEN": true,
      "EMAIL_REPLY": true,
      "EMAIL_BOUNCE": true,
      "LEAD_UNSUBSCRIBED": true,
      "LEAD_CATEGORY_UPDATED": true
    }
  }'

Webhook Levels

SmartLead supports three webhook scopes, set via the association_type field:
Levelassociation_typeScopeUse Case
User"user"All campaigns owned by the userCentralized notifications
Client"client"All campaigns for a specific clientAgency/white-label setups
Campaign"campaign"A single campaignPer-campaign tracking
If a User-level webhook exists, it takes priority over Client and Campaign-level webhooks for the same event type. Keep this in mind when configuring webhooks at multiple levels.

Event Types

EventDescriptionWhen It Fires
EMAIL_SENTEmail was sent to a leadEach sequence step send
FIRST_EMAIL_SENTFirst email of a sequence sentOnly sequence step 1
EMAIL_OPENLead opened an emailFirst open detected
EMAIL_LINK_CLICKLead clicked a tracked linkEach unique link click
EMAIL_REPLYLead replied to an emailEach reply received
EMAIL_BOUNCEEmail bouncedSoft or hard bounce
LEAD_UNSUBSCRIBEDLead clicked unsubscribeUnsubscribe action
LEAD_CATEGORY_UPDATEDLead’s category changedManual or auto-categorization
CAMPAIGN_STATUS_CHANGEDCampaign status changedStart, pause, complete, etc.
UNTRACKED_REPLIESUntracked reply receivedReply from unknown sender
MANUAL_STEP_REACHEDLead reached a manual stepPhone call, LinkedIn step, etc.
Start with EMAIL_REPLY, EMAIL_BOUNCE, and LEAD_UNSUBSCRIBED — these are the events that typically require action in your CRM. Add others as needed.

Webhook Payload Format

Every webhook sends a JSON POST request. The payload is a flat JSON object with an event_type field identifying the event. The remaining fields vary by event type.

EMAIL_SENT / FIRST_EMAIL_SENT

{
  "event_type": "EMAIL_SENT",
  "from_email": "sender@yourcompany.com",
  "to_email": "lead@example.com",
  "to_name": "John Doe",
  "time_sent": "2025-01-15T09:00:00Z",
  "campaign_name": "Q1 SaaS Outreach",
  "campaign_id": 123,
  "sequence_number": 1,
  "custom_subject": "Quick question about Acme Corp",
  "custom_email_message": "<html>Email body...</html>",
  "message_id": "abc123def456"
}

EMAIL_REPLY

{
  "event_type": "EMAIL_REPLY",
  "from_email": "sender@yourcompany.com",
  "subject": "Re: Quick question about Acme Corp",
  "to_email": "lead@example.com",
  "to_name": "John Doe",
  "time_replied": "2025-01-15T11:00:00Z",
  "reply_body": "<html>Thanks for reaching out. Let's set up a call.</html>",
  "preview_text": "Thanks for reaching out. Let's set up a call.",
  "campaign_name": "Q1 SaaS Outreach",
  "campaign_id": 123,
  "client_id": 456,
  "sequence_number": 1
}

EMAIL_OPEN

{
  "event_type": "EMAIL_OPEN",
  "from_email": "sender@yourcompany.com",
  "to_email": "lead@example.com",
  "to_name": "John Doe",
  "time_opened": "2025-01-15T10:30:00Z",
  "campaign_name": "Q1 SaaS Outreach",
  "campaign_id": 123,
  "sequence_number": 1
}
{
  "event_type": "EMAIL_LINK_CLICK",
  "from_email": "sender@yourcompany.com",
  "to_email": "lead@example.com",
  "to_name": "John Doe",
  "time_clicked": "2025-01-15T10:45:00Z",
  "link_clicked": ["https://example.com/demo"],
  "campaign_name": "Q1 SaaS Outreach",
  "campaign_id": 123,
  "sequence_number": 1
}

LEAD_UNSUBSCRIBED

{
  "event_type": "LEAD_UNSUBSCRIBED",
  "lead_email": "lead@example.com",
  "lead_name": "John Doe",
  "campaign_name": "Q1 SaaS Outreach",
  "campaign_id": 123,
  "unsubscribed_client_id_map": {}
}

LEAD_CATEGORY_UPDATED

{
  "event_type": "LEAD_CATEGORY_UPDATED",
  "lead_id": 789,
  "lead_email": "lead@example.com",
  "lead_name": "John",
  "lead_data": {
    "email": "lead@example.com",
    "first_name": "John",
    "last_name": "Doe",
    "company_name": "Acme Corp",
    "custom_fields": {},
    "category": {
      "name": "Interested",
      "sentiment_type": "positive"
    }
  },
  "category": "Interested",
  "lead_category_id": 5,
  "campaign_name": "Q1 SaaS Outreach",
  "campaign_id": 123,
  "from": "sender@yourcompany.com",
  "to": "lead@example.com",
  "history": [
    {"type": "SENT", "time": "2025-01-15T09:00:00Z", "email_body": "...", "subject": "Quick question"},
    {"type": "REPLY", "time": "2025-01-15T11:00:00Z", "email_body": "Thanks for reaching out..."}
  ],
  "lastReply": {
    "type": "REPLY",
    "time": "2025-01-15T11:00:00Z",
    "email_body": "Thanks for reaching out..."
  }
}
The LEAD_CATEGORY_UPDATED payload includes the full conversation history between the sending account and the lead, including all sent emails, replies, and threaded replies.
For complete payload details on all event types, see the Webhook Events Reference.

Handling Webhooks

Node.js / Express

const express = require('express');
const app = express();

app.use(express.json());

app.post('/webhooks/smartlead', (req, res) => {
  const event = req.body;

  // Acknowledge receipt immediately
  res.status(200).json({ received: true });

  // Process asynchronously
  switch (event.event_type) {
    case 'EMAIL_REPLY':
      console.log(`Reply from ${event.to_email}: ${event.preview_text}`);
      // Update CRM, notify sales team, etc.
      break;

    case 'EMAIL_BOUNCE':
      console.log(`Bounce: ${event.to_email}`);
      // Remove from mailing lists, flag in CRM
      break;

    case 'LEAD_UNSUBSCRIBED':
      console.log(`Unsubscribed: ${event.lead_email}`);
      // Update suppression list
      break;

    case 'LEAD_CATEGORY_UPDATED':
      console.log(`${event.lead_email} categorized as ${event.category}`);
      // Sync category to CRM
      break;

    default:
      console.log(`Event: ${event.event_type}`);
  }
});

app.listen(3000, () => console.log('Webhook server running on port 3000'));

Python / Flask

from flask import Flask, request, jsonify

app = Flask(__name__)

@app.route('/webhooks/smartlead', methods=['POST'])
def handle_webhook():
    event = request.json

    if event['event_type'] == 'EMAIL_REPLY':
        print(f"Reply from {event['to_email']}")
        # Sync to CRM, create task, notify Slack

    elif event['event_type'] == 'EMAIL_BOUNCE':
        print(f"Bounce: {event.get('to_email')}")
        # Flag in database, update lead quality score

    elif event['event_type'] == 'LEAD_UNSUBSCRIBED':
        print(f"Unsubscribed: {event['lead_email']}")
        # Add to suppression list

    elif event['event_type'] == 'LEAD_CATEGORY_UPDATED':
        print(f"{event['lead_email']} -> {event['category']}")
        # Sync category to CRM

    return jsonify({'received': True}), 200

if __name__ == '__main__':
    app.run(port=3000)
Always return a 200 status code quickly. If your endpoint times out or returns an error, SmartLead will retry the webhook. Return 2xx for success, 4xx for permanent failures (no retry), 5xx for temporary failures (will retry).

Webhook Headers

SmartLead includes the following headers with webhook deliveries:
HeaderDescription
Content-Typeapplication/json
X-Smartlead-SignatureHMAC SHA256 signature for payload verification
X-Request-IdUnique identifier for each webhook delivery
X-Webhook-LevelWebhook scope: user, client, or campaign

Verifying Webhook Signatures

Use the X-Smartlead-Signature header and your signing secret to validate that webhook payloads are authentically from SmartLead:
Python
import hmac
import hashlib

def verify_webhook_signature(payload_body, signature_header, signing_secret):
    """Verify the webhook signature using HMAC SHA256."""
    expected_signature = 'sha256=' + hmac.new(
        signing_secret.encode('utf-8'),
        payload_body,
        hashlib.sha256
    ).hexdigest()

    return hmac.compare_digest(expected_signature, signature_header)

# In your Flask handler:
@app.route('/webhooks/smartlead', methods=['POST'])
def handle_webhook():
    signature = request.headers.get('X-Smartlead-Signature', '')
    request_id = request.headers.get('X-Request-Id', '')

    if not verify_webhook_signature(request.data, signature, SIGNING_SECRET):
        return jsonify({'error': 'Invalid signature'}), 401

    # Process event...
    return jsonify({'received': True}), 200

Implementing Idempotency

Use the X-Request-Id header to prevent processing duplicate webhook deliveries:
Python
processed_events = set()  # Use Redis or a database in production

@app.route('/webhooks/smartlead', methods=['POST'])
def handle_webhook():
    request_id = request.headers.get('X-Request-Id', '')

    if request_id in processed_events:
        return jsonify({'status': 'already_processed'}), 200

    processed_events.add(request_id)
    # Process event...
    return jsonify({'received': True}), 200

Category Filtering

For LEAD_CATEGORY_UPDATED events, you can filter which categories trigger the webhook using the category_id_map:
Python
payload = {
    "name": "Hot Lead Alerts",
    "webhook_url": "https://yourapp.com/webhooks/hot-leads",
    "association_type": "campaign",
    "email_campaign_id": 123,
    "event_type_map": {
        "LEAD_CATEGORY_UPDATED": True
    },
    "category_id_map": {
        "5": True,   # Interested
        "7": True    # Meeting Booked
    }
}

response = requests.post(
    "https://server.smartlead.ai/api/v1/webhook/create",
    params={"api_key": API_KEY},
    json=payload
)

CRM Integration Examples

Syncing Replies to HubSpot

Python
import requests
import os

HUBSPOT_API_KEY = os.getenv("HUBSPOT_API_KEY")

def sync_reply_to_hubspot(event):
    """Create a HubSpot engagement when a lead replies."""
    contact_response = requests.get(
        f"https://api.hubapi.com/contacts/v1/contact/email/{event['to_email']}/profile",
        headers={"Authorization": f"Bearer {HUBSPOT_API_KEY}"}
    )

    if contact_response.status_code == 200:
        contact_id = contact_response.json()["vid"]

        requests.post(
            "https://api.hubapi.com/engagements/v1/engagements",
            headers={
                "Authorization": f"Bearer {HUBSPOT_API_KEY}",
                "Content-Type": "application/json"
            },
            json={
                "engagement": {"type": "EMAIL"},
                "associations": {"contactIds": [contact_id]},
                "metadata": {
                    "subject": event.get("subject", ""),
                    "text": event.get("preview_text", "")
                }
            }
        )

Sending Slack Notifications

Python
import requests
import os

SLACK_WEBHOOK_URL = os.getenv("SLACK_WEBHOOK_URL")

def notify_slack(event):
    """Send a Slack message when a lead replies."""
    message = {
        "text": (
            f"*New reply from {event['to_name']}* ({event['to_email']})\n"
            f"Campaign: {event['campaign_name']}\n"
            f"Reply: _{event.get('preview_text', 'N/A')[:200]}_"
        )
    }
    requests.post(SLACK_WEBHOOK_URL, json=message)

Retry Logic

SmartLead retries failed webhook deliveries automatically:
AttemptDelayDescription
1st retry1 minuteFirst retry after initial failure
2nd retry5 minutesSecond retry
3rd retry30 minutesFinal retry
After 3 failed attempts, the event is marked as failed. Use the Retrigger Webhooks endpoint to manually retry failed deliveries, or view webhook statistics via the Webhook Summary endpoint. Response code behavior:
  • 2xx — Success, no retry
  • 4xx — Permanent failure, no retry
  • 5xx — Temporary failure, SmartLead will retry

Managing Webhooks

Get Webhook Details

Python
webhook_id = 456

response = requests.get(
    f"https://server.smartlead.ai/api/v1/webhook/{webhook_id}",
    params={"api_key": API_KEY}
)

webhook = response.json()
print(f"ID: {webhook['id']} | URL: {webhook['webhook_url']}")

Update a Webhook

Python
webhook_id = 456

response = requests.put(
    f"https://server.smartlead.ai/api/v1/webhook/update/{webhook_id}",
    params={"api_key": API_KEY},
    json={
        "event_type_map": {
            "EMAIL_REPLY": True,
            "EMAIL_BOUNCE": True,
            "LEAD_CATEGORY_UPDATED": True
        }
    }
)

Delete a Webhook

Python
webhook_id = 456

response = requests.delete(
    f"https://server.smartlead.ai/api/v1/webhook/delete/{webhook_id}",
    params={"api_key": API_KEY}
)

Troubleshooting

Verify your endpoint is publicly accessible (not localhost). Check that the webhook is active. Ensure your endpoint returns a 200 response within 30 seconds. Check server logs for incoming requests. Use a tool like webhook.site for testing.
SmartLead may retry if your server didn’t respond with 200 in time. Implement idempotency by checking the X-Request-Id header — skip events you’ve already processed.
Not all event types include all fields. For example, reply_body and preview_text are only present on EMAIL_REPLY events. The LEAD_UNSUBSCRIBED event uses lead_email instead of to_email. Always check for field existence before accessing.
If a User-level webhook exists for the same event type, it takes priority over Client and Campaign-level webhooks. Check your webhook configuration at all levels.

What’s Next?

Webhook Events Reference

See all event types with complete payload structures

Create Webhook API

API reference for creating webhooks

Webhook Summary

Monitor webhook delivery statistics

Retrigger Webhooks

Manually retry failed webhook deliveries