Webhooks
Receive inbound messages, delivery receipts and status changes.
You register a URL, Genuka WA delivers every event there as a signed POST.
Registering an endpoint
In the portal, under Webhooks: enter the URL (HTTPS required) and tick the events you want. The
signing secret (whsec_…) is shown once, at creation.
An endpoint can cover every number on your account (general), one connected business, or a single phone number — and a business can be opted out of a general endpoint.
Available events
| Event | Contents |
|---|---|
messages | Inbound messages and statuses of the messages you sent |
message_template_status_update | A template was approved, rejected or paused |
account_update | Account change: verification, restriction, suspension |
phone_number_quality_update | A number's quality or sending tier changed |
The platform keeps no inbox: webhooks are how you read replies.
The payload
Every delivery is a POST carrying one event. data is the raw Meta value, untouched; the fields
around it tell you which business and number it belongs to.
{
"id": "dlv_2f8c1a...",
"type": "message_status",
"field": "messages",
"created_at": "2026-08-04T09:31:07.412Z",
"partner_id": "cl9x...",
"company_id": "cm31...",
"connection_id": "cn77...",
"waba_id": "102290129340398",
"phone_number_id": "106540352242922",
"data": {
"id": "wamid.HBgLMjM3...",
"status": "delivered",
"timestamp": "1785836801",
"recipient_id": "237699000000"
}
}Headers
| Header | Contents |
|---|---|
X-Genuka-Signature | The signature — see below |
X-Genuka-Event | Event type, e.g. message_status |
X-Genuka-Event-Field | The underlying Meta webhook field |
X-Genuka-Delivery | Delivery id, identical across retries — use it to de-duplicate |
X-Genuka-Webhook-Id | The endpoint that was targeted |
X-Genuka-Attempt | Attempt number, starting at 1 |
Verifying the signature
The header is X-Genuka-Signature, shaped t=1785836801,v1=5f3c…, where v1 is the hex
HMAC-SHA256 of `${t}.${rawBody}` keyed with your secret.
The timestamp is bound into the MAC: that is what stops a captured request from being replayed.
A receiver rejects any t outside its tolerance window (300 seconds by default), and t cannot be
edited without invalidating v1.
Sign the raw body, exactly as received. A JSON.parse followed by a JSON.stringify changes
key order and whitespace: the signature will never match.
import { verifySignature } from "@genuka/whatsapp/webhooks";
export async function POST(request: Request) {
const raw = await request.text();
const signature = request.headers.get("x-genuka-signature");
if (!(await verifySignature(process.env.WEBHOOK_SECRET!, raw, signature))) {
return new Response("invalid signature", { status: 401 });
}
// Process asynchronously, answer straight away.
void handle(JSON.parse(raw));
return new Response("ok");
}Minimum version: @genuka/whatsapp 0.1.1
verifySignature recognises both schemes and applies the matching algorithm: Genuka WA's
timestamped format (t=…,v1=…) and Meta's (X-Hub-Signature-256), the latter only useful if you
also receive Meta webhooks directly. In 0.1.0 only Meta's format was handled — verifying a
Genuka WA webhook failed every time.
If you are not using the library, the algorithm is a few lines:
import crypto from "node:crypto";
const TOLERANCE_SECONDS = 300;
export function verify(rawBody: string, header: string, secret: string): boolean {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const timestamp = Number(parts.t);
// Reject anything outside the window: this is what makes a captured
// request useless to replay later.
if (Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${timestamp}.${rawBody}`)
.digest("hex");
return crypto.timingSafeEqual(
Buffer.from(parts.v1, "utf8"),
Buffer.from(expected, "utf8"),
);
}Rotate the secret when you suspect exposure
Rotation takes effect immediately: the previous secret stops verifying as soon as you rotate, so update your receiver in the same window.
Three processing rules
Answer 200 immediately
Do your processing in the background. A slow endpoint triggers re-deliveries, which amplify load at exactly the wrong moment.
Events can be duplicated. The same message may be delivered to you twice. Make your processing
idempotent using the message id (wamid) or X-Genuka-Delivery.
Order is not guaranteed. A read receipt can arrive before the delivery receipt for the same
message. Never regress a status: the logical order is sent → delivered → read, and failed
is terminal.
Statuses of a sent message
| Status | Meaning |
|---|---|
sent | Accepted by WhatsApp's servers |
delivered | Reached the recipient's device |
read | Read — only if the user enabled read receipts |
failed | Failed, with Meta's error code |
deleted | Message deleted |
Retries & failures
A delivery counts as successful on any 2xx. Anything else — including a timeout past 10 seconds —
is retried 5 times with growing backoff (1 min, 5 min, 30 min, 2 h, 6 h), then marked failed.
Log and replay
The portal keeps the delivery history: payload, HTTP status, attempt count and your server's response. If something broke on your side, you can replay an event once your endpoint is back.
An API key plays no part in receiving webhooks: the only thing to check on the receiving end is the signature. Never trust the contents of an unsigned event.