W WA Gateway CRM ← Beranda

Dokumentasi API

Halaman ini menjelaskan cara memakai endpoint api dan kirim untuk mengirim & menerima pesan WhatsApp lewat hosting ini. Endpoint ini dipakai oleh local app (yang benar-benar terhubung ke WhatsApp) dan bisa juga dipanggil dari sistem eksternal untuk menitip pesan keluar.

1. Autentikasi

Semua endpoint kecuali webhook wajib mengirim header:

X-API-Secret: <isi API_SECRET dari .env>

Kalau header tidak ada → 401. Kalau isinya salah → 403. Nilai ini harus sama persis dengan API_SECRET di .env (lihat panel Admin untuk cek beberapa karakter pertama/terakhirnya).

Endpoint webhook memakai token yang berbeda (WEBHOOK_TOKEN), dikirim lewat parameter token di query string atau body — supaya kalau token ini bocor, penyerang cuma bisa menitip pesan ke outbox, tidak bisa mengakses endpoint lain.

POST kirim (disarankan untuk aplikasi luar)

Endpoint publik terpisah dari api, khusus untuk dipanggil dari aplikasi/sistem pihak ketiga yang ingin mengirim pesan WhatsApp. Parameternya hampir sama persis dengan form Tes Kirim Pesan di dashboard — bedanya, di sini Device ID tidak perlu dikirim manual.

Token sekarang per-device. Setiap kali perusahaan mendaftarkan Device ID baru di dashboard, sistem otomatis membuat token unik untuk device tersebut (kolom company_devices.token), yang ditampilkan sekali di halaman dashboard dan bisa dibuat ulang kapan saja. Token inilah yang dipakai untuk autentikasi di sini — device_id tujuan otomatis ditentukan dari token, tidak perlu dikirim terpisah. WEBHOOK_TOKEN di .env tidak dipakai lagi di sini (masih dipakai khusus untuk action=webhook versi lama).

Catatan: karena ini pesan keluar, nomor tujuan tidak otomatis dicatat sebagai kontak — pencatatan kontak hanya terjadi untuk chat masuk (lihat Pencatatan kontak).

FieldWajibKeterangan
tokenYaToken unik device, didapat dari dashboard perusahaan saat device didaftarkan (atau setelah "Buat Ulang Token"). Menentukan device_id tujuan secara otomatis
to_numberYaNomor tujuan (boleh format 08xx, otomatis dinormalisasi ke 62xx) — atau JID grup WhatsApp untuk kirim ke grup, mis. 120363xxxxxxxxxx@g.us (dilewatkan apa adanya, tidak ikut dinormalisasi)
message_textSalah satu wajib*Isi pesan / caption media
media_fileTidakUpload file langsung (multipart/form-data) — gambar/video/dokumen, maks 20MB. Kalau diisi, media_url/media_mimetype/media_filename di bawah diabaikan
media_urlSalah satu wajib*Alternatif kalau file sudah ada di URL publik (tidak upload langsung)
media_mimetypeTidakMimetype media, mis. image/jpeg (dipakai bersama media_url)
media_filenameTidakNama file media (dipakai bersama media_url)

*Minimal salah satu dari message_text, media_file, atau media_url harus diisi.

Contoh 1 — kirim pesan teks saja:

curl -s -X POST "https://crm.kiosq.id/kirim" \
  -d "token=TOKEN_DEVICE_ANDA" \
  -d "to_number=081234567890" \
  -d "message_text=Halo dari aplikasi luar"

Contoh 2 — upload file media langsung (multipart):

curl -s -X POST "https://crm.kiosq.id/kirim" \
  -F "token=TOKEN_DEVICE_ANDA" \
  -F "to_number=081234567890" \
  -F "message_text=Cek lampiran ya" \
  -F "media_file=@/path/ke/file.jpg"

Contoh 3 — pakai media yang sudah ada di URL publik:

curl -s -X POST "https://crm.kiosq.id/kirim" \
  -d "token=TOKEN_DEVICE_ANDA" \
  -d "to_number=081234567890" \
  -d "media_url=https://domain-anda.com/file.pdf" \
  -d "media_mimetype=application/pdf" \
  -d "media_filename=invoice.pdf"

