Appearance
Webhook
Terima notifikasi real-time dari gateway: pesan masuk, status terkirim, dan status dibaca.
Cara Kerja
Gateway melakukan HTTP POST ke URL endpoint Anda setiap kali ada event dari WhatsApp. Request berisi payload JSON beserta signature HMAC-SHA256 untuk memverifikasi keasliannya.
Event WhatsApp → [Gateway] → POST ke endpoint Anda → Anda proses eventJenis Event
Semua event dikirim ke webhook yang sama. Bedakan jenis event dari field kind:
kind | Kapan dikirim |
|---|---|
message | Ada pesan masuk ke nomor WhatsApp Anda |
receipt | Pesan yang Anda kirim sudah diterima atau dibaca penerima |
Mendaftarkan Endpoint
bash
curl -X POST https://api.example.com/v1/webhooks \
-H "Authorization: Bearer wag_xxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{"url": "https://yourserver.com/webhook/wa"}'Response (201 Created):
json
{
"id": "wh_1a2b3c...",
"url": "https://yourserver.com/webhook/wa",
"secret": "whsec_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"active": true,
"created_at": "2026-06-25T10:00:00Z"
}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.
Format Payload
Event: Pesan Masuk (kind: "message")
json
{
"kind": "message",
"message_id": "msg_7f3a...",
"session_id": "6281200000000@s.whatsapp.net",
"sender_jid": "628111111111@s.whatsapp.net",
"chat_jid": "628111111111@s.whatsapp.net",
"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 |
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 atau other (media, stiker, dll) |
text | string | Isi pesan teks (kosong jika type bukan text) |
timestamp_unix | int | Waktu pesan dalam Unix timestamp (detik) |
Event: Status Pengiriman (kind: "receipt")
Dikirim saat penerima menerima atau membaca pesan yang Anda kirim lewat gateway.
json
{
"kind": "receipt",
"session_id": "6281200000000@s.whatsapp.net",
"request_id": "order-12345-notif-1",
"whatsapp_message_id": "3EB0A1B2C3D4E5F6",
"recipient_jid": "628111111111@s.whatsapp.net",
"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) |
Contoh implementasi — handle dua 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 {
// 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})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.example.com/v1/webhooks \
-H "Authorization: Bearer wag_xxxxxxxxxxxx"Hapus webhook:
bash
curl -X DELETE https://api.example.com/v1/webhooks/wh_1a2b3c... \
-H "Authorization: Bearer wag_xxxxxxxxxxxx"