Webhooks Explained: Real-Time Messaging Event Handling
Webhooks are how messaging platforms tell your app that something happened — a message arrived, a conversation closed, a contact was created. This guide covers the full picture: how webhooks work, how to verify signatures, handle retries, process events idempotently, and avoid the most common production pitfalls. Includes Python and Node.js code examples.
TL;DR
A webhook is an HTTP callback your server receives when something happens inside a platform — a WhatsApp message comes in, a conversation closes, a contact is updated. Instead of asking the API every few seconds "anything new?", the platform asks your server "something just happened — here's the data." That shift eliminates wasted API calls, cuts latency from seconds to milliseconds, and lets you build truly real-time messaging workflows. This guide covers the full webhook lifecycle: event types, payload structures, signature verification, retry handling, idempotency, and production best practices — with working code in Python and Node.js.
What Are Webhooks? (Quick Answer)
A webhook is an HTTP callback that sends real-time notifications from one system to another when an event occurs. You register a URL on your server. When a defined event fires — a customer sends a WhatsApp message, an SMS is delivered, a conversation is closed by an agent — the platform sends a POST request to your URL with a JSON payload describing what happened.
Think of it like a doorbell. You don't stand at the door all day checking whether anyone has arrived. You wait. The doorbell rings when someone is there. Polling is the opposite: walking to the door every 5 seconds, checking, finding nothing, walking back, repeating forever. Webhooks are the doorbell. Polling is the anxious door-checker.
That analogy holds up technically. A polling loop burns API quota, adds latency proportional to your poll interval, and generates load on both sides. A webhook fires once, delivers the event, and is done.
Webhooks vs. Polling: Why Webhooks Win
Most developers start with polling because it feels simple. Hit an endpoint, check the response, sleep, repeat. It works. It just works badly at scale.
| Dimension | Polling | Webhooks |
|---|---|---|
| Latency | Up to your poll interval (e.g., 5–30 s) | Near-instant (< 1 s typical) |
| Server load | Constant — hits the API even when idle | Event-driven — zero load when nothing happens |
| API quota | Burns quota continuously | No quota consumed for event delivery |
| Scalability | Degrades as volume grows | Scales linearly with event volume |
| Complexity | Simple to set up initially | Requires endpoint, security, retry handling |
| Missed events | Possible if poll fails or overlaps | Platform handles retries automatically |
Polling still has a place. If you're integrating with a legacy system that doesn't support webhooks, or if you need to backfill historical data, polling is often your only option. But for any modern messaging integration where latency and efficiency matter, webhooks are the right default.
How Webhooks Work in Messaging
The data flow is straightforward. A customer sends a message on WhatsApp. The messaging platform receives it, processes it, and then immediately fires a POST request to the webhook URL you registered. Your server receives the request, does whatever it needs to do (save to database, trigger an automation, route to an agent), and responds with 200 OK.
Here is the full sequence:
Customer Platform Your Server
│ │ │
│── sends message ──────▶│ │
│ │── POST /webhook ──────▶│
│ │ payload: {...} │
│ │ │── validate signature
│ │ │── enqueue for processing
│ │◀── 200 OK ────────────│
│ │ │── process event async
Two things are critical in this flow. First, the 200 OK must come back fast — within 10 seconds, typically. If it doesn't, the platform treats the delivery as failed and schedules a retry. Do your processing asynchronously. Respond 200 immediately, then handle the event in a background job or queue.
Second, your endpoint must be publicly reachable over HTTPS. During local development, tools like ngrok create a public tunnel to your localhost so you can test without deploying.
Common Webhook Events for Messaging
The SendSeven webhook system fires 21 event types across six categories, covering the full lifecycle of messages, emails, conversations, contacts, and link tracking. Here is the complete reference:
| Event | When it fires | Typical use case |
|---|---|---|
| Message Events | ||
message.received | New message received from a contact | Trigger automation, notify agent, log to CRM |
message.sent | Message sent to a contact | Delivery tracking, audit log |
message.delivered | Message delivery confirmed by the channel | Update delivery status in dashboard or CRM |
message.failed | Message delivery failed | Alert ops team, trigger fallback channel |
message.read | Message read by the recipient | Analytics, engagement tracking |
| Email Events | ||
email.received | New email received | Route to inbox, create support ticket |
email.sent | Email sent | Delivery tracking, campaign log |
email.delivered | Email delivery confirmed | Update delivery status, confirm reachability |
email.bounced | Email bounced | Clean mailing list, flag invalid address |
email.opened | Email opened (tracking pixel) | Engagement analytics, trigger follow-up |
email.complained | Email marked as spam | Suppress contact, protect sender reputation |
| Conversation Events | ||
conversation.created | New conversation created | Assign to team, track response time |
conversation.closed | Conversation closed | Trigger CSAT survey, update ticket system |
conversation.assigned | Conversation assigned to an agent | Notify agent, update workload dashboard |
conversation.reopened | Closed conversation reopened | Re-notify team, restart SLA timer |
| Contact Events | ||
contact.created | New contact created | Sync to CRM, start onboarding sequence |
contact.updated | Contact details updated | Sync to CRM, update segmentation |
contact.deleted | Contact deleted | Remove from CRM, GDPR compliance log |
contact.subscribed | Contact subscribes to a list | Add to campaign audience, welcome sequence |
contact.unsubscribed | Contact unsubscribes from a list | Suppress from campaigns, compliance log |
| Link Tracking Events | ||
link.clicked | Tracked link clicked | Engagement scoring, conversion attribution |
Every event shares a common envelope structure. Here is a message.received payload from WhatsApp:
{
"id": "evt_msg_001",
"event": "message.received",
"created_at": "2026-02-10T14:30:00Z",
"tenant_id": "tenant_abc123",
"data": {
"message_id": "msg_a1b2c3d4",
"conversation_id": "conv_8f3a2b1c",
"contact_id": "contact_d4e5f6a7",
"channel": "whatsapp",
"content": "I need help with my order",
"message_type": "text",
"direction": "inbound",
"timestamp": "2026-02-10T14:30:00Z"
}
}
And a conversation.closed payload, which is richer because it summarizes the full conversation lifecycle:
{
"id": "evt_conv_closed_789",
"event": "conversation.closed",
"created_at": "2026-02-10T15:00:00Z",
"tenant_id": "tenant_abc123",
"data": {
"conversation_id": "conv_8f3a2b1c",
"contact_id": "contact_d4e5f6a7",
"channel": "whatsapp",
"status": "closed",
"assigned_user_id": "user_1a2b3c",
"closed_by": "user_1a2b3c",
"summary": "Order issue resolved",
"created_at": "2026-02-08T09:12:00Z",
"last_customer_message_at": "2026-02-10T14:30:00Z",
"last_agent_reply_at": "2026-02-10T14:45:00Z"
}
}
Notice the top-level id field on every event. That is your idempotency key — more on that in the retry section.
Webhook Security: Signature Verification
Here is the part most tutorials skip. An open webhook endpoint is a security liability. Anyone who knows your URL can send it fake events. A malicious actor could forge a message.received event to trigger a refund flow, a bot escalation, or a CRM update. Signature verification is how you prove the request actually came from SendSeven.
The mechanism is HMAC-SHA256. When you create a webhook, SendSeven returns a secret starting with whsec_. SendSeven uses this secret to compute a hash of the raw request body. It attaches the hash to the request in the X-Webhook-Signature header in the format sha256=<hex-digest>. Your server computes the same hash and compares the two. If they match, the request is legitimate.
Critical implementation detail: Always use a timing-safe comparison function. A naive string comparison like computed == received is vulnerable to timing attacks. Python has hmac.compare_digest. Node.js has crypto.timingSafeEqual. Use them.
Python (Flask)
import hmac
import hashlib
from flask import Flask, request, abort
app = Flask(__name__)
WEBHOOK_SECRET = "whsec_your_secret_here"
@app.route("/webhook", methods=["POST"])
def webhook():
# 1. Get the signature from the header
signature_header = request.headers.get("X-Webhook-Signature", "")
if not signature_header.startswith("sha256="):
abort(400, "Missing or malformed signature header")
received_sig = signature_header[len("sha256="):]
# 2. Compute the expected signature from the raw body
raw_body = request.get_data() # Must be raw bytes, not parsed JSON
expected_sig = hmac.new(
WEBHOOK_SECRET.encode("utf-8"),
raw_body,
hashlib.sha256
).hexdigest()
# 3. Timing-safe comparison
if not hmac.compare_digest(expected_sig, received_sig):
abort(403, "Signature verification failed")
# 4. Parse and process
event = request.get_json()
process_event_async(event) # Enqueue — do not block here
return "", 200
def process_event_async(event):
# Push to a queue (Redis, RabbitMQ, Celery, SQS, etc.)
# Never do slow work synchronously in the webhook handler
pass
Node.js (Express)
const express = require('express');
const crypto = require('crypto');
const app = express();
const WEBHOOK_SECRET = 'whsec_your_secret_here';
// Must use raw body parser — not express.json() — to get the raw bytes
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
// 1. Get the signature header
const signatureHeader = req.headers['x-webhook-signature'] || '';
if (!signatureHeader.startsWith('sha256=')) {
return res.status(400).send('Missing or malformed signature header');
}
const receivedSig = signatureHeader.slice('sha256='.length);
// 2. Compute expected signature from raw body
const expectedSig = crypto
.createHmac('sha256', WEBHOOK_SECRET)
.update(req.body) // req.body is a Buffer here because of express.raw()
.digest('hex');
// 3. Timing-safe comparison
const receivedBuf = Buffer.from(receivedSig, 'hex');
const expectedBuf = Buffer.from(expectedSig, 'hex');
if (
receivedBuf.length !== expectedBuf.length ||
!crypto.timingSafeEqual(expectedBuf, receivedBuf)
) {
return res.status(403).send('Signature verification failed');
}
// 4. Parse and process
const event = JSON.parse(req.body.toString());
processEventAsync(event); // Enqueue — do not block here
res.status(200).send();
});
function processEventAsync(event) {
// Push to a queue (BullMQ, SQS, etc.)
// Never do slow work synchronously in the webhook handler
}
Common mistake: If you parse the body with express.json() before computing the signature, you are hashing a re-serialized object, not the original raw bytes. The hashes will never match. Always read the raw body first, verify the signature, then parse the JSON.
For a broader look at securing your API integration, see the API authentication guide and the API key glossary entry.
Handling Failures: Retry Logic and Idempotency
Your server will sometimes be unavailable. Deployments happen. Network blips happen. SendSeven's retry policy is designed to give your endpoint time to recover without flooding it.
| Attempt | Delay after previous failure |
|---|---|
| 1st retry | 1 minute |
| 2nd retry | 5 minutes |
| 3rd retry | 30 minutes |
| 4th retry | 2 hours |
| 5th retry | 8 hours |
If your endpoint returns a non-2xx status, or doesn't respond within the timeout, that attempt counts as a failure and the schedule advances. After 20 consecutive failures the webhook is automatically paused — a circuit breaker that prevents a permanently dead endpoint from generating infinite retries. You can reactivate it from the delivery monitoring dashboard once your endpoint is healthy again.
Retries create a new problem: duplicate deliveries. If your server processed the event but crashed before returning 200, SendSeven will retry. Your handler will see the same event twice. The solution is idempotency.
Every event has a unique id at the top level (e.g., "id": "evt_msg_001"). Store processed event IDs. If you see the same ID twice, skip it.
Python: Idempotent handler
import redis
r = redis.Redis(host='localhost', port=6379, db=0)
PROCESSED_TTL = 86400 # 24 hours
def process_event(event: dict) -> bool:
event_id = event.get("id")
if not event_id:
return False
# Atomic check-and-set: returns True only if key was newly set
already_processed = not r.set(
f"webhook:processed:{event_id}",
"1",
ex=PROCESSED_TTL,
nx=True # Only set if Not eXists
)
if already_processed:
print(f"Skipping duplicate event: {event_id}")
return True # Still return 200 — not an error
# Safe to process — guaranteed first time
handle_event(event)
return True
def handle_event(event: dict):
event_type = event.get("event")
if event_type == "message.received":
handle_message_received(event["data"])
elif event_type == "conversation.closed":
handle_conversation_closed(event["data"])
# ... other event types
Node.js: Idempotent handler
const { createClient } = require('redis');
const redis = createClient();
await redis.connect();
const PROCESSED_TTL = 86400; // 24 hours
async function processEvent(event) {
const eventId = event.id;
if (!eventId) return false;
// SET NX: only sets the key if it doesn't already exist
const wasSet = await redis.set(
`webhook:processed:${eventId}`,
'1',
{ EX: PROCESSED_TTL, NX: true }
);
if (!wasSet) {
console.log(`Skipping duplicate event: ${eventId}`);
return true; // Still 200 — not an error
}
await handleEvent(event);
return true;
}
async function handleEvent(event) {
switch (event.event) {
case 'message.received':
await handleMessageReceived(event.data);
break;
case 'conversation.closed':
await handleConversationClosed(event.data);
break;
// ... other event types
}
}
If you don't have Redis, a database table with a unique constraint on event_id works just as well. The key principle: make your handler safe to call more than once with the same input.
Production Best Practices
Running webhooks in production is different from running them in development. These are the patterns that separate stable integrations from ones that break at 2am.
Respond 200 immediately, process asynchronously. Your webhook handler should do exactly two things synchronously: verify the signature and enqueue the event. Everything else — database writes, API calls, downstream notifications — goes into a queue. If your handler blocks on a slow database query and times out, you get a retry storm. A fast 200 with async processing is the correct architecture.
Use a proper job queue. Redis + Celery (Python), BullMQ (Node.js), AWS SQS, or RabbitMQ all work. The queue gives you durability (jobs survive restarts), concurrency control, and a retry mechanism independent of the webhook retries.
Log everything. Log the raw payload, the event ID, and the outcome of processing. When a bug surfaces in production — and it will — the logs are the only way to reconstruct what happened. Log before processing, not after. If processing throws, you still have the raw data.
Use HTTPS only. Never register a plain HTTP endpoint. Webhook payloads can contain contact data, message content, and conversation details. HTTPS is non-negotiable. Most cloud providers and services like ngrok handle this automatically.
Never log your secret. The whsec_ secret is a credential. Keep it in an environment variable. Never print it, never commit it, never include it in error messages. Treat it like a private key.
Monitor the delivery dashboard. Check GET /api/v1/webhook-endpoints/{webhook_id}/deliveries regularly, or set up an alert if your failure rate climbs. A growing queue of failed deliveries is a leading indicator of a problem, not a lagging one.
Understanding rate limiting is relevant here too — if your processing layer is slow, you may need to throttle event handling. See the rate limiting glossary for context.
Setting Up Webhooks with SendSeven
The full step-by-step walkthrough is in the webhook setup guide. Webhooks are available on all SendSeven plans, including API Only. Here is a concise overview of the API interactions.
Step 1: Register your webhook endpoint.
import requests
headers = {
"Authorization": "Bearer YOUR_API_KEY",
"Content-Type": "application/json"
}
payload = {
"url": "https://your-server.com/webhook",
"events": [
"message.received",
"message.sent",
"conversation.created",
"conversation.closed"
]
}
response = requests.post(
"https://api.sendseven.com/api/v1/webhook-endpoints",
json=payload,
headers=headers
)
webhook = response.json()
print(webhook["id"]) # Save this — you'll need it to update or delete
print(webhook["secret"]) # whsec_... — store this securely NOW
# It is only returned once at creation
The secret field is returned exactly once: at creation. If you lose it, you must rotate it. Store it in a secrets manager or environment variable immediately.
Step 2: Test locally with ngrok.
# In terminal 1: start your local server
python app.py # or node server.js
# In terminal 2: create a public tunnel to your local port
ngrok http 5000
# ngrok outputs something like:
# Forwarding https://abc123.ngrok.io -> http://localhost:5000
# Register https://abc123.ngrok.io/webhook as your webhook URL
# SendSeven will send live events to your local machine
Step 3: Update event subscriptions without re-creating the webhook.
const response = await fetch(
'https://api.sendseven.com/api/v1/webhook-endpoints/wh_your_webhook_id',
{
method: 'PUT',
headers: {
'Authorization': 'Bearer YOUR_API_KEY',
'Content-Type': 'application/json'
},
body: JSON.stringify({
events: [
'message.received',
'message.sent',
'message.delivered',
'message.failed',
'message.read',
'email.received',
'email.bounced',
'email.opened',
'conversation.created',
'conversation.closed',
'conversation.assigned',
'contact.created',
'contact.updated',
'contact.unsubscribed',
'link.clicked'
]
})
}
);
Step 4: Monitor delivery logs.
response = requests.get(
"https://api.sendseven.com/api/v1/webhook-endpoints/wh_your_webhook_id/deliveries",
headers=headers
)
deliveries = response.json()
for delivery in deliveries["data"]:
print(delivery["event"], delivery["status"], delivery["response_code"])
# e.g. message.received failed 503
The deliveries endpoint shows status, HTTP response code, and timestamps for every attempted delivery. It's the first place to check when events stop arriving.
If you're building WhatsApp-specific integrations on top of webhooks, the Python tutorial (Send WhatsApp Messages with Python) and Node.js tutorial (Send WhatsApp Messages with Node.js) in this series cover the sending side. Webhooks handle the receiving side. Together they give you full bidirectional messaging. For teams managing conversations at scale, see how webhooks power a WhatsApp team inbox with auto-assignment and routing.
Frequently Asked Questions
What is the difference between a webhook and a REST API?
A REST API is a pull mechanism — your app requests data when it needs it. A webhook is a push mechanism — the platform sends data to your app when something happens. They are complementary, not alternatives. You use the REST API to send messages and manage resources; you use webhooks to receive events about what happened.
What happens if my server is down when a webhook fires?
SendSeven will retry the delivery automatically: at 1 minute, 5 minutes, 30 minutes, 2 hours, and 8 hours after the initial failure. If all five retries fail, the event is marked as undelivered. After 20 consecutive failures, the webhook is paused. You can review undelivered events in the delivery log and replay them once your endpoint is back online.
Can I subscribe to all events with one webhook?
Yes. You can subscribe to all available events when creating the webhook by passing all event names in the events array. You can also subscribe to a subset and update the subscription later via PUT /api/v1/webhook-endpoints/{webhook_id} without deleting and recreating the webhook.
Why must I use the raw body for signature verification?
The HMAC signature is computed over the exact bytes the platform sent. If you parse the JSON first and then re-serialize it, the byte sequence may differ (whitespace, key ordering). The hashes will not match. Always buffer the raw request body before reading it as JSON, and compute the signature from the raw buffer.
Is webhook delivery guaranteed to be in order?
No. In high-volume scenarios, events can arrive out of order, especially after retries. Design your handlers to be order-independent. Use the created_at timestamp in the event payload for sequencing if your business logic requires it, not the delivery order.
How do I handle webhook events for all channels uniformly?
The SendSeven event envelope is channel-agnostic, making it ideal for a multi-channel inbox setup. The top-level structure — id, event, created_at, data — is identical for WhatsApp, Telegram, SMS, Email, Messenger, Instagram, and Live Chat (the 7 conversation channels). Browser Push events follow the same envelope structure for outbound notification delivery. The data.channel field tells you which channel the event originated from. One handler, all channels.