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:
- reply.received — Prospect replied to a campaign email. Payload: prospectId, prospectEmail, campaignId, mailboxId, subject, messageId (the reply's own Message-ID), receivedAt, channel — plus inReplyToMessageId, stepIndex and stepNumber when the reply names one of your emails (see below). Email replies also carry the reply text: fromEmail, bodyText (up to 4,000 characters) and bodyTruncated (true when the text was cut). Missed one? Read the same record any time with GET /api/v1/replies (full reference at /docs/integrations), or ask your AI agent with the list_replies tool.
- email.sent — A campaign email was accepted by your sending mailbox's server. Payload: prospectId, prospectEmail, campaignId, campaignName, stepIndex, stepNumber, mailboxId, fromEmail, subject, messageId, sentAt, channel.
- linkedin.reply_received — Prospect replied to a LinkedIn message or InMail. Payload: prospectId, prospectName, prospectLinkedinUrl, campaignId, enrollmentId, linkedinAccountId, messageText (the reply body, up to ~400 chars for Slack / full length in JSON), threadId, receivedAt.
- linkedin.invite_accepted — Prospect accepted a LinkedIn connection request. Payload: prospectId, prospectName, prospectLinkedinUrl, campaignId, enrollmentId, linkedinAccountId, acceptedAt.
- email.bounced — Hard or soft bounce. Payload: prospectEmail, bounceType, bounceReason, mailboxId, campaignId, bouncedAt.
- email.unsubscribed — Prospect clicked the unsubscribe link. Payload: prospectId, prospectEmail, campaignId, unsubscribedAt.
- email.opened — First open per prospect per campaign per day. Payload: prospectEmail, campaignId, stepIndex, openedAt.
- email.clicked — Link click. Payload: prospectEmail, campaignId, url, stepIndex, clickedAt.
- prospect.suppressed — Prospect added to suppression list. Payload: email, reason, suppressedAt.
- limit.hit — Daily sending limit reached. Payload: limitType (daily_mailbox | daily_domain | daily_prospect | cooldown), mailboxId, currentValue, limitValue, hitAt.
- webhook.test — Fired by the test button in Settings for endpoint verification.
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:
- Settings → Webhooks → New webhook.
- 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.
- Pick events. Start with reply.received + linkedin.reply_received + linkedin.invite_accepted for sales teams; add email.bounced + limit.hit for ops.
- Save. WarmySender returns a webhook secret — save it somewhere secure, you'll need it to verify signatures. The secret is shown once.
- 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:
- Use the test button in Settings → Webhooks for an end-to-end sanity check (fires webhook.test via the real delivery path).
- For ad-hoc testing of a specific event, use tools like RequestBin or webhook.site to inspect the payload shape before wiring to your production endpoint.
- LinkedIn event testing: reply to yourself from a test LinkedIn account (or have a teammate reply) and watch Settings → Webhooks → Recent Deliveries.
Troubleshooting:
- Signature mismatch → check you're hashing the raw body, not the parsed-then-reserialized JSON; check you're using the webhook secret, not the API key.
- No delivery when expected → confirm you're subscribed to the right event type; check Recent Deliveries in Settings for 4xx/5xx responses.
- Test button returns 200 but nothing arrives → your endpoint is returning 200 without actually doing anything, or the event got filtered; check Recent Deliveries for the test event.
- LinkedIn reply event not firing → only the FIRST reply per enrollment emits; subsequent messages in the same thread don't re-fire by design.
Related Guides: 'Slack Notifications' (native, no middleware), 'n8n Integration', 'Zapier Integration', 'Make Integration', 'HubSpot CRM Integration'.