Skip to content

Webhook

Terima notifikasi real-time dari gateway: pesan masuk (teks/reaksi/media), status pesan (terkirim/dibaca/gagal), dan status koneksi nomor.

Cara Kerja

Webhook mengikat satu nomor dan dipisah per kategori event ke URL berbeda. Gateway melakukan HTTP POST ke URL kategori yang sesuai setiap ada event, dengan signature HMAC-SHA256 untuk verifikasi keaslian.

Event WhatsApp → [Gateway] → POST ke URL kategori Anda → Anda proses event

URL kosong = kategori itu tidak dikirim. Isi hanya kategori yang Anda butuhkan agar server tak dibanjiri (mis. hanya messages_url bila Anda hanya perlu pesan masuk).

Empat Kategori URL

Kategorikind yang masukKapan dikirim
messages_urlmessagePesan masuk (teks/reaksi/media; broadcast: true bila Status/Story)
status_urlreceipt, statusLifecycle pesan yang Anda kirim: sent (ack server) & gagal permanen (status), diterima/dibaca (receipt)
connection_urlconnectionStatus koneksi nomor (tersambung/terputus)
events_urlpoll_vote, typing, presence, call, newsletter_messageVote poll, indikator mengetik, presence, panggilan, pesan baru dari channel yang di-follow — dipisah dari messages_url karena volumenya bisa tinggi (mis. grup/channel ramai)

Bedakan jenis persis dari field kind di payload (beberapa kind berbagi satu kategori URL).

Isi events_url bila Anda pakai fitur poll/typing/presence/call

poll_vote, typing, presence, call, dan newsletter_message hanya dikirim ke events_url — bukan lagi ke messages_url. Jika Anda sudah pakai fitur ini sebelumnya dan hanya mengisi messages_url, tambahkan events_url (boleh sama persis dengan messages_url bila Anda ingin tetap satu endpoint) agar event ini tetap sampai.


Mendaftarkan Webhook

Webhook diikat ke satu nomor via session_id. Isi hanya URL kategori yang Anda butuhkan:

bash
curl -X POST https://api.wagwayapp.com/v1/webhooks \
  -H "Authorization: Bearer wag_xxxxxxxxxxxx" \
  -H "Content-Type: application/json" \
  -d '{
    "session_id": "6281200000000:[email protected]",
    "messages_url": "https://yourserver.com/wa/messages",
    "status_url": "https://yourserver.com/wa/status"
  }'

Response (201 Created):

json
{
  "id": "wh_1a2b3c...",
  "phone": "6281200000000",
  "messages_url": "https://yourserver.com/wa/messages",
  "status_url": "https://yourserver.com/wa/status",
  "connection_url": "",
  "events_url": "",
  "secret": "whsec_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
  "active": true,
  "created_at": "2026-06-25T10:00:00Z"
}

Satu config per nomor: bila nomor sudah punya webhook, gunakan PUT /v1/webhooks/{id} untuk mengubah URL (secret tetap). Kosongkan sebuah URL untuk menonaktifkan kategori itu.

URL wajib https:// di production

Payload webhook membawa isi pesan pelanggan-akhir dalam plaintext. Signature HMAC (X-Webhook-Signature) menjamin payload berasal dari kami dan tidak diubah di tengah jalan, tapi tidak memberi kerahasiaan — di atas http:// polos, isi pesan bisa terbaca siapa pun di jalur jaringan. Karena itu registrasi http:// ditolak (ErrHTTPSRequired, 400 Bad Request) kecuali gateway berjalan di mode non-production. Pastikan endpoint Anda punya sertifikat TLS valid sebelum mendaftarkan URL.

Simpan secret dengan aman

secret hanya ditampilkan sekali saat pendaftaran. Simpan di environment variable atau secret manager — Anda akan membutuhkannya untuk verifikasi signature setiap request yang masuk.


Menguji & Memantau Webhook (Portal)

Di panel Webhooks, ada dua alat bantu debugging:

  • Test webhook — kirim payload contoh ke tiap endpoint (Pesan Masuk / Status Pesan / Status Koneksi) dari webhook tersimpan, lihat status HTTP, latency, serta cuplikan respons. Tombol ⚡ di samping masing-masing URL memungkinkan test dengan secret asli sehingga Anda bisa verifikasi signature.
  • Riwayat pengiriman — tabel log pengiriman nyata dengan detail event (Pesan Masuk, Status Pesan, Status Koneksi, Kegagalan Outbound), status, kode HTTP, dan bentuk payload yang dikirim (klik ikon untuk melihat JSON-nya). Event typing tidak dicatat. Filter riwayat berdasarkan nomor, jenis event, dan status sukses/gagal.

