Add Webhook
curl --request POST \
--url https://api.lemlist.com/api/hooks \
--header 'Authorization: Basic <encoded-value>' \
--header 'Content-Type: application/json' \
--data '
{
"targetUrl": "https://webhook.site/00000000-0000-0000-0000-000000000000"
}
'import requests
url = "https://api.lemlist.com/api/hooks"
payload = { "targetUrl": "https://webhook.site/00000000-0000-0000-0000-000000000000" }
headers = {
"Authorization": "Basic <encoded-value>",
"Content-Type": "application/json"
}
response = requests.post(url, json=payload, headers=headers)
print(response.text)const options = {
method: 'POST',
headers: {Authorization: 'Basic <encoded-value>', 'Content-Type': 'application/json'},
body: JSON.stringify({targetUrl: 'https://webhook.site/00000000-0000-0000-0000-000000000000'})
};
fetch('https://api.lemlist.com/api/hooks', options)
.then(res => res.json())
.then(res => console.log(res))
.catch(err => console.error(err));require 'uri'
require 'net/http'
url = URI("https://api.lemlist.com/api/hooks")
http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true
request = Net::HTTP::Post.new(url)
request["Authorization"] = 'Basic <encoded-value>'
request["Content-Type"] = 'application/json'
request.body = "{\n \"targetUrl\": \"https://webhook.site/00000000-0000-0000-0000-000000000000\"\n}"
response = http.request(request)
puts response.read_body<?php
$curl = curl_init();
curl_setopt_array($curl, [
CURLOPT_URL => "https://api.lemlist.com/api/hooks",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_ENCODING => "",
CURLOPT_MAXREDIRS => 10,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_CUSTOMREQUEST => "POST",
CURLOPT_POSTFIELDS => json_encode([
'targetUrl' => 'https://webhook.site/00000000-0000-0000-0000-000000000000'
]),
CURLOPT_HTTPHEADER => [
"Authorization: Basic <encoded-value>",
"Content-Type: application/json"
],
]);
$response = curl_exec($curl);
$err = curl_error($curl);
curl_close($curl);
if ($err) {
echo "cURL Error #:" . $err;
} else {
echo $response;
}{
"_id": "hoo_GvYkkWIcdPf2WsBDY",
"targetUrl": "https://n8n.example.com/webhook/lemlist-webhook-surprise",
"createdAt": "2025-10-29T00:47:42.035Z",
"type": "linkedinInterested"
}
Popular Resources
Add Webhook
Creates a webhook that receives real-time POST callbacks for selected events.
POST
/
hooks
Add Webhook
curl --request POST \
--url https://api.lemlist.com/api/hooks \
--header 'Authorization: Basic <encoded-value>' \
--header 'Content-Type: application/json' \
--data '
{
"targetUrl": "https://webhook.site/00000000-0000-0000-0000-000000000000"
}
'import requests
url = "https://api.lemlist.com/api/hooks"
payload = { "targetUrl": "https://webhook.site/00000000-0000-0000-0000-000000000000" }
headers = {
"Authorization": "Basic <encoded-value>",
"Content-Type": "application/json"
}
response = requests.post(url, json=payload, headers=headers)
print(response.text)const options = {
method: 'POST',
headers: {Authorization: 'Basic <encoded-value>', 'Content-Type': 'application/json'},
body: JSON.stringify({targetUrl: 'https://webhook.site/00000000-0000-0000-0000-000000000000'})
};
fetch('https://api.lemlist.com/api/hooks', options)
.then(res => res.json())
.then(res => console.log(res))
.catch(err => console.error(err));require 'uri'
require 'net/http'
url = URI("https://api.lemlist.com/api/hooks")
http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true
request = Net::HTTP::Post.new(url)
request["Authorization"] = 'Basic <encoded-value>'
request["Content-Type"] = 'application/json'
request.body = "{\n \"targetUrl\": \"https://webhook.site/00000000-0000-0000-0000-000000000000\"\n}"
response = http.request(request)
puts response.read_body<?php
$curl = curl_init();
curl_setopt_array($curl, [
CURLOPT_URL => "https://api.lemlist.com/api/hooks",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_ENCODING => "",
CURLOPT_MAXREDIRS => 10,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_CUSTOMREQUEST => "POST",
CURLOPT_POSTFIELDS => json_encode([
'targetUrl' => 'https://webhook.site/00000000-0000-0000-0000-000000000000'
]),
CURLOPT_HTTPHEADER => [
"Authorization: Basic <encoded-value>",
"Content-Type: application/json"
],
]);
$response = curl_exec($curl);
$err = curl_error($curl);
curl_close($curl);
if ($err) {
echo "cURL Error #:" . $err;
} else {
echo $response;
}{
"_id": "hoo_GvYkkWIcdPf2WsBDY",
"targetUrl": "https://n8n.example.com/webhook/lemlist-webhook-surprise",
"createdAt": "2025-10-29T00:47:42.035Z",
"type": "linkedinInterested"
}
Limits and conflicts
A workspace holds at most 200 webhooks, and atargetUrl can only be registered once. Both limits answer 409, with a JSON body whose error field tells them apart:
error | Cause | What to do |
|---|---|---|
Too many webhooks (max 200 per team, disabled webhooks included) | The workspace is at the cap. | Free a slot with Delete Webhook. Disabled webhooks count too — see below. |
A webhook with this targetUrl already exists | Another webhook already points to that URL. | Reuse it, or register a distinct URL. |
Uniqueness is on
targetUrl alone, regardless of type and campaignId. The
same URL cannot be registered twice to subscribe to two event types — either
register it once without a type (it then receives every event, and you filter
on your side), or give each subscription its own URL.Disabled webhooks still count toward the cap
When a delivery fails in a way that says the destination is gone — your endpoint answers404 or 410, or its host does not resolve — lemlist disables the webhook instead of deleting it, and notifies its owner. Get Many Webhooks keeps returning it, carrying disabled: true, disabledReason: "deliveryError", disabledAt and lastErrorStatus.
A disabled webhook stops firing but keeps its slot, and nothing purges it. A workspace showing a handful of live webhooks can therefore be refused a new one. When you check the remaining room, count every entry GET /hooks returns, not just the active ones, and delete those you no longer need.
Available event types
When creating a webhook, you can subscribe to specific events using thetype field. Events are organized by category below.
Lead state groups
These aggregate multiple activity types into a single lead-state change event.| Event | Description |
|---|---|
contacted | Lead was contacted (email sent, LinkedIn message, API call, etc.) |
hooked | Lead opened a message (email, LinkedIn, WhatsApp) |
attracted | Lead clicked a link or accepted a LinkedIn invite |
warmed | Lead replied (email, LinkedIn, WhatsApp, SMS) |
interested | Lead marked as interested |
notInterested | Lead marked as not interested |
Email activities
| Event | Description |
|---|---|
emailsSent | Email sent |
emailsOpened | Email opened |
emailsClicked | Link clicked in email |
emailsReplied | Email replied |
emailsBounced | Email bounced |
emailsFailed | Email failed to send |
emailsInterested | Lead marked as interested via email |
emailsNotInterested | Lead marked as not interested via email |
Unsubscribe activities
| Event | Description |
|---|---|
entityUnsubscribed | Contact unsubscribed from all communications — e.g. clicked the unsubscribe link or was marked as do-not-contact |
variableUnsubscribed | A specific contact variable (email address, phone number, LinkedIn profile) was unsubscribed |
emailsUnsubscribed | Lead stopped because their email or domain is on the unsubscribe list. Also matches apiUnsubscribed and manualUnsubscribed activities |
Since March 30, 2026, a contact unsubscribing emits
entityUnsubscribed (whole
contact) or variableUnsubscribed (single variable) instead of
emailsUnsubscribed. If your integration listens to emailsUnsubscribed,
also subscribe to entityUnsubscribed and variableUnsubscribed to keep
receiving unsubscribe events.LinkedIn activities
| Event | Description |
|---|---|
linkedinSent | LinkedIn message sent |
linkedinOpened | LinkedIn message opened |
linkedinReplied | LinkedIn message replied |
linkedinInterested | Lead marked as interested via LinkedIn |
linkedinNotInterested | Lead marked as not interested via LinkedIn |
linkedinSendFailed | LinkedIn message failed to send |
linkedinVisitDone | LinkedIn profile visit completed |
linkedinVisitFailed | LinkedIn profile visit failed |
linkedinFollowDone | LinkedIn follow completed |
linkedinFollowFailed | LinkedIn follow failed |
linkedinFollowSkipped | LinkedIn follow skipped |
linkedinInviteDone | LinkedIn invite sent |
linkedinInviteFailed | LinkedIn invite failed |
linkedinInviteAccepted | LinkedIn invite accepted |
linkedinEndorseDone | LinkedIn endorsement completed |
linkedinEndorseFailed | LinkedIn endorsement failed |
linkedinEndorseSkipped | LinkedIn endorsement skipped |
linkedinVoiceNoteDone | LinkedIn voice note sent |
linkedinVoiceNoteFailed | LinkedIn voice note failed |
linkedinLikeLastPostDone | LinkedIn like last post completed |
linkedinLikeLastPostNoPost | LinkedIn like last post — no post found |
linkedinLikeLastPostFailed | LinkedIn like last post failed |
linkedinWithdrawInvitationDone | LinkedIn invite withdrawn |
linkedinWithdrawInvitationFailed | LinkedIn invite withdrawal failed |
WhatsApp activities
| Event | Description |
|---|---|
whatsappMessageSent | WhatsApp message sent |
whatsappMessageDelivered | WhatsApp message delivered |
whatsappMessageOpened | WhatsApp message opened |
whatsappReplied | WhatsApp message replied |
whatsappMessageFailed | WhatsApp message failed |
SMS activities
| Event | Description |
|---|---|
smsSent | SMS sent |
smsDelivered | SMS delivered |
smsReplied | SMS replied |
smsFailed | SMS failed |
Call activities
| Event | Description |
|---|---|
aircallCreated | Call created |
aircallEnded | Call ended |
aircallDone | Call task completed |
aircallInterested | Lead marked as interested via call |
aircallNotInterested | Lead marked as not interested via call |
callRecordingDone | Call recording ready |
callTranscriptDone | Call transcript ready |
API activities
| Event | Description |
|---|---|
apiDone | API step executed |
apiInterested | Lead marked as interested via API |
apiNotInterested | Lead marked as not interested via API |
apiFailed | API step failed |
Manual activities
| Event | Description |
|---|---|
manualInterested | Lead marked as interested manually |
manualNotInterested | Lead marked as not interested manually |
Task activities
| Event | Description |
|---|---|
annotated | Lead annotated |
Workspace activities
| Event | Description |
|---|---|
paused | Lead paused |
resumed | Lead resumed |
stopped | Lead stopped |
campaignComplete | Campaign completed |
customDomainErrors | Custom domain error detected |
connectionIssue | Email account connection issue |
sendLimitReached | Daily send limit reached |
lemwarmPaused | Lemwarm paused |
Enrichment
| Event | Description |
|---|---|
enrichmentDone | Enrichment completed |
enrichmentError | Enrichment failed |
Contact sourcing
| Event | Description |
|---|---|
contactSourcingDone | A contact-sourcing run finished; the payload carries the buying committee it found |
contactSourcingFailed | A contact-sourcing run failed; the payload carries the reason |
data key.
Subscribe to
contactSourcingFailed as well as contactSourcingDone: a run
that fails never emits the done event, so an integration listening only for
the latter waits for a result that is not coming.Inbox activities
| Event | Description |
|---|---|
inboxLabelUpdated | Inbox conversation label changed |
Signal Agents
| Event | Description |
|---|---|
signalRegistered | New signal detected by a Signal Agent |
Deliverability hub
| Event | Description |
|---|---|
deliverabilityAlertTriggered | Deliverability alert triggered |
Deprecated events
These events have been removed from the API. Use the replacements below.| Removed event | Replacement |
|---|---|
skipped | No direct replacement |
emailsSendFailed | Use emailsFailed instead |
opportunitiesDone | Use annotated or task-level webhooks instead |
Verifying webhook authenticity
You can pass an optionalsecret in the request body when creating the webhook. It works like a shared password between lemlist and your endpoint:
- Stored encrypted at rest.
- Never returned by
GET /hooksor any other endpoint. - Immutable — it cannot be changed after creation. To rotate it, delete the webhook and create a new one.
- Sent back to your endpoint as a
secretfield in the JSON body of every webhook call, so you can verify the request originated from lemlist.
Webhook payload
Each event is delivered as aPOST to your targetUrl with a JSON body that mirrors the underlying activity record. Common fields:
| Field | Type | Description |
|---|---|---|
_id | string | Unique activity identifier (act_…) |
type | string | Event type (e.g. emailsSent, emailsReplied) |
teamId | string | Team the activity belongs to |
createdAt | ISO date | When the activity happened |
campaignId | string | Source campaign (absent for non-campaign events) |
campaignName | string | Campaign display name |
sequenceId | string | Sequence the task belonged to |
sequenceStep | number | 0-based step index in the sequence |
stepId | string | Stable identifier of the step (survives a reorder — see note below) |
leadId | string | Lead targeted by the activity |
leadEmail, leadFirstName, leadLastName | string | Denormalized lead identity |
sendUserId, sendUserEmail, sendUserName | string | Sender (lemlist user) identity |
subject | string | Email subject (email events only) |
secret | string | Echoed back if a secret was set on the webhook |
stepId identifies the step itself, while sequenceStep is a position that
shifts when a sequence’s steps are reordered — prefer stepId when you store a
reference. Both are sent: sequenceStep is unchanged. stepId is present on
every activity created from now on, and older activities gain it progressively
as historical records are backfilled.Email recipients (to / cc / bcc)
Email events (emailsSent, emailsReplied, emailsBounced) carry the full recipient lists as arrays of {address, name}. Addresses are lowercased. Fields are omitted when empty.
{
"type": "emailsSent",
"to": [{ "address": "lead@example.com", "name": "Alice Doe" }],
"cc": [{ "address": "manager@example.com", "name": "" }],
"bcc": [{ "address": "archive@crm.io", "name": "" }]
}
to— primary recipient(s). For outbound sends, this is the lead. For inbound replies, this is the mailbox owner; multi-recipienttolines (e.g. reply-all where the lead manually added people) are preserved.cc— explicit CC recipients on the email.bcc— only present for outboundemailsSentevents when the sender has a hidden BCC configured in Settings → Integrations (users.lemlist.bcc). Inbound BCC is never visible because SMTP strips it for non-BCC’d recipients.
Third-party reply flag
When a reply on a thread comes from an address that does not match the original lead’s known contact emails, lemlist attributes the reply to the third-party sender rather than to the lead, and the event carries an extra flag:{
"type": "emailsReplied",
"isThirdPartyReply": true
}
campaignId, leadId, and sequenceId (since the reply is no longer attached to the original campaign flow). Use the flag to route these events differently if needed.
Examples
{
"targetUrl": "https://webhook.site/your-id"
}
{
"targetUrl": "https://webhook.site/your-id",
"type": "linkedinInterested",
"campaignId": "cam_A1B2C3D4E5F6G7H8I9",
"isFirst": true
}
{
"targetUrl": "https://webhook.site/your-id",
"type": "whatsappReplied"
}
{
"targetUrl": "https://webhook.site/your-id",
"type": "signalRegistered"
```json with shared secret
{
"targetUrl": "https://webhook.site/your-id",
"secret": "s3cret-shared-with-my-endpoint"
}
Tip: You can include an optional
zapId if you use Zapier to track the webhook mapping on your side.Authorizations
Basic authentication header of the form Basic <encoded-value>, where <encoded-value> is the base64-encoded string username:password.
Query Parameters
Webhook for specific campaign
Webhook for first activity only
Zapier ID
Body
application/json
The URL that will receive webhook POST requests.
Optional event type to subscribe to. When omitted, all events are sent. See the full categorized list in the endpoint documentation.
Available options:
contacted, hooked, attracted, warmed, interested, notInterested, emailsSent, emailsOpened, emailsClicked, emailsReplied, emailsBounced, emailsFailed, emailsInterested, emailsNotInterested, emailsUnsubscribed, entityUnsubscribed, variableUnsubscribed, linkedinSent, linkedinOpened, linkedinReplied, linkedinInterested, linkedinNotInterested, linkedinSendFailed, linkedinVisitDone, linkedinVisitFailed, linkedinFollowDone, linkedinFollowFailed, linkedinFollowSkipped, linkedinInviteDone, linkedinInviteFailed, linkedinInviteAccepted, linkedinEndorseDone, linkedinEndorseFailed, linkedinEndorseSkipped, linkedinVoiceNoteDone, linkedinVoiceNoteFailed, linkedinLikeLastPostDone, linkedinLikeLastPostNoPost, linkedinLikeLastPostFailed, linkedinWithdrawInvitationDone, linkedinWithdrawInvitationFailed, whatsappMessageSent, whatsappMessageDelivered, whatsappMessageOpened, whatsappReplied, whatsappMessageFailed, smsSent, smsDelivered, smsReplied, smsFailed, aircallCreated, aircallEnded, aircallDone, aircallInterested, aircallNotInterested, apiDone, apiInterested, apiNotInterested, apiFailed, manualInterested, manualNotInterested, paused, resumed, stopped, campaignComplete, customDomainErrors, connectionIssue, sendLimitReached, lemwarmPaused, annotated, enrichmentDone, enrichmentError, callRecordingDone, callTranscriptDone, inboxLabelUpdated, signalRegistered, deliverabilityAlertTriggered, contactSourcingDone, contactSourcingFailed Optional shared secret. Stored encrypted, never returned by GET, and immutable once set. Sent back to your endpoint as a secret field in the body of every webhook call so you can verify the request originated from lemlist.
Was this page helpful?