WhatsApp Webhooks: How to Set Up and Handle Inbound Events with Chakra Chat

Every two-way WhatsApp integration — a chatbot, a CRM sync, an n8n automation, a custom notification pipeline — starts in the same place: a webhook. WhatsApp's Cloud API is asynchronous. When you send a message, you get an acknowledgment that it was accepted, not confirmation it arrived. Everything that happens after that — delivery, read receipts, replies, failures — comes back to you as an event on a webhook you control.

This guide covers how Chakra exposes those events, the two distinct webhook types available, their payload structures, how to verify events are genuinely from Chakra, how to handle the duplicate deliveries you will eventually see, and a simple router pattern for handling everything cleanly in one place.

Two Webhook Types, Not One

Chakra gives you two separate ways to receive inbound events, and picking the right one matters for how much parsing work lands on your side.

Pass-Through Events Webhook Chakra Events Webhook
Format Raw Meta Cloud API payload, unmodified Simplified, Chakra-normalized payload
Event coverage Everything Meta sends — messages, status, template sends, account updates A curated set: message, status, message_echo, smb_message_echo
Best for Teams already familiar with Meta's webhook schema, or porting existing integrations Teams that want less parsing overhead and a consistent shape regardless of Meta's schema changes
Typical use n8n/Zapier flows built against Meta's documented webhook format Custom backend integrations, CRM sync, simpler routing logic

You can configure either, or both — they're independent settings and nothing stops you from running a Chakra webhook for your primary integration and a pass-through one for a specific tool like n8n that expects Meta's native shape.

Setting Up the Pass-Through Webhook

This delivers Meta's own event format, untouched, exactly as documented in Meta's Cloud API webhook reference.

To configure it:

  1. Go to the WhatsApp Setup page in your Chakra dashboard
  2. Click the More tab
  3. Find the field labeled "Pass-through webhook URL for Meta events"
  4. Paste your endpoint URL
  5. Click Save

This is the path most n8n and Zapier integrations use, since those platforms typically expect (or are built against) Meta's own webhook shape.

Setting Up the Chakra Events Webhook

This is Chakra's own normalized format — a consistent envelope regardless of what Meta changes on their side.

To configure it:

  1. Go to WhatsApp Setup → More
  2. Enable the "Enable Chakra Webhooks" toggle
  3. Enter your endpoint in the "Chakra webhook URL" field
  4. Select which event types you want under "Which events to send" — only the types you check will be delivered
  5. Click Save

Your endpoint needs to be publicly reachable and doesn't require authentication on your side (verification happens via HMAC signature instead — more on that below).

Event Types and Payload Structure

