Everything comes back to you
Inbound messages, delivery acks, connection changes and agent handoffs are pushed to a URL you control. Signed, retried with backoff, and replayable from the console.
Endpoints
Subscribing
{
"url": "https://example.com/hooks/wavesenda",
"events": ["message.received", "message.failed", "session.logged_out"],
"session": null,
"headers": { "X-Gateway-Token": "..." },
"max_retries": 5,
"timeout_seconds": 10
}| Field | Notes |
|---|---|
events | Empty means everything, acks included. Subscribe explicitly unless you genuinely want the firehose. |
session | Null means all numbers. An agency can run one endpoint per client. |
headers | Extra headers, for an endpoint behind a gateway with its own token. Signature and event headers cannot be overridden. |
timeout_seconds | Default 10. Return 200 first and work afterwards. |
The response contains a secret, returned only at creation. Store it — it is what proves a delivery came from us.
The event catalogue
Names are resource.action and are part of the public contract. Renaming one would silently break every customer filtering on it, so they do not change.
| Event | Fires when |
|---|---|
message.received | Someone messages one of your numbers. |
message.sent | WhatsApp accepted an outbound message. |
message.delivered | It reached the handset. |
message.read | The recipient opened it. |
message.played | A voice note was listened to. |
message.failed | Terminal failure. Not retried further. |
message.deleted | A message was revoked, by either side. |
message.reaction | An emoji reaction was added. |
session.connected | A number finished linking. |
session.disconnected | The socket dropped. Usually transient. |
session.logged_out | Unlinked from the handset. Needs a new scan. |
session.awaiting_scan | A QR or pairing code is ready. |
session.failed | The gateway gave up bringing the socket up. |
session.at_risk | Health crossed into risk. Fires before the throttle bites, not after. |
group.participants_changed | Someone joined or left a group. |
call.received | An incoming WhatsApp call. |
contact.opted_out | A contact asked to stop hearing from you. |
ai.replied | An agent answered on its own. |
ai.handoff | An agent escalated to a person. |
campaign.completed | A campaign finished sending. |
Verifying a delivery
Every request carries an HMAC-SHA256 signature over <timestamp>.<raw body>, keyed with your endpoint secret. The timestamp is inside the signed material, so a captured delivery cannot be replayed at you later — but only if you actually check its age.
| Header | Example |
|---|---|
X-WaveSenda-Signature | t=1757404800,v1=6f2a… |
X-WaveSenda-Event | message.received |
X-WaveSenda-Delivery | The delivery id, stable across retries. |
X-WaveSenda-Attempt | Attempt number, starting at 1. |
import express from 'express'
import { verifyWebhook } from '@kunshort/wavesenda'
const app = express()
// The raw body is required — re-serialising JSON changes the bytes and the
// signature will never match.
app.post('/hooks/wavesenda', express.raw({ type: 'application/json' }), (req, res) => {
const ok = verifyWebhook(
req.body,
req.header('X-WaveSenda-Signature'),
process.env.WAVESENDA_WEBHOOK_SECRET,
)
if (!ok) return res.sendStatus(400)
// Acknowledge first; do the work off the request.
res.sendStatus(200)
const event = JSON.parse(req.body.toString())
queue.add(event)
})from flask import Flask, request, abort
from wavesenda import verify_webhook
app = Flask(__name__)
@app.post("/hooks/wavesenda")
def hook():
if not verify_webhook(
request.get_data(),
request.headers.get("X-WaveSenda-Signature"),
os.environ["WAVESENDA_WEBHOOK_SECRET"],
):
abort(400)
queue.enqueue(request.get_json())
return "", 200The payload
{
"event": "message.received",
"created_at": "2026-09-09T08:51:50.527600Z",
"data": {
"id": "0f8c2b1e-...",
"wa_message_id": "wamid.HBgM...",
"session": { "id": "8f14e45f-...", "name": "support" },
"conversation": "b41d0c2e-...",
"direction": "inbound",
"type": "text",
"from": "+237670000000",
"text": "Is the blue one still in stock?",
"timestamp": "2026-09-09T08:51:49Z"
}
}Fields are added within v1 without notice, so parse leniently and ignore anything you do not recognise rather than validating strictly and breaking on a new key.
Retries
A delivery counts as successful on any 2xx. Anything else is retried five times with a fixed backoff:
attempt 1 → +10s attempt 2 → +1m attempt 3 → +5m attempt 4 → +30m attempt 5 → +2h then exhausted
A 4xx other than 408 or 429 is treated as permanent and not retried at all — a bad URL or a rejected payload will fail identically forever, and retrying it just burns a worker per event. Redirects are not followed, so a 301 to your real handler is a failed delivery, not a slow one.
POST /v1/webhooks/{id}/enable/ once the receiver is fixed.When someone says they never got it
Every attempt is recorded with its response status, body and duration — which is expensive and is also the only way to answer that question. Find it under /v1/webhooks/deliveries/, or in Webhooks in the console, and replay it once the receiver is fixed.
X-WaveSenda-Delivery, or on the message id, and make the handler idempotent.