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
| Event | Sent when |
|---|---|
message.received | A message arrived, including in groups (is_group: true). |
message.sent | A message left the number, from any source: the dashboard, the API or the phone. |
message.status | A sent message was delivered, read, played or failed. |
instance.status | The connection connected, dropped, failed or needs a QR scan. |
message.reaction | Someone reacted to a message, or removed a reaction (reaction is empty). |
message.edited | A message was edited. text is the new text. |
message.deleted | A message was deleted for everyone. |
group.participants | Someone joined, left, was promoted or demoted in a group, or the number itself joined or left. |
group.updated | A group's name, description or settings changed. Only the fields that changed are sent. |
call | A call came in (received), was answered on another device (accepted) or was rejected (rejected). |
poll.vote | Someone voted on a poll the number sent. |
presence | A contact went online or offline, started or stopped typing, or is recording audio. Only from chats subscribed through the API. Off by default. |
label.updated | A label was created or edited (WhatsApp Business). |
label.deleted | A label was deleted. |
label.chat.added | A label was added to a chat. |
label.chat.removed | A label was removed from a chat. |
webhook.test | You 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
}
}| Field | Meaning |
|---|---|
id | The delivery id (msg_...), the same as the webhook-id header. Retries keep the same id: use it to skip duplicates. |
type | The event. |
created_at | When the event happened (ISO 8601, UTC), not when it was delivered. |
instance | The connection: id, name (the dashboard name, or the profile name) and phone (digits only, or null, for example while disconnected). |
data | The event's data. |
Rules that apply to every event:
- Phone numbers (
from,to,phone,by) are digits only, never WhatsApp's internal@lidid. When the number isn't known, the field isnull. - One-to-one chats come as
<phone>@c.uswhen the phone is known: thechat_idyou use to reply through the API. Groups come as...@g.us. timestampfields 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).
| Field | Meaning |
|---|---|
message_id | The message id. For your own sends, the same one the API returned. |
chat_id | The chat. |
is_group | true in a group. |
type | text, 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. |
text | The readable text: the body, the caption, or a description of the content (contact, location, voice note, list choice). null when there is none. |
media | url 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. |
selected | Only in list_response (row_id, title) and button_response (button_id, title): what the contact picked. |
timestamp | When 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
{
"type": "instance.status",
"data": { "status": "disconnected", "previous_status": "connected" }
}status is connected, disconnected, scan_qr or failed, the same words GET /instance uses. previous_status is the status before, suspended when it came out of a billing suspension, or null.
{
"type": "message.reaction",
"data": {
"message_id": "true_5581999999999@c.us_3EB0C767D26A1D5E",
"chat_id": "5581999999999@c.us",
"from": "5581999999999",
"from_me": false,
"reaction": "👍",
"is_group": false,
"timestamp": 1791989430
}
}message_id is the original message, the same id that came in message.received or message.sent. from is who reacted, edited or deleted, and from_me is true when it was the number itself. message.edited carries text (the new text) instead of reaction; message.deleted has neither.
{
"type": "group.participants",
"data": {
"group_id": "120363000000000000@g.us",
"type": "promote",
"participants": [{ "phone": "5581999999999", "role": "admin" }],
"by": "5581988888888",
"timestamp": 1791989517
}
}type is join, leave, promote or demote, or joined and left when it was the number itself. role is participant, admin, superadmin or left, when known. by is who made the change.
{
"type": "group.updated",
"data": {
"group_id": "120363000000000000@g.us",
"subject": "VIP Customers",
"by": "5581988888888",
"timestamp": 1791989600
}
}Only what changed comes: subject, description, members_can_add_new_member, members_can_send_messages or new_members_approval_required.
{
"type": "call",
"data": {
"call_id": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
"from": "5581999999999",
"status": "received",
"is_video": false,
"is_group": false,
"timestamp": 1791990000
}
}status is received (ringing), accepted (answered on another device) or rejected (rejected on the phone, or the caller gave up). See Rejecting a call.
{
"type": "poll.vote",
"data": {
"poll_id": "true_5581999999999@c.us_3EB0C767D26A1D5E",
"chat_id": "5581999999999@c.us",
"from": "5581999999999",
"selected_options": ["Morning"],
"timestamp": 1791990100
}
}poll_id is the message_id of the poll you sent. Options come by their text. An empty list means the vote was removed, and each new vote from the same person replaces the previous one.
{
"type": "presence",
"data": {
"chat_id": "5581999999999@c.us",
"is_group": false,
"presences": [{ "phone": "5581999999999", "presence": "typing", "last_seen": null }]
}
}presence is online, offline, typing, recording or paused. last_seen is almost always null. It only arrives for chats subscribed with POST /chats/{chatId}/presence/subscribe (see Contacts and chats). The same state repeated within a few seconds is dropped, so you get one event per change.
{ "type": "label.chat.added", "data": { "label_id": "5", "chat_id": "5581999999999@c.us" } }Every label event has label_id. label.updated adds name and color (WhatsApp's color index); creating and editing look the same. label.chat.added and label.chat.removed add chat_id.
{
"type": "webhook.test",
"data": { "message": "Evento de teste do Wappfy. Se a assinatura confere, o webhook esta pronto." }
}A fixed test text (in Portuguese). It is signed like any other event.
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:
| Header | Content |
|---|---|
webhook-id | The delivery id (msg_...), the same as the id in the body. |
webhook-timestamp | When this attempt was signed, in Unix seconds. |
webhook-signature | One or more signatures v1,<base64>, separated by spaces. |
To verify:
- 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.
- Reject the delivery if
webhook-timestampis more than 5 minutes away from your clock (replay protection). - The HMAC key is the part of the secret after
whsec_, decoded from base64. - Compute
base64(HMAC-SHA256(key, "{webhook-id}.{webhook-timestamp}.{raw body}")). - 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 standardwebhooksimport 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
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)
})
}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
2xxwithin 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.statuscan arrive before the matchingmessage.sent. Sort bycreated_atand don't assume a sequence. - The same event can arrive twice. A retry keeps the same
id(andwebhook-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.

