Skip to content

Example: handle webhook events

Verify signatures, deduplicate deliveries, and process events without dropping messages.

Follow this pattern when your server receives WhatsApp events from Wazapin.

Verify signature

Validate incoming signature on the raw body bytes before processing to secure your endpoint. See Webhook signature verification.

Parse and validate payload

Convert the verified raw payload to JSON and check the event direction (direction: "inbound" vs direction: "outbound").

Deduplicate events

Use the svix-id (or webhook-id) HTTP header as an idempotency key. Check your database/cache to ensure you have not processed this event ID before.

Acknowledge quickly

Return a 200 OK response to Wazapin within 3 seconds to prevent delivery retries. Queue the event payload for asynchronous processing.

Process asynchronously

Inspect msg_type or event types and dispatch to the correct task handler (text, media, interactive buttons, or delivery receipts).


Code example

Here is a template to receive, verify, and route webhook events:

import express from "express";
import { Webhook } from "svix";

const app = express();
const wh = new Webhook(process.env.WAZAPIN_WEBHOOK_SECRET!);

app.post("/webhooks/wazapin", express.raw({ type: "application/json" }), async (req, res) => {
  // 1. Verify webhook signature
  try {
    wh.verify(req.body, req.headers as Record<string, string>);
  } catch (err) {
    return res.status(403).send("Invalid signature");
  }

  const payload = JSON.parse(req.body.toString("utf8"));
  const eventId = req.headers["svix-id"] as string;

  // 2. Deduplicate using event ID
  if (await isAlreadyProcessed(eventId)) {
    return res.status(200).send("Duplicate acknowledged");
  }

  // 3. Acknowledge receipt quickly (within 3 seconds)
  res.status(200).send("OK");

  // 4. Process event asynchronously (outside request cycle)
  processEventAsync(payload, eventId).catch(console.error);
});

async function isAlreadyProcessed(id: string): Promise<boolean> {
  // Check your database/cache (e.g. Redis) here
  return false; 
}

async function processEventAsync(payload: any, eventId: string) {
  // Mark event as processed in your store to ensure idempotency
  await markAsProcessed(eventId);

  // Route by event properties
  if (payload.status) {
    // Delivery status update receipt
    return handleDeliveryStatus(payload);
  }

  if (payload.direction === "inbound") {
    switch (payload.msg_type) {
      case "text":
        return handleInboundText(payload);
      case "image":
      case "video":
      case "audio":
      case "document":
      case "sticker":
        return handleInboundMedia(payload);
      case "interactive":
        return handleInteractiveReplies(payload);
      default:
        console.log("Unhandled inbound message type:", payload.msg_type);
    }
  }
}

// Stubs for task guides
async function handleInboundText(payload: any) { /* See guide */ }
async function handleInboundMedia(payload: any) { /* See guide */ }
async function handleInteractiveReplies(payload: any) { /* See guide */ }
async function handleDeliveryStatus(payload: any) { /* See guide */ }
async function markAsProcessed(id: string) { /* Save state */ }
from fastapi import FastAPI, Request, HTTPException, BackgroundTasks
from svix.webhooks import Webhook, WebhookVerificationError
import os
import json

app = FastAPI()
wh = Webhook(os.environ["WAZAPIN_WEBHOOK_SECRET"])

@app.post("/webhooks/wazapin")
async def handle_webhook(request: Request, background_tasks: BackgroundTasks):
    body = await request.body()
    headers = dict(request.headers)
    event_id = headers.get("svix-id")

    # 1. Verify signature
    try:
        wh.verify(body, headers)
    except WebhookVerificationError:
        raise HTTPException(status_code=403, detail="Invalid signature")

    # 2. Check for duplicates
    if is_already_processed(event_id):
        return {"ok": True, "detail": "Duplicate"}

    payload = json.loads(body.decode("utf-8"))

    # 3. Schedule task to process asynchronously & return 200 OK
    background_tasks.add_task(process_event_async, payload, event_id)
    return {"ok": True}

def is_already_processed(event_id: str) -> bool:
    # Check Redis/DB here
    return False

def process_event_async(payload: dict, event_id: str):
    mark_as_processed(event_id)
    
    # Route event
    status = payload.get("status")
    direction = payload.get("direction")
    msg_type = payload.get("msg_type")

    if status:
        handle_delivery_status(payload)
    elif direction == "inbound":
        if msg_type == "text":
            handle_inbound_text(payload)
        elif msg_type in {"image", "video", "audio", "document", "sticker"}:
            handle_inbound_media(payload)
        elif msg_type == "interactive":
            handle_interactive_replies(payload)

def handle_inbound_text(payload): pass
def handle_inbound_media(payload): pass
def handle_interactive_replies(payload): pass
def handle_delivery_status(payload): pass
def mark_as_processed(event_id): pass

Route guides

Once your skeleton handler is set up, implement the business logic using these detailed guides:

Navigation

Type to search…

↑↓ navigate↵ selectEsc close