Isi pesan tidak disimpan di riwayat

Riwayat ini alat debugging bentuk payload, bukan arsip pesan. Field yang membawa isi percakapan — text, quoted_text, caption & data media, nama/opsi poll, teks tombol, dan titik lokasi — diganti penanda ukuran seperti [redacted: 42 bytes] sebelum disimpan. Endpoint Anda tetap menerima payload lengkap saat pengiriman; yang diredaksi hanya salinan yang kami simpan.

Retensi mengikuti paket nomor Anda — 3 hari (Free), 30 hari (Pro), 90 hari (Max) — dan tetap dibatasi maksimum 500 entri terbaru per akun, mana yang lebih dulu tercapai. Kalau Anda butuh arsip isi pesan, simpan sendiri di sisi penerima webhook.

URL webhook juga bisa diedit tanpa mengganti secret.


Format Payload

Event: Pesan Masuk (kind: "message")

json
{
  "kind": "message",
  "message_id": "msg_7f3a...",
  "session_id": "[email protected]",
  "sender_jid": "[email protected]",
  "sender_name": "Budi",
  "chat_jid": "[email protected]",
  "is_group": false,
  "type": "text",
  "text": "Halo, saya ingin bertanya...",
  "timestamp_unix": 1719340800
}
FieldTipeKeterangan
kindstringSelalu "message" untuk pesan masuk
message_idstringID unik pesan, gunakan untuk dedup di sisi Anda
session_idstringJID nomor WhatsApp Anda yang menerima pesan
sender_jidstringJID pengirim
sender_phonestringJID nomor telepon asli pengirim (@s.whatsapp.net). Dikirim bila tersedia. Berguna untuk grup: WhatsApp modern mengalamati anggota grup dengan @lid yang menyembunyikan nomor, sehingga sender_jid bisa berupa @lid — gunakan sender_phone untuk mengenali nomornya
sender_namestringNama tampilan (push name) pengirim. Kosong bila tak disertakan WhatsApp
chat_jidstringJID chat (sama dengan sender_jid untuk DM, berbeda untuk grup)
is_groupbooltrue jika pesan dari grup WhatsApp
typestringTipe pesan: text, reaction (reaksi emoji), image, video, audio, document, sticker, location, poll (poll dibuat), button_response (tombol pesan interaktif ditekan), atau other
textstringIsi pesan untuk type=text; caption untuk media; emoji untuk type=reaction (kosong = reaksi dihapus)
timestamp_unixintWaktu pesan dalam Unix timestamp (detik)

Contoh pesan dari grupsender_jid berupa @lid, nomor asli ada di sender_phone:

json
{
  "kind": "message",
  "message_id": "msg_9c2b...",
  "session_id": "[email protected]",
  "sender_jid": "164222688329773@lid",
  "sender_phone": "[email protected]",
  "sender_name": "Budi",
  "chat_jid": "[email protected]",
  "is_group": true,
  "type": "text",
  "text": "help",
  "timestamp_unix": 1719340800
}

Field tambahan saat pesan membawa media (type = image/video/audio/document/sticker):

json
{
  "kind": "message",
  "type": "image",
  "text": "ini captionnya",
  "media": {
    "mimetype": "image/jpeg",
    "file_length": 84213,
    "data": "/9j/4AAQSkZJRgABAQ...",
    "status": "downloaded"
  }
}
FieldTipeKeterangan
media.mimetypestringMIME type media (mis. image/jpeg, application/pdf)
media.extensionstringEkstensi file tanpa titik (mis. pdf, jpeg), diturunkan dari filename/mimetype
media.filenamestringNama file — hanya untuk document
media.file_lengthintUkuran byte; ukuran nyata jika downloaded, klaim pengirim jika tidak
media.datastringIsi file di-encode base64 standar; kosong jika tidak diunduh
media.statusstringdownloaded = data terisi; too_large = media melebihi batas 4 MB (metadata saja); error = unduh gagal (metadata saja)

Media besar & kegagalan unduh

Media di atas 4 MB tidak di-inline (status: "too_large") agar payload webhook tetap ringan; Anda tetap menerima metadata. status: "error" berarti unduh dari WhatsApp gagal — pesan tetap dikirim agar tidak hilang. Selalu cek media.status sebelum membaca media.data.

