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

Products

  • Ghala
  • Sarufi
  • Snippe
  • Sema

Explore

  • Use Cases
  • Pricing
  • 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

Receive incoming messages, delivery updates, and read receipts on your own endpoint in real time.

Two ways to receive events

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

When you're 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're additive: you get the data and the product keeps working. Reach for the override only when you're 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.

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 (message.received, message.status), and save.

Store the secret now. It's 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's 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'd be an SSRF surface. For local development, expose your server with a tunnel (ngrok, cloudflared) and register the tunnel URL.

Available events are message.received and message.status. Order and payment events aren't published yet, and asking for them returns 400 with the valid list in the message.

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 can't 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

Before writing any parsing code, register a webhook.site URL and message the number from a real handset. You'll see the exact payloads arrive, which beats coding against an example.

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.
  • Don't trust arrival order. Deliveries are unordered; sequence with the payload's own timestamps.
  • Watch your health counters. GET /api/v2/webhooks reports consecutive_failures and last_success_at. An endpoint is auto-disabled after 20 consecutive failed deliveries, so an outage you don't notice becomes silence you don't notice either.
  • 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.

Removing an endpoint

curl -X DELETE https://v2.ghala.io/api/v2/webhooks/WEBHOOK_ID \
  -H "Authorization: Bearer $ACCESS_TOKEN"

Anything still queued for it is dropped, so a decommissioned endpoint won't 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. Remove the override any time to hand the number back.

Choose this only when you're 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're live.

Step 3: Handle the events

Everything arrives as POST with Meta's envelope. The two payloads you'll 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.

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's 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
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
  • Removing an endpoint
  • 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