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:

  1. Buka http://localhost:3000
  2. Masukkan API key yang ditampilkan pemasang
  3. 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:

GET/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

KodeArtiYang harus dilakukan
400Isi permintaan salahBaca error; isinya menyebut field yang bermasalah
401API key salah atau tidak adaPeriksa header dan nilai API_KEY di .env
403Lisensi tidak mengizinkanUji coba habis, atau jumlah session melebihi paket
404Session, kontak, atau rute tidak adaPeriksa sessionId dan alamatnya
409Keadaan tidak memungkinkanUmumnya session belum tersambung. Cek statusnya dulu
429Terlalu banyak permintaanTunggu sesuai header Retry-After
500Kesalahan tak terdugaLihat 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 kirimMenjadi
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

BatasNilaiBisa diubah?
Permintaan HTTP per IP300 per menitTidak
Pesan per nomor per jam250Ya — HOURLY_MESSAGE_LIMIT
Jeda antar pesan3–9 detik acakYa — SEND_MIN_DELAY_MS / SEND_MAX_DELAY_MS
Penerima per kirim massal500Tidak
Nomor per pengecekan100Tidak
Ukuran media base6432 MBTidak
Isi permintaan50 MBTidak

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.

GET/api/sessions

Daftar semua session beserta statusnya.

POST/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.

GET/api/sessions/:id
PATCH/api/sessions/:id

Mengubah webhookUrl atau autostart.

POST/api/sessions/:id/start
POST/api/sessions/:id/stop
POST/api/sessions/:id/logout
DELETE/api/sessions/:id

Beda stop, logout, dan DELETE

  • stop — memutus koneksi, kredensial tetap tersimpan. Dijalankan lagi lewat start tanpa 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=false untuk menyimpan riwayat.

Mengambil QR

GET/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

POST/api/sessions/:id/messages

Semua tipe pesan memakai endpoint yang sama. Yang membedakan hanya field type dan isian pendampingnya.

FieldWajibKeterangan
toyaNomor tujuan atau ID grup
typetidakBawaannya 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.

CaraContohKapan 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

POST/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

POST/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

GET/api/sessions/:id/messages
QueryKeterangan
limitBawaan 50, maksimal 500
offsetBawaan 0
directionin atau out
chatIdSaring satu percakapan
POST/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

POST/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.

GET/api/sessions/:id/contacts/:target/picture

Mengembalikan URL foto profil. Bernilai kosong kalau nomor itu menyembunyikan fotonya.

Grup

GET/api/sessions/:id/groups

Semua grup yang diikuti nomor tersebut, lengkap dengan ID-nya.

GET/api/sessions/:id/groups/:groupId

Detail 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.

GET/api/contacts
QueryKeterangan
searchCari di nama, nomor, dan email
tagIdHanya kontak bertag ini
blockedtrue atau false
limit / offsetBawaan 50, maksimal 500
POST/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.

GET/api/contacts/:id
PATCH/api/contacts/:id
DELETE/api/contacts/:id
GET/api/contacts/export

Mengunduh seluruh buku alamat sebagai CSV.

POST/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

GET/api/tags
POST/api/tags
{ "name": "pelanggan-vip", "color": "#1fc161" }
DELETE/api/tags/:id

Menghapus tag hanya menghapus labelnya. Kontak yang memakainya tetap utuh.

Impor CSV

Dua langkah: lihat dulu, baru simpan.

POST/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.

POST/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

GET/api/autoreply
POST/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
}
FieldNilaiKeterangan
matchTypeany, exact, contains, starts_with, ends_with, regexany cocok dengan pesan apa pun
scopedm, group, allBawaannya dm
priorityangkaYang tertinggi diperiksa lebih dulu; hanya satu aturan yang membalas
cooldownSecondsdetikJeda sebelum aturan yang sama membalas orang yang sama lagi
stopOnHumanbooleanDiam kalau Anda baru saja membalas manual
sessionIdteks atau kosongKosong berarti berlaku untuk semua nomor

Menguji aturan tanpa mengganggu siapa pun

POST/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.

GET/api/autoreply/log

Riwayat balasan otomatis, termasuk yang dilewati beserta alasannya — cooldown, batas harian, atau jeda karena Anda sedang membalas manual.

POST/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

GET/api/schedules
POST/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
}
FieldNilaiWajib untuk
recurrenceonce, daily, weekly, monthlyselalu
targetTypenumber atau tagselalu
runAtepoch milidetik, harus di masa depanonce
timeOfDay"09:00" format 24 jamselain once
daysOfWeek[1,3,5] — 0 = Mingguweekly
dayOfMonth1–31monthly
GET/api/schedules/:id
PATCH/api/schedules/:id
DELETE/api/schedules/:id
GET/api/schedules/runs

Riwayat eksekusi semua jadwal — jawaban untuk "pengingatnya jadi terkirim tidak?"

POST/api/schedules/:id/run

Menjalankan 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.

GET/api/sessions/:id/polls
GET/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.

DELETE/api/sessions/:id/polls/:messageId

Berhenti 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

EventTerjadi saat
message.receivedAda pesan masuk
session.readyNomor berhasil tersambung setelah QR dipindai
session.logged_outTautan diputus dari HP, atau sesi kedaluwarsa
autoreply.sentBalasan otomatis terkirim
poll.voteAda 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.

GET/api/webhooks/deliveries

Riwayat 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.

GET/api/backups
POST/api/backups

Membuat backup sekarang. Tidak menghentikan layanan.

POST/api/backups/:file/verify
GET/api/backups/:file

Mengunduh berkas backup.

DELETE/api/backups/:file

Backup 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

GET/api/license
{
  "ok": true,
  "data": {
    "status": "licensed",
    "tier": "pro",
    "maxSessions": 10,
    "canSend": true,
    "renewsAt": 1785000000000,
    "tokenExpiresAt": 1756000000000
  }
}
statusArti
trialUji coba 14 hari, 3 nomor, fitur penuh
licensedBerlangganan aktif
expiredMasa berlaku habis; pengiriman berhenti
invalidKunci tidak dikenali
POST/api/license/refresh

Memaksa 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=0 demi "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