Batas ini turun dari 16 MB agar body POST tetap di bawah ~5,3 MB (base64 menambah ~33%). Alasannya praktis: nginx default client_max_body_size = 1 MB dan banyak framework membatasi body JSON di bawah 10 MB — payload 21 MB dari batas lama besar kemungkinan ditolak endpoint Anda sendiri lalu memicu retry, bukan terkirim. Kalau endpoint Anda memang sanggup menerima body besar, pastikan batasnya dinaikkan di reverse proxy Anda.

Field tambahan saat type=location (pengirim membagikan lokasi):

json
{
  "kind": "message",
  "type": "location",
  "location": {
    "latitude": -6.2088,
    "longitude": 106.8456,
    "name": "Monas"
  }
}
FieldTipeKeterangan
location.latitudenumberLintang
location.longitudenumberBujur
location.namestringNama tempat, bila pengirim menyertakan
location.addressstringAlamat teks, bila ada

Field tambahan saat type=poll (pengirim membuat polling):

json
{
  "kind": "message",
  "type": "poll",
  "text": "Makan siang di mana?",
  "poll": {
    "name": "Makan siang di mana?",
    "options": ["Padang", "Sunda", "Warteg"],
    "selectable_count": 1
  }
}
FieldTipeKeterangan
poll.namestringPertanyaan/judul poll (juga diisi ke text)
poll.optionsarray stringDaftar opsi (plaintext)
poll.selectable_countnumberMaks opsi yang boleh dipilih (1 = tunggal, 0 = tak dibatasi)

Menghubungkan poll dengan suaranya

Suara masuk tiba terpisah sebagai event kind: "poll_vote" yang berisi selected_hashes (SHA256 opsi terpilih, bukan teks — WhatsApp mengenkripsi vote). Untuk tahu opsi mana yang dipilih: hitung SHA256 dari tiap poll.options di atas, lalu cocokkan dengan selected_hashes. Korelasikan poll↔vote lewat message_id (poll) = poll_message_id (vote).

Field tambahan saat type=button_response (penerima menekan tombol pesan interaktif):

json
{
  "kind": "message",
  "type": "button_response",
  "text": "Ya",
  "button_response": {
    "selected_id": "confirm_yes",
    "selected_text": "Ya",
    "params_json": "{\"id\":\"confirm_yes\"}"
  }
}
FieldTipeKeterangan
button_response.selected_idstringID tombol/baris yang ditekan — cocokkan dengan id tombol yang Anda kirim
button_response.selected_textstringLabel tombol/baris yang dipilih (juga diisi ke text)
button_response.params_jsonstringPayload NativeFlow mentah (mis. baris single_select terpilih), bila ada

Field tambahan saat pesan adalah reply/quote (membalas pesan sebelumnya):

FieldTipeKeterangan
quoted_message_idstringID pesan yang dikutip — pakai sebagai quoted_message_id saat membalas
quoted_participantstringJID pengirim pesan yang dikutip (penting untuk grup)
quoted_textstringPratinjau teks pesan yang dikutip (kosong jika mengutip media)

Field tambahan saat type=reaction:

FieldTipeKeterangan
reacted_message_idstringID pesan yang mendapat reaksi — pakai untuk membalas reaksi via POST /v1/messages

Pola bot dua arah

message_id, sender_jid, dan text dari payload ini adalah sumber data untuk membalas-mengutip: petakan ke quoted_message_id, quoted_participant, dan quoted_text di kirim pesan reply/quote.

Event: Sedang Mengetik (kind: "typing")

Dikirim saat lawan bicara mulai/berhenti mengetik, atau merekam pesan suara.

json
{
  "kind": "typing",
  "session_id": "[email protected]",
  "sender_jid": "[email protected]",
  "chat_jid": "[email protected]",
  "is_group": false,
  "typing": true,
  "media": "text"
}
FieldTipeKeterangan
kindstringSelalu "typing"
session_idstringJID nomor WhatsApp Anda yang menerima event
sender_jidstringJID kontak yang sedang mengetik
chat_jidstringJID chat asal event
is_groupbooltrue jika dari grup
typingbooltrue = mulai mengetik, false = berhenti
mediastringtext = mengetik teks, audio = merekam pesan suara

Event: Status Pengiriman (kind: "receipt")

Dikirim saat penerima menerima atau membaca pesan yang Anda kirim lewat gateway.

