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.

DimensionPollingWebhooks
LatencyUp to your poll interval (e.g., 5–30 s)Near-instant (< 1 s typical)
Server loadConstant — hits the API even when idleEvent-driven — zero load when nothing happens
API quotaBurns quota continuouslyNo quota consumed for event delivery
ScalabilityDegrades as volume growsScales linearly with event volume
ComplexitySimple to set up initiallyRequires endpoint, security, retry handling
Missed eventsPossible if poll fails or overlapsPlatform 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:

EventWhen it firesTypical use case
Message Events
message.receivedNew message received from a contactTrigger automation, notify agent, log to CRM
message.sentMessage sent to a contactDelivery tracking, audit log
message.deliveredMessage delivery confirmed by the channelUpdate delivery status in dashboard or CRM
message.failedMessage delivery failedAlert ops team, trigger fallback channel
message.readMessage read by the recipientAnalytics, engagement tracking
Email Events
email.receivedNew email receivedRoute to inbox, create support ticket
email.sentEmail sentDelivery tracking, campaign log
email.deliveredEmail delivery confirmedUpdate delivery status, confirm reachability
email.bouncedEmail bouncedClean mailing list, flag invalid address
email.openedEmail opened (tracking pixel)Engagement analytics, trigger follow-up
email.complainedEmail marked as spamSuppress contact, protect sender reputation
Conversation Events
conversation.createdNew conversation createdAssign to team, track response time
conversation.closedConversation closedTrigger CSAT survey, update ticket system
conversation.assignedConversation assigned to an agentNotify agent, update workload dashboard
conversation.reopenedClosed conversation reopenedRe-notify team, restart SLA timer
Contact Events
contact.createdNew contact createdSync to CRM, start onboarding sequence
contact.updatedContact details updatedSync to CRM, update segmentation
contact.deletedContact deletedRemove from CRM, GDPR compliance log
contact.subscribedContact subscribes to a listAdd to campaign audience, welcome sequence
contact.unsubscribedContact unsubscribes from a listSuppress from campaigns, compliance log
Link Tracking Events
link.clickedTracked link clickedEngagement 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.

AttemptDelay after previous failure
1st retry1 minute
2nd retry5 minutes
3rd retry30 minutes
4th retry2 hours
5th retry8 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.