Dokumentasi
wagateway adalah REST API WhatsApp yang berjalan di server Anda sendiri. Halaman ini memuat seluruh endpoint yang tersedia, beserta contoh yang bisa langsung disalin.
Alamat dasar
http://localhost:3000
Awalan API
/api
Format
JSON
Jangan buka port 3000 ke internet apa adanya
API key adalah satu-satunya pengaman. Kalau aplikasi Anda berada di server lain, letakkan wagateway di belakang nginx dengan HTTPS, atau batasi aksesnya lewat firewall ke IP aplikasi Anda saja.
Pemasangan
Satu perintah, di server Linux mana pun yang sudah punya Docker:
curl -fsSL https://A.com/install.sh | bash
Skrip mengunduh docker-compose.yml, membuat API key acak,
lalu menjalankan layanan. Aman dijalankan ulang — .env yang
sudah ada tidak pernah ditimpa, jadi menjalankannya dua kali tidak akan membuat kunci baru
dan memutus pemasangan yang sedang jalan.
Setelah selesai, buka dasbor dan pindai QR:
- Buka
http://localhost:3000 - Masukkan API key yang ditampilkan pemasang
- Tambah session, lalu pindai QR lewat WhatsApp → Setelan → Perangkat tertaut → Tautkan perangkat
Anda langsung berada dalam uji coba 14 hari dengan fitur penuh dan 3 nomor.
Panggilan pertama
Setelah satu session tersambung, kirim pesan pertama Anda. Ganti
API_KEY dan nomor tujuannya:
curl -X POST http://localhost:3000/api/sessions/utama/messages \
-H "X-API-Key: API_KEY" \
-H "Content-Type: application/json" \
-d '{
"to": "08123456789",
"type": "text",
"text": "Halo dari wagateway"
}'
Balasannya:
{
"ok": true,
"data": {
"id": "3EB0F1C2A45B7D8E9012",
"to": "[email protected]",
"type": "text",
"timestamp": 1754500000000
}
}
Kenapa responsnya lambat?
Permintaan baru selesai setelah WhatsApp menerima pesannya, dan setiap pesan menunggu jeda anti-banned 3–9 detik. Jadi latensi 3–9 detik itu normal dan disengaja. Untuk banyak penerima, pakai kirim massal yang menjawab seketika lalu bekerja di latar belakang.
Autentikasi
Semua endpoint di bawah /api memerlukan API key. Dua cara,
keduanya setara:
X-API-Key: 3b79586b1afaf25ab45908325fa5c809…
# atau
Authorization: Bearer 3b79586b1afaf25ab45908325fa5c809…
API key adalah nilai API_KEY di berkas
.env Anda. Kunci itu Anda buat sendiri saat memasang.
API key bukan kunci lisensi
Kunci lisensi berawalan wag_live_ dan datang lewat email
pembelian. Kunci itu diisikan ke baris LICENSE_KEY di
.env, bukan dipakai memanggil API. Ini kekeliruan yang
paling sering terjadi.
Satu endpoint tidak memerlukan kunci — untuk pemeriksaan kesehatan:
/health{
"ok": true,
"uptime": 8412,
"sessions": { "total": 2, "connected": 2 },
"license": { "status": "licensed", "canSend": true }
}
Sengaja tanpa autentikasi supaya bisa dipakai healthcheck Docker dan load balancer. Isinya hanya angka — nama pelanggan dan kunci lisensi tidak pernah muncul di sini.
Format respons
Setiap jawaban punya bentuk yang sama. Berhasil:
{ "ok": true, "data": { … } }
Gagal:
{ "ok": false, "error": "penjelasan yang bisa dibaca manusia" }
Endpoint yang mengembalikan daftar menyertakan paging:
{
"ok": true,
"data": [ … ],
"paging": { "limit": 50, "offset": 0, "total": 214, "returned": 50 }
}
Periksa ok, jangan hanya kode HTTP. Beberapa endpoint
menjawab 200 dengan ok: false
— misalnya penyegaran lisensi yang gagal.
Kode error
| Kode | Arti | Yang harus dilakukan |
|---|---|---|
400 | Isi permintaan salah | Baca error; isinya menyebut field yang bermasalah |
401 | API key salah atau tidak ada | Periksa header dan nilai API_KEY di .env |
403 | Lisensi tidak mengizinkan | Uji coba habis, atau jumlah session melebihi paket |
404 | Session, kontak, atau rute tidak ada | Periksa sessionId dan alamatnya |
409 | Keadaan tidak memungkinkan | Umumnya session belum tersambung. Cek statusnya dulu |
429 | Terlalu banyak permintaan | Tunggu sesuai header Retry-After |
500 | Kesalahan tak terduga | Lihat docker compose logs |
Yang paling sering muncul di awal adalah 409: pesan dikirim
ke session yang QR-nya belum dipindai, atau yang terputus. Pesannya menyebutkan status
session saat itu.
Format nomor
Nomor tujuan boleh ditulis apa adanya. Semua bentuk berikut menghasilkan tujuan yang sama:
| Yang Anda kirim | Menjadi |
|---|---|
08123456789 | [email protected] |
628123456789 | [email protected] |
+62 812-3456-789 | [email protected] |
[email protected] | dipakai apa adanya |
Awalan 0 otomatis diganti 62,
dan tanda baca dibuang. Jadi nomor dari formulir pendaftaran biasanya bisa langsung dipakai
tanpa dibersihkan lebih dulu.
Grup wajib memakai bentuk lengkap
ID grup adalah deretan angka panjang yang tidak bisa dibedakan dari nomor telepon.
Karena itu grup harus selalu ditulis lengkap dengan akhiran, misalnya
[email protected]. Ambil daftarnya lewat
endpoint grup.
Batas permintaan
| Batas | Nilai | Bisa diubah? |
|---|---|---|
| Permintaan HTTP per IP | 300 per menit | Tidak |
| Pesan per nomor per jam | 250 | Ya — HOURLY_MESSAGE_LIMIT |
| Jeda antar pesan | 3–9 detik acak | Ya — SEND_MIN_DELAY_MS / SEND_MAX_DELAY_MS |
| Penerima per kirim massal | 500 | Tidak |
| Nomor per pengecekan | 100 | Tidak |
| Ukuran media base64 | 32 MB | Tidak |
| Isi permintaan | 50 MB | Tidak |
Saat kena batas HTTP, respons menyertakan lama tunggunya:
HTTP/1.1 429 Too Many Requests
Retry-After: 37
{ "ok": false, "error": "too many requests", "retryAfter": 37 }
Mengelola session
Satu session mewakili satu nomor WhatsApp. Berapa banyak yang boleh aktif ditentukan paket lisensi Anda.
/api/sessionsDaftar semua session beserta statusnya.
/api/sessions{
"id": "utama",
"webhookUrl": "https://aplikasi-anda.com/wa/webhook",
"autostart": true
}
id Anda tentukan sendiri dan dipakai di semua alamat
berikutnya. webhookUrl opsional dan menimpa
WEBHOOK_URL global untuk session ini saja.
/api/sessions/:id/api/sessions/:idMengubah webhookUrl atau autostart.
/api/sessions/:id/start/api/sessions/:id/stop/api/sessions/:id/logout/api/sessions/:idBeda stop, logout, dan DELETE
- stop — memutus koneksi, kredensial tetap tersimpan. Dijalankan lagi lewat
starttanpa pindai ulang. - logout — memutus tautan di sisi WhatsApp. Perangkat hilang dari daftar di HP, dan QR harus dipindai ulang.
- DELETE — menghapus session beserta riwayat pesannya. Tambahkan
?purgeMessages=falseuntuk menyimpan riwayat.
Mengambil QR
/api/sessions/:id/qr{
"ok": true,
"data": {
"status": "qr",
"qr": "data:image/png;base64,iVBORw0KG…",
"raw": "2@Xy8kL…"
}
}
qr adalah gambar siap tampil di
<img src>. raw adalah
teks mentahnya, kalau Anda ingin merender sendiri.
QR berumur pendek dan diganti otomatis. Kalau session baru saja dibuat, endpoint ini bisa
menjawab 409 karena soketnya masih menyambung — coba lagi
satu detik kemudian.
Cara yang lebih baik: dengarkan lewat socket
Dasbor tidak melakukan polling. Ia membuka koneksi Socket.IO dan menerima QR baru begitu tersedia. Kalau Anda membuat antarmuka sendiri, cara ini jauh lebih responsif — lihat contoh Node.js.
Kirim pesan
/api/sessions/:id/messages
Semua tipe pesan memakai endpoint yang sama. Yang membedakan hanya field
type dan isian pendampingnya.
| Field | Wajib | Keterangan |
|---|---|---|
to | ya | Nomor tujuan atau ID grup |
type | tidak | Bawaannya text |
Balasannya 201 dengan ID pesan WhatsApp:
{
"ok": true,
"data": {
"id": "3EB0F1C2A45B7D8E9012",
"to": "[email protected]",
"type": "text",
"timestamp": 1754500000000
}
}
Simpan id itu kalau Anda ingin memberi reaksi pada pesan
tersebut nanti, atau mencocokkannya dengan riwayat.
10 tipe pesan
Teks
{
"to": "08123456789",
"type": "text",
"text": "Pesanan #1042 sudah dikirim.",
"linkPreview": false
}
linkPreview: false mematikan pratinjau tautan.
Di grup, mentions berisi array JID yang ingin di-mention.
Gambar
{
"to": "08123456789",
"type": "image",
"media": { "url": "https://contoh.com/struk.jpg" },
"caption": "Struk pembelian Anda"
}
Tambahkan "viewOnce": true untuk sekali lihat.
Video
{
"to": "08123456789",
"type": "video",
"media": { "url": "https://contoh.com/panduan.mp4" },
"caption": "Cara memakai aplikasi",
"gif": false
}
Audio & voice note
{
"to": "08123456789",
"type": "audio",
"media": { "url": "https://contoh.com/pesan.ogg" },
"ptt": true,
"mimetype": "audio/ogg; codecs=opus"
}
ptt: true menampilkannya sebagai voice note dengan gelombang
suara. WhatsApp hanya merendernya begitu kalau codec-nya opus/ogg;
berkas MP3 akan muncul sebagai lampiran audio biasa.
Dokumen
{
"to": "08123456789",
"type": "document",
"media": { "url": "https://contoh.com/invoice-1042.pdf" },
"fileName": "Invoice-1042.pdf",
"mimetype": "application/pdf"
}
fileName wajib — nama inilah yang dilihat dan disimpan
penerima.
Stiker
{
"to": "08123456789",
"type": "sticker",
"media": { "url": "https://contoh.com/stiker.webp" }
}
Harus format WebP. PNG atau JPG tidak akan tampil sebagai stiker.
Lokasi
{
"to": "08123456789",
"type": "location",
"latitude": -6.175392,
"longitude": 106.827153,
"name": "Toko Pusat",
"address": "Jl. Medan Merdeka Utara, Jakarta"
}
latitude dan longitude harus
berupa angka, bukan teks.
Kontak
{
"to": "08123456789",
"type": "contact",
"fullName": "Budi Santoso",
"phone": "08129876543",
"organization": "PT Contoh Jaya"
}
Dikirim sebagai kartu kontak yang bisa langsung disimpan penerima.
Polling
{
"to": "[email protected]",
"type": "poll",
"question": "Jam berapa rapat besok?",
"options": ["09:00", "13:00", "15:00"],
"multiSelect": false
}
Antara 2 dan 12 pilihan, tanpa duplikat. Pakai
selectableCount kalau ingin membatasi jumlah pilihan pada
polling multi-jawaban. Hasilnya dibaca lewat endpoint polling.
Reaksi
{
"to": "08123456789",
"type": "reaction",
"chatId": "[email protected]",
"messageId": "3EB0F1C2A45B7D8E9012",
"emoji": "👍",
"fromMe": true
}
emoji kosong menghapus reaksi.
fromMe: true kalau yang direaksi adalah pesan yang Anda
kirim sendiri.
Tombol dan menu interaktif tidak tersedia
Tombol balasan cepat, daftar pilihan, dan pesan template adalah fitur WhatsApp Business API resmi yang berbayar per percakapan. wagateway memakai protokol WhatsApp Web, yang tidak menyediakannya. Polling adalah pengganti terdekat untuk pilihan berstruktur.
Melampirkan media
Field media menerima tepat
satu dari tiga cara. Mengisi dua sekaligus ditolak dengan
400.
| Cara | Contoh | Kapan dipakai |
|---|---|---|
url |
{ "url": "https://…/a.jpg" } |
Paling hemat memori — berkas dialirkan langsung, tidak ditampung dulu |
base64 |
{ "base64": "iVBORw0KG…" } |
Berkas yang baru dibuat aplikasi Anda dan belum punya URL. Maksimal 32 MB |
path |
{ "path": "invoice.pdf" } |
Berkas yang sudah ada di dalam volume, relatif terhadap MEDIA_DIR |
Awalan data:image/png;base64, boleh disertakan atau tidak —
keduanya diterima.
Kenapa path dikurung
path tidak boleh keluar dari MEDIA_DIR.
Tanpa batas itu, siapa pun yang memegang API key bisa mengirimkan berkas apa pun yang
bisa dibaca proses — termasuk .env Anda — ke nomor WhatsApp
mana pun.
Kirim massal
/api/sessions/:id/messages/bulk{
"recipients": ["08123456789", "08129876543", "08111222333"],
"type": "text",
"text": "Toko kami libur tanggal 17 Agustus."
}
Selain recipients, isian lainnya sama persis dengan kirim
satuan — jadi kirim massal bisa berupa gambar, dokumen, atau tipe apa pun.
Jawabannya 202, seketika:
{
"ok": true,
"data": {
"accepted": 3,
"rejected": [],
"queueDepth": 3,
"estimatedCompletionMs": 18000
}
}
202 berarti "diterima, belum selesai". Dengan jeda
anti-banned, 200 penerima memakan waktu sekitar 20 menit — tidak ada klien HTTP yang mau
menunggu selama itu. Pantau hasilnya lewat
riwayat pesan atau
webhook.
Nomor yang bentuknya tidak masuk akal masuk ke rejected
lengkap dengan alasannya, dan tidak menggagalkan sisanya. Isi pesan divalidasi sekali di
awal, jadi payload yang salah ditolak sebagai 400 di sini —
bukan sebagai 200 kegagalan diam-diam di latar belakang.
Broadcast bertag
/api/sessions/:id/messages/broadcast{
"tagId": 3,
"template": "Halo {nama}, tagihan Anda jatuh tempo besok.",
"skipBlocked": true
}
Bedanya dengan kirim massal: setiap penerima mendapat salinannya sendiri yang sudah
dirender. {nama} berubah menjadi nama masing-masing orang,
bukan terkirim sebagai teks mentah ke semuanya.
Variabel yang tersedia diambil dari kolom kontak: {nama},
{phone}, {email}, ditambah
kolom kustom apa pun yang Anda simpan. Coba dulu hasilnya lewat
POST /api/contacts/render sebelum benar-benar mengirim.
Alih-alih tagId, Anda juga bisa memberi
contactIds berupa array ID kontak.
Riwayat & tanda dibaca
/api/sessions/:id/messages| Query | Keterangan |
|---|---|
limit | Bawaan 50, maksimal 500 |
offset | Bawaan 0 |
direction | in atau out |
chatId | Saring satu percakapan |
/api/sessions/:id/messages/read{
"chatId": "[email protected]",
"messageIds": ["3EB0F1C2A45B7D8E9012"]
}
Menandai pesan sebagai sudah dibaca — centang biru muncul di sisi pengirim.
Cek nomor & foto profil
/api/sessions/:id/contacts/check{ "numbers": ["08123456789", "08129876543"] }
{
"ok": true,
"data": [
{ "input": "08123456789", "jid": "[email protected]", "exists": true },
{ "input": "08129876543", "jid": null, "exists": false }
]
}
Maksimal 100 nomor per permintaan. Jalankan ini sebelum broadcast besar: mengirim ke nomor yang tidak punya WhatsApp menghabiskan jatah per jam Anda tanpa hasil, dan pola semacam itu termasuk yang dinilai mencurigakan oleh WhatsApp.
/api/sessions/:id/contacts/:target/pictureMengembalikan URL foto profil. Bernilai kosong kalau nomor itu menyembunyikan fotonya.
Grup
/api/sessions/:id/groupsSemua grup yang diikuti nomor tersebut, lengkap dengan ID-nya.
/api/sessions/:id/groups/:groupIdDetail satu grup: nama, deskripsi, daftar peserta, dan admin.
Untuk mengirim ke grup, pakai ID lengkapnya sebagai to:
{ "to": "[email protected]", "type": "text", "text": "Halo semua" }
Buku alamat
Buku alamat berlaku untuk seluruh instalasi, bukan per session — jadi satu daftar kontak dipakai bersama semua nomor Anda.
/api/contacts| Query | Keterangan |
|---|---|
search | Cari di nama, nomor, dan email |
tagId | Hanya kontak bertag ini |
blocked | true atau false |
limit / offset | Bawaan 50, maksimal 500 |
/api/contacts{
"phone": "08123456789",
"name": "Budi Santoso",
"email": "[email protected]",
"notes": "Pelanggan sejak 2023",
"custom": { "kota": "Bandung", "paket": "Premium" },
"tags": ["pelanggan", "bandung"]
}
custom bebas isinya, dan setiap kuncinya bisa dipakai
sebagai variabel template broadcast — {kota},
{paket}. Tag yang belum ada dibuatkan otomatis.
Nomor ganda ditolak dengan 409.
/api/contacts/:id/api/contacts/:id/api/contacts/:id/api/contacts/exportMengunduh seluruh buku alamat sebagai CSV.
/api/contacts/render{ "template": "Halo {nama} dari {kota}", "tagId": 3, "limit": 3 }
Pratinjau template terhadap kontak sungguhan tanpa mengirim apa pun. Jalankan ini sebelum broadcast — variabel yang salah tulis akan terlihat di sini, bukan di HP pelanggan.
Tag
/api/tags/api/tags{ "name": "pelanggan-vip", "color": "#1fc161" }
/api/tags/:idMenghapus tag hanya menghapus labelnya. Kontak yang memakainya tetap utuh.
Impor CSV
Dua langkah: lihat dulu, baru simpan.
/api/contacts/preview{ "csv": "nama,nomor,email\nBudi,08123456789,[email protected]", "hasHeader": true }
Tidak menyimpan apa pun. Mengembalikan header yang terbaca, tebakan pemetaan kolom, lima baris contoh, dan jumlah total baris.
/api/contacts/import{
"csv": "…",
"hasHeader": true,
"mapping": { "phone": 1, "name": 0, "email": 2 },
"defaultTags": ["impor-agustus"],
"updateExisting": true
}
Impor ulang tidak menghapus data lama
Dengan updateExisting: true, kontak yang sudah ada
digabung, bukan ditimpa. Kolom kustom yang tidak ada di CSV baru tetap
bertahan. Jadi mengimpor ulang berkas berisi kolom nomor saja tidak akan menghapus
catatan dan kolom kustom yang sudah Anda isi.
Auto-reply
/api/autoreply/api/autoreply{
"name": "Salam pembuka",
"sessionId": "utama",
"matchType": "contains",
"pattern": "harga",
"replyText": "Daftar harga terbaru: https://contoh.com/harga",
"scope": "dm",
"priority": 10,
"cooldownSeconds": 3600,
"stopOnHuman": true,
"enabled": true
}
| Field | Nilai | Keterangan |
|---|---|---|
matchType | any, exact, contains, starts_with, ends_with, regex | any cocok dengan pesan apa pun |
scope | dm, group, all | Bawaannya dm |
priority | angka | Yang tertinggi diperiksa lebih dulu; hanya satu aturan yang membalas |
cooldownSeconds | detik | Jeda sebelum aturan yang sama membalas orang yang sama lagi |
stopOnHuman | boolean | Diam kalau Anda baru saja membalas manual |
sessionId | teks atau kosong | Kosong berarti berlaku untuk semua nomor |
Menguji aturan tanpa mengganggu siapa pun
/api/autoreply/test{ "text": "berapa harga paket premium?", "isGroup": false }
{
"ok": true,
"data": {
"matched": true,
"rule": { "id": 4, "name": "Salam pembuka", … },
"reply": "Daftar harga terbaru: https://contoh.com/harga"
}
}
Tidak mengirim apa pun dan tidak memakan cooldown. Endpoint ini ada supaya tidak ada yang harus menemukan regex-nya salah lewat pesan yang terlanjur masuk ke pelanggan.
/api/autoreply/logRiwayat balasan otomatis, termasuk yang dilewati beserta alasannya — cooldown, batas harian, atau jeda karena Anda sedang membalas manual.
/api/autoreply/resume{ "sessionId": "utama", "chatId": "[email protected]" }
Mengakhiri jeda manusia lebih cepat, saat Anda sudah selesai menangani percakapan itu.
Empat pengaman yang selalu aktif
Dua bot yang saling membalas bisa mengirim ribuan pesan dalam semenit dan membuat nomor
Anda diblokir. Karena itu berlaku: batas 5 balasan per percakapan per hari, 60 balasan
per nomor per jam, jeda 2 jam setelah Anda membalas manual dari HP, dan di grup hanya
membalas kalau nomor Anda di-mention. Semuanya bisa diatur lewat
.env.
Pesan terjadwal
/api/schedules/api/schedules{
"name": "Pengingat tagihan bulanan",
"sessionId": "utama",
"targetType": "tag",
"targetValue": "3",
"messageType": "text",
"payload": { "text": "Halo {nama}, tagihan bulan ini sudah terbit." },
"recurrence": "monthly",
"dayOfMonth": 25,
"timeOfDay": "09:00",
"enabled": true
}
| Field | Nilai | Wajib untuk |
|---|---|---|
recurrence | once, daily, weekly, monthly | selalu |
targetType | number atau tag | selalu |
runAt | epoch milidetik, harus di masa depan | once |
timeOfDay | "09:00" format 24 jam | selain once |
daysOfWeek | [1,3,5] — 0 = Minggu | weekly |
dayOfMonth | 1–31 | monthly |
/api/schedules/:id/api/schedules/:id/api/schedules/:id/api/schedules/runsRiwayat eksekusi semua jadwal — jawaban untuk "pengingatnya jadi terkirim tidak?"
/api/schedules/:id/runMenjalankan sekarang juga tanpa mengganggu jadwal berikutnya.
Jadwal memakai zona waktu server
"09:00" berarti jam sembilan menurut jam server, bukan
menurut jam pengguna. Pastikan TZ=Asia/Jakarta ada di
.env, kalau tidak jadwal Anda akan meleset tujuh jam.
Zona waktu yang sedang dipakai tampil di meta.timezone
pada GET /api/schedules.
Jadwal bulanan tanggal 31 tetap berjalan di bulan yang lebih pendek — tanggalnya dijepit ke hari terakhir bulan tersebut, jadi Februari terkirim tanggal 28 atau 29, bukan terlewat.
Polling
Polling dikirim lewat endpoint pesan biasa dengan
type: "poll". Endpoint di bawah ini untuk membaca hasilnya.
/api/sessions/:id/polls/api/sessions/:id/polls/:messageId{
"ok": true,
"data": {
"id": "3EB0F1C2A45B7D8E9012",
"chatId": "[email protected]",
"question": "Jam berapa rapat besok?",
"multiSelect": false,
"totalVoters": 7,
"options": [
{ "name": "09:00", "votes": 4, "percent": 57,
"voters": [{ "jid": "628123…@s.whatsapp.net", "phone": "628123…" }] },
{ "name": "13:00", "votes": 2, "percent": 29, "voters": [ … ] },
{ "name": "15:00", "votes": 1, "percent": 14, "voters": [ … ] }
]
}
}
Daftar isi polling tidak menyertakan voters supaya tetap
ringan; ambil satu polling untuk melihat siapa memilih apa.
Persentase dihitung terhadap jumlah pemilih, bukan jumlah suara. Pada polling multi-jawaban satu orang bisa memilih tiga opsi, dan persentase di atas 100 akan menyesatkan.
/api/sessions/:id/polls/:messageIdBerhenti melacak polling. Pollingnya tetap ada di chat WhatsApp, tetapi suara yang masuk setelah ini tidak bisa lagi dibaca — kuncinya ikut terhapus.
Webhook
Isi WEBHOOK_URL di .env, atau
setel per session lewat webhookUrl. Setiap kejadian dikirim
sebagai POST JSON.
Bentuk kiriman
POST https://aplikasi-anda.com/wa/webhook
Content-Type: application/json
X-Webhook-Event: message.received
X-Webhook-Session: utama
X-Webhook-Signature: 9f86d081884c7d659a2feaa0c55ad015a…
User-Agent: wagateway/1.0
{
"event": "message.received",
"sessionId": "utama",
"timestamp": 1754500000000,
"data": { … }
}
Kejadian
| Event | Terjadi saat |
|---|---|
message.received | Ada pesan masuk |
session.ready | Nomor berhasil tersambung setelah QR dipindai |
session.logged_out | Tautan diputus dari HP, atau sesi kedaluwarsa |
autoreply.sent | Balasan otomatis terkirim |
poll.vote | Ada yang memilih di polling Anda |
Memverifikasi tanda tangan
Kalau WEBHOOK_SECRET terisi, setiap kiriman membawa header
X-Webhook-Signature: HMAC-SHA256 dari
isi mentah permintaan, dalam heksadesimal.
Verifikasilah. Alamat webhook Anda terbuka di internet, dan tanpa pemeriksaan ini siapa pun yang menebaknya bisa mengirimkan "pesan masuk" palsu ke aplikasi Anda.
// PHP
$raw = file_get_contents('php://input');
$kirim = $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '';
$harusnya = hash_hmac('sha256', $raw, $secret);
if (!hash_equals($harusnya, $kirim)) {
http_response_code(401);
exit;
}
$data = json_decode($raw, true);
// Node.js — perhatikan express.raw, bukan express.json
import crypto from 'node:crypto'
app.post('/wa/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const harusnya = crypto.createHmac('sha256', secret).update(req.body).digest('hex')
const kirim = req.get('x-webhook-signature') ?? ''
if (harusnya.length !== kirim.length ||
!crypto.timingSafeEqual(Buffer.from(harusnya), Buffer.from(kirim))) {
return res.sendStatus(401)
}
const payload = JSON.parse(req.body)
res.sendStatus(200)
})
Tanda tangan dihitung dari isi mentah
Kalau framework Anda sudah mengurai JSON lebih dulu lalu Anda susun ulang jadi teks, tanda tangannya tidak akan cocok — spasi dan urutan kunci berubah. Ambil isi mentahnya sebelum diurai.
Percobaan ulang
Kiriman yang gagal diulang dengan jeda menaik, sampai
WEBHOOK_MAX_RETRIES kali. Yang diulang hanya kegagalan
sementara: timeout, 408, 429,
dan 5xx. Sebuah 400 tetap
400 empat detik kemudian, jadi tidak diulang.
Jawab 200 secepatnya, lalu kerjakan pemrosesannya di latar
belakang. Endpoint yang lambat memicu timeout, dan timeout memicu pengiriman ulang — Anda
akan menerima pesan yang sama beberapa kali.
/api/webhooks/deliveriesRiwayat pengiriman webhook: kode status, jumlah percobaan, dan pesan kesalahannya. Tempat pertama yang harus dilihat kalau aplikasi Anda tidak menerima apa-apa.
Backup
Backup berjalan otomatis setiap hari pukul BACKUP_TIME
dan langsung diverifikasi. Yang disimpan 7 harian dan 4 mingguan.
/api/backups/api/backupsMembuat backup sekarang. Tidak menghentikan layanan.
/api/backups/:file/verify/api/backups/:fileMengunduh berkas backup.
/api/backups/:fileBackup ada di volume yang sama dengan databasenya
Itu melindungi dari kerusakan data dan salah hapus — bukan dari kehilangan server. Salin ke luar secara berkala:
docker run --rm \
-v wagateway_wagateway-data:/data \
-v "$PWD":/keluar alpine \
tar czf /keluar/wagateway-backup.tar.gz -C /data backups
Jadwalkan dengan cron, dan kirim hasilnya ke penyimpanan lain.
Lisensi
/api/license{
"ok": true,
"data": {
"status": "licensed",
"tier": "pro",
"maxSessions": 10,
"canSend": true,
"renewsAt": 1785000000000,
"tokenExpiresAt": 1756000000000
}
}
status | Arti |
|---|---|
trial | Uji coba 14 hari, 3 nomor, fitur penuh |
licensed | Berlangganan aktif |
expired | Masa berlaku habis; pengiriman berhenti |
invalid | Kunci tidak dikenali |
/api/license/refreshMemaksa pemeriksaan ulang ke server lisensi. Berguna tepat setelah Anda menempelkan kunci baru atau memperpanjang langganan.
Verifikasi berlangsung luring. Setelah lisensi diperiksa, gateway menyimpan token yang berlaku 30 hari, lalu memperbaruinya diam-diam di latar belakang. Server Anda tetap bisa mengirim pesan meski koneksi ke A.com terputus berhari-hari.
Anti-banned
Nomor bisa diblokir WhatsApp
wagateway memakai protokol WhatsApp Web lewat library tidak resmi. Pengamanan bawaan menurunkan risiko secara berarti, tetapi tidak menghilangkannya. Yang paling menentukan adalah perilaku pengiriman Anda.
Yang sudah aktif tanpa perlu Anda atur:
- Jeda acak 3–9 detik antar pesan, tidak pernah dua jeda yang sama persis
- Simulasi "sedang mengetik" sebelum pesan teks
- Batas 250 pesan per nomor per jam
- Antrean per nomor, sehingga permintaan bersamaan tetap terkirim berurutan
Yang menjadi tanggung jawab Anda:
- Jangan menonaktifkan jeda. Menyetel
SEND_MIN_DELAY_MS=0demi "kirim cepat" adalah cara tercepat kehilangan nomor - Kirim hanya ke orang yang memang mengharapkan pesan Anda
- Nomor baru jangan langsung dipakai blast — naikkan volumenya perlahan selama beberapa hari
- Pakai nomor kedua, jangan nomor pribadi
- Cek dulu nomornya punya WhatsApp sebelum broadcast besar
- Sediakan cara berhenti berlangganan, dan hormati permintaannya
Masalah umum
"API key salah" padahal sudah sesuai email
Yang dikirim lewat email adalah kunci lisensi
(wag_live_…), bukan API key. Kunci lisensi ditempel ke
baris LICENSE_KEY di .env.
API key adalah nilai API_KEY yang dibuat pemasang —
lihat dengan grep API_KEY .env.
Selalu dijawab 409 saat mengirim pesan
Session belum tersambung. Cek GET /api/sessions/:id:
status qr berarti QR belum dipindai,
connecting berarti tunggu sebentar, dan
logged_out berarti tautannya diputus dari HP dan perlu
pindai ulang.
QR tidak muncul atau dijawab 409
Soketnya masih menyambung. Coba lagi satu detik kemudian. Kalau lewat sepuluh detik
masih kosong, lihat docker compose logs -f — biasanya
server tidak bisa keluar ke internet.
Pesan terjadwal terkirim di jam yang salah
Jadwal mengikuti zona waktu server. Pastikan TZ=Asia/Jakarta
ada di .env, lalu
docker compose restart. Zona waktu yang sedang berlaku
tampil di meta.timezone pada
GET /api/schedules.
Webhook tidak pernah sampai
Buka GET /api/webhooks/deliveries. Kalau di sana kosong,
WEBHOOK_URL belum terisi. Kalau ada tapi statusnya
gagal, kode status dan pesan kesalahannya menunjukkan penyebabnya — umumnya alamat
tidak bisa dijangkau dari dalam kontainer, atau sertifikat HTTPS bermasalah.
Tanda tangan webhook tidak pernah cocok
Hampir selalu karena isi permintaan sudah diurai lalu disusun ulang. HMAC dihitung dari byte mentah; JSON yang di-encode ulang berbeda spasi dan urutan kuncinya. Ambil isi mentahnya sebelum diurai.
Balasan otomatis tidak jalan
Lihat GET /api/autoreply/log — balasan yang dilewati
tercatat lengkap dengan alasannya. Penyebab tersering: masih dalam cooldown, sudah
melewati batas harian per percakapan, atau sedang berhenti karena Anda membalas manual
dari HP dalam 2 jam terakhir. Di grup, aturan hanya berlaku kalau nomor Anda
di-mention.
Kirim satu pesan makan waktu 5 detik
Itu jeda anti-banned, dan memang disengaja. Untuk banyak penerima gunakan kirim massal, yang menjawab seketika lalu mengirim di latar belakang.
Nomor tiba-tiba keluar sendiri
WhatsApp memutus perangkat tertaut kalau HP utama tidak online berminggu-minggu, atau
kalau nomor yang sama dipindai di tempat lain. Pastikan HP utama tetap sesekali
terhubung internet. Kalau ini terjadi, event
session.logged_out dikirim ke webhook Anda.
PHP / CodeIgniter
Mengirim pesan
<?php
function kirimWa(string $ke, string $teks): array
{
$ch = curl_init('http://localhost:3000/api/sessions/utama/messages');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30, // jeda anti-banned bisa 9 detik
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'X-API-Key: ' . getenv('WA_API_KEY'),
],
CURLOPT_POSTFIELDS => json_encode([
'to' => $ke,
'type' => 'text',
'text' => $teks,
]),
]);
$isi = curl_exec($ch);
$kode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($isi === false) {
throw new RuntimeException('gateway tidak dapat dihubungi');
}
$data = json_decode($isi, true);
if ($kode >= 400 || empty($data['ok'])) {
throw new RuntimeException($data['error'] ?? "gagal (HTTP $kode)");
}
return $data['data'];
}
Menerima webhook di CodeIgniter 4
<?php namespace App\Controllers;
use CodeIgniter\Controller;
class WaWebhook extends Controller
{
public function terima()
{
// Isi MENTAH, bukan $this->request->getJSON():
// tanda tangan dihitung dari byte aslinya.
$raw = $this->request->getBody();
$kirim = $this->request->getHeaderLine('X-Webhook-Signature');
$secret = getenv('WA_WEBHOOK_SECRET');
if (!hash_equals(hash_hmac('sha256', $raw, $secret), $kirim)) {
return $this->response->setStatusCode(401);
}
$payload = json_decode($raw, true);
// Jawab dulu, proses belakangan. Endpoint yang lambat memicu
// timeout, dan timeout memicu pengiriman ulang.
if ($payload['event'] === 'message.received') {
$this->antrikan($payload['data']);
}
return $this->response->setStatusCode(200)->setJSON(['ok' => true]);
}
}
Daftarkan rutenya di app/Config/Routes.php, dan kecualikan
dari perlindungan CSRF — kiriman ini datang dari server, bukan dari peramban:
$routes->post('wa/webhook', 'WaWebhook::terima');
Node.js
Mengirim pesan
const GATEWAY = 'http://localhost:3000'
const API_KEY = process.env.WA_API_KEY
async function kirimWa(to, text, sessionId = 'utama') {
const res = await fetch(`${GATEWAY}/api/sessions/${sessionId}/messages`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': API_KEY
},
body: JSON.stringify({ to, type: 'text', text }),
signal: AbortSignal.timeout(30_000)
})
const body = await res.json()
if (!res.ok || !body.ok) throw new Error(body.error ?? `HTTP ${res.status}`)
return body.data
}
Menampilkan QR secara langsung
import { io } from 'socket.io-client'
const socket = io('http://localhost:3000', {
auth: { apiKey: process.env.WA_API_KEY }
})
socket.on('session.qr', ({ sessionId, qr }) => {
// qr adalah data URL, siap dipasang ke <img src>
console.log(`QR baru untuk ${sessionId}`)
})
socket.on('session.ready', ({ sessionId, phoneNumber }) => {
console.log(`${sessionId} tersambung sebagai ${phoneNumber}`)
})
socket.on('message', (pesan) => {
console.log('pesan masuk', pesan)
})
Socket memakai API key yang sama, dikirim lewat auth.apiKey
saat berjabat tangan. Ini yang dipakai dasbor bawaan, dan jauh lebih responsif daripada
polling QR.
Python
import os
import requests
GATEWAY = "http://localhost:3000"
API_KEY = os.environ["WA_API_KEY"]
def kirim_wa(ke: str, teks: str, session_id: str = "utama") -> dict:
r = requests.post(
f"{GATEWAY}/api/sessions/{session_id}/messages",
headers={"X-API-Key": API_KEY},
json={"to": ke, "type": "text", "text": teks},
timeout=30, # jeda anti-banned bisa 9 detik
)
body = r.json()
if not body.get("ok"):
raise RuntimeError(body.get("error", f"HTTP {r.status_code}"))
return body["data"]
Memverifikasi webhook (Flask)
import hmac
import hashlib
import os
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = os.environ["WA_WEBHOOK_SECRET"].encode()
@app.post("/wa/webhook")
def terima():
raw = request.get_data() # byte mentah, sebelum diurai
kirim = request.headers.get("X-Webhook-Signature", "")
harusnya = hmac.new(SECRET, raw, hashlib.sha256).hexdigest()
if not hmac.compare_digest(harusnya, kirim):
abort(401)
payload = request.get_json()
# jawab cepat, proses di latar belakang
return {"ok": True}, 200
Masih buntu?
Sertakan keluaran dua perintah ini saat menghubungi kami. Keduanya menjawab sebagian besar pertanyaan sebelum sempat ditanyakan.
docker compose logs --tail 50
curl -s http://localhost:3000/health