GhalaGhalaHelp Center
Back to ghala.ioghala.ioSign in
  • Getting Started
    • Ghala Documentation
    • Send Your First WhatsApp Message via API
    • Receiving Events with Webhooks
  • Commerce
    • Start Selling on WhatsApp
    • Set Up the AI Sales Agent
    • Get Paid with Snippe
  • Ai Automation
    • Configuring AI Auto-Reply for WhatsApp
    • Setting Up Human Handover Protocol
  • Campaigns Messaging
    • WhatsApp Message Templates: Complete Guide
    • Bulk WhatsApp Messaging: Complete Campaign Guide
  • Contacts Crm
    • WhatsApp Contact Management Guide
  • Best Practices
    • WhatsApp Customer Support Best Practices
    • Message Template Best Practices
  • Api Reference
    • Ghala Developer API Reference
    • Ghala API vs Meta Cloud API
    • Supported Capabilities
    • Multiple Numbers and Multi-Tenant Platforms
    • Errors and Retries
    • Limits and Quotas
    • Authentication and Access Tokens
    • Connecting and Onboarding a Number
    • Templates and Media
    • Developer Tooling
    • Versioning and Changelog

Products

  • Ghala
  • Sarufi
  • Snippe
  • Sema

Explore

  • Use Cases
  • Pricing
  • Ghala Academy
  • Blog

Developers

  • Docs
  • API Reference
  • API Quickstart
  • Webhooks Guide

Contact

  • SkyCity Mall, 9th Floor, Dar es Salaam, Tanzania
  • info@ghala.io
  • +255 699 920 009
© 2026 Neurotech Company LimitedTerms of ServicePrivacy PolicySitemap
  1. Help Center
  2. Getting Started
  3. Receiving Events with Webhooks

Receiving Events with Webhooks

v2

Two ways to receive a number's events, how to verify a signed delivery, and what to do when an endpoint is disabled.

Two ways to receive events

By default your number's events flow into Ghala; that is what powers the inbox, AI auto-reply, and analytics. You do not need to configure anything for that.

When you are building your own integration, there are two ways to get those events onto your own server, and they are not interchangeable:

Event subscriptions WhatsApp callback override
What it does Ghala sends you a signed copy of each event Meta delivers to you instead of Ghala
Ghala inbox, AI agent, orders Keep working Pause for that number
Payload format Ghala events (message.received, message.status) Raw WhatsApp Cloud API
Set up in Dashboard → Developer → Events Dashboard → Developer → Webhooks

Start with event subscriptions. They are additive: you get the data and the product keeps working. Reach for the override only when you are deliberately replacing Ghala's message handling for that number.

The two are mutually exclusive. Registering a subscription is refused while a callback override is in place.

Both are configured in the dashboard. There is no /api/v2 endpoint for registering, listing, or deleting event subscriptions. /api/v2/webhooks returns 404; if you have seen that path in older documentation or from an assistant, it was never served.

Event subscriptions

Step 1: Register your endpoint

Create an event subscription in the dashboard: Developer → Events. Enter your public HTTPS endpoint, pick the events you want, and save.

Store the secret now. It is returned exactly once, at creation, and is encrypted at rest afterwards. There is no way to read it back; if you lose it, delete the subscription and register a new one.

There is no verification handshake to implement. Ghala starts delivering as soon as the subscription is ACTIVE.

Endpoint requirements. Public HTTPS only. Plain http://, loopback, link-local (169.254.169.254), and RFC1918 private addresses are rejected with 400. Ghala makes these requests, so they would be an SSRF surface. For local development, expose your server with a tunnel (ngrok, cloudflared) and register the tunnel URL.

If you run several numbers, register a separate path per number — /ghala/acme/webhook, /ghala/bakari-ltd/webhook. Each subscription has its own signing secret, so the URL is what tells you which secret to verify with. See Multiple Numbers and Multi-Tenant Platforms.

Step 2: Verify every delivery

Each delivery carries four headers:

X-Ghala-Signature: sha256=<hex(HMAC-SHA256(secret, "{timestamp}.{raw_body}"))>
X-Ghala-Timestamp: <unix seconds>
X-Ghala-Event:     message.received
X-Ghala-Delivery:  <ULID, stable across retries>

Recompute the signature and compare. Three details decide whether this is real security or decoration:

  1. Sign the exact bytes you received. Parsing the JSON and re-serializing it changes whitespace and key order, and the signature will never match. Read the raw body.
  2. Compare in constant time. A plain == leaks the expected signature one byte at a time.
  3. Reject a stale timestamp. More than 300 seconds of skew means someone is replaying an old delivery.
import crypto from "node:crypto";
import express from "express";

