If you have read Meta's Cloud API docs, you already know our send format. Take Meta's message object, add from with the phone number ID you connected, and POST it to /v1/messages. What comes back is Meta's response, wamid and all.
Around that we added what Meta leaves to you. Test and live keys. Tenants, so one account can hold many businesses. An Idempotency-Key header, so a retried POST does not send twice. A batch call that takes 10,000 messages.
Each response also carries Server-Timing: wb;dur=…, meta;dur=…, so a slow send tells you whose milliseconds they were.
# What a delivery looks like. Point it at your handler to test it.curl http://localhost:3000/whatsapp \-H"Content-Type: application/json" \-H"X-WhaBuzz-Signature: t=1792141200,v1=5f2b9c…e9" \-H"X-WhaBuzz-Event-Id: evt_01JAB3K9X2S8C4M7Q1V5R6T0YZ" \-H"X-WhaBuzz-Event-Type: message.inbound" \-H"X-WhaBuzz-Delivery-Id: dlv_01JAB3Y3B6D9F2H5K8N1Q4T7W0" \-H"X-WhaBuzz-Attempt: 1" \-d '{"id": "evt_01JAB3K9X2S8C4M7Q1V5R6T0YZ","type": "message.inbound","phone_number_id": "1013622581841046","wa_id": "16505551234","dedupe_key": "msg:wamid.HBgLMTY1MDU1NTEyMzQVAgASGBQzQUY5QTdGNkU0RTFGMkI3QzFBMDEAAA=","data": {"message": {"type": "text","text": { "body": "Hi, is the 7am batch open tomorrow?" } } },"raw_path": "entry[0].changes[0].value.messages[0]" }'
import crypto from"node:crypto";import express from"express";const app = express();app.post("/whatsapp", express.raw({ type: "*/*" }), (req, res) => {const raw = req.body.toString("utf8");const sig = req.get("X-WhaBuzz-Signature") ?? "";if (!verify(raw, sig, process.env.WHABUZZ_SECRET)) return res.sendStatus(401);const event = JSON.parse(raw);if (event.type === "webhook.verify") return res.json({ challenge: event.challenge }); res.sendStatus(200); // answer within 3 seconds, then do the work jobs.add(event); // your queue; dedupe on event.dedupe_key});function verify(raw, header, secret) {const parts = header.split(",").map((p) => p.split(/=(.*)/s));const t = Number(parts.find(([k]) => k === "t")?.[1]);if (!t || Math.abs(Date.now() / 1000 - t) > 300) returnfalse;const want = crypto.createHmac("sha256", secret).update(`${t}.${raw}`).digest();return parts.some(([k, v]) => {if (k !== "v1") returnfalse;const got = Buffer.from(v, "hex");return got.length === want.length && crypto.timingSafeEqual(got, want); });}
import hashlibimport hmacimport osimport timefrom flask import Flask, requestapp = Flask(__name__)SECRET = os.environ["WHABUZZ_SECRET"].encode()@app.post("/whatsapp")def whatsapp(): raw = request.get_data()ifnot verify(raw, request.headers.get("X-WhaBuzz-Signature", "")):return"", 401 event = request.get_json()if event["type"] == "webhook.verify":return {"challenge": event["challenge"]} jobs.put(event) # your queue; dedupe on event["dedupe_key"]return"", 200# answer within 3 secondsdef verify(raw: bytes, header: str) -> bool: parts = [p.split("=", 1) for p in header.split(",") if"="in p] t = next((int(v) for k, v in parts if k == "t"), 0)if abs(time.time() - t) > 300:returnFalse want = hmac.new(SECRET, f"{t}.".encode() + raw, hashlib.sha256).hexdigest()returnany(hmac.compare_digest(v, want) for k, v in parts if k == "v1")
Answer 2xx inside 3 seconds and do the work after. A retry or a replay of the same event carries the same dedupe_key, so you can skip what you have already handled. Webhooks guide
02 · Webhooks
What you get, down to the header format
These are the numbers in our code today. The docs carry the same figures, with code.
specv1
Retry ladder
The first attempt waits 3 seconds for a 2xx. After that we retry at 30 s, 1 m, 2 m, 5 m, 10 m, 20 m, 40 m and 1 h, then hourly until 24 hours have passed. Retries wait 10 seconds each. Redirects are not followed.
Dead deliveries
After 24 hours a delivery is marked dead. Replay one with POST /v1/webhook-deliveries/{id}/replay, or up to 10,000 at once by filter on the endpoint.
Signature
HMAC-SHA256 of {t}.{raw body}, keyed with your whsec_ secret. Reject anything more than 300 seconds from your clock.
Both secrets sign during the overlap, so the header carries two v1 values. The overlap is 24 hours unless you set 0 to 168.
Meta's errors
Passed through with Meta's HTTP status and "source": "meta". 131047 means the 24-hour window is closed and you need a template. 131009 means a parameter value is invalid. Our own errors say "source": "whabuzz", for example number_not_connected.
Retention
Message bodies are kept 90 days by default. Set 30, 180 or 365 with PATCH /v1/tenant. Replay works inside that window. Media is streamed through and never stored.
Where it runs
Cloudflare's Asia-Pacific region, for Workers and for data (D1, Durable Objects, R2).
Limits
10 webhook endpoints per tenant. 10,000 messages per batch call. Idempotency keys are held for 24 hours.
Prices exclude GST. Sign-up is open to Indian businesses: confirm your email and a WhatsApp code to your mobile.
04 · FAQ
Four things developers ask first
What happens if the 24-hour window has closed?
Meta rejects a free-form message with error 131047, and we return it to you as is, with "source": "meta". Send an approved template instead. We never pre-check the window, so the rule you hit is always Meta's current one.
Who bills for the messages?
Meta, at Meta's rate, on the business's own Meta billing. We charge for connected numbers only: ₹500 a month for the first, ₹250 for each after, plus GST, drawn a day at a time from a prepaid wallet.
What happens to a webhook my server misses?
We retry on the ladder above for 24 hours. After that the delivery is marked dead and you can replay it for as long as the event is retained, 90 days by default. Replays carry the same dedupe_key as the original.
Can I move a number from WATI, Plivo or another BSP?
Yes, if the WhatsApp Business Account is in your business's name. Connect it, pull your approved templates across with POST /v1/templates/sync, then switch the webhook subscription. Expect a short gap where inbound messages reach neither provider. The migration guide has the steps, including what to do if the provider owns the account.