Respons sukses — pesan langsung masuk ke tabel wa_messages dengan status pending, menunggu local app mengambil & mengirimnya:

{ "ok": true, "id": 103, "status": "pending" }

Respons gagal (parameter kurang / token salah / dll), selalu dalam format JSON dengan kode HTTP sesuai (lihat tabel error):

{ "error": "to_number, dan message_text/media_file/media_url wajib diisi" }

Kalau token tidak dikenali:

{ "error": "invalid token" }

POST kirim-batch (kirim banyak pesan/nomor sekaligus)

Versi batch dari kirim — untuk aplikasi luar yang perlu menitip banyak pesan sekaligus dalam 1 request, baik ke banyak nomor berbeda, beberapa pesan ke nomor yang sama, atau kombinasi keduanya. Auth & cara kerja dasarnya sama persis dengan kirim (token per-device, device_id tujuan otomatis dari token) — bedanya di sini body request berupa array pesan.

Beda penting dengan kirim:

Field body (level atas):

FieldWajibKeterangan
tokenYaToken unik device, sama seperti di kirim
messagesYaArray berisi minimal 1 pesan, maksimal 200. Tiap elemen adalah object dengan field di tabel bawah

Field tiap elemen di dalam messages[]:

FieldWajibKeterangan
to_numberYaNomor tujuan (boleh format 08xx, otomatis dinormalisasi ke 62xx) — atau JID grup WhatsApp untuk kirim ke grup, mis. 120363xxxxxxxxxx@g.us (dilewatkan apa adanya, tidak ikut dinormalisasi)
message_textSalah satu wajib*Isi pesan / caption media
media_urlSalah satu wajib*URL publik file media (gambar/video/dokumen)
media_mimetypeTidakMimetype media, mis. image/jpeg
media_filenameTidakNama file media
scheduled_atTidakJadwalkan kirim nanti, format Y-m-d H:i:s. Kosongkan untuk kirim sekarang

*Minimal salah satu dari message_text atau media_url harus diisi per item.

Contoh — kirim ke 3 nomor berbeda + 1 nomor dapat 2 pesan sekaligus:

curl -s -X POST "https://crm.kiosq.id/kirim-batch" \
  -H "Content-Type: application/json" \
  -d '{
    "token": "TOKEN_DEVICE_ANDA",
    "messages": [
      { "to_number": "081234567890", "message_text": "Halo Budi, promo hari ini!" },
      { "to_number": "081298765432", "message_text": "Halo Siti, promo hari ini!" },
      { "to_number": "6281211112222", "message_text": "Konfirmasi pesanan #1023" },
      { "to_number": "6281211112222", "media_url": "https://domain-anda.com/invoice-1023.pdf", "media_mimetype": "application/pdf", "media_filename": "invoice-1023.pdf" }
    ]
  }'

Respons — status 200 selama request-nya valid, walau sebagian item gagal (cek results[].ok per item):

{
  "ok": true,
  "total": 4,
  "queued": 3,
  "failed": 1,
  "results": [
    { "index": 0, "ok": true, "id": 501, "status": "queued" },
    { "index": 1, "ok": true, "id": 502, "status": "queued" },
    { "index": 2, "ok": false, "error": "to_number, dan message_text/media_url wajib diisi" },
    { "index": 3, "ok": true, "id": 503, "status": "queued" }
  ]
}

Respons gagal di level request (bukan per-item) — mis. body bukan JSON, "messages" kosong/bukan array, atau melebihi batas 200 item:

{ "error": "\"messages\" wajib diisi berupa array berisi minimal 1 pesan" }

GET action=pending

Dipakai local app untuk mengambil (dan langsung meng-klaim) pesan outbox yang berstatus pending untuk satu device.

ParameterWajibKeterangan
session / deviceYaDevice ID (mis. A1B2C3)
limitTidakJumlah maksimal pesan diambil sekali panggil (default 10, maks 50)
curl -s "https://crm.kiosq.id/api?action=pending&session=A1B2C3&limit=10" \
  -H "X-API-Secret: SECRET_ANDA"

