Webhooks

Be told when a message arrives, instead of reading on a schedule to find out.

PUT
Set a webhook

/webhook  ·  needs accounts  ·  every plan

One webhook per API key. Today the only event is message.received: a new message from someone else on WhatsApp. What you send yourself, and what you read, is never an event. An account's capabilities.webhooks says whether its channel can.

  • Set. PUT /webhook with url, events and, optionally, channels creates it or replaces it. The address must be https and must not resolve to a private address. The first answer carries the signing secret, once; add ?rotate=true to issue a new one, which stops the old at once.
  • Channels. Name the channels you want, from WhatsApp. Left out, a new webhook takes every channel that is on for you, and an existing one keeps the channels it has — a webhook set before there was a choice is Gmail's, so it keeps hearing about email and nothing it did not ask for. A channel that is off for you is a 400.
  • Check. GET /webhook (needs read) says where it points, which channels it listens to, when it last delivered, whether the receiver is answering (state is active or failing) and how many events failed in the last 24 hours. 404 if there is none.
  • Try. POST /webhook/test sends a signed ping now; any 2xx counts as delivered.
  • Delete. DELETE /webhook, 204. Stops the events and drops what was still owed.
curl -X PUT https://messengeragent.dev/api/v1/webhook \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://app.yourcompany.com/hooks/messages", "events": ["message.received"], "channels": ["whatsapp"]}'

# 200 — the secret is in this answer only. Store it.
{
  "url": "https://app.yourcompany.com/hooks/messages",
  "events": ["message.received"],
  "channels": ["whatsapp"],
  "createdAt": "2026-10-12T08:30:00+00:00",
  "secret": "whsec_…"
}

# Try the receiver: sends a signed "ping" now and says what it answered
curl -X POST https://messengeragent.dev/api/v1/webhook/test -H "Authorization: Bearer $API_KEY"
# { "delivered": true, "statusCode": 200, "error": null }

# Is it healthy?
curl https://messengeragent.dev/api/v1/webhook -H "Authorization: Bearer $API_KEY"
# { "url": "…", "events": ["message.received"], "channels": ["whatsapp"], "createdAt": "…", "state": "active",
#   "lastDelivery": { "at": "…", "succeeded": true, "statusCode": 200, "lastDeliveredAt": "…" },
#   "failuresLast24Hours": 0 }

The event

Each event is a JSON POST to your address, signed with X-AgentMessenger-Signature: sha256=<HMAC-SHA256 of the raw body, keyed by the secret>. Delivery is at least once: a retry carries the same event id, so de-duplicate on it. Answer any 2xx to acknowledge. Failures are retried after 30 seconds, 2 minutes, 10 minutes, 1 hour, then every 6 hours, for 24 hours. A receiver that has failed for longer is marked failing and tried once an hour until it answers. The event's channel and accountId say where it came from; the message has the id, conversationId and text the messages endpoint returns.

  • WhatsApp. Announced as the message arrives, in any chat of the account, a group included. Attachments are not listed; type says what it is, spelled as the messages endpoint spells it.

WhatsApp

{
  "id": "evt_0199…",
  "type": "message.received",
  "createdAt": "2026-11-03T09:00:03Z",
  "accountId": "01a115a8-…",
  "channel": "whatsapp",
  "message": {
    "id": "3EB0A1B2C3D4",
    "conversationId": "15551230001@c.us",
    "direction": "inbound",
    "fromMe": false,
    "type": "text",
    "subject": null,
    "text": "Hi! Do you have the blue hoodie in size M?",
    "at": "2026-11-03T09:00:00Z",
    "sender": { "name": "Aigerim K." },
    "to": [], "cc": [], "attachments": []
  }
}

Verify the signature

Compute the HMAC over the raw request body, before you parse it, and compare it to the header in constant time.

// Node: check the signature against the raw body — before you parse it
const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(rawBody).digest('hex')
const ok = header && header.length === expected.length &&
  crypto.timingSafeEqual(Buffer.from(header), Buffer.from(expected))