Guides/For developers

Receiving replies & message statuses over webhooks

Enable webhooks, read the payload, and verify the WACO signature. 7 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/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

Monitoring deliveries

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

Delivering to more than one application

If the same events need to reach two different systems, a billing app and a warehouse app, say, add the destinations under Developer → Additional webhooks. One account can have up to three additional destinations alongside the main webhook.

Each destination stands on its own:

You do not have to wait for us. If you would rather handle it yourself, your first application can forward the raw copy to the second one. Forward the body byte for byte together with the X-WACO-Signature header, and reply 200 to WACO before forwarding, so a slow second application never makes our webhook look failed.
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.