Respons (pesan langsung berubah status jadi processing begitu diambil, supaya tidak diambil dobel):

{
  "items": [
    {
      "id": 101,
      "to_number": "6281234567890",
      "message_text": "Halo dari hosting",
      "media_url": null,
      "media_mimetype": null,
      "media_filename": null
    }
  ]
}

POST action=pending-batch

Dipakai gateway yang mengelola banyak device sekaligus untuk melaporkan status koneksi (connected/disconnected) SEMUA device yang dia konfigurasi dalam satu request -- termasuk yang sedang disconnected, supaya hosting tahu device mana yang offline, bukan cuma yang aktif kirim pesan. Endpoint ini tidak mengembalikan pesan apapun -- pengambilan pesan pending tetap lewat action=pending per device seperti biasa.

Body JSON:

FieldWajibKeterangan
devicesTidakArray Device ID yang dikelola gateway ini (informasional, boleh dikosongkan)
device_statusYaArray objek {"id": "...", "status": "connected"|"disconnected"}, satu per device
curl -s -X POST "https://crm.kiosq.id/api?action=pending-batch" \
  -H "X-API-Secret: SECRET_ANDA" \
  -H "Content-Type: application/json" \
  -d '{"devices":["DEV001","DEV002"],"device_status":[{"id":"DEV001","status":"connected"},{"id":"DEV002","status":"disconnected"}]}'

Respons:

{ "ok": true, "updated": 2 }

Device ID yang belum terdaftar di company Anda diabaikan diam-diam (tidak menggagalkan device lain dalam batch yang sama). Status ditampilkan sebagai badge Online/Offline di halaman Dashboard, dan perubahan status (bukan tiap poll) ikut dicatat ke riwayat log device.

Fallback untuk gateway lama yang belum pindah ke pending-batch: sisipkan status koneksi lewat query param device_status (JSON di-urlencode) di action=pending yang sudah ada -- opsional, tidak mengubah perilaku lama kalau tidak dikirim:

curl -s "https://crm.kiosq.id/api?action=pending&session=DEV001&device_status=%5B%7B%22id%22%3A%22DEV001%22%2C%22status%22%3A%22connected%22%7D%5D" \
  -H "X-API-Secret: SECRET_ANDA"

Ada juga format yang lebih simpel buat lapor status device TUNGGAL yang lagi di-hit (tidak perlu bungkus JSON sama sekali) -- cukup query param status sejajar dengan session:

curl -s "https://crm.kiosq.id/api?action=pending&session=DEV001&status=connected" \
  -H "X-API-Secret: SECRET_ANDA"

POST action=report

Dipakai local app untuk melaporkan hasil pengiriman satu pesan yang sudah diambil lewat pending.

ParameterWajibKeterangan
session / deviceYa (query string)Device ID

Body JSON:

FieldWajibKeterangan
idYaID pesan (dari respons pending)
statusYasent atau failed
wa_message_idTidakID pesan dari WhatsApp (untuk pelacakan status lanjutan)
chat_jidTidakJID chat WhatsApp tujuan
errorTidakPesan error kalau status=failed
curl -s -X POST "https://crm.kiosq.id/api?action=report&session=A1B2C3" \
  -H "X-API-Secret: SECRET_ANDA" \
  -H "Content-Type: application/json" \
  -d '{"id":101,"status":"sent","wa_message_id":"3EB0...","chat_jid":"6281234567890@s.whatsapp.net"}'

POST action=push

Dipakai local app untuk mengirim pesan masuk (chat baru dari lawan bicara) atau update status pengiriman (terkirim/sampai/dibaca) ke hosting.

