Receive WhatsApp replies and statuses via webhook
Receive WhatsApp replies and delivery statuses on your server: register a webhook, verify the HMAC signature, deduplicate, handle retries and replay.
To receive WhatsApp replies and delivery statuses, register an HTTPS URL in Genuka WA: every event
arrives there as a signed POST. Your endpoint verifies the HMAC-SHA256 signature in the
X-Genuka-Signature header against the raw body, stores the event once using its id, answers
2xx within 10 seconds, then processes it in the background.
Last updated October 8, 2026
How do I register a webhook endpoint?
Expose a public HTTPS URL
Genuka WA refuses http:// URLs, URLs carrying a username and password, and URLs pointing at a
private or local address. To develop on your machine, go through an HTTPS tunnel. Redirects are
not followed: a 3xx response counts as a failure, so register the exact final URL.
Register it
In the dashboard under Webhooks, or through the API:
curl -X POST https://wa.genuka.com/api/v1/webhooks \
-H "Authorization: Bearer $GENUKA_WA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://api.example.com/webhooks/genuka",
"connectionId": "con_1",
"events": ["message.received", "message.sent", "message.delivered", "message.read",
"message.failed", "template.status_changed"]
}'Without connectionId or companyId, the endpoint covers every number your key can reach. An
empty or missing events means "every event". template.status_changed brings Meta's verdict on
your templates (the template_status event): without it, an endpoint subscribed to messages only
never receives it.
Copy the secret
The 201 response contains secret (whsec_…). It is only returned at creation and on rotation
(PATCH /api/v1/webhooks/{id} with "rotateSecret": true): store it in your environment, for
example as GENUKA_WEBHOOK_SECRET. A rotation takes effect immediately.
Test it
POST /api/v1/webhooks/{id}/test sends your URL one sample of each event family and returns the
HTTP status it got for each. The samples are signed with your secret, like a real delivery: it is
how you test your signature check before the first real message.
What does an event look like?
Each delivery is a JSON POST carrying one event. The fields around data tell you which
business and number it belongs to; connection_id is the one to reuse when you reply.
{
"id": "cmg7v2k0x0001…",
"type": "inbound_message",
"field": "messages",
"created_at": "2026-10-08T09:31:07.412Z",
"partner_id": "cl9x…",
"company_id": "cm31…",
"connection_id": "cn77…",
"waba_id": "102290129340398",
"phone_number_id": "106540352242922",
"data": {
"from": "237699001122",
"id": "wamid.HBgLMjM3…",
"timestamp": "1791451865",
"type": "text",
"text": { "body": "I won't be home tomorrow" }
}
}For the five historical types, data is the raw object Meta sent, untouched:
type | What it is | data |
|---|---|---|
inbound_message | A message from the customer | Meta's message object |
message_status | sent, delivered, read or failed for a message you sent | Meta's status object |
template_status | A template was approved, rejected or paused | Meta's change value |
account_update | A change on the WhatsApp Business account | Meta's change value |
phone_quality | A number's quality or tier changed | Meta's change value |
Newer events carry a Genuka name (user_preference.stopped, template.quality_changed,
unknown.received…) and a normalized data. Always switch on type, and ignore what you do not
handle without erroring: new types can appear. See also the
Webhooks reference.
How do I read a customer reply?
What an inbound_message contains depends on data.type
(Meta docs, incoming messages):
{
"context": { "from": "237690000000", "id": "wamid.of_the_message_you_sent…" },
"from": "237699001122",
"id": "wamid.HBgL…",
"timestamp": "1791451865",
"type": "interactive",
"interactive": { "type": "button_reply", "button_reply": { "id": "yes", "title": "Confirm" } }
}type: "text"→data.text.body.type: "interactive"→data.interactive.button_reply.idordata.interactive.list_reply.id, theidyou gave the button or row (Meta docs).type: "button"→data.button.payload: a quick-reply button on a template (Meta docs).data.context.id, when present, is thewamidof the message the customer is replying to.
How do I read a delivery status?
{
"id": "wamid.HBgLMjM3…",
"status": "failed",
"timestamp": "1791451901",
"recipient_id": "237699001122",
"errors": [{ "code": 131026, "title": "…" }]
}data.id is the messageId returned by POST /api/v1/messages. errors only appears on a
failure (Meta docs, statuses).
Which headers come with each delivery?
| Header | Contents |
|---|---|
X-Genuka-Signature | t=<timestamp>,v1=<hex HMAC> |
X-Genuka-Event | The event's type |
X-Genuka-Event-Field | The underlying Meta webhook field |
X-Genuka-Delivery | The delivery id, equal to the body's id, identical across attempts |
X-Genuka-Webhook-Id | The endpoint that was targeted |
X-Genuka-Attempt | The attempt number, starting at 1 |
X-Genuka-Test | true on a test event, absent otherwise |
How do I verify the HMAC signature?
The algorithm, exactly as Genuka WA signs:
- Read the raw body, before any
JSON.parse. - From
X-Genuka-Signature, readt(Unix seconds) and everyv1(there can be several). - Reject if
tis more than 300 seconds away from your clock. - Compute the hex HMAC-SHA256 of the string
t+.+ raw body, keyed with the whole secret,whsec_prefix included. - Accept if one of the
v1values equals it, compared in constant time.
A late redelivery still passes the window
Every attempt is signed as it leaves, with a fresh t. A retry six hours later, or a manual
replay, is therefore accepted by the 300-second tolerance; a request captured and replayed by a
third party is not, because t cannot be edited without invalidating v1.
The verification function, with no dependency:
import crypto from "node:crypto";
const TOLERANCE_SECONDS = 300;
export function verifySignature(rawBody: string, header: string | null, secret: string): boolean {
if (!header) return false;
let timestamp = Number.NaN;
const signatures: string[] = [];
for (const part of header.split(",")) {
const [key, value] = part.split("=", 2).map((s) => s.trim());
if (key === "t") timestamp = Number(value);
if (key === "v1" && value) signatures.push(value);
}
if (!Number.isFinite(timestamp) || signatures.length === 0) return false;
// The timestamp is part of the HMAC: outside the window, a captured request is worthless.
if (Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) return false;
const expected = crypto
.createHmac("sha256", secret) // the whole secret, whsec_ prefix included
.update(`${timestamp}.${rawBody}`)
.digest("hex");
// timingSafeEqual throws on two buffers of different byte lengths, and the header is written
// by whoever sends the request: compare the byte lengths, not the string lengths ("é" is one
// character but two bytes), or a forged header turns a 401 into a 500.
const expectedBytes = Buffer.from(expected);
return signatures.some((candidate) => {
const candidateBytes = Buffer.from(candidate);
return (
candidateBytes.length === expectedBytes.length &&
crypto.timingSafeEqual(candidateBytes, expectedBytes)
);
});
}Then the endpoint, for your framework:
import { after } from "next/server";
import { verifySignature } from "@/lib/verify-signature";
export async function POST(request: Request) {
const raw = await request.text(); // the body exactly as it was signed
const signature = request.headers.get("x-genuka-signature");
if (!verifySignature(raw, signature, process.env.GENUKA_WEBHOOK_SECRET!)) {
return new Response("invalid signature", { status: 401 });
}
// A test event is signed like a real one: it must not write anything to your database.
if (request.headers.get("x-genuka-test") === "true") return new Response(null, { status: 204 });
const event = JSON.parse(raw);
// Store first (fast, de-duplicated on event.id), process after the response.
const fresh = await saveEventOnce(event);
if (fresh) after(() => processEvent(event));
return new Response(null, { status: 204 });
}How do I answer fast enough to avoid redeliveries?
A delivery succeeds on any 2xx. Everything else fails and will be retried: a 4xx or 5xx
status, a redirect, or a response that takes longer than 10 seconds.
The sequence that holds under load:
- verify the signature;
- store the event, de-duplicated on its
id— one insert, a few milliseconds; - answer
204; - process afterwards, in
after(), a job queue or a worker.
Only answer 5xx if storing itself failed: that is the one case where you need Genuka WA to try
again. Processing that fails after the response is replayed from your table, not from the webhook.
How do I de-duplicate events?
The body's id — equal to the X-Genuka-Delivery header — is identical across attempts and on a
manual replay. It is your idempotency key.
create table genuka_events (
id text primary key, -- the body's id, stable across attempts
type text not null,
payload jsonb not null,
received_at timestamptz not null default now(),
processed_at timestamptz -- null until processing succeeds
);export async function saveEventOnce(event: { id: string; type: string }): Promise<boolean> {
const result = await db.query(
"insert into genuka_events (id, type, payload) values ($1, $2, $3) on conflict (id) do nothing",
[event.id, event.type, JSON.stringify(event)],
);
return result.rowCount === 1; // false: already received, nothing to do
}Two more points:
- Several endpoints, several
ids. If two of your endpoints cover the same number, each gets its own delivery, with its ownid. In that case also de-duplicate on the business key:data.id(thewamid) for an inbound message, thedata.id+data.statuspair for a status. - Test events have an
idstarting withtest_, theX-Genuka-Test: trueheader and noX-Genuka-Deliveryheader. Filter them out before writing anything to your database, as the samples above do.
Finally, order is not guaranteed: a read can arrive before the delivered for the same message,
and delivered may never arrive at all. When the customer has the chat open as the message lands,
Meta treats it as delivered and read at once and sends only read
(Meta docs, statuses).
So treat read as implying delivered, and never move a status backwards — sent →
delivered → read, and failed is final.
What happens if my server is down?
Genuka WA makes up to six attempts in total:
| Attempt | When |
|---|---|
| 1 | As soon as the event arrives |
| 2 | At least 1 minute after the previous one failed |
| 3 | At least 5 minutes later |
| 4 | At least 30 minutes later |
| 5 | At least 2 hours later |
| 6 | At least 6 hours later, then the delivery is marked failed |
Retries leave from a queue that is drained every 10 minutes, so an attempt can arrive up to about ten minutes after its minimum delay, longer under a heavy backlog.
Once your endpoint is back, find what failed and replay it:
curl "https://wa.genuka.com/api/v1/webhooks/wh_1/deliveries?status=failed" \
-H "Authorization: Bearer $GENUKA_WA_API_KEY"
curl -X POST https://wa.genuka.com/api/v1/webhooks/wh_1/deliveries/cmg7v2k0x0001/replay \
-H "Authorization: Bearer $GENUKA_WA_API_KEY"The replay answers 202 and starts over with a fresh attempt budget. A delivery still pending is
refused (409 delivery_pending): it is already in the queue. The log keeps the exact body of each
delivery; how long it is kept depends on your plan and is reported in meta.retentionDays. The
dashboard's Webhooks section offers the same log and the same resend button.
How do I reply to a message I received?
As long as the customer wrote to you within the last 24 hours, you can answer with free-form text
(Meta docs).
Reuse the event's connection_id, put a + in front of data.from, and quote the message with
replyTo:
{
"connectionId": "cn77…",
"to": "+237699001122",
"replyTo": "wamid.HBgLMjM3…",
"text": "Thanks, noted!"
}To show the blue ticks, call POST /api/v1/messages/{wamid}/read with { "connectionId": … } in
the body — the wamid URL-encoded. Outside the window you need a template: see
Order notifications.
FAQ
Why does my signature never match?
Almost always one of four causes: the body was parsed and re-serialized before verification (a
global express.json(), for instance); the secret was copied without its whsec_ prefix; the
server clock drifts by more than five minutes; or the secret has been rotated since.
Can I receive Meta's webhooks directly?
No. Cloud API webhooks reach Genuka WA, which relays them to you signed with your secret. For the
five historical types, data stays Meta's original object: code that already reads Meta's format
feels at home.
Do I need an API key to receive webhooks?
No. The only thing to check on the receiving end is the signature. Never process an unsigned event.
Do statuses arrive in order?
No: read can come before delivered, or even arrive alone. Rank statuses, treat read as
implying delivered, and never move them backwards.
Sources
Send a WhatsApp campaign through the API
Send a WhatsApp campaign through the API: marketing template, recipients, launch, plan quotas, Meta's marketing limits and delivery tracking.
Coexistence: WhatsApp API and the Business app
Keep the WhatsApp Business app on your number while using the API: requirements (2.24.17+), onboarding steps, what syncs and the limits.