Guides/For developers

Receiving replies & message statuses over webhooks

Enable webhooks, read the payload, and verify the WACO signature. 6 min read

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.

Works alongside the team inbox. Webhooks are active for every account — including those using the team inbox. The same events go to both: your team replies from the inbox, and your own system receives a copy via webhook. The two never wait on each other — trouble on one does not hold up the other.

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/videoid, 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

Monitoring deliveries

The Developer page shows your webhook delivery history (last 15 events). What the statuses mean:

The two-way loop: receive the reply here, then answer with free-form reply (/api/v1/balas) while still inside the 24-hour window — no template needed.
Still stuck after following this guide? Contact us from the dashboard — attach a screenshot if you have one, it speeds everything up.