Field bodyWajibKeterangan
message_idYaID unik pesan dari WhatsApp
chat_jidYaJID chat
directionTidakin (default) atau out
from_meTidaktrue/false — menentukan status awal (sent/received)
wa_statusTidakAngka 0-5 (lihat tabel status WA di bawah)
sender_number, recipient_number, push_nameTidakInfo pengirim. Kalau direction=in, sender_number + push_name otomatis dicatat sebagai kontak baru (lihat Pencatatan kontak)
message_type, message_textTidakJenis & isi pesan
is_groupTidakBoolean (true/false) -- tandai apakah chat ini dari grup WhatsApp. Kalau tidak dikirim, otomatis dideteksi dari akhiran chat_jid (@g.us = grup, @s.whatsapp.net = pribadi) -- kirim eksplisit kalau local app sudah tahu pasti
media_base64, media_mimetype, media_filenameTidakMedia di-encode base64 untuk disimpan di database
media_truncatedTidaktrue kalau media dipotong karena kebesaran
wa_timestampTidakUnix timestamp pesan dari WhatsApp
curl -s -X POST "https://crm.kiosq.id/api?action=push&session=A1B2C3" \
  -H "X-API-Secret: SECRET_ANDA" \
  -H "Content-Type: application/json" \
  -d '{
    "message_id": "3EB0...",
    "chat_jid": "6281234567890@s.whatsapp.net",
    "direction": "in",
    "sender_number": "6281234567890",
    "push_name": "Budi",
    "message_type": "text",
    "message_text": "Halo, ini balasan dari pelanggan",
    "is_group": false
  }'

Tabel kode wa_status:

NilaiArti
0Error
1Pending
2Terkirim ke server WhatsApp
3Sampai di HP tujuan
4Sudah dibaca
5Voice note diputar
Pencatatan kontak otomatis. Nomor dicatat sebagai kontak perusahaan (tabel contacts, dipakai untuk fitur Blast Pesan) hanya dari chat masuk — yaitu hit action=push dengan direction=in. Nomor tujuan dari pesan keluar (kirim, action=webhook, atau action=push dengan direction=out) tidak dicatat sebagai kontak. Nama kontak diisi dari push_name, dan sekali tersimpan tidak akan ditimpa.

GET POST action=webhook (versi lama, masih didukung)

Fungsinya sama dengan kirim di atas (masuk ke outbox lewat token WEBHOOK_TOKEN), tapi lewat api dan tidak mendukung upload file langsung (hanya media_url). Untuk integrasi baru, disarankan pakai kirim. Sama seperti kirim, ini pesan keluar sehingga nomor tujuan tidak dicatat sebagai kontak.

Endpoint untuk pihak eksternal (Zapier, sistem lain) menitip pesan ke outbox tanpa perlu header custom — cukup token di query string atau body, supaya kompatibel dengan tool yang tidak bisa kirim header sendiri.

FieldWajibKeterangan
tokenYaHarus sama dengan WEBHOOK_TOKEN di .env
device_idYaDevice ID tujuan
to_number / toYaNomor tujuan (boleh format 08xx, otomatis dinormalisasi ke 62xx) — atau JID grup WhatsApp untuk kirim ke grup, mis. 120363xxxxxxxxxx@g.us (dilewatkan apa adanya, tidak ikut dinormalisasi)
message_text / message / textSalah satu wajib*Isi pesan
media_urlSalah satu wajib*URL media publik yang mau dikirim
media_mimetype, media_filenameTidakMetadata media

*Minimal salah satu dari message_text atau media_url harus diisi.

curl -s -X POST "https://crm.kiosq.id/api?action=webhook" \
  -H "Content-Type: application/json" \
  -d '{
    "token": "WEBHOOK_TOKEN_ANDA",
    "device_id": "A1B2C3",
    "to_number": "081234567890",
    "message_text": "Halo dari sistem eksternal"
  }'

Atau lewat GET (query string), cocok untuk tool yang cuma bisa hit URL:

https://crm.kiosq.id/api?action=webhook&token=WEBHOOK_TOKEN_ANDA&device_id=A1B2C3&to_number=081234567890&message=Halo

Respons sukses:

{ "ok": true, "id": 102 }

GET action=media

Mengambil isi file media (gambar/video/dokumen) yang tersimpan di database, mis. untuk ditampilkan di aplikasi lain.

ParameterWajibKeterangan
session / deviceYaDevice ID
idYaID baris pesan (kolom id)
curl -s "https://crm.kiosq.id/api?action=media&session=A1B2C3&id=101" \
  -H "X-API-Secret: SECRET_ANDA" \
  -o file-hasil.jpg

