Receiving replies & message statuses over webhooks
Sending alone leaves your application blind: it does not know when a customer replies, and it does not know whether a message actually arrived. Webhooks close that gap — WACO POSTs every event to your URL as it happens.
Enabling them
On the Developer page, enter your webhook
URL (HTTPS required) and save. WACO shows a secret
(whsec_…) used to verify signatures — note it once, it is used forever.
The events you receive
Two kinds, distinguished by the event field:
Incoming reply — message.received
{
"event": "message.received",
"waktu": "2026-08-13T05:20:00.000Z",
"dari": "628111222333",
"nama": "Budi",
"nomor_bisnis": "628158825011",
"pesan": { "id": "wamid.ABC", "tipe": "text", "teks": "Hello, I would like to register" }
}
For interactive buttons, pesan.teks contains the label that was pressed.
Media — image, document, audio, video, sticker
When media arrives, pesan.tipe names the kind and the detail sits in
pesan.data. A caption, if any, also appears in pesan.teks:
{
"event": "message.received",
"waktu": "2026-08-13T05:20:00.000Z",
"dari": "628111222333",
"nama": "Budi",
"pesan": {
"id": "wamid.ABC",
"tipe": "image",
"teks": "here is the ID card photo",
"data": {
"id": "1085073820629812",
"mime_type": "image/jpeg",
"sha256": "e5f8…",
"caption": "here is the ID card photo"
}
}
}
pesan.data follows the kind: image/video →
id, mime_type, sha256, caption?; document adds
filename; audio adds voice; sticker adds
animated.
Downloading the media file
pesan.data.id is a media ID, not a URL — the file itself is not
in the webhook. Download it with your API key; the gateway fetches it from Meta, so you need no
credentials beyond your waco_… key:
curl -L https://waco.id/api/v1/media/1085073820629812 \ -H "Authorization: Bearer waco_YOUR_KEY" \ -o id-card.jpg
The response is the file itself (Content-Type matching mime_type).
You can only download media that arrived at your own number.
Message status — message.status
{
"event": "message.status",
"waktu": "2026-08-13T05:21:00.000Z",
"pesan_id": "wamid.ABC",
"status": "delivered",
"ke": "628111222333"
}
status is sent, delivered, read or
failed. pesan_id matches the message_id returned when you
sent, so you can map it back to your own record.
Messages sent from the WhatsApp app — message.echo (optional)
If your number runs in coexistence mode (still used in the WhatsApp Business app on a phone as well as through the API), your staff sometimes reply straight from the phone. By default those replies are not sent to your webhook, so the conversation looks one-sided in your own system.
Turn on “Include messages sent from the WhatsApp app on the phone” on the Developer page to receive a copy:
{
"event": "message.echo",
"waktu": "2026-08-30T04:11:00.000Z",
"ke": "628111222333",
"nomor_bisnis": "628770001111",
"sumber": "aplikasi_whatsapp",
"pesan": {
"id": "wamid.ABC",
"tipe": "text",
"teks": "Sure, let me check that for you"
}
}
Unlike message.received this one is outbound, so it carries ke
(not dari) and has no nama. For media, pesan.data holds the
original object and can be downloaded through GET /api/v1/media/{id} just like an
incoming one. pesan.id is the same wamid you see in message.status, so
the two can be matched.
Note: messages you send through the API yourself are not echoed here — you
already have their message_id from the /kirim response. The event is off
by default so existing integrations never start receiving a new event type unexpectedly.
Verify the signature — REQUIRED
Every POST carries an X-WACO-Signature: sha256=… header: an HMAC-SHA256 over the
raw body using your secret. Recompute and compare before trusting the payload —
otherwise anyone who learns your URL can forge it.
Node.js (use the raw body, not parsed JSON):
const crypto = require('crypto');
app.post('/webhook/waco', express.raw({ type: 'application/json' }), (req, res) => {
const received = req.header('X-WACO-Signature') || '';
const computed = 'sha256=' +
crypto.createHmac('sha256', process.env.WACO_WEBHOOK_SECRET)
.update(req.body).digest('hex');
const ok = received.length === computed.length &&
crypto.timingSafeEqual(Buffer.from(received), Buffer.from(computed));
if (!ok) return res.sendStatus(401);
const event = JSON.parse(req.body.toString());
// ... handle the event ...
res.sendStatus(200);
});
PHP:
$body = file_get_contents('php://input');
$received = $_SERVER['HTTP_X_WACO_SIGNATURE'] ?? '';
$computed = 'sha256=' . hash_hmac('sha256', $body, getenv('WACO_WEBHOOK_SECRET'));
if (!hash_equals($computed, $received)) { http_response_code(401); exit; }
$event = json_decode($body, true);
// ... handle the event ...
http_response_code(200);
Things worth knowing
- Answer quickly with a 2xx. Anything else counts as a failure and WACO retries with increasing delays (1 s → 5 s → 30 s → 2 min → … up to hours). Queue heavy work rather than doing it before you respond.
- Delivery can happen more than once. Because of retries, make your handler
idempotent — use
pesan.id/pesan_idas the key. - What gets sent:
message.received,message.status, and — if you turn it on —message.echo. Internal events (number quality, account changes) are not forwarded to your application.
Monitoring deliveries
The Developer page shows your webhook delivery history (last 15 events). What the statuses mean:
- Delivered — your server answered 2xx; the event is in your hands.
- Queued — waiting its turn (usually seconds).
- Failed, retrying — your server did not answer 2xx; WACO retries with increasing delays for up to ~26 hours.
- Skipped — an event type that is never forwarded to applications (for example message copies from the phone on coexistence numbers) — not a failure.
- Gave up — kept failing past the retry limit. Check your server, then contact us if you need a redelivery.
/api/v1/balas) while still inside the 24-hour window — no template needed.