Webhooks — Getting Started

WarmySender sends real-time webhook notifications for key events: every campaign email sent, email replies, LinkedIn replies (with the reply body), LinkedIn invite acceptances, bounces, unsubscribes, opens, clicks, and sending-limit alerts. This guide is the canonical reference — specific integrations (Slack, n8n, HubSpot, etc.) link back here.

Event Catalog:

All events use the same envelope: { type, data, timestamp }.

Sent emails, and linking replies to them:
• email.sent fires once for every campaign email, the moment your sending mailbox's server accepts it. "Sent" means accepted by your mailbox's server — not queued, and not yet a delivery confirmation. If the email bounces later, you receive email.bounced. Warmup emails and test sends are not included.
• email.sent payload: prospectId, prospectEmail, campaignId, campaignName, stepIndex (counts from 0), stepNumber (the "Step N" you see in the app), mailboxId, fromEmail, subject, messageId (the email's Message-ID), sentAt, channel ("email").
Example:
{
"type": "email.sent",
"data": { "prospectId": "f3c4a2d1-...", "prospectEmail": "[email protected]", "campaignId": "c1a2b3c4-...", "campaignName": "Q3 founders outreach", "stepIndex": 1, "stepNumber": 2, "mailboxId": "5bd50e20-...", "fromEmail": "[email protected]", "subject": "Quick question, Jane", "messageId": "<[email protected]>", "sentAt": "2026-06-10T10:42:00.000Z", "channel": "email" },
"timestamp": "2026-06-10T10:42:01.000Z"
}
• Linking a reply to the email it answered: in reply.received, messageId is the reply's own Message-ID. inReplyToMessageId is the messageId of your email that the prospect answered — the same value you received in email.sent — and stepIndex / stepNumber say which step it was. These three fields are included only when the reply names one of your emails; otherwise they are left out.
Example reply.received (email):
{
"type": "reply.received",
"data": { "prospectId": "f3c4a2d1-...", "prospectEmail": "[email protected]", "campaignId": "c1a2b3c4-...", "mailboxId": "5bd50e20-...", "subject": "Re: Quick question, Jane", "messageId": "<[email protected]>", "receivedAt": "2026-06-10T11:00:00.000Z", "channel": "email", "inReplyToMessageId": "<[email protected]>", "stepIndex": 1, "stepNumber": 2 },
"timestamp": "2026-06-10T11:00:04.000Z"
}
• Missed a webhook? GET /api/v1/emails/sent returns the same records, so you can fill any gap. Filter by campaignId, prospectId, since and until; results come newest first (or oldest first with order=asc), up to 200 per page with a cursor. It needs an API key with the Campaigns: read permission. Your AI agent can read the same list with the list_sent_emails tool.
• How long records are kept: for as long as the campaign exists, and at least 13 months after a contact finishes the campaign. Deleting a campaign deletes its records. Emails sent before July 2026 are not available.

Creating a Webhook:

  1. Settings → Webhooks → New webhook.
  2. Paste your endpoint URL. Slack Incoming Webhook URLs (https://hooks.slack.com/…) are auto-formatted as Block Kit messages — see the 'Slack Notifications' guide. Everything else receives the signed JSON payload.
  3. Pick events. Start with reply.received + linkedin.reply_received + linkedin.invite_accepted for sales teams; add email.bounced + limit.hit for ops.
  4. Save. WarmySender returns a webhook secret — save it somewhere secure, you'll need it to verify signatures. The secret is shown once.
  5. Click the test button on the webhook row to send a webhook.test event end-to-end. You should receive a POST within ~10 seconds.

Signature Verification (HMAC-SHA256):
Every delivery includes these headers:
• X-Warmy-Event-Id — unique event UUID (use for idempotency on your side)
• X-Warmy-Event-Type — e.g., linkedin.reply_received
• X-Warmy-Signature — combined Stripe-style format: t=<unix_ms>,v1=<hmac_hex>. The canonical timestamp lives inside this header — parse v1= out before comparing, and use the parsed t= for the HMAC input. (X-Warmy-Timestamp is also sent as a separate header for convenience/logging but the canonical copy is inside X-Warmy-Signature.)
• Content-Type: application/json

The HMAC input is the string `${t}.${rawBody}` signed with your webhook secret using SHA-256.

Verify in Node.js (Express). Two common pitfalls: (1) reading X-Warmy-Signature as raw hex — it isn't, parse v1= first. (2) comparing against re-serialized JSON — key order, whitespace, and unicode escaping won't byte-match what we signed. Always capture the RAW request body before json parsing.

const crypto = require('crypto');
// Capture raw body BEFORE express.json():
// app.use(express.json({ verify: (req, _r, buf) => { req.rawBody = buf.toString('utf8'); } }));
app.post('/webhook', (req, res) => {
const sigHeader = req.headers['x-warmy-signature'] || '';
const parts = Object.fromEntries(sigHeader.split(',').map(p => p.split('=')));
const ts = parts.t; const sig = parts.v1;
if (!ts || !sig) return res.sendStatus(400);
if (Math.abs(Date.now() - parseInt(ts, 10)) > 5 * 60 * 1000) return res.sendStatus(401); // 5-min replay guard
const expected = crypto.createHmac('sha256', WEBHOOK_SECRET).update(ts + '.' + req.rawBody).digest('hex');
const a = Buffer.from(sig, 'hex'); const b = Buffer.from(expected, 'hex');
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return res.sendStatus(401);
// signature valid — process the event
res.sendStatus(200);
});

Skipping verification is acceptable on a private network (self-hosted n8n on a VPN). Always verify when the endpoint is publicly reachable.

Idempotency & Deduplication:
Delivery is at-least-once — the same event may be delivered more than once due to retries or network races. Use the X-Warmy-Event-Id header as your dedupe key (store it in a database or cache for ~24 h).

For LinkedIn events, we dedupe at the source (per-enrollment for replies, per-acceptance for invites) so our LinkedIn provider webhook retries don't cause duplicate emissions. Your endpoint still needs its own dedupe as defense in depth.

First-Reply Semantics (linkedin.reply_received):
This event fires once per enrollment when the prospect first responds. Subsequent messages in the same thread do NOT re-fire (same semantics as campaigns auto-pausing on first reply). If you need every inbound message on a thread (not just the first reply), the webhook event catalog above isn't sufficient — please contact support to discuss options; this isn't currently exposed as a standalone event.

Retry Schedule:
On any non-2xx response or 5-second timeout, we retry up to 10 times with exponential backoff: 1 min → 5 min → 15 min → 1 h → 3 h → 6 h → 12 h → 24 h → 48 h → 72 h. If your endpoint asks us to slow down and says how long to wait — as a number of seconds or as a date — we honour that wait, capped at our maximum backoff. After 10 failed attempts, the delivery is marked failed and the webhook's failure count is incremented.

Account Safety (LinkedIn Events):
WarmySender's webhook delivery does not make any LinkedIn API calls on your behalf — events are emitted from our own recorded state only, and each reply or acceptance is recorded exactly once, so a repeated upstream signal never produces a duplicate event or extra work. Toggling webhooks on or off doesn't change how often we poll LinkedIn via our LinkedIn provider; your LinkedIn account's daily action limits are driven by campaign sends / invites / profile views, not by webhook activity.

Testing:

Troubleshooting:

Related Guides: 'Slack Notifications' (native, no middleware), 'n8n Integration', 'Zapier Integration', 'Make Integration', 'HubSpot CRM Integration'.

Related guides in Integrations

Back to all documentation | Contact support