kenari.dev

Webhooks#

Meta sends WhatsApp events (inbound messages, delivery statuses, template reviews, quality changes) to kenari. kenari works out which account each event belongs to and forwards it to the endpoints you register. Each request is signed with Standard Webhooks. A failed delivery is retried for about 20 hours.

Before you start#

You need at least one connected number. Creating an endpoint on an account with no live numbers fails with 409 no_numbers. See Connecting a number.

Add an endpoint#

  1. Open Webhooks in the dashboard and choose Add endpoint.
  2. Enter the Endpoint URL. It must be https:// and publicly reachable (see URL rules).
  3. Under Events, tick the fields you want. You need at least one and can pick up to 40, all from the list below.
  4. Optionally pick a Number. Leave it on All numbers to get events for every number on the account, or choose one number to get only changes whose value.metadata.phone_number_id matches it.
  5. Save. The dashboard shows your endpoint's signing secret once and never again, so copy it into your secret store right away.

After saving, choose Send test to queue a synthetic event. It goes through the normal delivery pipeline, retries included, and shows up under Deliveries.

Fields#

These are the only field names an endpoint accepts. The dashboard reads the same list from GET /api/webhooks/fields.

FieldWhat it carries
messagesInbound messages and delivery statuses (value.statuses[]).
message_template_status_updateA template was approved, rejected, paused or disabled.
message_template_quality_updateA template's quality score changed.
message_template_components_updateA template's components were edited.
template_category_updateMeta changed a template's category.
account_updateWABA changes: verification, bans, partner removal.
account_alertsMessaging limit and other account alerts.
phone_number_quality_updateA number's quality rating or messaging tier changed.
phone_number_name_updateA display name review finished.
business_capability_updateBusiness-level limits changed.
securityTwo-step verification PIN changes and other security events.
historyCoexistence: the one-time chat history sync.
smb_message_echoesCoexistence: messages sent from the WhatsApp Business app.
smb_app_state_syncCoexistence: contacts synced from the WhatsApp Business app.

The last three only fire for coexistence numbers.

Payload format#

kenari forwards Meta's webhook body. It doesn't wrap it in an envelope of its own, rename keys or re-encode values, so you can reuse code written for Meta's Cloud API webhooks.

json
{  "object": "whatsapp_business_account",  "entry": [    {      "id": "<WABA_ID>",      "changes": [        {          "field": "messages",          "value": {            "messaging_product": "whatsapp",            "metadata": { "phone_number_id": "<PHONE_NUMBER_ID>" },            "messages": [              {                "from": "15555550100",                "id": "wamid....",                "timestamp": "1760000000",                "type": "text",                "text": { "body": "Hello" }              }            ]          }        }      ]    }  ]}

Batched events#

Meta sometimes puts entries for several WhatsApp Business accounts (WABAs) into one request. kenari handles this as follows:

  • All entries belong to your account: you get Meta's request body unchanged, byte for byte.
  • Entries span several kenari accounts: kenari splits the batch. You get a body that holds only your account's entries. Each entry object is copied byte for byte from Meta's original. kenari writes only the outer {"object": ..., "entry": [...]} wrapper, and entries keep the order Meta sent them in.

An endpoint gets one delivery per incoming Meta event when at least one change matches its fields (and its number, if set). That delivery holds all of your account's entries from the event. In a batched event, it can include changes for fields or numbers the endpoint didn't subscribe to. Check changes[].field and value.metadata.phone_number_id before you act on a change.

Test events#

Send test and the coexistence pre-flight check send a synthetic messages event. It has "kenari_test": true at the top level, a wamid.kenari_test.… message id and the text This is a test webhook from kenari.. Skip events with kenari_test in production handlers.

Request headers#

Every delivery is an HTTP POST with these headers:

HeaderValue
content-typeapplication/json
user-agentkenari-webhooks/1
webhook-idThe delivery id. It's the same on every retry of that delivery.
webhook-timestampUnix time in seconds when this attempt was signed.
webhook-signatureOne or more space-separated v1,<base64> signatures.
X-Meta-Signature-256Only sent in some cases; see below.

Verify the signature#

kenari signs deliveries under the Standard Webhooks spec:

  • Secret: your endpoint secret has the form whsec_<base64>. The HMAC key is the base64-decoded part after whsec_.
  • Signed content: `${webhook-id}.${webhook-timestamp}.${raw request body}`
  • Algorithm: HMAC-SHA256, base64-encoded, prefixed with v1,.

After you rotate a secret, the header carries two signatures, one from the new secret and one from the old, separated by a space. A request is genuine if any one of them matches.

The official standardwebhooks libraries implement all of this. kenari's own tests check its signatures against them in both Node and Python.

Node.js#

bash
npm install standardwebhooks
js
import express from "express";import { Webhook } from "standardwebhooks";const wh = new Webhook(process.env.KENARI_WEBHOOK_SECRET); // "whsec_..."const app = express();// Keep the body as raw bytes: the signature covers them exactly.app.post("/webhooks/whatsapp", express.raw({ type: "application/json" }), (req, res) => {  let payload;  try {    payload = wh.verify(req.body.toString("utf8"), {      "webhook-id": req.header("webhook-id"),      "webhook-timestamp": req.header("webhook-timestamp"),      "webhook-signature": req.header("webhook-signature"),    });  } catch {    return res.status(401).end();  }  // Acknowledge fast, then do the work asynchronously.  res.status(204).end();  handleEvent(req.header("webhook-id"), payload);});

If you'd rather not add a dependency, this computes the same signature by hand:

