Webhooks

Register a webhook, the events Wappfy sends, the payload envelope, signature verification and retries.

A webhook lets Wappfy tell your system what happens on the number, as it happens: a message arrived, yours was read, the connection dropped. You don't need to poll the API.

Register a webhook

Webhooks are managed in the dashboard, one per connection.

Open the Webhook card

In Connections, open the connection, go to the Integrations tab and find the Webhook card.

Enter the destination URL

It must be https, public (no localhost or internal network), and the final address: redirects are not followed.

Pick the events

A new webhook comes with every event selected except presence, which generates a lot of traffic. Tick it only if you use it.

Create it and copy the secret

Click Create webhook and copy the signing secret (whsec_...). It is shown only once: when the webhook is created and when you rotate it.

Send a test event

Click Send test event. Wappfy sends a signed webhook.test to your URL and shows the HTTP status your server answered.

Only the owner of the connection can manage its webhook.

When Wappfy adds a new event, existing webhooks don't start receiving it on their own. Tick it on the card when your system is ready for it.

Events

EventSent when
message.receivedA message arrived, including in groups (is_group: true).
message.sentA message left the number, from any source: the dashboard, the API or the phone.
message.statusA sent message was delivered, read, played or failed.
instance.statusThe connection connected, dropped, failed or needs a QR scan.
message.reactionSomeone reacted to a message, or removed a reaction (reaction is empty).
message.editedA message was edited. text is the new text.
message.deletedA message was deleted for everyone.
group.participantsSomeone joined, left, was promoted or demoted in a group, or the number itself joined or left.
group.updatedA group's name, description or settings changed. Only the fields that changed are sent.
callA call came in (received), was answered on another device (accepted) or was rejected (rejected).
poll.voteSomeone voted on a poll the number sent.
presenceA contact went online or offline, started or stopped typing, or is recording audio. Only from chats subscribed through the API. Off by default.
label.updatedA label was created or edited (WhatsApp Business).
label.deletedA label was deleted.
label.chat.addedA label was added to a chat.
label.chat.removedA label was removed from a chat.
webhook.testYou clicked Send test event. It can't be subscribed to.

The full schema of each event is in the Webhooks section of the API Reference tab.

The envelope

Every event is a POST with Content-Type: application/json and the same envelope:

{
  "id": "msg_2KWPBgLlAfxdpx2AI54pPJ85f4W",
  "type": "message.received",
  "created_at": "2026-10-13T17:17:59Z",
  "instance": { "id": 12, "name": "Support", "phone": "5581988888888" },
  "data": {
    "message_id": "false_5581999999999@c.us_3EB0C767D26A1D5E",
    "chat_id": "5581999999999@c.us",
    "from": "5581999999999",
    "is_group": false,
    "type": "image",
    "text": "Is this one still available?",
    "media": { "url": "https://app.wappfy.io/api/midia/TOKEN.jpg", "mimetype": "image/jpeg" },
    "timestamp": 1791911879
  }
}
FieldMeaning
idThe delivery id (msg_...), the same as the webhook-id header. Retries keep the same id: use it to skip duplicates.
typeThe event.
created_atWhen the event happened (ISO 8601, UTC), not when it was delivered.
instanceThe connection: id, name (the dashboard name, or the profile name) and phone (digits only, or null, for example while disconnected).
dataThe event's data.

Rules that apply to every event:

  • Phone numbers (from, to, phone, by) are digits only, never WhatsApp's internal @lid id. When the number isn't known, the field is null.
  • One-to-one chats come as <phone>@c.us when the phone is known: the chat_id you use to reply through the API. Groups come as ...@g.us.
  • timestamp fields are Unix seconds.

Message events

message.received and message.sent share the same data. message.received has from (who sent it; in a group, the participant who wrote). message.sent has to (who it went to, or null in a group).

FieldMeaning
message_idThe message id. For your own sends, the same one the API returned.
chat_idThe chat.
is_grouptrue in a group.
typetext, image, video, audio, voice (voice note), document, sticker, location, contact, poll, list, list_response or button_response. A new WhatsApp type may show up with its original name: handle unknown types without failing.
textThe readable text: the body, the caption, or a description of the content (contact, location, voice note, list choice). null when there is none.
mediaurl and mimetype of the file, or null. The url is a Wappfy link that needs no authentication and is kept for a limited time: download what you need to keep.
selectedOnly in list_response (row_id, title) and button_response (button_id, title): what the contact picked.
timestampWhen the message was sent.

message.status tells you what happened to a message the number sent:

{
  "id": "msg_2KWPDAqd5sHSD7vN2mLx5WkCbJr",
  "type": "message.status",
  "created_at": "2026-10-13T17:18:04Z",
  "instance": { "id": 12, "name": "Support", "phone": "5581988888888" },
  "data": {
    "message_id": "true_5581999999999@c.us_3EB0C767D26A1D5E",
    "chat_id": "5581999999999@c.us",
    "to": "5581999999999",
    "is_group": false,
    "status": "read",
    "timestamp": 1791911884
  }
}

status is delivered, read, played (audio or video played) or failed. It only moves forward: a late update that would go backwards is not sent.

Other event payloads

Verify the signature

Every delivery is signed following Standard Webhooks. Check the signature before trusting the body: without it, anyone who finds your URL can send you fake events.

Each delivery carries three headers:

HeaderContent
webhook-idThe delivery id (msg_...), the same as the id in the body.
webhook-timestampWhen this attempt was signed, in Unix seconds.
webhook-signatureOne or more signatures v1,<base64>, separated by spaces.