Respons berupa file binari langsung (bukan JSON), dengan header Content-Type & Content-Disposition sesuai media aslinya.

POST action=log

Dipakai local app untuk mengirim log status/aktivitas device ke hosting — mis. koneksi WhatsApp putus, berhasil reconnect, error saat kirim, QR code discan ulang, dsb. Log ini terpisah dari riwayat pesan (wa_messages) dan bisa dilihat perusahaan lewat halaman Log Device di dashboard mereka.

ParameterWajibKeterangan
session / deviceYa (query string)Device ID

Body JSON:

FieldWajibKeterangan
messageYaIsi log (maks 5000 karakter, dipotong otomatis kalau lebih)
levelTidakdebug / info (default) / warn / error. Nilai lain otomatis jadi info
contextTidakObject bebas untuk detail tambahan (disimpan sebagai JSON), mis. {"attempt":2,"reason":"timeout"}
curl -s -X POST "https://crm.kiosq.id/api?action=log&session=A1B2C3" \
  -H "X-API-Secret: SECRET_ANDA" \
  -H "Content-Type: application/json" \
  -d '{
    "level": "warn",
    "message": "Koneksi WhatsApp terputus, mencoba reconnect",
    "context": { "attempt": 2, "reason": "timeout" }
  }'

Respons sukses:

{ "ok": true }
Log otomatis dibersihkan: baris yang lebih tua dari 30 hari per device akan terhapus otomatis secara berkala setiap ada request masuk (tidak perlu cron job terpisah). Jangan kirim log terlalu sering/verbose (mis. tiap detik) — kirim hanya untuk event yang berarti (connect/disconnect/error), bukan setiap polling.

POST action=qr-update

Dipakai local app untuk mengirim QR code WhatsApp yang baru dibuat (atau kabar "berhasil connect") ke hosting, supaya perusahaan bisa scan ulang device-nya lewat tombol Scan Ulang di dashboard mereka tanpa perlu buka dashboard local app. Lihat juga action=reconnect-check di bawah untuk sisi sebaliknya (local app mengecek apakah ada permintaan scan ulang).

ParameterWajibKeterangan
session / deviceYa (query string)Device ID

Body JSON:

FieldWajibKeterangan
statusYaqr (QR baru siap discan) atau connected (berhasil tersambung, QR lama tidak berlaku lagi)
qrWajib kalau status=qrQR code dalam bentuk data URL (data:image/png;base64,...), langsung bisa dipakai sebagai src tag <img>
curl -s -X POST "https://crm.kiosq.id/api?action=qr-update&session=A1B2C3" \
  -H "X-API-Secret: SECRET_ANDA" \
  -H "Content-Type: application/json" \
  -d '{"status":"qr","qr":"data:image/png;base64,iVBORw0KGgo..."}'

Respons sukses:

{ "ok": true }

POST action=reconnect-check

Dipoll local app secara berkala (independen dari action=pending, supaya tetap jalan walau device-nya lagi disconnected) untuk mengecek apakah ada device yang baru saja diklik Scan Ulang di dashboard perusahaan. Device yang balik dilaporkan langsung di-clear flag-nya di hosting (one-shot per klik) — local app yang menerimanya harus segera unlink, generate QR baru, lalu laporkan lewat action=qr-update di atas.

Body JSON:

FieldWajibKeterangan
devicesYaArray Device ID yang mau dicek, boleh lebih dari satu sekaligus kalau local app mengelola banyak device
curl -s -X POST "https://crm.kiosq.id/api?action=reconnect-check" \
  -H "X-API-Secret: SECRET_ANDA" \
  -H "Content-Type: application/json" \
  -d '{"devices":["A1B2C3","D4E5F6"]}'

Respons — reconnect berisi Device ID yang barusan minta scan ulang (array kosong kalau tidak ada):

{ "ok": true, "reconnect": ["A1B2C3"] }

13. Kode error umum

KodeArti
400Parameter/body wajib tidak lengkap atau tidak valid
401Header X-API-Secret tidak dikirim
403Secret / token salah
404Action tidak dikenal, atau media tidak ditemukan
405Method salah — kirim/kirim-batch hanya menerima POST
500Error internal — cek writable/app_logs/app.log lewat panel Admin

