Appearance
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 eventURL 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
| Kategori | kind yang masuk | Kapan dikirim |
|---|---|---|
messages_url | message | Pesan masuk (teks/reaksi/media; broadcast: true bila Status/Story) |
status_url | receipt, status | Lifecycle pesan yang Anda kirim: sent (ack server) & gagal permanen (status), diterima/dibaca (receipt) |
connection_url | connection | Status koneksi nomor (tersambung/terputus) |
events_url | poll_vote, typing, presence, call, newsletter_message | Vote 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
typingtidak 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
}| Field | Tipe | Keterangan |
|---|---|---|
kind | string | Selalu "message" untuk pesan masuk |
message_id | string | ID unik pesan, gunakan untuk dedup di sisi Anda |
session_id | string | JID nomor WhatsApp Anda yang menerima pesan |
sender_jid | string | JID pengirim |
sender_phone | string | JID 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_name | string | Nama tampilan (push name) pengirim. Kosong bila tak disertakan WhatsApp |
chat_jid | string | JID chat (sama dengan sender_jid untuk DM, berbeda untuk grup) |
is_group | bool | true jika pesan dari grup WhatsApp |
type | string | Tipe pesan: text, reaction (reaksi emoji), image, video, audio, document, sticker, location, poll (poll dibuat), button_response (tombol pesan interaktif ditekan), atau other |
text | string | Isi pesan untuk type=text; caption untuk media; emoji untuk type=reaction (kosong = reaksi dihapus) |
timestamp_unix | int | Waktu pesan dalam Unix timestamp (detik) |
Contoh pesan dari grup — sender_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"
}
}| Field | Tipe | Keterangan |
|---|---|---|
media.mimetype | string | MIME type media (mis. image/jpeg, application/pdf) |
media.extension | string | Ekstensi file tanpa titik (mis. pdf, jpeg), diturunkan dari filename/mimetype |
media.filename | string | Nama file — hanya untuk document |
media.file_length | int | Ukuran byte; ukuran nyata jika downloaded, klaim pengirim jika tidak |
media.data | string | Isi file di-encode base64 standar; kosong jika tidak diunduh |
media.status | string | downloaded = 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"
}
}| Field | Tipe | Keterangan |
|---|---|---|
location.latitude | number | Lintang |
location.longitude | number | Bujur |
location.name | string | Nama tempat, bila pengirim menyertakan |
location.address | string | Alamat 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
}
}| Field | Tipe | Keterangan |
|---|---|---|
poll.name | string | Pertanyaan/judul poll (juga diisi ke text) |
poll.options | array string | Daftar opsi (plaintext) |
poll.selectable_count | number | Maks 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\"}"
}
}| Field | Tipe | Keterangan |
|---|---|---|
button_response.selected_id | string | ID tombol/baris yang ditekan — cocokkan dengan id tombol yang Anda kirim |
button_response.selected_text | string | Label tombol/baris yang dipilih (juga diisi ke text) |
button_response.params_json | string | Payload NativeFlow mentah (mis. baris single_select terpilih), bila ada |
Field tambahan saat pesan adalah reply/quote (membalas pesan sebelumnya):
| Field | Tipe | Keterangan |
|---|---|---|
quoted_message_id | string | ID pesan yang dikutip — pakai sebagai quoted_message_id saat membalas |
quoted_participant | string | JID pengirim pesan yang dikutip (penting untuk grup) |
quoted_text | string | Pratinjau teks pesan yang dikutip (kosong jika mengutip media) |
Field tambahan saat type=reaction:
| Field | Tipe | Keterangan |
|---|---|---|
reacted_message_id | string | ID 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"
}| Field | Tipe | Keterangan |
|---|---|---|
kind | string | Selalu "typing" |
session_id | string | JID nomor WhatsApp Anda yang menerima event |
sender_jid | string | JID kontak yang sedang mengetik |
chat_jid | string | JID chat asal event |
is_group | bool | true jika dari grup |
typing | bool | true = mulai mengetik, false = berhenti |
media | string | text = 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
}| Field | Tipe | Keterangan |
|---|---|---|
kind | string | Selalu "receipt" |
session_id | string | JID nomor WhatsApp yang mengirim pesan asli |
request_id | string | Idempotency-Key dari request kirim pesan (kosong jika worker sempat restart) |
whatsapp_message_id | string | ID pesan di WhatsApp |
recipient_jid | string | JID penerima yang membaca/menerima pesan |
receipt_type | string | delivered (centang dua ✓✓), read (centang biru ✓✓), atau played (media view-once) |
timestamp_unix | int | Waktu 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(eventstatus) — WhatsApp menerima pesan (ack server). Awal lifecycle; membawawhatsapp_message_idyang akan muncul lagi di eventreceiptberikutnya.delivered/read/played— eventreceipt(lihat bagian di atas), sudah sinkron dengan status Meta.failed(eventstatus) — pesan gagal permanen (tidak akan berhasil walau dicoba ulang). Menggantikansent; 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
}| Field | Tipe | Keterangan |
|---|---|---|
kind | string | Selalu "status" |
session_id | string | JID nomor WhatsApp pengirim |
request_id | string | Idempotency-Key request kirim Anda (korelasikan ke pesan asli) |
message_id | string | ID pesan gateway (msg_…) dari respons 202 |
whatsapp_message_id | string | ID pesan WhatsApp — ada pada sent, untuk korelasi ke receipt |
to | string | JID tujuan |
status | string | "sent" (ack server) atau "failed" (gagal permanen) |
reason | string | Kode gagal — hanya ada saat status: "failed" (lihat tabel) |
detail | string | Keterangan singkat, opsional (mis. "media URL returned HTTP 403") |
timestamp_unix | int | Waktu event (Unix detik) |
Kode reason:
reason | Arti | Tindakan |
|---|---|---|
INVALID_RECIPIENT | JID/nomor tujuan tidak valid | Perbaiki format to |
MEDIA_FAILED | URL media tidak bisa diambil (403/404/redirect/timeout), terlalu besar (>64 MB), atau tipe tak dikenal | Pakai URL publik langsung (tanpa redirect), pastikan dapat diakses server |
SEND_FAILED | Kegagalan permanen lain saat mengirim | Periksa detail |
RETRIES_EXHAUSTED | Kegagalan transien (koneksi/upload ke WhatsApp) melewati batas percobaan ulang (~5×) | Nomor kemungkinan sedang bermasalah/terputus lama; cek status sesi lalu kirim ulang |
Catatan:
reasonadalah enum tetap — aman dipakai untuk logika program.detailuntuk 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
}event | Arti | Tindakan |
|---|---|---|
connected | Nomor tersambung (pertama kali atau pulih setelah putus) | — |
disconnected | Putus dan tidak pulih dalam beberapa saat (bukan blip jaringan sesaat) | Pantau; biasanya pulih sendiri |
logged_out | WhatsApp membatalkan tautan perangkat — butuh pair ulang (terminal) | Segera pair ulang nomor dari panel |
disconnectedsengaja di-debounce: putus-nyambung singkat (mis. saat WhatsApp update) tidak memicu event, agar server Anda tak dibanjiri.logged_outselalu 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
}| Field | Tipe | Keterangan |
|---|---|---|
poll_message_id | string | ID pesan polling asal (dari respons kirim poll) untuk korelasi |
selected_hashes | array string | Hash 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
}| Field | Tipe | Keterangan |
|---|---|---|
event | string | offer (panggilan masuk/berdering) atau terminate (diakhiri) |
video | bool | true bila panggilan video |
reason | string | Alasan terminate (mis. timeout), bila ada |
rejected | bool | true 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
}| Field | Tipe | Keterangan |
|---|---|---|
online | bool | true = sedang online, false = offline |
last_seen_unix | int | Terakhir 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
}| Field | Tipe | Keterangan |
|---|---|---|
newsletter_jid | string | JID channel asal pesan |
server_id | int | ID server pesan channel — wajib untuk react ke pesan ini |
message_id | string | ID pesan WhatsApp — juga dibutuhkan untuk react |
type | string | text/image/video/… bila WhatsApp menyertakan konten; bisa kosong |
text | string | Isi 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:
| Header | Contoh Nilai |
|---|---|
Content-Type | application/json |
X-Webhook-ID | wh_1a2b3c... |
X-Message-ID | msg_7f3a... |
X-Timestamp | 1719340800 |
X-Webhook-Signature | t=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
- Ambil timestamp
tdari header - Buat string:
<t>.<raw_body>(timestamp + titik + body request mentah) - Hitung HMAC-SHA256 dari string tersebut menggunakan
secret - Bandingkan hasilnya dengan nilai
v1secara constant-time - Tolak jika selisih waktu antara
tdan 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
endjava
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):
| Percobaan | Jeda 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 OKsesegera 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"