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 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:

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