14. Alur kirim pesan end-to-end

  1. Pesan keluar dibuat: lewat form Tes Kirim Pesan di dashboard perusahaan, atau lewat kirim/action=webhook dari sistem eksternal → masuk ke tabel wa_messages dengan status=pending.
  2. Local app polling action=pending secara berkala → pesan otomatis berubah jadi status=processing begitu diambil.
  3. Local app benar-benar mengirim pesan lewat WhatsApp, lalu melapor lewat action=report → status akhir jadi sent atau failed.
  4. Kalau ada balasan dari pelanggan, atau update status seperti "sudah dibaca", local app kirim lewat action=push.
  5. Media yang diterima bisa diambil lagi lewat action=media.
Nomor telepon otomatis dinormalisasi: awalan 0 diganti jadi 62 (mis. 0812345678906281234567890). Tidak perlu format ini di sisi pengirim. Kalau tujuannya grup WhatsApp (nilai to_number berakhiran @g.us), normalisasi ini otomatis dilewati — JID grup dikirim apa adanya ke local app, karena grup tidak punya nomor telepon.
Jangan sebarkan nilai API_SECRET / WEBHOOK_TOKEN di tempat publik (repo, chat, dsb). Kalau bocor, ganti segera lewat .env.

15. Bikin script local app (curl + prompt AI)

"Local app" adalah proses yang jalan terus di komputer/server Anda sendiri, terhubung langsung ke WhatsApp (lewat library semacam whatsapp-web.js atau Baileys di Node.js), yang secara berkala polling & push data ke hosting ini lewat api. Dua cara untuk membuatnya:

A. Coba manual dulu pakai curl

Sebelum menulis kode, pastikan alurnya jalan dengan curl langsung dari terminal:

# 1. Cek ada pesan pending untuk device
curl -s "https://crm.kiosq.id/api?action=pending&session=A1B2C3&limit=10" \
  -H "X-API-Secret: SECRET_ANDA"

# 2. Lapor pesan #101 sudah terkirim
curl -s -X POST "https://crm.kiosq.id/api?action=report&session=A1B2C3" \
  -H "X-API-Secret: SECRET_ANDA" -H "Content-Type: application/json" \
  -d '{"id":101,"status":"sent","chat_jid":"6281234567890@s.whatsapp.net"}'

# 3. Kirim balasan pelanggan yang masuk
curl -s -X POST "https://crm.kiosq.id/api?action=push&session=A1B2C3" \
  -H "X-API-Secret: SECRET_ANDA" -H "Content-Type: application/json" \
  -d '{"message_id":"3EB0...","chat_jid":"6281234567890@s.whatsapp.net","direction":"in","message_text":"Halo"}'

# 4. Lapor device baru saja reconnect
curl -s -X POST "https://crm.kiosq.id/api?action=log&session=A1B2C3" \
  -H "X-API-Secret: SECRET_ANDA" -H "Content-Type: application/json" \
  -d '{"level":"info","message":"Device reconnect berhasil"}'

B. Generate script-nya pakai AI

Salin prompt di bawah ini ke Claude/ChatGPT (atau AI assistant lain) untuk membuatkan script Node.js lengkap — sudah mencakup semua endpoint (pending, report, push, log). URL API sudah otomatis terisi sesuai domain hosting ini — tinggal ganti <ISI_DEVICE_ID_SAYA> dan <ISI_API_SECRET_SAYA> sebelum dikirim.

Prompt siap-salin
Buatkan saya script (bahasa: Node.js) yang berjalan terus-menerus (long-running
process / daemon) di komputer lokal saya untuk menjembatani WhatsApp (via
library whatsapp-web.js atau Baileys) dengan REST API hosting berikut:

Base URL API: https://crm.kiosq.id/api
Device ID: <ISI_DEVICE_ID_SAYA>          # 4-6 karakter, dari dashboard perusahaan
API Secret: <ISI_API_SECRET_SAYA>        # header X-API-Secret, dari admin hosting

