Outgoing Webhooks
Outgoing webhooks allow you to receive real-time HTTP notifications when events occur in your workspace. When an event fires, CampaignLark sends a POST request to your configured endpoint with a JSON payload describing the event.
Outgoing webhooks are ideal for:
- Syncing contact data to external CRMs
- Triggering workflows in automation platforms
- Logging engagement events to analytics systems
- Building custom integrations and workflows
Webhook Delivery
Request Format
All webhook deliveries are sent as POST requests with the following headers:
| Header | Value |
|---|---|
Content-Type | application/json |
X-Webhook-Signature | HMAC-SHA256 hex digest of the request body |
User-Agent | CampaignLark/1.0 (+https://campaignlark.com) |
Envelope
Every webhook delivery follows this envelope structure:
{
"id": "evt_69b27e24be03b743d4b9b7b7",
"event_type": "contact.created",
"workspace_id": 12,
"created_at": "2026-03-12T08:49:40.208Z",
"data": { ... }
}
| Field | Type | Description |
|---|---|---|
id | string | Unique event ID, prefixed with evt_ |
event_type | string | The event type that triggered this delivery |
workspace_id | integer | The workspace where the event occurred |
created_at | string | ISO 8601 timestamp of when the event was created |
data | object | Event-specific payload (see below) |
Verifying Signatures
Every webhook includes an X-Webhook-Signature header containing an HMAC-SHA256 hex digest of the raw request body, signed with your webhook secret. Always verify this signature before processing the payload to ensure the request came from CampaignLark.
- JavaScript
- Python
- Go
- PHP
- Ruby
- C#
const crypto = require("crypto");
function verifyWebhookSignature(body, signature, secret) {
const expected = crypto
.createHmac("sha256", secret)
.update(body)
.digest("hex");
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expected)
);
}
import hmac
import hashlib
def verify_webhook_signature(body: bytes, signature: str, secret: str) -> bool:
expected = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
return hmac.compare_digest(signature, expected)
package main
import (
"crypto/hmac"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
)
func verifyWebhookSignature(body []byte, signature string, secret string) bool {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(body)
expected := hex.EncodeToString(mac.Sum(nil))
return subtle.ConstantTimeCompare([]byte(signature), []byte(expected)) == 1
}
<?php
function verifyWebhookSignature($body, $signature, $secret) {
$expected = hash_hmac('sha256', $body, $secret);
return hash_equals($signature, $expected);
}
require 'openssl'
def verify_webhook_signature(body, signature, secret)
expected = OpenSSL::HMAC.hexdigest('SHA256', secret, body)
Rack::Utils.secure_compare(signature, expected)
end
using System;
using System.Security.Cryptography;
using System.Text;
public static bool VerifyWebhookSignature(string body, string signature, string secret)
{
using (var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret)))
{
var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(body));
var expected = BitConverter.ToString(hash).Replace("-", "").ToLower();
return CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(signature),
Encoding.UTF8.GetBytes(expected)
);
}
}
Retry Policy
Your endpoint must respond with a 2xx status code to acknowledge receipt. If delivery fails (non-2xx response or network error), CampaignLark retries with the following backoff schedule:
| Attempt | Delay |
|---|---|
| 1 | Immediate |
| 2 | 5 seconds |
| 3 | 1 minute |
| 4 | 5 minutes |
| 5 | 10 minutes |
| 6 | 30 minutes |
| 7 | 1 hour |
| 8 | 4 hours |
After 8 failed attempts, the delivery is permanently discarded. Your endpoint must respond within 10 seconds or the delivery will be treated as a failure.
The complete retry cycle spans approximately 5.5 hours from the initial attempt to the final retry.
The Contact Object
Many events include a contact object. This object always has the same shape, regardless of the event type:
{
"id": "69b27e24be03b743d4b9b7b3",
"status": "SUBSCRIBED",
"fields": {
"email_address": "jane@example.com",
"first_name": "Jane",
"last_name": "Doe",
"phone_number": null,
"country": null
},
"tags": [
{ "id": 1, "name": "VIP" },
{ "id": 5, "name": "Newsletter" }
],
"created_at": "2026-03-12T08:34:16Z",
"updated_at": "2026-03-12T08:49:40Z"
}
| Field | Type | Description |
|---|---|---|
id | string | The contact's unique ID |
status | string | One of SUBSCRIBED, UNCONFIRMED, UNSUBSCRIBED, CLEANED, COMPLAINED |
fields | object | Map of merge tag to value. Unset fields are null. |
tags | array | Array of { id, name } objects for each tag assigned to the contact |
created_at | string | ISO 8601 timestamp |
updated_at | string | ISO 8601 timestamp |
last_opened_at | string? | ISO 8601 timestamp, present only if the contact has opened an email |
last_clicked_at | string? | ISO 8601 timestamp, present only if the contact has clicked a link |
unsubscribed_at | string? | ISO 8601 timestamp, present only if the contact has unsubscribed |
Event Types
Contact Lifecycle
contact.created
Fired when a new contact is created.
Payload: Contact Object
{
"id": "69b27e24be03b743d4b9b7b3",
"status": "SUBSCRIBED",
"fields": { "email_address": "jane@example.com", "first_name": "Jane" },
"tags": [],
"created_at": "2026-03-12T08:34:16Z",
"updated_at": "2026-03-12T08:34:16Z"
}
contact.updated
Fired when a contact's fields, status, or tags are modified.
Payload: Contact Object
{
"id": "69b27e24be03b743d4b9b7b3",
"status": "SUBSCRIBED",
"fields": { "email_address": "jane@example.com", "first_name": "Jane", "last_name": "Doe" },
"tags": [{ "id": 1, "name": "VIP" }],
"created_at": "2026-03-12T08:34:16Z",
"updated_at": "2026-03-12T09:15:00Z"
}
contact.deleted
Fired when a contact is deleted.
Payload: Contact Object (snapshot at time of deletion)
{
"id": "69b27e24be03b743d4b9b7b3",
"status": "SUBSCRIBED",
"fields": { "email_address": "jane@example.com", "first_name": "Jane" },
"tags": [{ "id": 1, "name": "VIP" }],
"created_at": "2026-03-12T08:34:16Z",
"updated_at": "2026-03-12T08:34:16Z"
}
contact.subscribed
Fired when a contact's status changes to SUBSCRIBED (e.g. via opt-in confirmation or resubscription).
Payload: Same as contact.created
contact.unsubscribed
Fired when a contact unsubscribes.
Payload: Same as contact.created
contact.cleaned
Fired when a contact is marked as cleaned due to a hard bounce.
Payload:
{
"contact": { ... },
"message_id": "abc123@mail.example.com",
"bounce_reason": "550 5.1.1 User unknown"
}
| Field | Type | Description |
|---|---|---|
contact | object | Contact Object |
message_id | string | The message ID of the bounced email |
bounce_reason | string | The bounce response message from the receiving server |
contact.complained
Fired when a contact reports an email as spam (feedback loop).
Payload:
{
"contact": { ... },
"message_id": "abc123@mail.example.com",
"campaign_id": "69b27e24be03b743d4b9b7b3"
}
| Field | Type | Description |
|---|---|---|
contact | object | Contact Object |
message_id | string | The message ID of the complained email |
campaign_id | string? | Campaign ID, if the email was sent from a campaign |
automation_id | string? | Automation ID, if the email was sent from an automation |
Tags
contact.tagged
Fired when one or more tags are added to a contact.
Payload:
{
"contact": { ... },
"modified_tags": [
{ "id": 1, "name": "VIP" },
{ "id": 5, "name": "Newsletter" }
]
}
| Field | Type | Description |
|---|---|---|
contact | object | Contact Object |
modified_tags | array | The tags that were added, each with id and name |
contact.untagged
Fired when one or more tags are removed from a contact.
Payload: Same as contact.tagged
Segments
contact.segment.entered
Fired when a contact enters a segment after a recalculation.
Payload:
{
"contact": { "id": "69b27e24be03b743d4b9b7b3" },
"segment": { "id": 4, "name": "Engaged Users" }
}
| Field | Type | Description |
|---|---|---|
contact | object | Contains only the contact id |
segment | object | The segment entered, with id and name |
Segment events provide a lightweight contact reference (id only) instead of the full contact object for performance reasons, as segment recalculations can affect thousands of contacts at once.
contact.segment.exited
Fired when a contact exits a segment after a recalculation.
Payload: Same as contact.segment.entered
Email Engagement
email.sent
Fired when an email is successfully delivered to the recipient's mail server.
Payload:
{
"contact_id": "69b27e24be03b743d4b9b7b3",
"message_id": "abc123@mail.example.com",
"campaign_id": "69b27e24be03b743d4b9b7b3"
}
| Field | Type | Description |
|---|---|---|
contact_id | string | The recipient contact ID |
message_id | string | The unique message ID assigned by the mail server |
campaign_id | string? | Present if the email was part of a campaign |
automation_id | string? | Present if the email was part of an automation |
email.opened
Fired when a contact opens an email.
Payload: Same structure as email.sent.
email.clicked
Fired when a contact clicks a link in an email.
Payload:
{
"contact_id": "69b27e24be03b743d4b9b7b3",
"message_id": "abc123@mail.example.com",
"campaign_id": "69b27e24be03b743d4b9b7b3",
"url": "https://example.com/offer"
}
| Field | Type | Description |
|---|---|---|
contact_id | string | The recipient contact ID |
message_id | string | The unique message ID |
campaign_id | string? | Present if the email was part of a campaign |
automation_id | string? | Present if the email was part of an automation |
url | string | The URL that was clicked |
email.bounced
Fired when an email bounces.
Payload:
{
"contact_id": "69b27e24be03b743d4b9b7b3",
"message_id": "abc123@mail.example.com",
"campaign_id": "69b27e24be03b743d4b9b7b3",
"bounce_type": "hard",
"bounce_reason": "550 5.1.1 User unknown"
}
| Field | Type | Description |
|---|---|---|
contact_id | string | The recipient contact ID |
message_id | string | The unique message ID |
campaign_id | string? | Present if the email was part of a campaign |
automation_id | string? | Present if the email was part of an automation |
bounce_type | string | The bounce category (e.g. hard, soft) |
bounce_reason | string | The response message from the receiving server |
Forms
form.submitted
Fired when a contact submits a form.
Payload:
{
"form": {
"id": 1,
"name": "Newsletter Signup"
},
"contact": { ... },
"ip_address": "1.1.1.1",
"country": "AU",
"asn": "AS7545",
"is_proxy": false
}
| Field | Type | Description |
|---|---|---|
form | object | The form that was submitted, with id and name |
contact | object | Contact Object of the created or updated contact |
ip_address | string | The IP address of the visitor |
country | string | Two-letter country code based on IP geolocation |
asn | string | Autonomous System Number of the visitor's network |
is_proxy | boolean | Whether the visitor is using a VPN or proxy |
Best Practices
Security:
- Always verify the
X-Webhook-Signatureheader before processing any payload - Use constant-time comparison functions to prevent timing attacks
- Keep your webhook secret secure
Performance:
- Respond with a
200status code immediately upon receipt - Process webhook data asynchronously if your logic takes more than a few seconds
- Your endpoint must respond within 10 seconds to avoid timeouts
Reliability:
- Handle duplicates — in rare cases, the same event may be delivered more than once. Use the
idfield to deduplicate - Implement idempotent processing to safely handle duplicate deliveries
- Monitor failures — if your endpoint consistently fails, deliveries will be discarded after 8 attempts
- Use Webhook.site for debugging and inspecting webhook payloads during development
- A single action may trigger multiple events (e.g. updating a contact and adding a tag manually fires both
contact.updatedandcontact.tagged)