# Receive WhatsApp replies and statuses via webhook

URL: https://wa.genuka.com/en/docs/guides/receive-messages-webhooks
Language: English

> 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?

1. ### 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.

2. ### Register it

   In the dashboard under **Webhooks**, or through the API:

   ```bash title="POST /api/v1/webhooks"
   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.

3. ### 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.

4. ### 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.

```json title="POST https://api.example.com/webhooks/genuka"
{
  "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](https://wa.genuka.com/en/docs/webhooks).

### How do I read a customer reply?

What an `inbound_message` contains depends on `data.type`
([Meta docs, incoming messages](https://developers.facebook.com/documentation/business-messaging/whatsapp/webhooks/reference/messages)):

```json title="data — the customer tapped a reply button (interactive message)"
{
  "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.id` or `data.interactive.list_reply.id`,
  the `id` you gave the button or row
  ([Meta docs](https://developers.facebook.com/documentation/business-messaging/whatsapp/webhooks/reference/messages/interactive)).
* `type: "button"` → `data.button.payload`: a quick-reply button on a template
  ([Meta docs](https://developers.facebook.com/documentation/business-messaging/whatsapp/webhooks/reference/messages/button)).
* `data.context.id`, when present, is the `wamid` of the message the customer is replying to.

### How do I read a delivery status?

```json title="data — failed message_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](https://developers.facebook.com/documentation/business-messaging/whatsapp/webhooks/reference/messages/status)).

### 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:

1. Read the **raw body**, before any `JSON.parse`.
2. From `X-Genuka-Signature`, read `t` (Unix seconds) and every `v1` (there can be several).
3. Reject if `t` is more than 300 seconds away from your clock.
4. Compute the hex HMAC-SHA256 of the string `t` + `.` + raw body, keyed with the **whole** secret,
   `whsec_` prefix included.
5. Accept if one of the `v1` values equals it, compared in constant time.

> [!NOTE]
> **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:

```ts title="verify-signature.ts"
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:

**Next.js**

```ts title="app/api/webhooks/genuka/route.ts"
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 });
}
```

**Express**

```ts title="server.ts"
// Run with tsx (`npx tsx server.ts`), which resolves ./verify-signature.js to the .ts file.
import express from "express";
import { verifySignature } from "./verify-signature.js";

const app = express();

// Declared BEFORE app.use(express.json()): a global parser would consume the body,
// and the signature would never match.
app.post("/webhooks/genuka", express.raw({ type: "application/json" }), async (req, res) => {
  const raw = req.body.toString("utf8");
  const signature = req.get("X-Genuka-Signature") ?? null;
  if (!verifySignature(raw, signature, process.env.GENUKA_WEBHOOK_SECRET!)) {
    return res.status(401).send("invalid signature");
  }
  // A test event is signed like a real one: it must not write anything to your database.
  if (req.get("X-Genuka-Test") === "true") return res.sendStatus(204);

  const event = JSON.parse(raw);
  const fresh = await saveEventOnce(event); // INSERT … ON CONFLICT (id) DO NOTHING
  res.sendStatus(204); // answer first
  if (fresh) await queue.add("genuka-event", event); // process afterwards, outside the request
});

app.use(express.json()); // the rest of your API
```

**Python (Flask)**

```python title="app.py"
import hashlib
import hmac
import json
import os
import time

from flask import Flask, abort, request

app = Flask(__name__)
SECRET = os.environ["GENUKA_WEBHOOK_SECRET"].encode()  # the whole secret, whsec_ prefix included
TOLERANCE_SECONDS = 300


def verify_signature(raw, header):
    if not header:
        return False
    timestamp, signatures = None, []
    for part in header.split(","):
        key, _, value = part.partition("=")
        key, value = key.strip(), value.strip()
        # isascii(): isdigit() alone accepts "²", on which int() raises.
        if key == "t" and value.isascii() and value.isdigit():
            timestamp = int(value)
        elif key == "v1" and value:
            signatures.append(value)
    if timestamp is None or not signatures:
        return False
    # The timestamp is part of the HMAC: outside the window, a captured request is worthless.
    if abs(time.time() - timestamp) > TOLERANCE_SECONDS:
        return False
    expected = hmac.new(SECRET, f"{timestamp}.".encode() + raw, hashlib.sha256).hexdigest()
    return any(hmac.compare_digest(s.encode(), expected.encode()) for s in signatures)


@app.post("/webhooks/genuka")
def genuka_webhook():
    raw = request.get_data()  # the bytes received, before any parsing
    if not verify_signature(raw, request.headers.get("X-Genuka-Signature")):
        abort(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 "", 204

    event = json.loads(raw)
    if save_event_once(event):  # INSERT … ON CONFLICT (id) DO NOTHING
        enqueue(event)  # Celery, RQ…: processing does not hold the response
    return "", 204
```

**@genuka/whatsapp**

```ts title="app/api/webhooks/genuka/route.ts"
import { verifySignature } from "@genuka/whatsapp/webhooks";

export async function POST(request: Request) {
  const raw = await request.text();
  const ok = await verifySignature(
    process.env.GENUKA_WEBHOOK_SECRET!,
    raw,
    request.headers.get("x-genuka-signature"),
  );
  if (!ok) return new Response("invalid signature", { status: 401 });

  // … same as the Next.js tab from here
  return new Response(null, { status: 204 });
}
```

The [library](https://wa.genuka.com/en/docs/library)'s `verifySignature` is asynchronous (WebCrypto, so it also runs on
an edge runtime) and requires `@genuka/whatsapp` 0.1.1 or later.

## 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:

1. verify the signature;
2. store the event, de-duplicated on its `id` — one insert, a few milliseconds;
3. answer `204`;
4. 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.

```sql title="schema.sql"
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
);
```

```ts title="save-event-once.ts"
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 `id`s.** If two of your endpoints cover the same number, each gets
  its own delivery, with its own `id`. In that case also de-duplicate on the business key:
  `data.id` (the `wamid`) for an inbound message, the `data.id` + `data.status` pair for a status.
* **Test events** have an `id` starting with `test_`, the `X-Genuka-Test: true` header and no
  `X-Genuka-Delivery` header. 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](https://developers.facebook.com/documentation/business-messaging/whatsapp/webhooks/reference/messages/status)).
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:

```bash title="List failed deliveries, then replay one"
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](https://developers.facebook.com/documentation/business-messaging/whatsapp/messages/send-messages)).
Reuse the event's `connection_id`, put a `+` in front of `data.from`, and quote the message with
`replyTo`:

```json title="POST /api/v1/messages"
{
  "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](https://wa.genuka.com/en/docs/guides/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

* [Meta — Messages webhook reference](https://developers.facebook.com/documentation/business-messaging/whatsapp/webhooks/reference/messages)
* [Meta — Interactive message replies](https://developers.facebook.com/documentation/business-messaging/whatsapp/webhooks/reference/messages/interactive)
* [Meta — Button message replies](https://developers.facebook.com/documentation/business-messaging/whatsapp/webhooks/reference/messages/button)
* [Meta — Status messages webhook](https://developers.facebook.com/documentation/business-messaging/whatsapp/webhooks/reference/messages/status)
* [Meta — Send messages (customer service window)](https://developers.facebook.com/documentation/business-messaging/whatsapp/messages/send-messages)
