Webhooks allow your application to receive real-time HTTP notifications whenever events happen in your accountβsuch as when a lead replies to an email, a link is clicked, an in-thread reply is dispatched, or an account status changes.
Instead of continuously polling our REST APIs, webhooks push events directly to your server as soon as they occur.
π API Reference: View complete endpoints and schemas in the Webhook API Documentation.
SkySenders supports two types of webhook destinations:
HTTP/HTTPS Webhooks (Http): Sends raw JSON payloads to your custom server endpoint with optional HMAC-SHA256 signature verification.
Slack Incoming Webhooks (Slack): Automatically formats event data using Slack Block Kit cards, including lead context, campaign information, and status badges.
You can subscribe a webhook to any combination of the following events:
email.sent: Triggered when an email is sent to a lead.
email.opened: Triggered the first time a lead opens an email.
email.link_clicked: Triggered the first time a lead clicks any tracked link.
email.bounced: Triggered when an email hard bounces or fails delivery.
email.reply: Triggered when a lead sends a reply (includes AI sentiment & category).
email.reply_sent: Triggered when a reply is sent back to the lead in an email thread.
email.unsubscribed: Triggered when a lead unsubscribes.
email.disconnected: Triggered if a mailbox disconnects (e.g., revoked OAuth or expired token).
campaign.status_changed: Triggered when a campaign is paused, resumed, completed, or errors.
linkedin.connection_sent: Triggered when a connection invitation is sent.
linkedin.connection_accepted: Triggered when a lead accepts your LinkedIn connection request.
linkedin.message_sent: Triggered when a direct message is sent on LinkedIn.
linkedin.inmail_sent: Triggered when an InMail message is sent.
linkedin.reply: Triggered when a lead replies to a LinkedIn message.
linkedin.disconnected: Triggered if a LinkedIn account disconnects or requires checkpoint verification.
All HTTP webhook notifications follow a unified JSON envelope:
{
"event": "email.reply",
"timestamp": "2026-10-10T10:00:00.000Z",
"workspace_id": 14,
"data": {
"lead": {
"contact_id": 1042,
"email": "[email protected]"
},
"sequence": {
"id": 85
},
"mailbox_email": "[email protected]",
"message": {
"id": 50123,
"from": "[email protected]",
"to": "[email protected]",
"subject": "Re: Quick question regarding your stack",
"preview": "Thanks for reaching out! Let's schedule a demo next Tuesday.",
"reply_category_id": 1,
"is_ai_categorised": true,
"received_at": "2026-10-10T10:00:00.000Z"
}
}
}Every webhook POST request delivered to your endpoint includes the following HTTP headers:
Content-Type: Always application/json
User-Agent: Outreach-Webhook/1.0
X-Webhook-Id: The ID of the configured webhook in your workspace.
X-Webhook-Event: The event type name (e.g. email.reply, campaign.status_changed).
X-Webhook-Signature: HMAC-SHA256 signature header (included when a secret is configured).
X-Webhook-Test: Set to true if this is a manual test dispatch from the dashboard.
To ensure incoming webhook requests genuinely originate from us and have not been altered in transit, we recommend setting a Secret when creating your webhook.
When a secret is configured (format: ws_...), SkySenders calculates an HMAC-SHA256 signature and attaches it in the X-Webhook-Signature header:
Header Format: X-Webhook-Signature: t=1725010130,v1=5d41402abc4b2a76b9719d911017c5926ebd896b02a6cf4971c6dc0d6df6cf25
t: Unix timestamp (in seconds) of when the request was dispatched.
v1: The HMAC-SHA256 hexadecimal hash computed over "${t}.${rawRequestBody}".
1. Extract t (timestamp) and v1 (signature) from the X-Webhook-Signature header.
2. Verify that the timestamp is within your tolerance threshold (we recommend 300 seconds / 5 minutes) to protect against replay attacks.
3. Compute the HMAC-SHA256 hash using your webhook secret over the string ${t}.${raw_request_body}.
4. Compare your calculated hash with v1 using a timing-safe equality check.
const crypto = require('crypto');
function verifyWebhook(rawBody, signatureHeader, secret, toleranceSeconds = 300) {
if (!signatureHeader || !secret) return false;
const parts = Object.fromEntries(
signatureHeader.split(',').map(param => param.split('='))
);
const timestamp = parseInt(parts.t, 10);
const receivedSignature = parts.v1;
if (!timestamp || !receivedSignature) return false;
// Protect against replay attacks (5-minute tolerance)
const currentTimestamp = Math.floor(Date.now() / 1000);
if (Math.abs(currentTimestamp - timestamp) > toleranceSeconds) {
return false;
}
// Compute expected HMAC-SHA256
const payloadToSign = `${timestamp}.${typeof rawBody === 'string' ? rawBody : JSON.stringify(rawBody)}`;
const expectedSignature = crypto
.createHmac('sha256', secret)
.update(payloadToSign, 'utf8')
.digest('hex');
// Constant-time comparison
const expectedBuffer = Buffer.from(expectedSignature, 'utf8');
const receivedBuffer = Buffer.from(receivedSignature, 'utf8');
if (expectedBuffer.length !== receivedBuffer.length) return false;
return crypto.timingSafeEqual(expectedBuffer, receivedBuffer);
}import hmac
import hashlib
import time
def verify_webhook(raw_body: str, signature_header: str, secret: str, tolerance_seconds: int = 300) -> bool:
if not signature_header or not secret:
return False
parts = dict(item.split("=") for item in signature_header.split(","))
timestamp = int(parts.get("t", 0))
received_signature = parts.get("v1", "")
# Replay protection (5-minute tolerance)
if abs(int(time.time()) - timestamp) > tolerance_seconds:
return False
payload_to_sign = f"{timestamp}.{raw_body}".encode("utf-8")
expected_signature = hmac.new(
secret.encode("utf-8"),
payload_to_sign,
hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected_signature, received_signature)You can test webhooks at any time without having to wait for live lead interactions:
Test Pre-Save URL: Send simulated events to test an arbitrary URL directly from the creation modal before saving it.
Test Existing Webhook: In the dashboard, click "Send Test Event" on any existing webhook and pick the event type you want to simulate.
Test Deliveries Include: "is_test": true in the JSON payload, X-Webhook-Test: true in the HTTP header, and a [TEST WEBHOOK] banner block if delivered to a Slack channel.
Your webhook listener should respond with an HTTP 2xx status code (200 OK, 202 Accepted, or 204 No Content) as quickly as possible (recommended under 3 seconds). Heavy background processing (e.g., CRM syncing or database updates) should be offloaded to an asynchronous background worker or queue on your end.
We are actively rolling out an enhanced Retry & Delivery Engine with dedicated controls:
Automatic Retries with Exponential Backoff: If your server returns a 5xx error, drops connection, or times out, we will automatically retry the delivery multiple times across an exponential backoff schedule so you never miss an event.
Configurable Retry Settings: Choose retry counts and thresholds that match your infrastructure needs.
Detailed Execution & Failure Logs: A dedicated log viewer inside your dashboard showing exact request payloads, response codes, latency in milliseconds, and error details for failed deliveries.
Verify Signatures: Always validate the X-Webhook-Signature header to ensure requests are authentic.
Acknowledge Quickly: Return HTTP 200 immediately and process work in a background worker or queue.
Handle Duplicates (Idempotency): Use the combination of event timestamp and identifiers (like message.id or lead.contact_id) to ensure your system handles potential duplicate requests gracefully.
Use HTTPS: Ensure your webhook endpoints use SSL/TLS (https://) in production environments.