Genuka WA docs

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:

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.

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.

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:

typeWhat it isdata
inbound_messageA message from the customerMeta's message object
message_statussent, delivered, read or failed for a message you sentMeta's status object
template_statusA template was approved, rejected or pausedMeta's change value
account_updateA change on the WhatsApp Business accountMeta's change value
phone_qualityA number's quality or tier changedMeta'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):

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).
  • type: "button" → data.button.payload: a quick-reply button on a template (Meta docs).
  • data.context.id, when present, is the wamid of the message the customer is replying to.

How do I read a delivery status?

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).

Which headers come with each delivery?

HeaderContents
X-Genuka-Signaturet=<timestamp>,v1=<hex HMAC>
X-Genuka-EventThe event's type
X-Genuka-Event-FieldThe underlying Meta webhook field
X-Genuka-DeliveryThe delivery id, equal to the body's id, identical across attempts
X-Genuka-Webhook-IdThe endpoint that was targeted
X-Genuka-AttemptThe attempt number, starting at 1
X-Genuka-Testtrue 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.

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:

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:

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 });
}

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.

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
);
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 ids. 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). 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:

AttemptWhen
1As soon as the event arrives
2At least 1 minute after the previous one failed
3At least 5 minutes later
4At least 30 minutes later
5At least 2 hours later
6At 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:

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). Reuse the event's connection_id, put a + in front of data.from, and quote the message with replyTo:

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.

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

On this page