Pass-Through Format (Meta's Native Shape)

Everything arrives wrapped in Meta's standard entrychangesvalue structure, with a field indicating what kind of event it is. Two of the most common:

An inbound text message (field: "messages"):

{
  "object": "whatsapp_business_account",
  "entry": [
    {
      "id": "83784929738012",
      "changes": [
        {
          "value": {
            "messaging_product": "whatsapp",
            "metadata": {
              "display_phone_number": "917259775805",
              "phone_number_id": "694794789348887"
            },
            "contacts": [
              { "profile": { "name": "John" }, "wa_id": "919911223344" }
            ],
            "messages": [
              {
                "from": "919911223344",
                "id": "wamid.HBgMOTEST3AxMjU4NDMzFQIAEhgUM0VCMDM3RjlEQTNDQTBEMjQ2Q275",
                "timestamp": "1756322278",
                "type": "text",
                "text": { "body": "Hi, can you please help me out with an issue" }
              }
            ]
          },
          "field": "messages"
        }
      ]
    }
  ]
}

An inbound image message — same envelope, different type:

{
  "messages": [
    {
      "from": "919911223344",
      "id": "wamid.HBgMOTEST3AxMjU4NDMzFQIAEhgUM0VCMDM3RjlEQTNDQTBEMjQ2Q275",
      "timestamp": "1756322278",
      "type": "image",
      "image": {
        "mime_type": "image/jpeg",
        "sha256": "t8K9fuzwAoeBSOoIZWkBlI1hTflUHqSwCvGwJ+exm74=",
        "id": "1402363984735353"
      }
    }
  ]
}

Note that the image event only gives you a media ID, not the file itself — you fetch the actual media separately via Chakra's media-fetch endpoint using that ID.

A template send event (field: "message_template_sends") — fired when an API-triggered template message goes out, useful for logging exactly what was sent and to whom without maintaining your own separate send log:

{
  "field": "message_template_sends",
  "value": {
    "message_template_sends": [
      {
        "id": "wamid.HBgMOTE5OTAxMjU4NDMzFQIAERgSMjNBQkQ5NDA5NTFEN0MzOTU2AA==",
        "timestamp": 1763821065,
        "to": "919911223344",
        "type": "template",
        "template": {
          "language": { "policy": "deterministic", "code": "en_US" },
          "name": "new_year_promo_24",
          "components": [
            { "type": "body", "parameters": [{ "type": "text", "text": "John" }] }
          ]
        }
      }
    ]
  }
}

Since this webhook passes through Meta's format unmodified, delivery/read status events, error events, and account-level updates all follow the same shape documented in Meta's own Cloud API webhook reference — worth keeping that page bookmarked alongside this guide.

Chakra Events Format

Every event arrives with exactly two top-level keys:

{
  "event": "message",
  "payload": { /* MessagePayload or DeliveryStatusPayload, depending on event type */ }
}

event is one of four values:

  • message — an inbound message received on your number
  • status — a delivery status update for a message sent earlier via API (sent, delivered, read, failed)
  • message_echo — an outbound message sent from within the Chakra platform itself (e.g., an agent reply from the shared inbox), useful if you're syncing full conversation history to an external system
  • smb_message_echo — an outbound message sent from the WhatsApp Business App side, relevant specifically if you're running Coexistence and need to capture messages sent from the app, not just the API

This normalized shape is what makes the Chakra webhook easier to build a router against — you're always branching on the same event field, regardless of what Meta's underlying schema looks like that month.

Verifying Webhook Authenticity with HMAC

Since your webhook endpoint is public and requires no inbound auth, verifying that an event genuinely came from Chakra (and hasn't been tampered with) matters before you act on it.

Setup:

  1. Go to Admin → Team → Secrets and set an HMAC secret
  2. Once set, every webhook call includes an X-Chakra-Signature-256 header

Verification logic: generate an HMAC-SHA256 hash of the raw request body string using your secret as the key, and compare it against the header value.

const crypto = require("crypto");

function verifyChakraSignature(rawBody, signatureHeader, secret) {
  const expectedHash = crypto
    .createHmac("sha256", secret)
    .update(rawBody) // must be the raw string, not a parsed object
    .digest("hex");

  // timing-safe comparison
  return crypto.timingSafeEqual(
    Buffer.from(expectedHash, "utf8"),
    Buffer.from(signatureHeader, "utf8")
  );
}

app.post("/webhooks/chakra", express.raw({ type: "*/*" }), (req, res) => {
  const rawBody = req.body.toString("utf8");
  const signature = req.headers["x-chakra-signature-256"];

  if (!verifyChakraSignature(rawBody, signature, process.env.CHAKRA_HMAC_SECRET)) {
    return res.status(401).send("Invalid signature");
  }

  const event = JSON.parse(rawBody);
  handleEvent(event);
  res.sendStatus(200);
});

The detail that trips people up: you must hash the raw body string, not a version that's been parsed to JSON and re-stringified — most frameworks parse the body automatically before your handler runs, so you typically need a raw-body middleware (as shown above with express.raw) specifically for this route. Also note the header value has no sha256= prefix, unlike some other providers' HMAC conventions — don't strip a prefix that isn't there.

Handling Duplicate Events

WhatsApp's webhook delivery is effectively at-least-once, not exactly-once — network retries, temporary endpoint downtime, or Meta-side retries can all result in the same event arriving more than once. Building deduplication in from the start avoids double-processing a message (sending two replies, double-counting a delivery status, triggering a workflow twice).

The reliable dedup key is the message ID (wamid... for messages, or the corresponding ID field for status events) — store recently processed IDs and check against them before acting:

const processedIds = new Map(); // swap for Redis/DB in production
const DEDUP_TTL_MS = 10 * 60 * 1000; // 10 minutes is generally enough

function isDuplicate(messageId) {
  const seenAt = processedIds.get(messageId);
  if (seenAt && Date.now() - seenAt < DEDUP_TTL_MS) return true;
  processedIds.set(messageId, Date.now());
  return false;
}

function handleEvent(event) {
  const messageId = event.payload?.id || event.payload?.messages?.[0]?.id;
  if (messageId && isDuplicate(messageId)) {
    return; // already processed, skip silently
  }
  routeEvent(event);
}

In production, use Redis or your database with a TTL rather than an in-memory Map — a single-process Map won't survive a restart or work across multiple server instances.

Building a Simple Message Router

Once events are verified and deduplicated, the practical next step is routing them to the right handler based on type. Here's a minimal router for the Chakra event format:

function routeEvent(event) {
  switch (event.event) {
    case "message":
      return handleInboundMessage(event.payload);
    case "status":
      return handleStatusUpdate(event.payload);
    case "message_echo":
      return handleAgentReply(event.payload);
    case "smb_message_echo":
      return handleAppSentMessage(event.payload);
    default:
      console.warn("Unhandled event type:", event.event);
  }
}

function handleInboundMessage(payload) {
  const messageType = payload.type; // "text", "image", "interactive", etc.

  switch (messageType) {
    case "text":
      return processTextMessage(payload);
    case "image":
    case "document":
      return processMediaMessage(payload); // fetch media by ID separately
    case "interactive":
      return processButtonOrListReply(payload);
    default:
      console.log("Unrecognized message type:", messageType);
  }
}

function handleStatusUpdate(payload) {
  // payload.status: "sent" | "delivered" | "read" | "failed"
  updateMessageRecordInDb(payload.id, payload.status, payload.errorCode);
}

This same branching pattern extends cleanly if you're on the pass-through webhook instead — you'd branch on changes[].field (messages, message_template_sends, message_status equivalents) rather than event, then drill into value.messages[0].type the same way.

A Note on Call Events

If you're using Chakra's Calling API alongside messaging, call-related events (initiated, connected, ended, missed) are delivered through the same pass-through mechanism, following Meta's own call event webhook schema. If your router already branches on field for message-related events, extending it to handle a calls field follows the identical pattern — check Meta's official Cloud API documentation for the exact call event field names, since that schema is passed through unmodified rather than normalized by Chakra.

Using This with n8n

For n8n specifically, the typical setup uses the pass-through webhook, since n8n's WhatsApp-oriented workflows and community templates are generally built against Meta's native format:

  1. Set up a Webhook trigger node in n8n and copy its test/production URL
  2. Paste that URL into the "Pass-through webhook URL for Meta events" field under WhatsApp Setup → More
  3. Add a filter/if node early in your workflow to discard non-message events (status updates, template send confirmations) if your flow only cares about inbound messages
  4. From there, route into an AI Agent node, a CRM node, or whatever your workflow needs — the raw Meta payload is available to reference throughout

This is also where an early filter step earns its keep: without it, every status and delivery event triggers your full workflow unnecessarily, which adds cost and noise for platforms billing per execution.

A Few Practical Notes Before You Ship This

  • Respond 200 fast, process async. Acknowledge the webhook immediately and do any slow processing (database writes, calling another API) after responding, or in a background job — a slow response risks Meta or Chakra treating the delivery as failed and retrying, compounding your duplicate-handling load.
  • Log raw payloads during development. Meta's schema has minor variations across message types, and having raw examples on hand saves time when you hit an edge case your router doesn't cover yet.
  • Keep your HMAC secret out of version control, obviously, but also rotate it if you ever suspect it's been exposed — regenerating it in Admin → Team → Secrets immediately invalidates the old signature.
  • Watch your endpoint's uptime. A webhook that's down when an event fires means a missed message or status update on your side, with no automatic backfill — worth monitoring this endpoint like any other production-critical service.

Bringing It Together

Webhooks are the unglamorous foundation everything else in a WhatsApp integration depends on — get the event format, verification, and deduplication right once, and every chatbot, CRM sync, or automation you build on top of it inherits that reliability. Start with whichever webhook type matches your tooling (Chakra's normalized format for custom backends, pass-through for n8n or Meta-schema-native tools), verify signatures from day one rather than retrofitting it later, and dedupe on message ID before your router does anything stateful.

If you're building this out, Chakra's full API documentation covers the complete event and endpoint reference referenced throughout this guide.