const SECRET = process.env.GHALA_WEBHOOK_SECRET; // whsec_...
const MAX_SKEW_SECONDS = 300;

function isValid(rawBody, signature, timestamp) {
  const ts = Number(timestamp);
  if (!Number.isFinite(ts)) return false;
  if (Math.abs(Date.now() / 1000 - ts) > MAX_SKEW_SECONDS) return false;

  const hmac = crypto.createHmac("sha256", SECRET);
  hmac.update(`${timestamp}.`);
  hmac.update(rawBody); // the exact bytes, still a Buffer
  const expected = Buffer.from(hmac.digest("hex"), "hex");
  const received = Buffer.from(
    String(signature ?? "").replace(/^sha256=/, ""),
    "hex",
  );

  return (
    expected.length === received.length &&
    crypto.timingSafeEqual(expected, received)
  );
}

const app = express();

// express.raw, not express.json: a parsed body cannot be verified.
app.post(
  "/ghala/webhook",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const valid = isValid(
      req.body,
      req.get("X-Ghala-Signature"),
      req.get("X-Ghala-Timestamp"),
    );
    if (!valid) return res.sendStatus(401);

    res.sendStatus(200); // acknowledge first, process after

    const deliveryId = req.get("X-Ghala-Delivery");
    if (alreadyHandled(deliveryId)) return;

    const event = req.get("X-Ghala-Event");
    const payload = JSON.parse(req.body.toString("utf8"));
    handle(event, payload);
  },
);

app.listen(3000);
import hashlib
import hmac
import os
import time

from flask import Flask, request, abort

SECRET = os.environ["GHALA_WEBHOOK_SECRET"].encode()  # whsec_...
MAX_SKEW_SECONDS = 300

app = Flask(__name__)


@app.post("/ghala/webhook")
def ghala_webhook():
    raw = request.get_data()  # the exact bytes
    timestamp = request.headers.get("X-Ghala-Timestamp", "")
    signature = request.headers.get("X-Ghala-Signature", "")

    try:
        if abs(time.time() - int(timestamp)) > MAX_SKEW_SECONDS:
            abort(401)
    except ValueError:
        abort(401)

    expected = hmac.new(
        SECRET, f"{timestamp}.".encode() + raw, hashlib.sha256
    ).hexdigest()
    if not hmac.compare_digest(expected, signature.removeprefix("sha256=")):
        abort(401)

    delivery_id = request.headers.get("X-Ghala-Delivery")
    if not already_handled(delivery_id):
        handle(request.headers.get("X-Ghala-Event"), request.get_json())

    return "", 200

Step 3: Handle the events

X-Ghala-Event names what happened; the payload is JSON in the body.

Event Fires when
message.received A customer sends a message to your number
message.status A message you sent changes state: sent, delivered, read, or failed
message.sent The number sends a message, from any source
template.status A template's Meta review state changes
account.error Something is wrong with the number's WhatsApp account
conversation.escalated A conversation is handed to a human
order.paid An order on the number's shop is paid

message.received and message.status are the two confirmed to publish today; the rest are declared but not confirmed as delivering.

Write your handler so it dispatches on the header and tolerates fields it does not recognise:

function handle(event, payload) {
  switch (event) {
    case "message.received":
      return onInbound(payload);
    case "message.status":
      return onStatusChange(payload);
    default:
      // Unknown event types are not an error. Log and move on, so a new
      // event type cannot take your endpoint down.
      return log.info({ event }, "unhandled ghala event");
  }
}

Payload field reference. The per-event JSON schemas are not published in this help centre yet. Until they are, read the exact envelope from Developer → Events in the dashboard: the event log holds 30 days of events, and opening one shows the precise body a subscriber was sent. That is the authoritative shape — not a third-party request bin, and not an example on a page.

Rules of thumb

  • Acknowledge fast. Return 200 within a few seconds and process asynchronously; slow responses count as failures.
  • Expect duplicates. Delivery is at-least-once, so the same event can arrive more than once. Deduplicate on X-Ghala-Delivery, which is stable across retries.
  • Do not trust arrival order. Deliveries are unordered; sequence with the payload's own timestamps.
  • Make handlers idempotent. Duplicates and out-of-order arrival both stop mattering if processing the same event twice is harmless.
  • Replying is a normal send. Answering an incoming message within its 24-hour window is a regular send-message call. Webhook in, API out is a complete bot.

When deliveries fail

An endpoint is auto-disabled after 20 consecutive failed deliveries. An outage you do not notice otherwise becomes silence you do not notice either.