js
import { createHmac, timingSafeEqual } from "node:crypto";export function verifyKenari(rawBody, headers, secret) {  const id = headers["webhook-id"];  const timestamp = headers["webhook-timestamp"];  const header = headers["webhook-signature"];  if (!id || !timestamp || !header) return false;  // Reject stale or future timestamps (the official libraries allow 5 minutes).  const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));  if (!Number.isFinite(age) || age > 300) return false;  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");  const expected = createHmac("sha256", key)    .update(`${id}.${timestamp}.${rawBody}`)    .digest("base64");  return header.split(" ").some((part) => {    const [version, signature] = part.split(",");    if (version !== "v1" || !signature) return false;    const a = Buffer.from(signature);    const b = Buffer.from(expected);    return a.length === b.length && timingSafeEqual(a, b);  });}

Python#

bash
pip install standardwebhooks
python
import osfrom flask import Flask, requestfrom standardwebhooks import Webhookwh = Webhook(os.environ["KENARI_WEBHOOK_SECRET"])  # "whsec_..."app = Flask(__name__)@app.post("/webhooks/whatsapp")def whatsapp_webhook():    raw = request.get_data()  # raw bytes, not request.json    headers = {        "webhook-id": request.headers.get("webhook-id", ""),        "webhook-timestamp": request.headers.get("webhook-timestamp", ""),        "webhook-signature": request.headers.get("webhook-signature", ""),    }    try:        payload = wh.verify(raw, headers)    except Exception:        return "", 401    handle_event(headers["webhook-id"], payload)    return "", 204

X-Meta-Signature-256#

kenari also passes along Meta's own X-Hub-Signature-256 value, as X-Meta-Signature-256, but only when your body is byte-identical to what Meta signed. After a batch split, the body is different, so the header is left out.

The header is only useful for bring-your-own (BYO) numbers. Meta signs their events with your app secret, so you can check them yourself. For Embedded Signup and coexistence numbers, Meta signs with kenari's app secret, which you don't have. Always rely on webhook-signature, and treat X-Meta-Signature-256 as an optional extra check.

Responding and retries#

A delivery succeeds when your endpoint returns any 2xx status within 10 seconds. Anything else counts as a failure:

  • a non-2xx status, including 3xx (kenari doesn't follow redirects)
  • a timeout (the 10 seconds cover DNS lookup, connecting and the response)
  • a connection error or TLS failure
  • a DNS result that includes a blocked address

kenari reads at most the first 1 KB of your response body and keeps it on the delivery for debugging. Return 2xx fast and do slow work asynchronously.

Failed deliveries are retried on this schedule:

AttemptSent after the previous failureTime since first attempt
1immediately0
25 seconds5 s
31 minute≈ 1 min
45 minutes≈ 6 min
530 minutes≈ 36 min
62 hours≈ 2 h 36 min
76 hours≈ 8 h 36 min
812 hours≈ 20 h 36 min

If the 8th attempt also fails, the delivery is parked. It won't be retried automatically, but you can replay it.

A 410 Gone response is different. It's never retried: the delivery is parked and the endpoint is disabled straight away.

Duplicates and ordering#

  • Retries of one delivery all use the same webhook-id. Store the ids you've processed and skip repeats.
  • Delivery order is best effort. kenari sends up to 10 requests to one endpoint at the same time, so a later event can arrive first. Use the timestamps inside Meta's payload when order matters.

Automatic disabling#

kenari counts consecutive failed attempts per endpoint. A success resets the count.

ConditionWhat happens
20 consecutive failuresThe account is warned by email. The dashboard shows the endpoint's consecutive failure count.
50 consecutive failures and no success in the last 24 hoursThe endpoint is disabled (reason consecutive_failures) and the account is emailed.
Any 410 Gone responseThe endpoint is disabled at once (reason gone).

A disabled endpoint receives nothing. Deliveries that come due while it's disabled are parked. Choose Enable to turn it back on, which also resets the failure count, then replay anything you missed.

Replay#

Open an endpoint's Deliveries, pick one and choose Replay. kenari creates a new delivery with the same body, linked to the original through replay_of. It has a new webhook-id, so your duplicate check won't skip it.

Replay only works while the delivery and its payload still exist:

  • Deliveries, their attempts and their payloads are deleted once they're older than your account's retention window (24 hours or 7 days, set in Settings). After that, they no longer appear under Deliveries.
  • Payloads for the coexistence history field are deleted as soon as the delivery succeeds or is parked, so kenari keeps none of your chat history. Replaying one of these fails with 410 payload_expired.

Rotating the signing secret#

Choose Rotate secret on an endpoint. You get a new secret, shown once. For the next 24 hours, every delivery is signed with both the new and the old secret, so you can deploy the new one without dropping events. After 24 hours, only the new secret signs.

URL rules#

kenari checks the URL when you save the endpoint and again on every delivery attempt.

  • HTTPS only. Plain http:// is accepted only on development deployments of kenari.
  • At most 2048 characters.
  • No credentials in the URL. https://user:pass@host/... is rejected. Authenticate with the signature instead.
  • Public addresses only. kenari resolves the hostname before each attempt. If any resolved address is in a blocked range, the attempt fails. Otherwise kenari connects to one of the resolved addresses, with TLS SNI set to your hostname, so a later DNS answer can't redirect the request. An IP address typed straight into the URL is checked when you save.

Blocked ranges:

FamilyRanges
IPv40.0.0.0/8, 10.0.0.0/8, 100.64.0.0/10, 127.0.0.0/8, 169.254.0.0/16, 172.16.0.0/12, 192.0.0.0/24, 192.168.0.0/16, 198.18.0.0/15, 224.0.0.0/4, 240.0.0.0/4
IPv6::, ::1, fc00::/7, fe80::/10, ff00::/8, and IPv4-mapped addresses (::ffff:a.b.c.d), which are checked against the IPv4 list

kenari's own server addresses are blocked too.