Script harus melakukan hal-hal berikut dalam loop:

1. POLLING OUTBOX (tiap 3-5 detik):
   GET https://crm.kiosq.id/api?action=pending&session=<ISI_DEVICE_ID_SAYA>&limit=10
   Header: X-API-Secret: <ISI_API_SECRET_SAYA>
   -> Ambil array "items". Untuk tiap item, kirim pesan (message_text, dan
      kalau ada media_url/media_mimetype/media_filename kirim juga medianya)
      lewat WhatsApp ke to_number.

2. LAPOR HASIL KIRIM (setelah tiap pesan selesai dikirim/gagal):
   POST https://crm.kiosq.id/api?action=report&session=<ISI_DEVICE_ID_SAYA>
   Header: X-API-Secret: <ISI_API_SECRET_SAYA>, Content-Type: application/json
   Body: { "id": <id item dari langkah 1>, "status": "sent" atau "failed",
           "wa_message_id": <id pesan dari WhatsApp jika ada>,
           "chat_jid": <JID chat tujuan>,
           "error": <pesan error kalau status=failed, null kalau sukses> }

3. TERUSKAN PESAN MASUK (event message dari WhatsApp) & UPDATE STATUS
   (event ack/read):
   POST https://crm.kiosq.id/api?action=push&session=<ISI_DEVICE_ID_SAYA>
   Header: X-API-Secret: <ISI_API_SECRET_SAYA>, Content-Type: application/json
   Body: { "message_id", "chat_jid", "direction": "in", "from_me": false,
           "sender_number", "push_name", "message_type", "message_text",
           "wa_status" (0-5, kalau ini update status pesan keluar),
           "media_base64"/"media_mimetype"/"media_filename" kalau ada media }

4. KIRIM LOG STATUS DEVICE (saat event penting: connected, disconnected,
   reconnecting, error, qr_generated -- JANGAN kirim log tiap detik/polling):
   POST https://crm.kiosq.id/api?action=log&session=<ISI_DEVICE_ID_SAYA>
   Header: X-API-Secret: <ISI_API_SECRET_SAYA>, Content-Type: application/json
   Body: { "level": "info"|"warn"|"error"|"debug", "message": "<deskripsi
           singkat event>", "context": { ...detail tambahan opsional... } }

5. LAPOR STATUS KONEKSI SEMUA DEVICE (kalau script ini mengelola lebih dari
   satu device sekaligus, tiap siklus polling, atau tiap ada perubahan
   connected/disconnected):
   POST https://crm.kiosq.id/api?action=pending-batch
   Header: X-API-Secret: <ISI_API_SECRET_SAYA>, Content-Type: application/json
   Body: { "devices": [<semua Device ID yang dikelola script ini>],
           "device_status": [ { "id": "<Device ID>", "status": "connected"
           atau "disconnected" }, ... untuk SETIAP device, termasuk yang
           sedang disconnected -- jangan cuma kirim yang online ] }

Ketentuan tambahan:
- Semua request pakai header X-API-Secret di atas, KECUALI kalau nanti saya
  sebutkan endpoint lain yang pakai token per-device (jangan diasumsikan).
- Tangani error jaringan dengan retry + backoff sederhana, jangan sampai
  proses polling berhenti total kalau satu request gagal.
- Nomor telepon dari WhatsApp (format 62xxxxxxxxxx@s.whatsapp.net) perlu
  diubah ke format polos (62xxxxxxxxxx) sebelum dikirim sebagai
  sender_number/recipient_number.
- Simpan konfigurasi (base URL, device ID, secret) di file .env terpisah,
  jangan hardcode di source code.
- Tambahkan graceful shutdown (SIGINT/SIGTERM) yang menutup koneksi
  WhatsApp dengan bersih.

Tolong buatkan strukturnya modular (terpisah: client WhatsApp, client API
hosting, loop polling) supaya mudah saya modifikasi.
Setelah script jadi, jalankan dulu di lingkungan development dan pantau lewat halaman Log Device di dashboard — kirim log level info saat script berhasil connect, supaya Anda tahu integrasinya jalan tanpa perlu cek terminal terus-menerus.