json
{
  "kind": "receipt",
  "session_id": "[email protected]",
  "request_id": "order-12345-notif-1",
  "whatsapp_message_id": "3EB0A1B2C3D4E5F6",
  "recipient_jid": "[email protected]",
  "receipt_type": "read",
  "timestamp_unix": 1719340900
}
FieldTipeKeterangan
kindstringSelalu "receipt"
session_idstringJID nomor WhatsApp yang mengirim pesan asli
request_idstringIdempotency-Key dari request kirim pesan (kosong jika worker sempat restart)
whatsapp_message_idstringID pesan di WhatsApp
recipient_jidstringJID penerima yang membaca/menerima pesan
receipt_typestringdelivered (centang dua ✓✓), read (centang biru ✓✓), atau played (media view-once)
timestamp_unixintWaktu receipt dalam Unix timestamp (detik)

Event: Status Pengiriman (kind: "status")

Pengiriman via POST /v1/messages bersifat asinkron — API membalas 202 accepted (masuk antrian), pengiriman ke WhatsApp terjadi setelahnya. Lewat status_url Anda mengikuti lifecycle setiap pesan tanpa menebak-nebak:

sent ──> delivered ──> read / played
   └────> failed  (gagal permanen)
  • sent (event status) — WhatsApp menerima pesan (ack server). Awal lifecycle; membawa whatsapp_message_id yang akan muncul lagi di event receipt berikutnya.
  • delivered / read / played — event receipt (lihat bagian di atas), sudah sinkron dengan status Meta.
  • failed (event status) — pesan gagal permanen (tidak akan berhasil walau dicoba ulang). Menggantikan sent; tak ada receipt yang akan menyusul.

Korelasikan seluruh rantai lewat request_id (Idempotency-Key Anda). Bila worker restart di tengah jalan, receipt bisa kehilangan request_id — cocokkan pakai whatsapp_message_id dari event sent sebagai cadangan.

Contoh sent:

json
{
  "kind": "status",
  "session_id": "[email protected]",
  "request_id": "promo-banner-juli-2026",
  "message_id": "msg_1e7c7fad36b452253736742fa7637a4a",
  "whatsapp_message_id": "3EB0XXXXXXXXXXXXXXXX",
  "to": "[email protected]",
  "status": "sent",
  "timestamp_unix": 1719340990
}

Contoh failed:

json
{
  "kind": "status",
  "session_id": "[email protected]",
  "request_id": "promo-banner-juli-2026",
  "message_id": "msg_1e7c7fad36b452253736742fa7637a4a",
  "to": "[email protected]",
  "status": "failed",
  "reason": "MEDIA_FAILED",
  "detail": "media URL returned HTTP 403",
  "timestamp_unix": 1719341000
}
FieldTipeKeterangan
kindstringSelalu "status"
session_idstringJID nomor WhatsApp pengirim
request_idstringIdempotency-Key request kirim Anda (korelasikan ke pesan asli)
message_idstringID pesan gateway (msg_…) dari respons 202
whatsapp_message_idstringID pesan WhatsApp — ada pada sent, untuk korelasi ke receipt
tostringJID tujuan
statusstring"sent" (ack server) atau "failed" (gagal permanen)
reasonstringKode gagal — hanya ada saat status: "failed" (lihat tabel)
detailstringKeterangan singkat, opsional (mis. "media URL returned HTTP 403")
timestamp_unixintWaktu event (Unix detik)

Kode reason:

reasonArtiTindakan
INVALID_RECIPIENTJID/nomor tujuan tidak validPerbaiki format to
MEDIA_FAILEDURL media tidak bisa diambil (403/404/redirect/timeout), terlalu besar (>64 MB), atau tipe tak dikenalPakai URL publik langsung (tanpa redirect), pastikan dapat diakses server
SEND_FAILEDKegagalan permanen lain saat mengirimPeriksa detail
RETRIES_EXHAUSTEDKegagalan transien (koneksi/upload ke WhatsApp) melewati batas percobaan ulang (~5×)Nomor kemungkinan sedang bermasalah/terputus lama; cek status sesi lalu kirim ulang

Catatan: reason adalah enum tetap — aman dipakai untuk logika program. detail untuk manusia dan bisa berubah; jangan parsing.

Event: Status Koneksi (kind: "connection")

Dikirim ke connection_url saat status koneksi nomor berubah — biar Anda tahu ketika nomor putus atau tersambung tanpa harus polling.

