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:

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

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. 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 our LinkedIn provider retries don't cause duplicate events 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