Skip to content
WaveSenda
Start free
Docs / Webhooks

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

GET
/v1/webhooks/events/
The event catalogue
POST
/v1/webhooks/
Create an endpoint
POST
/v1/webhooks/{id}/test/
Fire a test delivery
POST
/v1/webhooks/{id}/rotate-secret/
Rotate the signing secret
POST
/v1/webhooks/{id}/enable/
Re-enable after auto-disable
GET
/v1/webhooks/deliveries/
Every attempt, with status and response
POST
/v1/webhooks/deliveries/{id}/replay/
Send one again

Subscribing

POST /v1/webhooks/
{
  "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
}
FieldNotes
eventsEmpty means everything, acks included. Subscribe explicitly unless you genuinely want the firehose.
sessionNull means all numbers. An agency can run one endpoint per client.
headersExtra headers, for an endpoint behind a gateway with its own token. Signature and event headers cannot be overridden.
timeout_secondsDefault 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.

EventFires when
message.receivedSomeone messages one of your numbers.
message.sentWhatsApp accepted an outbound message.
message.deliveredIt reached the handset.
message.readThe recipient opened it.
message.playedA voice note was listened to.
message.failedTerminal failure. Not retried further.
message.deletedA message was revoked, by either side.
message.reactionAn emoji reaction was added.
session.connectedA number finished linking.
session.disconnectedThe socket dropped. Usually transient.
session.logged_outUnlinked from the handset. Needs a new scan.
session.awaiting_scanA QR or pairing code is ready.
session.failedThe gateway gave up bringing the socket up.
session.at_riskHealth crossed into risk. Fires before the throttle bites, not after.
group.participants_changedSomeone joined or left a group.
call.receivedAn incoming WhatsApp call.
contact.opted_outA contact asked to stop hearing from you.
ai.repliedAn agent answered on its own.
ai.handoffAn agent escalated to a person.
campaign.completedA 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.

HeaderExample
X-WaveSenda-Signaturet=1757404800,v1=6f2a…
X-WaveSenda-Eventmessage.received
X-WaveSenda-DeliveryThe delivery id, stable across retries.
X-WaveSenda-AttemptAttempt number, starting at 1.
Node / Express
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)
})
Python / Flask
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 "", 200
Compare in constant time
Both SDKs do. If you implement verification yourself, use a timing-safe comparison and reject anything older than about five minutes.

The payload

message.received
{
  "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:

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.

Endpoints that stay broken get switched off
After roughly a day of continuous failure — 100 consecutive attempts — the endpoint is disabled and stops receiving. Re-enable it from the console or with 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.

Design for at-least-once
A delivery can arrive twice — a retry after your handler succeeded but the response was lost, or a manual replay. Key on X-WaveSenda-Delivery, or on the message id, and make the handler idempotent.
Webhooks · WaveSenda Docs