json
{
  "kind": "connection",
  "session_id": "6281200000000:[email protected]",
  "event": "logged_out",
  "timestamp_unix": 1719340800
}
eventArtiTindakan
connectedNomor tersambung (pertama kali atau pulih setelah putus)
disconnectedPutus dan tidak pulih dalam beberapa saat (bukan blip jaringan sesaat)Pantau; biasanya pulih sendiri
logged_outWhatsApp membatalkan tautan perangkat — butuh pair ulang (terminal)Segera pair ulang nomor dari panel

disconnected sengaja di-debounce: putus-nyambung singkat (mis. saat WhatsApp update) tidak memicu event, agar server Anda tak dibanjiri. logged_out selalu dikirim segera.

Event: Suara Polling (kind: "poll_vote")

Dikirim saat seseorang memilih pada polling yang Anda kirim (type: "poll").

json
{
  "kind": "poll_vote",
  "session_id": "[email protected]",
  "voter_jid": "[email protected]",
  "chat_jid": "[email protected]",
  "poll_message_id": "3EB0F1A2B3C4D5E6F7",
  "selected_hashes": ["a1b2c3...", "d4e5f6..."],
  "timestamp_unix": 1719900000
}
FieldTipeKeterangan
poll_message_idstringID pesan polling asal (dari respons kirim poll) untuk korelasi
selected_hashesarray stringHash SHA256 (hex) tiap opsi yang dipilih, bukan teksnya

Opsi berupa hash, bukan teks

WhatsApp mengenkripsi isi vote — kami hanya menerima hash opsi terpilih. Untuk tahu opsi mana yang dipilih, hitung SHA256 dari teks tiap opsi poll yang Anda kirim, lalu cocokkan dengan selected_hashes. Array kosong berarti pemilih membatalkan pilihannya.

Event: Panggilan (kind: "call")

json
{
  "kind": "call",
  "session_id": "[email protected]",
  "call_id": "ABCDEF123456",
  "from_jid": "[email protected]",
  "event": "offer",
  "video": false,
  "rejected": false,
  "timestamp_unix": 1719900000
}
FieldTipeKeterangan
eventstringoffer (panggilan masuk/berdering) atau terminate (diakhiri)
videobooltrue bila panggilan video
reasonstringAlasan terminate (mis. timeout), bila ada
rejectedbooltrue bila gateway menolak panggilan otomatis (auto-reject aktif)

Auto-reject diatur per-nomor lewat POST /v1/sessions/{id}/calls/auto-reject — lihat Kelola Session.

Event: Presence Kontak (kind: "presence")

Online/offline kontak. Hanya dikirim setelah Anda subscribe kontak tersebut via POST /v1/sessions/{id}/presence/subscribe.

json
{
  "kind": "presence",
  "session_id": "[email protected]",
  "contact_jid": "[email protected]",
  "online": false,
  "last_seen_unix": 1719899000
}
FieldTipeKeterangan
onlinebooltrue = sedang online, false = offline
last_seen_unixintTerakhir terlihat (Unix detik); 0/absen bila online atau kontak menyembunyikan last-seen

Event: Pesan Channel yang Di-follow (kind: "newsletter_message")

Pesan baru yang diposting oleh channel (newsletter) yang Anda ikuti — channel milik orang lain, bukan yang Anda kelola. Untuk channel yang Anda kelola, server_id sudah dikembalikan langsung di respons kirim pesan, jadi event ini tak diperlukan di sana.

Ini satu-satunya cara memperoleh server_id + message_id pesan channel orang lain — keduanya wajib untuk memberi reaksi ke pesan itu via POST /v1/sessions/{id}/newsletters/{nl}/react.

json
{
  "kind": "newsletter_message",
  "session_id": "[email protected]",
  "newsletter_jid": "120363408670490828@newsletter",
  "server_id": 102,
  "message_id": "ABC123DEF456",
  "type": "text",
  "text": "Halo pengikut channel!",
  "timestamp_unix": 1719899000
}
FieldTipeKeterangan
newsletter_jidstringJID channel asal pesan
server_idintID server pesan channel — wajib untuk react ke pesan ini
message_idstringID pesan WhatsApp — juga dibutuhkan untuk react
typestringtext/image/video/… bila WhatsApp menyertakan konten; bisa kosong
textstringIsi teks/caption bila konten ikut; kosong bila update hanya membawa ID

Konten tidak selalu ikut

Sebagian live update channel hanya membawa server_id+message_id (tanpa type/text). server_id dan message_id selalu ada — cukup untuk react. Jika butuh isi pesan penuh, andalkan update yang menyertakan konten.

Contoh implementasi — handle beberapa jenis event:

