Search by

SDK PHP dengan satu antarmuka untuk banyak gateway WhatsApp unofficial: Fonnte, OpenWA, ApiMe, Evolution API, Wuzapi, Wwebjs, dan Waxum.

Package info

github.com/mdestafadilah/sikuwa

pkg:composer/mdestafadilah/sikuwa

Statistics

Installs: 154

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

0.12.0 2026-10-18 15:17 UTC

This package is auto-updated.

Last update: 2026-10-28 09:16:47 UTC


README

SDK PHP untuk beberapa gateway WhatsApp unofficial. Tujuannya satu antarmuka yang sama untuk banyak gateway, sehingga penggantian provider atau otomatis pilih provider tidak mengubah kode pemanggil.

Rilis stabil Total unduhan PHP Lisensi

Namespace Sikuwa\Whatsapp\, PSR-4, tanpa pustaka HTTP pihak ketiga: transport-nya cURL langsung, dan satu-satunya dependensi runtime adalah psr/log. Membutuhkan PHP 8.1+.

Gateway yang didukung

Gateway Jenis Basis Tautan
Fonnte Berbayar (cloud) — https://fonnte.com/
OpenWA Self-hosted Node.js https://github.com/rmyndharis/OpenWA
ApiMe Self-hosted Go / WhatsMeow https://github.com/open-apime/apime
Evolution API Self-hosted Node.js / Baileys https://github.com/evolution-foundation/evolution-api
Wuzapi Self-hosted Go / WhatsMeow https://github.com/asternic/wuzapi
Wwebjs Self-hosted Node.js / whatsapp-web.js https://github.com/avoylenko/wwebjs-api
Waxum Self-hosted Rust / whatsapp-rust https://github.com/imtaqin/waxum

Instalasi

composer require mdestafadilah/sikuwa

Butuh PHP 8.1+, ext-mbstring, dan ext-curl; satu-satunya dependensi runtime adalah psr/log.

Untuk mengikuti main alih-alih rilis stabil:

composer require mdestafadilah/sikuwa:dev-main

Kalau butuh fork sendiri atau commit tertentu, daftarkan repositori GitHub-nya:

{
    "repositories": [
        { "type": "vcs", "url": "https://github.com/mdestafadilah/sikuwa" }
    ],
    "require": {
        "mdestafadilah/sikuwa": "dev-main"
    }
}

Pemakaian

<?php

require 'vendor/autoload.php';

use Sikuwa\Whatsapp\Client;

$client = new Client([
    'provider' => 'OpenWA',
    'token'    => 'owa_k1_…',
    'url'      => 'http://localhost:2785',
    'session'  => 'my-session',
]);

echo $client->send([
    'destination' => '081234567890',
    'message'     => 'Halo dari SIKUWA',
]);
// Sukses, messageId: 3EB0…

Opsi yang dikosongkan akan dicari di environment (WHATSAPP_*), jadi di aplikasi CodeIgniter cukup new Client() tanpa argumen apa pun:

$provider = $client->provider();      // instance gateway yang terpilih
$result   = $provider->sendMessage($message);

log_message(
    str_starts_with($result, 'Sukses') ? 'info' : 'error',
    "Notifikasi {$provider->getProvider()}: {$result}"
);

Bentuk pesan

Satu bentuk yang sama untuk semua gateway:

['destination' => '081234567890', 'message' => 'Halo', 'delay' => 2]

Untuk pengiriman massal, kirim list dari array seperti itu:

$client->send([
    ['destination' => '0811111111', 'message' => 'Pesan pertama'],
    ['destination' => '0822222222', 'message' => 'Pesan kedua', 'delay' => 5],
]);

Nomor boleh ditulis dalam format apa pun yang lazim di Indonesia (0812…, +62 812…, 62812…) — SDK menormalkannya sendiri. JID grup (…@g.us) dan WID (…@c.us) diteruskan apa adanya.

delay dihitung dalam detik dan opsional: jeda sebelum pesan dikirim (Fonnte, Evolution API) atau jeda antar pesan pada pengiriman berurutan. Bila tidak diisi, nilainya diambil dari pacing; kalau pacing pun mati, tiap gateway memakai bawaannya sendiri — Fonnte 2 detik, OpenWA 3 detik, sisanya tanpa jeda.

Kirim gambar & berkas

Dua method terpisah, satu bentuk pesan yang sama untuk semua gateway:

$client->sendImage([
    'destination' => '081234567890',
    'image'       => 'data:image/png;base64,iVBORw0KGgo…',
    'caption'     => 'Bukti transfer',
]);

$client->sendFile([
    'destination' => '081234567890',
    'file'        => $base64Pdf,
    'filename'    => 'invoice-1209.pdf',
    'caption'     => 'Invoice bulan ini',
]);
Kunci Wajib Keterangan
destination ya Sama seperti sendMessage()
image / file ya Isi berkasnya. media diterima sebagai alias untuk keduanya
filename tidak Nama yang dilihat penerima. Kosong = disusun dari jenis berkasnya (lampiran.png)
caption tidak Teks yang menyertai berkas