To verify:

  1. Read the raw body, exactly the bytes you received. If your framework already parsed the JSON, serializing it again changes the bytes and the signature won't match.
  2. Reject the delivery if webhook-timestamp is more than 5 minutes away from your clock (replay protection).
  3. The HMAC key is the part of the secret after whsec_, decoded from base64.
  4. Compute base64(HMAC-SHA256(key, "{webhook-id}.{webhook-timestamp}.{raw body}")).
  5. Accept if any of the v1,... signatures in the header is equal to yours, compared in constant time.

With the official library (Node.js)

npm install standardwebhooks
import express from 'express'
import { Webhook } from 'standardwebhooks'

const webhook = new Webhook(process.env.WAPPFY_WEBHOOK_SECRET) // the whsec_... from the dashboard
const app = express()

// express.raw keeps the body as a Buffer, untouched by the JSON parser
app.post('/wappfy', express.raw({ type: 'application/json' }), (req, res) => {
  let event
  try {
    event = webhook.verify(req.body, req.headers)
  } catch {
    return res.status(401).end()
  }
  res.status(204).end()
  handle(event) // ideally through a queue of your own, after answering
})

app.listen(3000)

The Standard Webhooks library also exists for Python, Go, PHP, Ruby, Java, C#, Rust and more: see standardwebhooks.com.

Without a library

Node.js
import { createHmac, timingSafeEqual } from 'node:crypto'

const TOLERANCE_S = 5 * 60

/**
 * secret:  the whsec_... from the dashboard
 * headers: the request headers, with lowercase names
 * rawBody: the body exactly as received (string)
 */
export function isValidSignature(secret, headers, rawBody) {
  const id = headers['webhook-id']
  const timestamp = headers['webhook-timestamp']
  const signatures = headers['webhook-signature']
  if (!id || !timestamp || !signatures || !/^\d+$/.test(timestamp)) return false

  // 1. within the 5-minute tolerance
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > TOLERANCE_S) return false

  // 2. the HMAC key is what comes after whsec_, decoded from base64
  const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64')

  // 3. the signed content is "id.timestamp.body"
  const expected = createHmac('sha256', key).update(`${id}.${timestamp}.${rawBody}`).digest()

  // 4. the header may carry several signatures: valid if ANY matches
  return signatures.split(' ').some((item) => {
    const [version, value] = item.split(',')
    if (version !== 'v1' || !value) return false
    const received = Buffer.from(value, 'base64')
    return received.length === expected.length && timingSafeEqual(received, expected)
  })
}
Python
import base64
import hashlib
import hmac
import time

TOLERANCE_S = 5 * 60


def is_valid_signature(secret: str, headers, raw_body: bytes) -> bool:
    """secret: the whsec_... from the dashboard; raw_body: the bytes as received."""
    msg_id = headers.get("webhook-id")
    timestamp = headers.get("webhook-timestamp")
    signatures = headers.get("webhook-signature")
    if not msg_id or not timestamp or not signatures or not timestamp.isdigit():
        return False

    # 1. within the 5-minute tolerance
    if abs(time.time() - int(timestamp)) > TOLERANCE_S:
        return False

    # 2. the HMAC key is what comes after whsec_, decoded from base64
    key = base64.b64decode(secret.removeprefix("whsec_"))

    # 3. the signed content is "id.timestamp.body"
    signed = f"{msg_id}.{timestamp}.".encode() + raw_body
    expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()

    # 4. the header may carry several signatures: valid if ANY matches
    for item in signatures.split(" "):
        version, _, value = item.partition(",")
        if version == "v1" and hmac.compare_digest(value.encode(), expected.encode()):
            return True
    return False


# Flask: is_valid_signature(SECRET, request.headers, request.get_data())

Rotating the secret

Click Rotate secret on the Webhook card. The new secret starts signing immediately, and the previous one keeps signing alongside it for 24 hours. During that time every delivery carries two signatures in webhook-signature, one per secret. That is why verification accepts any matching signature: you can update your system calmly without losing events. After 24 hours only the new secret is valid.

Delivery and retries

  • Answer with any 2xx within 10 seconds. Anything else, a redirect (not followed) or no answer within 10 seconds counts as a failure. The response body is ignored. If processing takes long, put the event in a queue of your own and answer right away.
  • Order is not guaranteed. A message.status can arrive before the matching message.sent. Sort by created_at and don't assume a sequence.
  • The same event can arrive twice. A retry keeps the same id (and webhook-id). Store the ids you have processed and skip repeats.
  • Retries. Without a 2xx, Wappfy retries after 5 s, 5 min, 30 min, 2 h, 5 h, 10 h and 10 h (about 28 hours in total). After that the delivery is given up, and you can still resend it from the dashboard.
  • Automatic disable. If no delivery is accepted for 3 days (72 hours), the webhook is disabled and the account owner gets an email. Fix the destination, send the test event and click Reactivate on the card.

The Webhook card shows the 20 most recent deliveries with their event, status, HTTP code and attempts, and a Resend button that sends a delivery again with the same webhook-id. Delivery history is kept for 7 days.

Rejecting a call

With the call_id and from of a call event with status received, you can reject the call:

curl -X POST https://app.wappfy.io/api/v1/calls/reject \
  -H "X-Api-Key: wpf_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "call_id": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", "from": "5581999999999" }'

The caller sees the call drop within a second or two. A rejection made through the API does not produce a call event with status rejected: WhatsApp only sends that one when the caller gives up or the call is rejected on the phone. The 200 from this route is your confirmation. This route is under the Calls tag in the API Reference tab.

Best practices

Answer fast

Return a 2xx within 10 seconds and process the event afterwards.

Deduplicate

Use id (or the webhook-id header) to skip retries you already handled.

Verify every delivery

Check the signature on the raw body before trusting it.

Keep the secret secret

Treat whsec_... like a password. If it leaks, rotate it.

On this page