javascript
app.post('/webhook/wa', (req, res) => {
  // ... verifikasi signature dulu (lihat bagian Verifikasi di bawah) ...

  const payload = JSON.parse(req.body)

  if (payload.kind === 'receipt') {
    const { request_id, recipient_jid, receipt_type } = payload
    console.log(`Pesan ${request_id} → ${receipt_type} oleh ${recipient_jid}`)
    // Update status di database Anda
  } else if (payload.kind === 'status') {
    const { request_id, reason, detail } = payload
    console.error(`Pesan ${request_id} GAGAL: ${reason} (${detail})`)
    // Tandai gagal & putuskan apakah perlu kirim ulang dengan input yang diperbaiki
  } else {
    // kind === 'message' (atau tidak ada kind untuk payload lama)
    console.log(`Pesan dari ${payload.sender_jid}: ${payload.text}`)
  }

  res.json({ ok: true })
})

Header Request

Setiap request webhook menyertakan header berikut:

HeaderContoh Nilai
Content-Typeapplication/json
X-Webhook-IDwh_1a2b3c...
X-Message-IDmsg_7f3a...
X-Timestamp1719340800
X-Webhook-Signaturet=1719340800,v1=abc123...

Verifikasi Signature

Selalu verifikasi signature sebelum memproses payload. Ini memastikan request benar-benar dari gateway kami dan payload tidak dimodifikasi di tengah jalan.

Format Signature

Header X-Webhook-Signature berformat:

t=<unix_timestamp>,v1=<hex_hmac_sha256>

Cara Verifikasi

  1. Ambil timestamp t dari header
  2. Buat string: <t>.<raw_body> (timestamp + titik + body request mentah)
  3. Hitung HMAC-SHA256 dari string tersebut menggunakan secret
  4. Bandingkan hasilnya dengan nilai v1 secara constant-time
  5. Tolak jika selisih waktu antara t dan waktu sekarang lebih dari 5 menit (mencegah replay attack)

Jangan skip verifikasi

Tanpa verifikasi, siapapun bisa mengirim request palsu ke endpoint Anda dan menyuntikkan data berbahaya ke sistem Anda.


Contoh Implementasi

php
<?php

function verifyWebhookSignature(string $secret, string $rawBody, string $signatureHeader): bool
{
    // Parse "t=1234,v1=abc..."
    $parts = [];
    foreach (explode(',', $signatureHeader) as $part) {
        [$k, $v] = explode('=', $part, 2);
        $parts[$k] = $v;
    }

    if (empty($parts['t']) || empty($parts['v1'])) {
        return false;
    }

    // Tolak jika timestamp terlalu lama (>5 menit)
    if (abs(time() - (int)$parts['t']) > 300) {
        return false;
    }

    // Hitung HMAC
    $expected = hash_hmac('sha256', $parts['t'] . '.' . $rawBody, $secret);

    // Bandingkan constant-time untuk mencegah timing attack
    return hash_equals($expected, $parts['v1']);
}

// --- Handler endpoint webhook ---

$secret  = getenv('WEBHOOK_SECRET'); // whsec_xxx dari saat pendaftaran
$rawBody = file_get_contents('php://input');
$sigHeader = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';

if (!verifyWebhookSignature($secret, $rawBody, $sigHeader)) {
    http_response_code(401);
    exit('Signature tidak valid');
}

$payload = json_decode($rawBody, true);

// Proses pesan
if ($payload['type'] === 'text') {
    $pengirim = $payload['sender_jid'];
    $pesan    = $payload['text'];
    error_log("Pesan dari {$pengirim}: {$pesan}");
}

// Selalu balas 200 OK agar tidak di-retry
http_response_code(200);
echo json_encode(['ok' => true]);
js
import crypto from 'crypto'
import express from 'express'

const app = express()

// Penting: gunakan raw body untuk verifikasi signature
app.use('/webhook/wa', express.raw({ type: 'application/json' }))