Isi image/file boleh salah satu dari tiga bentuk; SDK yang menyesuaikannya ke bentuk yang diminta tiap gateway:

  • data URI (data:image/png;base64,…) — paling aman, karena jenis berkasnya ikut terbawa;
  • base64 telanjang — jenisnya lalu ditebak dari filename, dan menjadi application/octet-stream bila ekstensinya tidak dikenali;
  • URL publik (https://…) — hanya bila gateway boleh mengunduhnya sendiri.

Yang menentukan sebuah berkas dikirim sebagai gambar atau dokumen adalah jenis berkasnya, bukan method yang dipanggil: sendFile() dengan PNG tetap terkirim sebagai gambar, dan sendImage() dengan PDF tetap terkirim sebagai dokumen.

Tiap gateway menempuh jalur yang berbeda, dan itu memang sifat gateway-nya:

Gateway Gambar Berkas Bentuk isi
Fonnte POST /send POST /send multipart, byte mentah — atau kolom url bila sumbernya URL
OpenWA POST /api/sessions/{id}/messages/send-image …/send-document JSON, base64 telanjang + mimetype terpisah
ApiMe POST /api/instances/{id}/messages/media …/messages/document multipart, byte mentah
Evolution API POST /message/sendMedia/{instance} sama, mediatype=document JSON, base64 telanjang atau URL
Wuzapi POST /chat/send/image POST /chat/send/document JSON, data URI (wajib)
Wwebjs POST /client/sendMessage/{id} sama, contentType=MessageMedia JSON, base64 telanjang — atau MessageMediaFromURL bila sumbernya URL
Waxum POST /api/v1/sessions/{id}/messages/image …/messages/document JSON, {data, mimetype} — atau {url} bila sumbernya URL

Enam hal yang mudah menjebak, dan sudah ditangani SDK:

  • ApiMe dan Wuzapi hanya menerima isi berkasnya, bukan URL — keduanya tidak mengunduh apa pun sendiri. Menyerahkan URL ke sana melempar ConfigurationException yang menyuruh mengunduh berkasnya lebih dulu.
  • Evolution API menolak data URI. Pemeriksaannya memakai isBase64() yang tidak mengenali awalan data:…;base64,, jadi SDK mengirim base64 telanjang. Untuk dokumen, fileName wajib ada — tanpa itu Evolution membalas HTTP 400.
  • Wuzapi meminta dokumen sebagai octet-stream, apa pun jenis berkas aslinya. Itu bentuk yang diminta dokumentasinya, jadi jenis berkasnya tidak diteruskan ke sana.
  • Waxum memisahkan isi dari jenisnya. Base64 dikirim sebagai {data, mimetype}, bukan data URI — dan mimetype wajib ikut, karena tanpa itu waxum menolak medianya. URL publik diteruskan sebagai {url}; waxum mengunduhnya sendiri lewat pemeriksa SSRF-nya.
  • Fonnte baru bisa mengirim media pada paket berbayar (super/advanced/ultra). Penolakannya datang sebagai reason biasa, jadi pesannya muncul apa adanya di log.
  • Wwebjs memisahkan caption dari berkasnya. Caption tidak ikut masuk ke objek berkas: ia dititipkan di options.caption, sedangkan isinya di content. SDK yang menyusun keduanya, jadi satu berkas berteks pengantar tetap satu request.

Batas ukuran 16 MB

WhatsApp menolak media di atas 16 MB. SDK memeriksa ukurannya sebelum request apa pun dikirim, jadi berkas 30 MB gagal seketika dengan pesan yang menjelaskan — bukan setelah seluruh isinya terunggah, lalu ditolak gateway dengan pesan yang tidak menyebut apa-apa.

ConfigurationException: Berkas 'laporan.pdf' berukuran 17.0 MB, melebihi batas
WhatsApp 16 MB. Kompres atau perkecil berkasnya lebih dulu; WhatsApp menolak
media sebesar ini apa pun gateway-nya

Perhatikan bahwa SDK tidak mengompres berkasnya untuk Anda. Kompresi mengubah data, dan SDK tidak tahu bagian mana yang boleh turun kualitasnya — foto bukti transfer yang dikompres sampai tidak terbaca adalah kerusakan yang tidak bisa dibatalkan. Ukuran berkasnya bisa diperiksa sendiri lewat API publik File:

use Sikuwa\Whatsapp\Support\File;

$file = File::from($base64Pdf, 'laporan.pdf');

$file->size();             // 17825792 — null bila isinya URL publik
$file->exceedsLimit();     // true — memakai batas WhatsApp 16 MB
$file->exceedsLimit(1024 * 1024);   // atau batas Anda sendiri
$file->readableSize();     // "17.0 MB"

Ukuran berkas dari URL publik tidak bisa diketahui SDK (yang mengunduhnya gateway), jadi size() mengembalikan null dan exceedsLimit() false — memeriksa null lebih dulu bila Anda ingin memperlakukannya berbeda.

Indikator "sedang mengetik"

Balasan yang muncul seketika mudah dikenali sebagai robot. sendTyping() membuat WhatsApp menampilkan "sedang mengetik…" lebih dulu:

$client->sendTyping(['destination' => '081234567890', 'duration' => 3]);

$client->send(['destination' => '081234567890', 'message' => 'Halo']);

// Hapus indikatornya bila pesannya tidak jadi dikirim.
$client->sendTyping(['destination' => '081234567890', 'state' => 'paused']);
Kunci Wajib Keterangan
destination ya Sama seperti sendMessage()
state tidak composing (bawaan), paused, atau recording
duration ya, kecuali paused Lama indikator ditampilkan, dalam detik

state boleh memakai istilah gateway mana pun; SDK memetakannya ke kosakata di atas — typing menjadi composing, stop menjadi paused, audio menjadi recording. Jadi kode yang sama jalan di semua gateway.

duration wajib saat menampilkan indikator, dan itu bukan sekadar formalitas: Fonnte menuntutnya di sisi server, sedangkan Evolution API menahan indikator selama durasi itu lalu menghapusnya sendiri. Dengan durasi 0 keduanya tidak menampilkan apa pun — dan itu gagal tanpa pesan apa-apa, jadi SDK menolaknya lebih dulu dengan ConfigurationException.

Gateway Endpoint Keadaan duration
Fonnte POST /typing ketik atau berhenti (stop) dipakai; wajib
OpenWA POST /api/sessions/{id}/chats/typing typing | recording | paused diabaikan
ApiMe POST /api/instances/{id}/whatsapp/presence composing | recording | paused diabaikan
Evolution API POST /chat/sendPresence/{instance} composing | recording | paused dipakai, satuan milidetik
Wuzapi POST /chat/presence composing + Media: audio | paused diabaikan
Wwebjs POST /chat/sendStateTyping · …/sendStateRecording · …/clearState endpoint berbeda per keadaan diabaikan; indikatornya bertahan ~25 detik di server
Waxum POST /api/v1/sessions/{id}/chatstate/send composing | recording | paused diabaikan

Lima hal yang mudah menjebak, dan sudah ditangani SDK:

  • Evolution API ikut menahan pemanggil. Servernya yang mengirim composing, menunggu delay, lalu mengirim paused — jadi panggilan ini memblokir selama duration. Durasi di atas 20 detik dipotong Evolution menjadi beberapa siklus. Ini satu-satunya gateway yang membersihkan indikatornya sendiri di dalam satu request; yang lain menyimpan statusnya sampai dihapus dengan paused atau sampai ada pesan yang benar-benar terkirim — sedangkan indikator Wwebjs kedaluwarsa sendiri setelah ~25 detik.
  • Fonnte tidak punya indikator merekam suara, hanya indikator mengetik. Meminta recording di sana melempar ConfigurationException — bukan diam-diam berubah menjadi indikator mengetik.
  • Wuzapi tidak punya keadaan recording. Merekam suara dikirim sebagai composing dengan Media berisi audio, dan itu yang disusun SDK.
  • Wwebjs memakai endpoint berbeda untuk tiap keadaan, bukan satu endpoint dengan kolom status. Karena tidak ada kolom durasi, duration di sana hanya menentukan berapa lama SDK menunggu sebelum pesannya dikirim.
  • Waxum memakai endpoint yang sama untuk semua keadaan, dengan kolom state — kosakatanya kebetulan sama dengan kosakata baku SDK ini, jadi tidak ada penerjemahan kata yang perlu dilakukan.

Jeda antar pesan (pacing)

Mengirim beruntun dengan jeda yang seragam mudah dikenali sebagai robot. Pacing menyusun jeda dari tiga bagian, dan ketiganya bisa dipakai sendiri-sendiri maupun bersamaan:

Kunci Arti
WHATSAPP_PACING_CYCLE Daftar jeda tetap yang dipakai bergiliran, detik. 0,30 berarti pesan ke-1 tanpa jeda, ke-2 jeda 30 detik, ke-3 tanpa jeda, dan seterusnya
WHATSAPP_PACING_INTERVAL Jitter acak yang ditambahkan ke tiap jeda siklus, detik. 20-30 berarti setiap jeda ditambah 20–30 detik acak
WHATSAPP_PACING_LONG_CHARS Ambang pesan panjang, dalam karakter. Bawaannya 300
WHATSAPP_PACING_LONG_FACTOR Berapa kali jeda dilipatkan untuk pesan sepanjang itu. Bawaannya 3

Jadi jeda sebelum pesan ke-i adalah (siklus[i % jumlah siklus] + jitter) × pengali pesan panjang. Dengan 0,30 + 20-30 + ambang bawaan 300 karakter, jedanya berurutan 20–30, 50–60, 20–30, … detik untuk pesan pendek, dan 60–90, 150–180, 60–90, … detik untuk pesan 300 karakter ke atas.

Pesan panjang sengaja ditunggu lebih lama: mengirim teks panjang beruntun lebih mencurigakan daripada mengirim pesan pendek, dan pesan panjang juga lebih lama "dibaca". Panjangnya dihitung dalam karakter, bukan byte — 250 huruf beraksen tetap dianggap pesan pendek. Aturannya bisa dimatikan dengan WHATSAPP_PACING_LONG_CHARS=0, atau dinetralkan dengan WHATSAPP_PACING_LONG_FACTOR=1.

Bawaannya mati: selama kedua kunci pertama kosong, tiap gateway memakai jeda bawaannya sendiri seperti sebelumnya. Ini disengaja — mengirim banyak pesan menahan proses pemanggil selama total jeda itu, jadi pacing harus dinyalakan dengan sadar.

Untuk satu panggilan saja, sertakan kuncinya bersama pesan. Yang disebutkan saja yang ditimpa; sisanya tetap diambil dari .env:

$client->send([
    'messages' => [
        ['destination' => '0811111111', 'message' => 'Pesan pertama'],
        ['destination' => '0822222222', 'message' => 'Pesan kedua'],
    ],
    'pacing' => ['cycle' => '0,45', 'interval' => '10-20', 'long_factor' => 5],
]);

delay pada satu pesan tetap menang atas pacing untuk pesan itu — termasuk 'delay' => 0, yang berarti "pesan ini tanpa jeda". Pesan pertama tidak pernah ditunggu: jeda sebelum pengiriman pertama adalah urusan pemanggil, bukan SDK.

Siapa yang benar-benar mengerjakan jedanya berbeda per gateway, dan itu memang sifat gateway-nya:

Gateway Jeda dikerjakan oleh
Fonnte Server, lewat kolom delay tiap pesan
OpenWA Server, lewat delayBetweenMessages — satu angka untuk seluruh batch, diambil dari jeda sebelum pesan kedua
Evolution API Server, lewat kolom delay payload (dalam milidetik)
ApiMe, Wuzapi, Wwebjs SDK, dengan sleep() di antara request

Karena jedanya dititipkan ke server, Fonnte, OpenWA, dan Evolution API tidak menahan klien dua kali. OpenWA juga menambahkan pengacakan di sisinya sendiri (randomizeDelay).

Pagar laju (warmup)

Pacing menyusun jeda dari isi pesan; pagar laju menyusunnya dari posisi. Akun yang baru dipakai mengirim 200 pesan dalam semenit lebih mencurigakan daripada akun yang mengirim 200 pesan sepanjang satu jam — berapa pun panjang teksnya. Pagar laju menaruh batas keras: paling banyak sekian pesan per sekian detik.

Kunci Arti
WHATSAPP_THROTTLE_MAX Jumlah pesan yang boleh dikirim per jendela
WHATSAPP_THROTTLE_WINDOW Panjang satu jendela, detik

Aturannya berbasis posisi: pesan ke-1 sampai ke-MAX masuk jendela pertama dan tidak ditunggu, pesan MAX+1 sampai 2×MAX menunggu satu jendela penuh, 2×MAX+1 sampai 3×MAX menunggu dua jendela, dan seterusnya. Jadi posisi ke-i (berbasis nol) menunggu ⌊i ÷ MAX⌋ × WINDOW detik. Jeda yang sangat panjang dijepit di 600 detik (10 menit) supaya satu batch besar tidak membekukan proses pemanggil selama berjam-jam.

Bawaannya mati. Seperti pacing, ia menahan proses pemanggil, jadi nyalakan dengan sadar — dan pilih MAX/WINDOW sesuai umur akun, bukan langsung ke angka "aman" yang besar.

Batas ini dihitung per panggilan. Posisinya selalu mulai dari nol di tiap send(), dan SDK tidak menyimpan keadaan antar-panggilan. Jadi pemanggil yang mengirim satu pesan per request — pola yang justru paling sering dipakai aplikasi nyata — tidak dibatasi sama sekali: tiap panggilan hanya berisi satu pesan di posisi 0, sehingga MAX+1 tidak pernah tercapai. Untuk pola itu, tegakkan kuotanya di sisi aplikasi Anda (mis. hitungan harian di database). Pacing pun tidak melihat kasus ini, karena tidak ada "pesan berikutnya" untuk diberi jeda.

// Paling banyak 20 pesan per 60 detik:
//   posisi 0..19  -> 0 detik
//   posisi 20..39 -> 60 detik
//   posisi 40..59 -> 120 detik

Untuk satu panggilan saja, sertakan kunci throttle:

$client->send([
    'messages' => [ /* ... */ ],
    'throttle' => ['max' => 10, 'window' => 30],
]);

Pacing dan pagar laju boleh dipakai bersamaan. Yang diambil bukan penjumlahan keduanya, melainkan yang terbesar — tujuannya sama (memberi napas antar pesan), dan menjumlahkannya cuma membuat batch selesai dua kali lebih lambat tanpa manfaat tambahan. Jadi bila pacing menuntut 30 detik untuk pesan ke-3 sementara pagar laju menuntut 60 detik, jedanya 60 detik, bukan 90.

Perhatikan bahwa delay eksplisit pada satu pesan tetap menang atas keduanya untuk pesan itu — sama seperti perilakunya terhadap pacing.

Indikator mengetik otomatis

sendTyping() di atas eksplisit — pemanggil yang memanggilnya. Kalau ingin indikatornya muncul sendiri di setiap send(), nyalakan WHATSAPP_TYPING dan SDK yang mengurusnya: ia menampilkan "sedang mengetik…" untuk tujuan itu, lalu menunggu selama indikatornya tampil sebelum pesannya dikirim.

Lamanya mengikuti panjang pesan, sama seperti pacing. Pesan 300 karakter yang "diketik" dalam satu detik sama tidak wajarnya dengan "Halo" yang diketipkan sepuluh detik:

lama = panjang pesan ÷ kecepatan ketik, dijepit antara min dan max
Kunci Arti
WHATSAPP_TYPING Saklar on/off. Bawaannya mati
WHATSAPP_TYPING_SPEED Kecepatan ketik, dalam karakter per detik. Bawaannya 15
WHATSAPP_TYPING_MIN Lama tampil paling singkat, detik. Bawaannya 2
WHATSAPP_TYPING_MAX Lama tampil paling lama, detik. Bawaannya 20

Dengan bawaannya, 10 karakter menjadi 2 detik, 60 karakter 4 detik, 150 karakter 10 detik, dan 300 karakter ke atas berhenti di 20 detik.

Bawaannya mati karena fitur ini menyisipkan satu request tambahan ke jalur kirim dan menahan pemanggil selama durasinya. Hanya saklarnya yang menyalakan — mengisi WHATSAPP_TYPING_SPEED saja di .env tidak mengubah apa pun.

$client->send(['destination' => '081234567890', 'message' => 'Halo']);
// 1. POST /chat/presence  { "State": "composing" }
// 2. tunggu 2 detik
// 3. POST /chat/send/text

Untuk satu panggilan saja, sertakan kunci typing. Berbeda dari .env yang jadi saklar global, menulis kunci ini di dalam array pesan sudah berarti permintaan — jadi ia menyalakan fiturnya tanpa perlu WHATSAPP_TYPING:

$client->send([
    'destination' => '081234567890',
    'message'     => 'Laporan harian sudah siap',
    'typing'      => ['speed' => 8, 'max' => 30],   // atau ['enabled' => false]
]);

Kuncinya boleh juga ditaruh di tiap pesan dalam pengiriman massal, jadi satu pesan bisa dibiarkan tanpa indikator sementara yang lain memakainya:

$client->send([
    ['destination' => '0811111111', 'message' => 'Pesan pertama'],
    ['destination' => '0822222222', 'message' => 'Pesan kedua', 'typing' => ['enabled' => false]],
]);

Siapa yang benar-benar menunggu berbeda per gateway, dan itu memang sifat gateway-nya:

Gateway Yang terjadi
Evolution API Servernya sudah menunggu dan menghapus indikatornya sendiri, jadi SDK tidak menunggu lagi — kalau tidak, pemanggil menunggu dua kali
ApiMe, Wuzapi, OpenWA, Fonnte, Wwebjs, Waxum Indikator hanya menyimpan status, jadi SDK yang menghabiskan durasinya

Tiga hal yang mudah menjebak, dan sudah ditangani SDK:

  • Fonnte dan OpenWA mengirim seluruh batch dalam satu request. SDK tidak punya kesempatan menyisipkan indikator di antara pesan, jadi yang dimunculkan hanya indikator untuk tujuan pesan pertama. Memunculkan untuk semua tujuan sekaligus justru membuat penerima terakhir melihat "sedang mengetik" lalu diam lama sebelum pesannya datang — lebih buruk daripada tanpa indikator. ApiMe, Evolution API, wuzapi, Wwebjs, dan Waxum mengirim satu per satu, jadi tiap pesan dapat indikatornya sendiri.
  • Berkas tidak didahului indikator. sendImage() dan sendFile() tidak melewati indikator "sedang mengetik" — yang dikirim bukan ketikan, dan mengatakannya akan berbohong. Pakai sendTyping() sendiri kalau memang mau.
  • Indikator yang gagal tidak menggagalkan pesannya. Mengirim pesan jauh lebih penting daripada hiasannya, jadi kegagalan composing (endpoint tidak ada, sesi tidak terhubung, timeout) dilewati begitu saja — dan tidak menambah jeda yang tidak dipakai. Kegagalan itu dicatat ke logger kalau Anda memasangnya — lihat Logger (opsional) — dan tetap senyap tanpa logger. Kalau perlu ditangani sendiri, panggil sendTyping() dan tangani exception-nya.

Dua gaya pemanggilan

send() melempar exception kalau gagal; notify() mengembalikan string dan tidak pernah melempar.

// Notifikasi yang tidak boleh menggagalkan request pemanggil:
$result = $client->notify($message);   // selalu string

// Pengiriman yang kegagalannya harus ditangani:
try {
    $client->send($message);
} catch (\Sikuwa\Whatsapp\Exceptions\WhatsappException $e) {
    // ...
}

Memilih gateway

WHATSAPP_PROVIDER menerima nama gateway (tidak peka huruf besar/kecil) atau Auto. Mode Auto mengundi hanya di antara gateway yang WHATSAPP_TOKEN_<Provider>-nya terisi, sehingga undian tidak pernah jatuh ke gateway yang belum dikonfigurasi.

Client::configured();      // mis. ['OpenWA', 'Wuzapi']
$client->providerName();   // 'OpenWA' — nama saja, tanpa membangun objek
$client->provider();       // instance gateway terpilih

provider() mengembalikan instance baru setiap dipanggil. Untuk Auto itu berarti undiannya diulang — panggil sekali lalu simpan hasilnya kalau beberapa pesan harus lewat gateway yang sama.

Perhatikan bahwa send() memanggil provider() sendiri, jadi pada mode Auto pesan berurutan bisa mendarat di gateway yang berbeda. Untuk mengetahui dan mengunci siapa pengirimnya, jangan pakai send():

$gateway = $client->provider();            // undian terjadi di sini, sekali
$nama    = $gateway->getProvider();        // 'OpenWA'

$gateway->sendMessage($pesan);             // pasti lewat OpenWA

providerName() berguna untuk mencatat ke log atau memeriksa konfigurasi; ia sendiri tidak mengunci pilihan, karena tiap pemanggilan mengundi ulang.

Membagi batch ke beberapa gateway

Undian Auto terjadi sekali per panggilan, jadi satu send() berisi seratus pesan tetap keluar lewat satu gateway saja: semua beban menumpuk di satu perangkat, sementara gateway lain yang sudah dikonfigurasi menganggur. Untuk pengiriman massal itu justru pola yang paling cepat menabrak batas laju gateway.

WHATSAPP_ROTATE membagi batch itu ke semua gateway yang punya WHATSAPP_TOKEN_<Provider>:

WHATSAPP_PROVIDER=auto
WHATSAPP_ROTATE=1
$client->send(['messages' => $seratusPesan]);
// Sukses, 100/100 pesan terkirim lewat 3 gateway (OpenWA 34, Wuzapi 33, Wwebjs 33)
  • Urutan gatewaynya diundi tiap panggilan, dan pembagiannya bergiliran — jumlah pesan tiap gateway berbeda paling banyak satu. Undian murni per pesan terlihat lebih "acak", tetapi ia bisa menaruh 90 dari 100 pesan di satu gateway, yang berarti fiturnya gagal tepat pada satu-satunya alasan ia dinyalakan.
  • Hanya untuk pesan teks berbentuk daftar, minimal dua pesan. Satu pesan, media, checkNumbers(), dan pembuatan sesi tidak terpengaruh.
  • Menuntut provider = auto. Kalau WHATSAPP_PROVIDER menyebut satu nama gateway, send() menolaknya dengan ConfigurationException — pemanggil yang memilih gatewaynya sendiri tidak boleh diam-diam dikirimi potongan lewat gateway lain.

Tiga hal yang berubah, dan sebaiknya diketahui sebelum menyalakannya:

Yang berubah Akibatnya
Urutan pesan Potongan pertama dikirim habis dulu, jadi pesan ke-6 bisa tiba sebelum pesan ke-2. Jangan dipakai untuk rangkaian yang urutannya berarti.
Jeda & pagar laju Dihitung per gateway, karena tiap gateway menerima daftarnya sendiri. WHATSAPP_THROTTLE_MAX=20 berarti 20 per gateway, bukan 20 untuk seluruh batch.
Laporan hasil Menyebut pembagiannya (lewat 3 gateway (OpenWA 34, …)). Potongan yang gagal dilaporkan bersama nama gatewaynya, dan potongan lain tetap dicoba.

Satu gateway bertoken bukan kegagalan: batchnya dikirim lewat gateway itu saja, dan kejadiannya dicatat ke logger sebagai peringatan — supaya tidak ada yang mengira batchnya sudah dibagi padahal belum.

Sesi

Selain mengirim pesan, SDK bisa membuat dan memeriksa sesi WhatsApp lewat antarmuka yang sama di semua gateway:

$session = $client->checkSession();   // sesi dari konfigurasi

if (! $session->isConnected()) {
    echo $session->qrTag();           // <img> QR siap tampil
}

// Buat sesi/instance baru, lalu pindai QR-nya.
$baru = $client->createSession(['name' => 'Notifikasi Sekolah']);
echo $baru->id;

Gateway menyebutnya berbeda-beda — OpenWA dan Wwebjs "session", ApiMe dan Evolution API "instance", wuzapi dan Fonnte "device" — tetapi semuanya mengembalikan Sikuwa\Whatsapp\Session yang sama:

Properti Isi
$provider Nama gateway, mis. OpenWA
$id Id sesi menurut gateway
$status Status apa adanya dari gateway, mis. CONNECTED atau open
$connected Apakah sesi siap mengirim pesan
$qr QR sebagai data URI, bila ada
$token Kredensial yang baru diterbitkan gateway, bila ada
$phoneNumber / $profileName Identitas yang tersambung, bila ada
$raw Amplop asli gateway, apa adanya

isConnected(), hasQr(), toArray(), dan toJson() tersedia sebagai penolong; (string) $session menghasilkan ringkasan siap log, mis. OpenWA: CONNECTED (sess-1). Nilai yang tidak disediakan gateway dibiarkan kosong, tidak ditebak.

$token berisi kredensial yang diterbitkan saat sesi dibuat — token perangkat Fonnte, atau hash.apikey Evolution API. Itulah yang diisi ke WHATSAPP_TOKEN supaya pesan bisa dikirim lewat sesi tersebut. Baik $token maupun $raw sengaja tidak ikut di toArray()/toJson(), karena keluaran itu biasanya berakhir di log atau response HTTP.

Nama sesi diambil dari $options bila diberikan, selain itu dari konfigurasi — WHATSAPP_SESSION_<Provider> / WHATSAPP_INSTANCE_<Provider> lebih dulu, baru WHATSAPP_SESSION / WHATSAPP_INSTANCE — jadi createSession() tanpa argumen tetap masuk akal di aplikasi yang kredensialnya sudah ada di .env. Fonnte adalah pengecualian: createSession() di sana memang menuntut name dan device, karena perangkat baru butuh nomor yang belum pernah dipakai.

QR sesi

Sesi yang belum tersambung bisa dimintai QR-nya lewat showQr(). Hasilnya Session yang sama, dengan $qr terisi:

$qr = $client->showQr();

if ($qr->hasQr()) {
    echo $qr->qrTag();     // <img src="data:image/png;base64,…" width="260" height="260">
    echo $qr->qrImage();   // data URI — untuk atribut src
    echo $qr->qrBase64();  // base64 murni — untuk disimpan atau dikirim sebagai JSON
}

$qr selalu data URI penuh atau string kosong, walaupun gateway tidak sepakat soal bentuknya: Fonnte mengirim base64 PNG telanjang di url, OpenWA dan wuzapi mengirim data URI yang sudah lengkap, Evolution API mengirim base64 di base64. Perangkaiannya dikerjakan Support\Qr::dataUri(), jadi pemanggil tidak perlu tahu bedanya.

Sesi yang sudah tersambung tidak punya QR. Gateway yang mengatakannya terus terang dilaporkan sebagai isConnected() === true dengan hasQr() === false, bukan sebagai exception:

Gateway Jawaban saat sudah tersambung
Evolution API {"instance":{"state":"open"}}
Fonnte {"status":false,"reason":"device already connect"}
Wuzapi error already logged in
OpenWA HTTP 400 — sebabnya bercampur dengan sesi yang belum qr_ready, jadi tetap dilempar
Wwebjs JSON qr code not ready or already scanned — dibedakan dengan membaca status sesi
Waxum {"qr_codes":[],"status":"logged_in"} — dibedakan dari isi qr_codes dan status sesinya

Karena itu showQr() aman dipanggil tanpa memeriksa checkSession() lebih dulu:

$qr = $client->showQr();

if ($qr->isConnected()) {
    // Sudah tersambung — tidak ada yang perlu dipindai.
} elseif ($qr->hasQr()) {
    echo $qr->qrTag('Scan untuk menyambungkan WhatsApp', 320);
}

Daftar sesi

Selain membuat dan memeriksa satu sesi, SDK bisa mendaftar semua sesi yang dikenal gateway lewat listSessions():

foreach ($client->listSessions() as $session) {
    echo "{$session->id}: {$session->status}\n";

    if (! $session->isConnected()) {
        echo $client->showQr($session->id)->qrTag();
    }
}

Setiap entri adalah Session yang sama — bentuknya identik dengan checkSession(). Method ini tidak butuh WHATSAPP_SESSION/ WHATSAPP_INSTANCE karena ia mendaftar seluruh sesi, bukan satu sesi tertententu.

Wuzapi tidak mendukungnya: tokennya sendiri yang menentukan sesi mana yang dipakai, jadi satu token adalah satu sesi. Di sana listSessions() melempar ConfigurationException yang menjelaskan hal itu, bukan mengembalikan array kosong yang seolah-olah gateway tidak punya sesi.

Wwebjs hanya mengembalikan nama session: endpoint-nya tidak menyertakan keadaan sambungan. Setiap Session hanya terisi $id dan $provider; status dan connected dibiarkan kosong. Panggil checkSession($id) per session untuk mengetahui keadaannya.

Gateway Endpoint Catatan
OpenWA GET /api/sessions —
ApiMe GET /api/instances —
Evolution API GET /instance/fetchInstances Field berbeda dari sesi tunggal: name, connectionStatus
Fonnte POST /get-devices Memakai account token, bukan token perangkat
Wuzapi — Tidak didukung (token = sesi)
Wwebjs GET /session/getSessions Hanya nama session, tanpa keadaan
Waxum GET /api/v1/sessions Setiap entri sudah membawa status runtime

Catatan per gateway:

  • OpenWA — GET /api/sessions/{id}/qr menuntut API key berperan operator; kunci baca biasa ditolak dengan HTTP 403. Endpoint ini hanya menjawab saat sesi sedang menunggu dipindai, dan status pada balasannya (qr_ready) adalah kesiapan QR, bukan status sesi.
  • ApiMe — OpenAPI-nya menyebut balasan endpoint ini hanya sebagai "QR code base64", tanpa skema. SDK karena itu membaca beberapa kemungkinan nama field sekaligus (qr, qrcode, qrCode, base64, image), termasuk data yang berupa string.
  • Wuzapi — QR hanya keluar saat sesinya tersambung ke server WhatsApp tetapi belum login; no session dan not connected tetap kegagalan.
  • Fonnte — QR memakai token perangkat (WHATSAPP_TOKEN), bukan account token seperti createSession()/checkSession(). showQr('08123456789') mengirim nomornya sebagai whatsapp. Mode type=code (kode pairing) belum didukung.
  • Wwebjs — QR-nya diambil dari GET /session/qr/{id}/image, yang mengirim PNG biner; SDK yang membungkusnya menjadi data URI. Endpoint itu menjawab JSON saat QR-nya tidak ada, dan sebabnya bisa dua hal sekaligus — session belum selesai dimuat, atau QR-nya sudah dipindai. Yang kedua dibedakan dengan menanyakan GET /session/status/{id} sekali.
  • Waxum — balasan GET /api/v1/sessions/{id}/qr memuat daftar kode di qr_codes, bukan satu kode; yang dipakai SDK adalah elemen pertamanya. Isinya string mentah yang harus digambar jadi QR oleh pemanggil, bukan base64, jadi $qr sengaja dibiarkan kosong kecuali kalau payload-nya memang sudah berupa gambar — memasangnya sebagai data URI PNG akan menghasilkan <img> yang rusak. Bentuk mentahnya tetap bisa dibaca lewat $session->raw. Sesi yang runtime-nya belum hidup ditolak HTTP 503, dan itu tetap dilempar.
Gateway Membuat sesi Memeriksa sesi QR sesi
OpenWA POST /api/sessions — id, name, config GET /api/sessions/{id} GET /api/sessions/{id}/qr
ApiMe POST /api/instances — name, webhook_url, webhook_secret GET /api/instances/{id} GET /api/instances/{id}/qr
Evolution API POST /instance/create — instanceName, qrcode, webhook GET /instance/connectionState/{instance} GET /instance/connect/{instance}
Wuzapi POST /session/connect — subscribe, immediate GET /session/status GET /session/qr
Fonnte POST /add-device — name, device, autoread POST /get-devices POST /qr
Wwebjs POST /session/start/{id} — id, webhookUrl GET /session/status/{id} GET /session/qr/{id}/image
Waxum POST /api/v1/sessions — id, name, webhook, device GET /api/v1/sessions/{id}/status GET /api/v1/sessions/{id}/qr

Catatan:

  • Sesi baru biasanya belum tersambung. OpenWA membuatnya INITIALIZING, Evolution API created, Fonnte created, Wwebjs starting, Waxum connecting. Ambil QR-nya dengan showQr(), lalu pantau dengan checkSession().

  • wuzapi tidak mengenal id sesi — token yang terpasang sudah menentukan sesinya, jadi createSession() di sana berarti menyambungkan sesi dan checkSession($id) mengabaikan argumennya. Yang menandakan sesi siap dipakai adalah LoggedIn, bukan Connected.

  • Fonnte memakai dua kredensial yang berbeda. Mengirim pesan memakai token perangkat (WHATSAPP_TOKEN), sedangkan Device API — menambah dan membaca perangkat — menuntut account token (WHATSAPP_ACCOUNT_TOKEN); token perangkat ditolak di sana dengan "unknown user". Karena itu checkSession() tanpa argumen mencari perangkat yang tokennya sama dengan WHATSAPP_TOKEN, sehingga pertanyaan "apakah perangkat yang saya pakai siap?" terjawab langsung.

    $device = $client->createSession(['name' => 'Notifikasi', 'device' => '08123456789']);
    
    // Perangkat baru menerbitkan tokennya sendiri — simpan ke WHATSAPP_TOKEN.
    file_put_contents('.env', "WHATSAPP_TOKEN={$device->token}\n", FILE_APPEND);
  • Membuat instance ApiMe menuntut token user (JWT) atau API token. Token ber-scope instance — yang justru dipakai untuk mengirim pesan — ditolak dengan HTTP 403, jadi pembuatan instance biasanya dijalankan sekali dari dashboard atau skrip admin.

  • Nama instance Evolution API hanya boleh huruf kecil dan angka. SDK menolaknya lebih awal dengan pesan yang jelas, bukan meneruskan HTTP 400.

  • Nama session Wwebjs hanya boleh huruf, angka, garis bawah, dan tanda minus; SDK menolaknya lebih awal, bukan meneruskan HTTP 422 dari middleware-nya. createSession() di sana juga menunggu Chromium selesai dimuat — bisa mendekati WHATSAPP_TIMEOUT bawaan (10 detik), jadi naikkan timeout kalau sering berakhir TimeoutException.

  • Waxum menolak id sesi yang sudah dipakai dengan HTTP 409. Jadi urutannya berbeda dari OpenWA: panggil checkSession() lebih dulu, dan createSession() hanya kalau sesinya memang belum ada. Id sesinya juga ikut menentukan nama direktori penyimpanan, jadi SDK tidak meng-encode-nya.

Konfigurasi

Lihat .env.example. Ringkasnya:

Kunci Keterangan
WA_NOTIFICATION Penanda notifikasi aktif. SDK tidak menegakkannya; tersedia lewat $client->enabled()
WHATSAPP_PROVIDER Auto atau nama gateway
WHATSAPP_TOKEN_<Provider> Token per gateway. Inilah yang dihitung mode Auto
WHATSAPP_TOKEN Token umum, dipakai bila token khusus gateway tidak ada. Tidak dihitung mode Auto
WHATSAPP_URL_<Provider> Base URL per gateway. Ini yang sebaiknya dipakai untuk self-hosted
WHATSAPP_URL Base URL cadangan bila kunci per-provider kosong. Diabaikan Fonnte
WHATSAPP_SESSION_<Provider> Id session per gateway. Ini yang sebaiknya dipakai bila beberapa gateway butuh session berbeda
WHATSAPP_SESSIONS_<Provider> Daftar id session per gateway, dipisah koma. Mendaftar semua sesi yang dikenal aplikasi; kunci tunggal di atas menentukan yang aktif
WHATSAPP_SESSION Id session cadangan bila kunci per-provider kosong
WHATSAPP_INSTANCE_<Provider> Id/nama instance per gateway
WHATSAPP_INSTANCES_<Provider> Daftar id instance per gateway, dipisah koma. Seperti WHATSAPP_SESSIONS_<Provider>, untuk ApiMe dan EvolutionAPI
WHATSAPP_INSTANCE Id/nama instance cadangan bila kunci per-provider kosong
WHATSAPP_ACCOUNT_TOKEN Khusus Fonnte Device API (add-device, get-devices). Bukan token perangkat
WHATSAPP_TIMEOUT Batas waktu request, detik (1–60, default 10)
WHATSAPP_PACING_CYCLE Jeda tetap yang dipakai bergiliran antar pesan, detik. Mis. 0,30. Kosong = pacing mati
WHATSAPP_PACING_INTERVAL Jitter acak yang ditambahkan ke tiap jeda siklus, detik. Mis. 20-30
WHATSAPP_PACING_LONG_CHARS Ambang pesan panjang, karakter (default 300). 0 = aturannya dimatikan
WHATSAPP_PACING_LONG_FACTOR Pengali jeda untuk pesan panjang (default 3). 1 = tidak ada pengalian
WHATSAPP_TYPING Munculkan indikator "sedang mengetik" sendiri sebelum mengirim. Kosong = mati
WHATSAPP_TYPING_SPEED Kecepatan ketik, karakter per detik (default 15)
WHATSAPP_TYPING_MIN Lama indikator tampil paling singkat, detik (default 2)
WHATSAPP_TYPING_MAX Lama indikator tampil paling lama, detik (default 20)
WHATSAPP_THROTTLE_MAX Batas jumlah pesan per jendela (warmup). Kosong = pagar laju mati
WHATSAPP_THROTTLE_WINDOW Panjang satu jendela pagar laju, detik
WHATSAPP_RETRIES Percobaan ulang otomatis untuk 429/503 yang menyebutkan Retry-After, pada pengiriman pesan dan pemeriksaan nomor (default 0)
WHATSAPP_CHECK_NUMBERS Periksa nomor tujuan lebih dulu dan lewati yang dipastikan tidak terdaftar. Kosong = mati
WHATSAPP_ROTATE Bagi satu batch pesan teks ke semua gateway yang bertoken. Menuntut WHATSAPP_PROVIDER=auto. Kosong = mati

URL per gateway

Satu WHATSAPP_URL bersama tidak cukup kalau Anda memakai lebih dari satu gateway self-hosted: nilainya berlaku untuk provider apa pun yang sedang aktif, jadi URL OpenWA akan ikut terpakai Wuzapi. Pakai kunci per-provider:

WHATSAPP_URL_OpenWA=https://wa-1.internal
WHATSAPP_URL_Wuzapi=https://wa-2.internal

Setara lewat opsi konstruktor: ['urls' => ['OpenWA' => 'https://wa-1.internal']].

Urutan pembacaannya: kunci per-provider → url yang diberikan eksplisit → WHATSAPP_URL → default provider. Kunci per-provider sengaja tidak jatuh ke WHATSAPP_URL, sama seperti WHATSAPP_TOKEN_<Provider> yang tidak jatuh ke WHATSAPP_TOKEN.

Nilai default bila semuanya dikosongkan: OpenWA https://openwa.whatsapp.com, ApiMe https://api-me.whatsapp.com, Evolution API https://evolution-api.whatsapp.com, Wuzapi https://wuzapi.whatsapp.com, Wwebjs https://wwebjs.whatsapp.com, Waxum https://waxum.whatsapp.com. Fonnte punya endpoint tetap sendiri.

Session per gateway

Alasan yang sama berlaku untuk session: satu WHATSAPP_SESSION bersama akan dipakai OpenWA, Wwebjs, dan Waxum sekaligus, sehingga tiga gateway tidak bisa memakai nama session yang berbeda — padahal ketiganya sering berjalan berdampingan dan tidak boleh berbagi session. Pakai kunci per-provider:

WHATSAPP_SESSION_OpenWA=notif-sekolah
WHATSAPP_SESSION_Wwebjs=notif-wwebjs
WHATSAPP_SESSION_Waxum=notif-waxum
WHATSAPP_INSTANCE_ApiMe=8f1c…
WHATSAPP_INSTANCE_EvolutionAPI=sikuwa

Urutan pembacaannya sama seperti token dan URL: kunci per-provider → nilai session/instance yang diberikan eksplisit → WHATSAPP_SESSION / WHATSAPP_INSTANCE. Kunci per-provider sengaja tidak jatuh ke kunci bersama. Tiap gateway membaca kuncinya sendiri, jadi WHATSAPP_SESSION_OpenWA tidak akan pernah terpakai oleh Wwebjs atau Waxum.

Nama provider ditulis apa adanya mengikuti kunci per-provider yang sudah ada (WHATSAPP_SESSION_OpenWA), bukan diubah menjadi huruf besar.

Beberapa sesi untuk satu gateway

Kunci tunggal di atas hanya menampung satu sesi aktif. Kalau satu gateway mengelola beberapa sesi sekaligus — mis. satu server OpenWA dengan sesi untuk departemen sales, support, dan billing — daftarkan semuanya di kunci jamak yang dipisah koma:

WHATSAPP_SESSIONS_OpenWA=sales,support,billing
WHATSAPP_SESSIONS_Wwebjs=sales,support
WHATSAPP_INSTANCES_ApiMe=8f1c…,9a2b…

Kunci jamak mendaftar sesi yang dikenal aplikasi; kunci tunggal (WHATSAPP_SESSION_<Provider>) menentukan yang aktif. Bila kunci tunggal tidak diisi, elemen pertama daftar jamak menjadi sesi aktif bawaan. Kunci tunggal tetap menang bila keduanya diisi, karena sesi aktif adalah pilihan eksplisit, bukan urutan dalam daftar.

Daftar sesi yang efektif bisa dibaca lewat $client->config()->sessions():

foreach ($client->config()->sessions('OpenWA') as $id) {
    $session = $client->checkSession($id);   // periksa tiap sesi
    if (! $session->isConnected()) {
        echo $client->showQr($id)->qrTag();   // QR per sesi
    }
}

Setara lewat opsi konstruktor: ['sessions' => ['OpenWA' => ['sales', 'support', 'billing']]] dan ['instances' => ['ApiMe' => ['uuid-1', 'uuid-2']]]. Opsi eksplisit menang atas environment, sama seperti tokens dan urls.

listSessions() berbeda dari daftar ini: ia mendaftar sesi yang ada di sisi server, bukan yang terdaftar di .env. Membandingkan keduanya — config()->sessions() vs listSessions() — adalah cara menemukan sesi yang sudah dikonfigurasi tetapi belum dibuat di server, atau sebaliknya.

Catatan per gateway

  • Fonnte — selalu membalas HTTP 200; keberhasilan sebenarnya ada di field status. Endpoint-nya tetap, jadi WHATSAPP_URL sengaja tidak dibaca: kalau dibaca, satu nilai yang ditujukan untuk gateway self-hosted akan mengalihkan pengiriman Fonnte ke host yang salah. Yang diterima hanya URL yang jelas milik Fonnte: opsi url eksplisit atau WHATSAPP_URL_Fonnte. Device API-nya memakai account token (WHATSAPP_ACCOUNT_TOKEN), bukan token perangkat, dan host-nya diturunkan dari URL pengiriman — jadi override proxy ikut berlaku untuk add-device dan get-devices.
  • OpenWA — butuh WHATSAPP_SESSION (atau WHATSAPP_SESSION_OpenWA). Satu pesan dikirim ke send-text (sinkron, balasannya messageId); lebih dari satu dikirim ke send-bulk (asinkron, balasannya batchId, maksimum 100 pesan per batch).
  • ApiMe — butuh WHATSAPP_INSTANCE (atau WHATSAPP_INSTANCE_ApiMe) dan token ber-scope instance; JWT user maupun API token global ditolak dengan HTTP 403. Pengiriman memakai Idempotency-Key deterministik, sehingga kartu yang ter-scan dua kali beruntun tidak menghasilkan dua pesan.
  • Evolution API — butuh WHATSAPP_INSTANCE (atau WHATSAPP_INSTANCE_EvolutionAPI). delay dititipkan ke server lewat payload (dalam milidetik), jadi klien tidak ikut menunggu. Nilainya diambil dari delay pesan, atau dari pacing bila tidak diisi.
  • Wuzapi — tidak butuh instance: tokennya sendiri yang menentukan sesi. Auth memakai header Token, bukan Authorization seperti yang tertulis di README wuzapi.
  • Wwebjs — butuh WHATSAPP_SESSION (atau WHATSAPP_SESSION_Wwebjs). Auth memakai header x-api-key, dan API key-nya opsional: selama API_KEY tidak diisi di sisi server, semua endpoint terbuka. Pengiriman menuntut session yang sudah CONNECTED; selama belum, middleware-nya membalas HTTP 404 — dan SDK menerjemahkannya menjadi petunjuk yang jelas, bukan "Not Found" apa adanya.
  • Waxum — butuh WHATSAPP_SESSION (atau WHATSAPP_SESSION_Waxum), dan endpointnya semuanya di bawah /api/v1. Auth memakai Authorization: Bearer berisi token superadmin (SUPERADMIN_TOKEN di sisi server; JWT ber-klaim role: superadmin juga diterima). Berbeda dari gateway lain, ia tidak punya endpoint batch — pesan massal dikirim satu per satu — dan createSession() menolak id yang sudah ada dengan HTTP 409, jadi periksa dulu dengan checkSession(). Seluruh error-nya berbentuk {"success":false,"error":{"code":N,"message":"…"}}, dengan HTTP 503 untuk sesi yang belum tersambung.

Memeriksa nomor (checkNumbers())

WhatsApp tidak memberi tahu apakah sebuah nomor bisa dihubungi sebelum pesannya benar-benar dikirim, jadi satu-satunya cara mengetahuinya adalah bertanya lebih dulu. checkNumbers() melakukan itu, dan hasilnya sama di tujuh gateway:

$hasil = $client->checkNumbers(['081234567890', '089876543210']);

foreach ($hasil->registered() as $nomor) {
    $client->send(['destination' => $nomor, 'message' => 'Halo']);
}

Nomornya boleh ditulis dalam bentuk apa pun yang lazim — 0812…, +62 812…, atau 62812…:

$hasil = $client->checkNumbers('081234567890');

$hasil->isRegistered('081234567890');       // true
$hasil->isRegistered('+62 812-3456-7890');  // true — bentuknya tidak penting
$hasil->whatsappId('081234567890');         // "6281234567890@s.whatsapp.net", bila gateway menyebutkannya

isRegistered() mengembalikan false untuk nomor yang tidak terperiksa maupun yang dipastikan tidak terdaftar. Keduanya diperlakukan sama karena arah yang aman untuk pemakaiannya adalah tidak mengirim; registered() dan unregistered() hanya berisi nomor yang benar-benar dijawab gateway, sehingga daftar itu tidak pernah melebih-lebihkan apa yang diketahui.

Berapa nomor yang bisa diperiksa sekali panggil berbeda antar gateway, tetapi tidak terlihat dari sisi pemanggil:

Gateway Endpoint Sekali panggil
Fonnte POST /validate sampai 500 nomor (kelebihannya dipotong SDK)
OpenWA GET /api/sessions/{id}/contacts/check/{number} satu nomor
ApiMe POST /api/instances/{id}/whatsapp/check satu nomor
Evolution API POST /chat/whatsappNumbers/{instance} seluruh daftar
Wuzapi POST /user/check seluruh daftar
Wwebjs POST /client/isRegisteredUser/{sessionId} satu nomor
Waxum POST /api/v1/sessions/{id}/contacts/check seluruh daftar

Tiga catatan pemakaian:

  • OpenWA menuntut API key berperan operator di endpoint ini; kunci baca biasa ditolak HTTP 403.
  • Memeriksa nomor adalah traffic keluar tentang pihak ketiga, sama seperti mengirim pesan. Menyapu daftar besar sekaligus berisiko bagi akun yang dipakai, jadi perlakukan seperti pengiriman: sedikit dulu, dan dengan jeda.
  • Bila gateway menolak dengan 429/503 dan menyebutkan Retry-After, pemeriksaannya diulang sendiri mengikuti WHATSAPP_RETRIES — sama seperti pengiriman pesan. Pada gateway yang memeriksa satu nomor per request, hanya nomor yang ditolak yang diulang. Lihat Percobaan ulang.

Menyaring otomatis sebelum kirim (check_numbers)

checkNumbers() di atas adalah cara manual: Anda memanggilnya, lalu memilih sendiri siapa yang dikirimi. Kalau yang diinginkan adalah jaring pengaman yang selalu aktif, nyalakan WHATSAPP_CHECK_NUMBERS:

WHATSAPP_CHECK_NUMBERS=1

Mulai saat itu setiap sendMessage() memeriksa tujuannya lebih dulu, dan membuang yang dipastikan tidak terdaftar. Jumlahnya dilaporkan di hasil pengiriman, jadi penyaringan ini tidak berjalan senyap:

Sukses, messageId: 3EB0ABC (1 tujuan dilewati karena tidak terdaftar: 089876543210)

Bila semuanya tersaring, tidak ada request pengiriman sama sekali dan hasilnya Tidak ada pesan terkirim: N tujuan dilewati karena tidak terdaftar di WhatsApp. Daftar contohnya dipotong lima nomor supaya satu batch besar tidak membanjiri hasil.

Bisa juga disetel per panggilan lewat opsi check_numbers — itu menang atas kunci .env:

$client = new Client(['check_numbers' => true]);   // paksa nyala
$client = new Client(['check_numbers' => false]);  // paksa mati

Empat perilaku yang disengaja:

  • Hanya yang dipastikan tidak ada yang dibuang. Nomor yang tidak disebut jawaban gateway berarti "belum diperiksa", bukan "tidak terdaftar", jadi ia tetap dikirim. Membuangnya akan menghilangkan pesan yang sebenarnya baik-baik saja.
  • Pemeriksaan yang gagal tetap mengirim. Timeout, atau kredensial yang tidak cukup untuk endpoint pemeriksaan, tidak menahan pesan — kegagalannya hanya dicatat ke logger (lihat "Logger (opsional)"). Satu timeout tidak boleh menghentikan seluruh batch.
  • Tujuan berbentuk JID tidak diperiksa. Grup (…@g.us) memang bukan nomor, dan menormalkannya justru merusak bentuknya.
  • Berlaku untuk pesan teks saja. Media (sendImage()/sendFile()) tidak ikut disaring.

Harganya adalah request tambahan sebelum pesan berangkat. Empat gateway memeriksa seluruh daftar dalam satu request, tetapi OpenWA, ApiMe, dan Wwebjs memeriksa satu nomor per request — daftar sepuluh nomor berarti sepuluh request pemeriksaan lebih dulu. Karena itu bawaannya mati.

Menghindari diblokir WhatsApp

WhatsApp memblokir nomor karena pola pengirimannya, bukan karena SDK yang dipakai. Karena itu tidak ada saklar ajaib di sini: yang ada adalah alat untuk membentuk pola yang wajar, dan sisanya tanggung jawab pemanggil. Bagian ini menjelaskan mana yang dikerjakan SDK dan mana yang tidak.

Yang bisa diatur lewat SDK:

  • Jeda antar pesan — WHATSAPP_PACING_*. Jeda tetap bergiliran ditambah jitter acak, dan pesan panjang ditunggu lebih lama.
  • Pagar laju (warmup) — WHATSAPP_THROTTLE_MAX / WHATSAPP_THROTTLE_WINDOW. Batas keras berapa pesan per rentang waktu, cocok untuk akun yang baru mulai.
  • Indikator "sedang mengetik" — WHATSAPP_TYPING. Pesan datang setelah indikator yang lamanya sepadan dengan panjang teks, bukan seketika.
  • delay eksplisit pada tiap pesan, bila pemanggil ingin mengatur sendiri.
  • checkNumbers() — menyaring nomor yang benar-benar terdaftar di WhatsApp sebelum mengirim, supaya tidak ada pesan yang menembak nomor mati. Bisa dipakai manual, atau dijadikan jaring pengaman otomatis lewat WHATSAPP_CHECK_NUMBERS.

Yang tidak bisa, dan sebaiknya tidak diharapkan dari SDK:

  • Penjadwalan jam kirim. SDK ini sinkron — ia tidak menunggu jam 9 pagi sebelum mengirim. Aturlah dari pemanggil (cron, queue worker) dan panggil SDK saat itu.
  • Pemanasan akun. Warmup sejati berarti mengirim sedikit pesan dulu selama beberapa hari lalu menaikkannya perlahan. SDK hanya menegakkan batas yang Anda berikan; angka yang benar untuk akun hari ke-1 berbeda dari hari ke-30, dan itu keputusan pemanggil.
  • Pemilihan tujuan. checkNumbers() bisa memberi tahu nomor mana yang terdaftar, tetapi terdaftar bukan berarti mau menerima: nomor yang sah tapi tidak pernah mengenal Anda tetap pemicu blokir yang paling sering. Keputusan siapa yang pantas dikirimi tetap milik pemanggil.

Perhatikan bahwa percobaan ulang sudah ditangani SDK saat gateway menyebutkan Retry-After — lihat "Percobaan ulang (HTTP 429 & 503)" di bawah. Yang tetap jadi tanggung jawab pemanggil adalah menjadwalkan ulang batch yang gagal, mis. menjalankannya lagi satu jam kemudian.

Jadi urutan yang disarankan: nyalakan pacing, pasang pagar laju yang rendah untuk akun baru, kirim dalam jumlah kecil, dan naikkan batasnya hanya setelah pola pengiriman terlihat wajar.

Percobaan ulang (HTTP 429 & 503)

Gateway yang membalas 429 (batas laju) atau 503 (sesi belum tersambung) sering menyertakan header Retry-After — berapa detik pemanggil sebaiknya menunggu. SDK membacanya dan, bila WHATSAPP_RETRIES diisi, mengulang percobaannya sendiri setelah menunggu selama itu.

Berlaku untuk kedua jalur yang berbicara ke gateway: pengiriman pesan dan pemeriksaan nomor (checkNumbers(), termasuk saat dipakai otomatis lewat WHATSAPP_CHECK_NUMBERS).

Kunci Arti
WHATSAPP_RETRIES Berapa kali percobaan ulang per request. Bawaannya 0 (mati)

Dua syarat harus terpenuhi bersamaan, dan itu disengaja:

  • Statusnya aman diulang — hanya 429 dan 503. 401/403/404/409 tidak akan sembuh kalau diulang: token yang ditolak tetap ditolak. Mengulanginya hanya memperlambat kegagalan yang sudah pasti.
  • Gateway menyebut berapa lama harus menunggu. Tanpa Retry-After, SDK langsung melempar exception-nya. Menebak jeda sendiri berarti menabrak dinding yang sama lagi, dan pemanggil yang tahu jadwalnya lebih tepat memutuskan.
// .env: WHATSAPP_RETRIES=2
// Satu pesan, 2 pesan lain tidak terpengaruh.
$client->send([
    ['destination' => '0811111111', 'message' => 'satu'],
    ['destination' => '0822222222', 'message' => 'dua'],
]);

// Di balik layar, bila pesan ke-2 kena 429 dengan Retry-After: 5
//   1. POST pesan 1  -> 200
//   2. POST pesan 2  -> 429
//   3. tunggu 5 detik
//   4. POST pesan 2  -> 200   (berhasil, tidak ada yang hilang)
//   5. POST pesan 3  -> 200

Percobaan ulang dihitung per request, bukan per batch. Pada gateway yang mengirim satu pesan per request (ApiMe, Wuzapi, Wwebjs, Evolution API) itu berarti satu pesan yang ditolak diulang sendiri, dan pesan lain tidak ikut terulang. Begitu pula pada pemeriksaan nomor di gateway yang memeriksa satu nomor per request (OpenWA, ApiMe, Wwebjs): hanya nomor yang ditolak yang ditanyakan lagi, sehingga daftar panjang tidak kembali ke awal dan tidak menanyakan ulang nomor yang sudah dijawab.

Retry-After diterima dalam dua bentuk — jumlah detik (30) maupun tanggal HTTP (Wed, 21 Oct 2026 07:28:00 GMT). Bentuk tanggal diubah menjadi selisih detik dari waktu sekarang.

Bila jatah percobaan habis, exception terakhir dilempar apa adanya, dan getRetryAfter() di dalamnya masih berisi angka dari gateway — jadi pemanggil yang menangani batch besar tetap bisa membaca angka itu dan menjadwalkan sendiri:

use Sikuwa\Whatsapp\Exceptions\RateLimitException;

try {
    $client->send($banyak);
} catch (RateLimitException $e) {
    $tunggu = $e->getRetryAfter() ?? 60;   // gateway bisa saja tidak menyebutnya
    // simpan sisa nomor ke queue, jalankan lagi setelah $tunggu detik
}

Bawaannya mati karena mengulang berarti menahan proses pemanggil lebih lama, sama seperti pacing dan pagar laju.

Logger (opsional)

SDK ini tidak mencatat apa pun sendiri. Ia memakai logger kalau Anda memberikannya, dan hanya untuk kejadian yang tadinya senyap sama sekali:

  • indikator "sedang mengetik" yang gagal ditampilkan — pesannya tetap dikirim;
  • percobaan ulang setelah gateway menolak dengan 429/503.
use Monolog\Logger;
use Sikuwa\Whatsapp\Client;

$client = new Client([
    'provider' => 'OpenWA',
    'logger'   => new Logger('whatsapp'),   // apa pun yang memenuhi PSR-3
]);

Catatannya berlevel warning, dengan provider, destination, dan error di dalam konteksnya — cukup untuk tahu apa yang terjadi tanpa ikut menuliskan isi pesannya:

Indikator "sedang mengetik" gagal ditampilkan; pesannya tetap dikirim
  provider=OpenWA destination=081234567890 error="OpenWA menolak pesan (HTTP 500): …"

Gateway menolak sementara; mencoba ulang setelah jeda
  provider=ApiMe status=429 retry_after=30 remaining=0

Dua hal yang sengaja begitu:

  • Bawaannya null, sehingga perilakunya sama persis seperti sebelum fitur ini ada — tanpa logger, jalur kirimnya tidak berubah sama sekali.
  • Logger yang melempar tidak menggagalkan pengiriman. Kegagalan hiasan tidak boleh berubah menjadi kegagalan pesan hanya karena disk penuh, atau karena format konteksnya tidak disukai kelas logger-nya.

Tidak ada kunci .env untuk ini: sebuah objek tidak bisa dibaca dari .env, dan menebak kelas logger dari namanya adalah cara yang lebih buruk daripada membiarkannya kosong.

Mengirim banyak pesan lewat worker

SDK ini sinkron: send() menahan proses pemanggil sampai semua pesannya selesai. Kalau Anda menyerahkan 500 nomor sekaligus dengan pacing dan pagar laju menyala, satu panggilan itu bisa menahan PHP-FPM berjam-jam — di luar max_execution_time, dan kalau gagal di tengah Anda tidak tahu mana yang sudah terkirim.

Karena itu pengiriman besar sebaiknya dipecah sendiri oleh aplikasi Anda, dan SDK-nya dipanggil dalam potongan kecil. SDK tidak menyimpan antrean: siapa yang belum terkirim, jam berapa harus dikirim, dan bagaimana mengulang yang gagal adalah urusan aplikasi.

Satu hal yang bisa dikerjakan SDK di dalam satu potongan: kalau beberapa gateway dikonfigurasi, WHATSAPP_ROTATE membagi potongan itu ke semuanya sekaligus — lihat Membagi batch ke beberapa gateway. Antreannya tetap milik Anda; yang dibagi hanya rutenya.

Polanya

Tiga komponen, semuanya di sisi Anda:

  1. Tabel antrean — satu baris per pesan, dengan status.
  2. Worker — dijalankan cron, mengambil sekian baris, memanggil SDK.
  3. Jadwal — kapan worker boleh jalan, dan kapan pesan boleh dikirim.
CREATE TABLE outbox (
    id          BIGINT PRIMARY KEY AUTO_INCREMENT,
    destination VARCHAR(20)  NOT NULL,
    message     TEXT         NOT NULL,
    status      VARCHAR(16)  NOT NULL DEFAULT 'pending',  -- pending|sent|failed
    attempts    TINYINT      NOT NULL DEFAULT 0,
    last_error  TEXT         NULL,
    due_at      DATETIME     NOT NULL,                    -- kapan boleh dikirim
    sent_at     DATETIME     NULL,
    INDEX (status, due_at)
);
// worker.php — cron: */5 * * * * php worker.php
require 'vendor/autoload.php';

use Sikuwa\Whatsapp\Client;
use Sikuwa\Whatsapp\Exceptions\RateLimitException;

$client = new Client();          // pacing + throttle dari .env
$db     = new PDO(/* ... */);

/**
 * Placeholder `?` sebanyak nilainya.
 *
 * Menulis `IN (?)` lalu menghubungkan id-nya dengan koma adalah kesalahan yang
 * mudah terjadi: satu placeholder hanya cocok dengan SATU nilai, sehingga
 * sisanya diam-diam tidak ikut diperbarui — tanpa error apa pun.
 *
 * @param array<int,int|string> $ids
 */
$placeholders = static fn (array $ids): string => implode(',', array_fill(0, \count($ids), '?'));

// 1. Ambil potongan kecil. Batas inilah yang menjaga satu panggilan tetap pendek.
$rows = $db->query("
    SELECT * FROM outbox
    WHERE status = 'pending' AND due_at <= NOW()
    ORDER BY due_at
    LIMIT 50
")->fetchAll();

if ($rows === []) {
    exit;                        // tidak ada pekerjaan
}

// 2. Kelompokkan per nomor. Mengirim ke satu nomor dalam satu panggilan
//    membuat kegagalan satu pesan tidak menyeret nomor lain.
$byTarget = [];

foreach ($rows as $row) {
    $byTarget[$row['destination']][] = $row;
}

foreach ($byTarget as $group) {
    $ids = array_column($group, 'id');

    // 3. Kirim. Satu panggilan per nomor, dan luruskan `pending` hanya
    //    SETELAH send() kembali — kalau prosesnya mati di tengah, paling
    //    banyak pesan itu terkirim ulang, bukan hilang tanpa jejak.
    try {
        $client->send(array_map(
            fn (array $row): array => ['destination' => $row['destination'], 'message' => $row['message']],
            $group
        ));

        $db->prepare('UPDATE outbox SET status = ?, sent_at = NOW() WHERE id IN (' . $placeholders($ids) . ')')
           ->execute(['sent', ...$ids]);

    } catch (RateLimitException $e) {
        // Kena batas laju bukan kesalahan pesannya, jadi `attempts` tidak
        // dinaikkan — hanya jadwalnya yang mundur. Angkanya dari gateway,
        // bukan tebakan: itulah guna getRetryAfter().
        $delay = $e->getRetryAfter() ?? 300;
        $sql = 'UPDATE outbox SET due_at = DATE_ADD(NOW(), INTERVAL ? SECOND) WHERE id IN (' . $placeholders($ids) . ')';
        $db->prepare($sql)->execute([$delay, ...$ids]);

    } catch (\Throwable $e) {
        // Kegagalan lain (nomor tidak terdaftar, token ditolak): naikkan
        // hitungan, dan menyerah setelah tiga kali supaya tidak berputar
        // selamanya di baris yang memang tidak akan pernah berhasil.
        foreach ($group as $row) {
            $attempts = (int) $row['attempts'] + 1;
            $db->prepare('UPDATE outbox SET attempts = ?, last_error = ?, status = ? WHERE id = ?')
               ->execute([
                   $attempts,
                   $e->getMessage(),
                   $attempts >= 3 ? 'failed' : 'pending',
                   $row['id'],
               ]);
        }
    }
}

Empat hal yang membuat pola ini bekerja

  • Potongan kecil (LIMIT 50) adalah pengaman utamanya. Dengan pacing 0,30 + jitter 20–30 detik, 50 pesan butuh sekitar 20 menit — masih di dalam max_execution_time yang wajar, dan kalau worker mati di tengah, yang hilang paling banyak 50 baris yang statusnya masih pending.
  • Menandai sent setelah send() kembali. Jangan menandai sebelum mengirim: kalau prosesnya mati di antaranya, pesan yang sebenarnya terkirim akan dikirim ulang. Lebih baik ada pesan yang terlewat sesekali daripada pelanggan menerima notifikasi yang sama dua kali.
  • due_at yang menentukan jam kirim, bukan SDK. SDK tidak tahu jam 9 pagi — isi due_at dengan jam yang Anda inginkan, dan worker hanya mengambil baris yang sudah jatuh tempo. Ini juga cara menghindari mengirim tengah malam.
  • RateLimitException menaikkan due_at, bukan attempts. Kena batas laju bukan kesalahan pesannya, jadi jangan dihitung sebagai percobaan gagal yang menghabiskan jatahnya.

Kenapa SDK tidak menyediakan antreannya

Menaruh antrean di dalam library berarti SDK harus tahu database Anda, jadwal cron Anda, dan kebijakan percobaan ulang Anda — lalu menyimpannya di antara panggilan. Pada titik itu ia bukan lagi library, melainkan aplikasi, dan ia akan bertabrakan dengan queue yang sudah Anda pakai (Laravel Queue, Symfony Messenger, BullMQ). Yang SDK kerjakan adalah mengirim satu potongan dengan laju yang wajar; sisanya memang keputusan aplikasi.

Untuk potongan besar sekaligus, Throttle tetap berguna — ia yang membuat panggilan tidak menembak terlalu cepat. Tetapi batas LIMIT-nya tetap di sisi Anda: SDK menegakkan laju, bukan durasi.

Error

Semua error melempar subclass dari Sikuwa\Whatsapp\Exceptions\WhatsappException:

Kelas Kapan
ConfigurationException Token/URL/session/instance belum diisi, atau bentuk pesan salah. Tidak akan sembuh kalau diulang
UnknownProviderException Nama gateway tidak dikenali
ApiException Gateway menjawab tapi menolak. Membawa getStatus(), getBody(), getErrorKind(), dan getRetryAfter()
AuthException 401 — token ditolak
ForbiddenException 403 — token kurang hak (mis. bukan instance token di ApiMe)
NotFoundException 404 — instance/session tidak ditemukan, atau perangkat Fonnte yang dicari tidak ada di akun
ConflictException 409 — masih ada pengiriman dengan Idempotency-Key yang sama
RateLimitException 429 — batas laju gateway. Percobaan ulang otomatis bila WHATSAPP_RETRIES diisi dan Retry-After tersedia
ServiceUnavailableException 503 — sesi WhatsApp belum siap. Ikut percobaan ulang otomatis dengan syarat yang sama
TimeoutException Request melewati WHATSAPP_TIMEOUT

Pengiriman massal ke gateway tanpa endpoint batch (ApiMe, Evolution API, wuzapi, Wwebjs) mengirim satu per satu. Kegagalan satu nomor tidak menghentikan sisanya; semuanya dikumpulkan lalu dilempar sebagai satu ApiException, jadi pemanggil melihat gambaran lengkapnya:

2/3 pesan terkirim. Gagal: 62822: ApiMe menolak pesan (HTTP 400): nomor tidak terdaftar

Pengujian

composer install
composer test

Tidak ada jaringan yang tersentuh: test menyuntikkan Transport palsu lewat opsi httpClient (alias transport), sehingga tidak perlu monkey-patching global.

$client = new Client(['provider' => 'Fonnte'], $transportPalsu);

Butuh satu gateway saja?

Kalau hanya memakai satu gateway, SDK ini berlebihan. Gunakan langsung SDK PHP OpenWA yang sudah teruji.

Feature

  • Tujuh gateway: Fonnte, OpenWA, ApiMe, Evolution API, Wuzapi, Wwebjs, Waxum
  • Check Session
  • Create sessions
  • Show QR
  • Send messages
  • Jeda antar pesan (pacing)
  • Send Media Image
  • Send Media File
  • Human Being Typing (sendTyping() eksplisit dan otomatis lewat WHATSAPP_TYPING)
  • Pagar laju / warmup (WHATSAPP_THROTTLE_MAX & WHATSAPP_THROTTLE_WINDOW)
  • Percobaan ulang otomatis 429/503 dengan menghormati Retry-After (WHATSAPP_RETRIES), untuk pengiriman pesan maupun pemeriksaan nomor
  • Periksa ukuran media sebelum kirim (File::size(), exceedsLimit(), batas WhatsApp 16 MB)
  • Multi Session — mendaftar semua sesi/instance per gateway (listSessions())
  • Periksa nomor terdaftar sebelum kirim (checkNumbers(), NumberCheck)
  • Penyaring tujuan otomatis di jalur kirim teks (WHATSAPP_CHECK_NUMBERS)
  • Logger PSR-3 opsional untuk kejadian yang tidak menggagalkan pengiriman
  • Bagi batch ke beberapa gateway saat kirim banyak (WHATSAPP_ROTATE)

Kredit

Terinspirasi dari SDK PHP OpenWA.