To inspect what happened, open the subscription under Developer → Events. Each delivery attempt records:

  • the status — PENDING, DELIVERED, or FAILED
  • the attempt count, and when the next retry is due while still pending
  • the HTTP status your endpoint returned on the last attempt
  • the last error — a timeout, a TLS failure, or the status itself

That is what tells a bad certificate apart from a 500 in your own handler. Settled attempts are pruned after 7 days, so it is a recent window rather than a full history.

To reactivate a disabled endpoint, fix the underlying problem and register the endpoint again under Developer → Events. Registering produces a new signing secret, shown once, so update your handler's secret at the same time.

To recover events you missed, use the event log: it is written whether or not anyone is subscribed, is retained 30 days, and each entry carries the exact payload a subscriber is sent. Re-delivery is not exposed, so catching up means reading the log rather than asking for a replay.

Removing an endpoint drops anything still queued for it, so a decommissioned endpoint will not receive a backlog days later.

WhatsApp callback override

The other path points your number's WhatsApp callback at your server. Events then arrive directly from Meta in the standard WhatsApp Cloud API webhook format.

Trade-off to know: while the override is active, events for that number go to you instead of Ghala, so the Ghala inbox and AI auto-reply pause for it, orders stop being captured, and any event subscriptions go silent. Remove the override any time to hand the number back.

Choose this only when you are replacing Ghala's message handling. If you just want a copy of the data, use event subscriptions above.

Step 1: Build an endpoint that passes verification

Meta requires a one-time verification handshake: a GET with hub.mode, hub.verify_token, and hub.challenge query parameters. Check the token, echo the challenge.

import express from "express"

const app = express()
app.use(express.json())

const VERIFY_TOKEN = process.env.VERIFY_TOKEN // you choose this value

// Meta calls this once when the webhook is configured
app.get("/webhook", (req, res) => {
  const mode = req.query["hub.mode"]
  const token = req.query["hub.verify_token"]
  const challenge = req.query["hub.challenge"]
  if (mode === "subscribe" && token === VERIFY_TOKEN) {
    return res.status(200).send(challenge)
  }
  res.sendStatus(403)
})

// Events arrive here
app.post("/webhook", (req, res) => {
  res.sendStatus(200) // acknowledge first, process after

  const value = req.body.entry?.[0]?.changes?.[0]?.value
  for (const message of value?.messages ?? []) {
    console.log("incoming:", message.from, message.text?.body)
  }
  for (const status of value?.statuses ?? []) {
    console.log("status:", status.id, status.status)
  }
})

app.listen(3000)

For local development, expose your server with a tunnel and use the tunnel URL.

Step 2: Point your number at it

Open Developer → Webhooks, enter your callback URL and the verify token you chose, and save. Ghala configures the override with Meta and runs the verification handshake immediately; if your endpoint echoes the challenge, you are live.

Step 3: Handle the events

Everything arrives as POST with Meta's envelope. The two payloads you will care about:

An incoming customer message

{
  "entry": [{
    "changes": [{
      "field": "messages",
      "value": {
        "contacts": [{ "profile": { "name": "Amina" }, "wa_id": "255712345678" }],
        "messages": [{
          "id": "wamid.HBgM...",
          "from": "255712345678",
          "timestamp": "1721300000",
          "type": "text",
          "text": { "body": "Do you deliver to Dodoma?" }
        }]
      }
    }]
  }]
}

Other type values (image, audio, document, location, interactive) carry a matching object instead of text. Meta's payload examples are the reference for every one of them.

A status update for a message you sent

{
  "entry": [{
    "changes": [{
      "field": "messages",
      "value": {
        "statuses": [{
          "id": "wamid.HBgM...",
          "status": "delivered",
          "timestamp": "1721300005",
          "recipient_id": "255712345678"
        }]
      }
    }]
  }]
}

status progresses sent → delivered → read, or lands on failed with an errors array explaining why.

Deliveries carry Meta's X-Hub-Signature-256 header, and failed deliveries are retried with backoff, so deduplicate by message id. Keep your verify token secret and unguessable; it is what stops strangers registering your endpoint.

What's next

  • Every endpoint, parameter, and error code: API Reference
  • Send the reply: Send Your First WhatsApp Message
  • Several numbers to route: Multiple Numbers and Multi-Tenant Platforms
PreviousSend Your First WhatsApp Message via APINextStart Selling on WhatsApp

On this page

  • Two ways to receive events
  • Event subscriptions
  • Step 1: Register your endpoint
  • Step 2: Verify every delivery
  • Step 3: Handle the events
  • Rules of thumb
  • When deliveries fail
  • WhatsApp callback override
  • Step 1: Build an endpoint that passes verification
  • Step 2: Point your number at it
  • Step 3: Handle the events
  • What's next