Webhooks — Getting Started
WarmySender sends real-time webhook notifications for key events: 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, sequenceId, mailboxId, subject, messageId, receivedAt, 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 }.
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 Unipile 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. Honor Retry-After on HTTP 429 — we parse both seconds and HTTP-date formats, capped at our max 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 database state only (post atomic WHERE repliedAt IS NULL / WHERE acceptedAt IS NULL guards, so Unipile retries don't cause duplicate events or extra work). Toggling webhooks on or off doesn't change how often we poll LinkedIn via Unipile; 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'.