function verifySignature(secret, rawBody, signatureHeader) {
  const parts = Object.fromEntries(
    signatureHeader.split(',').map(p => p.split('='))
  )

  if (!parts.t || !parts.v1) return false

  // Tolak jika timestamp terlalu lama (>5 menit)
  if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) return false

  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${parts.t}.${rawBody}`)
    .digest('hex')

  // Bandingkan constant-time
  return crypto.timingSafeEqual(
    Buffer.from(expected),
    Buffer.from(parts.v1)
  )
}

app.post('/webhook/wa', (req, res) => {
  const secret    = process.env.WEBHOOK_SECRET
  const rawBody   = req.body
  const sigHeader = req.headers['x-webhook-signature'] ?? ''

  if (!verifySignature(secret, rawBody, sigHeader)) {
    return res.status(401).json({ error: 'Signature tidak valid' })
  }

  const payload = JSON.parse(rawBody)

  if (payload.type === 'text') {
    console.log(`Pesan dari ${payload.sender_jid}: ${payload.text}`)
  }

  // Selalu balas 200 OK agar tidak di-retry
  res.json({ ok: true })
})

app.listen(3000)
python
import hashlib
import hmac
import time
import os
from flask import Flask, request, jsonify, abort

app = Flask(__name__)

def verify_signature(secret: str, raw_body: bytes, signature_header: str) -> bool:
    parts = dict(p.split('=', 1) for p in signature_header.split(','))

    if 't' not in parts or 'v1' not in parts:
        return False

    # Tolak jika timestamp terlalu lama (>5 menit)
    if abs(time.time() - int(parts['t'])) > 300:
        return False

    payload = f"{parts['t']}.".encode() + raw_body
    expected = hmac.new(secret.encode(), payload, hashlib.sha256).hexdigest()

    # Bandingkan constant-time
    return hmac.compare_digest(expected, parts['v1'])

@app.route('/webhook/wa', methods=['POST'])
def webhook():
    secret      = os.environ['WEBHOOK_SECRET']
    raw_body    = request.get_data()
    sig_header  = request.headers.get('X-Webhook-Signature', '')

    if not verify_signature(secret, raw_body, sig_header):
        abort(401, 'Signature tidak valid')

    payload = request.get_json(force=True)

    if payload.get('type') == 'text':
        print(f"Pesan dari {payload['sender_jid']}: {payload['text']}")

    # Selalu balas 200 OK agar tidak di-retry
    return jsonify({'ok': True})
go
package main

import (
	"crypto/hmac"
	"crypto/sha256"
	"crypto/subtle"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"io"
	"log"
	"math"
	"net/http"
	"os"
	"strconv"
	"strings"
	"time"
)

func verifySignature(secret string, rawBody []byte, signatureHeader string) bool {
	parts := strings.Split(signatureHeader, ",")
	sigParts := make(map[string]string)
	for _, part := range parts {
		kv := strings.SplitN(part, "=", 2)
		if len(kv) == 2 {
			sigParts[kv[0]] = kv[1]
		}
	}

	tStr, okT := sigParts["t"]
	v1Str, okV1 := sigParts["v1"]
	if !okT || !okV1 {
		return false
	}

	t, err := strconv.ParseInt(tStr, 10, 64)
	if err != nil {
		return false
	}

	// Tolak jika timestamp terlalu lama (>5 menit)
	if math.Abs(float64(time.Now().Unix()-t)) > 300 {
		return false
	}

	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write([]byte(fmt.Sprintf("%d.", t)))
	mac.Write(rawBody)
	expectedMAC := mac.Sum(nil)

	expectedHex := hex.EncodeToString(expectedMAC)

	// Bandingkan constant-time
	return subtle.ConstantTimeCompare([]byte(expectedHex), []byte(v1Str)) == 1
}

func webhookHandler(w http.ResponseWriter, r *http.Request) {
	secret := os.Getenv("WEBHOOK_SECRET")
	rawBody, err := io.ReadAll(r.Body)
	if err != nil {
		http.Error(w, "Bad Request", http.StatusBadRequest)
		return
	}

	sigHeader := r.Header.Get("X-Webhook-Signature")
	if !verifySignature(secret, rawBody, sigHeader) {
		http.Error(w, "Signature tidak valid", http.StatusUnauthorized)
		return
	}

	var payload map[string]interface{}
	if err := json.Unmarshal(rawBody, &payload); err == nil {
		if payload["type"] == "text" {
			log.Printf("Pesan dari %v: %v\n", payload["sender_jid"], payload["text"])
		}
	}

	// Selalu balas 200 OK
	w.WriteHeader(http.StatusOK)
	w.Write([]byte(`{"ok": true}`))
}

func main() {
	http.HandleFunc("/webhook/wa", webhookHandler)
	log.Fatal(http.ListenAndServe(":3000", nil))
}
ruby
require 'sinatra'
require 'openssl'
require 'json'

def verify_signature(secret, raw_body, signature_header)
  return false unless signature_header

  parts = signature_header.split(',').map { |p| p.split('=', 2) }.to_h
  return false unless parts['t'] && parts['v1']

  # Tolak jika timestamp terlalu lama (>5 menit)
  return false if (Time.now.to_i - parts['t'].to_i).abs > 300

  payload = "#{parts['t']}.#{raw_body}"
  expected = OpenSSL::HMAC.hexdigest('SHA256', secret, payload)

  # Bandingkan constant-time
  Rack::Utils.secure_compare(expected, parts['v1'])
end

post '/webhook/wa' do
  secret = ENV['WEBHOOK_SECRET']
  request.body.rewind
  raw_body = request.body.read
  sig_header = request.env['HTTP_X_WEBHOOK_SIGNATURE']

  unless verify_signature(secret, raw_body, sig_header)
    halt 401, 'Signature tidak valid'
  end

  payload = JSON.parse(raw_body)
  if payload['type'] == 'text'
    puts "Pesan dari #{payload['sender_jid']}: #{payload['text']}"
  end

  # Selalu balas 200 OK
  content_type :json
  { ok: true }.to_json
end
java
import org.springframework.web.bind.annotation.*;
import org.springframework.http.*;
import org.springframework.beans.factory.annotation.Value;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.security.MessageDigest;
import java.util.Map;

@RestController
public class WebhookController {

    @Value("${webhook.secret}")
    private String secret;

    @PostMapping("/webhook/wa")
    public ResponseEntity<?> handleWebhook(
            @RequestHeader(value = "X-Webhook-Signature", required = false) String sigHeader,
            @RequestBody byte[] rawBody) {

        if (!verifySignature(secret, rawBody, sigHeader)) {
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Signature tidak valid");
        }

        // TODO: Parse payload (misal dengan Jackson ObjectMapper)

        // Selalu balas 200 OK
        return ResponseEntity.ok(Map.of("ok", true));
    }

    private boolean verifySignature(String secret, byte[] rawBody, String sigHeader) {
        if (sigHeader == null) return false;

        String[] parts = sigHeader.split(",");
        String t = null;
        String v1 = null;

        for (String part : parts) {
            String[] kv = part.split("=", 2);
            if (kv.length == 2) {
                if ("t".equals(kv[0])) t = kv[1];
                if ("v1".equals(kv[0])) v1 = kv[1];
            }
        }

        if (t == null || v1 == null) return false;

        long timestamp = Long.parseLong(t);
        // Tolak jika timestamp terlalu lama (>5 menit)
        if (Math.abs(System.currentTimeMillis() / 1000 - timestamp) > 300) {
            return false;
        }

        try {
            Mac mac = Mac.getInstance("HmacSHA256");
            SecretKeySpec secretKey = new SecretKeySpec(secret.getBytes(), "HmacSHA256");
            mac.init(secretKey);
            
            mac.update((t + ".").getBytes());
            byte[] hash = mac.doFinal(rawBody);
            
            StringBuilder hexString = new StringBuilder();
            for (byte b : hash) {
                String hex = Integer.toHexString(0xff & b);
                if (hex.length() == 1) hexString.append('0');
                hexString.append(hex);
            }
            
            // Bandingkan constant-time
            return MessageDigest.isEqual(hexString.toString().getBytes(), v1.getBytes());
        } catch (Exception e) {
            return false;
        }
    }
}

Retry & Dead Letter

Jika endpoint Anda merespons dengan status non-2xx atau koneksi timeout, sistem akan mencoba kirim ulang secara otomatis dengan interval yang semakin panjang (exponential backoff):

PercobaanJeda sebelum retry
1 (asli)
2~30 detik
3~5 menit
4~30 menit

Setelah semua percobaan gagal, pesan dipindah ke dead letter queue untuk investigasi manual.

Rekomendasi:

  • Selalu balas 200 OK sesegera mungkin, lalu proses payload secara asinkron di background job.
  • Implementasikan dedup di sisi Anda menggunakan message_id — pesan yang sama bisa diterima lebih dari sekali saat ada retry.

Kelola Webhook

Lihat daftar webhook:

bash
curl https://api.wagwayapp.com/v1/webhooks \
  -H "Authorization: Bearer wag_xxxxxxxxxxxx"

Hapus webhook:

bash
curl -X DELETE https://api.wagwayapp.com/v1/webhooks/wh_1a2b3c... \
  -H "Authorization: Bearer wag_xxxxxxxxxxxx"

Produk independen — tidak terafiliasi dengan Meta Platforms, Inc. atau WhatsApp Inc.