Kirim satu pesan lewat API: WhatsApp, Telegram, dan bot Telegram

Kirim pesan lewat API WhatsApp dan Telegram ke satu pelanggan: satu permintaan, teks dan lampiran sendiri, tanpa broadcast. Cocok untuk kode dan pengingat.

Kirim satu pesan lewat API WhatsApp dan Telegram di WSENDER
Ada kejadian di sistem Anda, beberapa detik kemudian pesannya sudah di tangan pelanggan

Ada pesan yang tidak bisa ditunda sampai broadcast sore nanti. Pelanggan baru saja membayar pesanan. Baru saja booking jadwal kunjungan. Meminta kode verifikasi lewat telepon, sementara operator masih menunggu di telepon.

Pesan seperti ini ditujukan ke satu orang, hanya berguna selama beberapa menit, dan tidak akan terulang. Membuat broadcast khusus untuknya terasa janggal: audiensnya satu nomor, teksnya untuk satu kasus, dan semua itu demi satu pesan.

Sekarang ada metode tersendiri untuk keperluan ini. Satu permintaan HTTP mengirim satu pesan ke orang tertentu, dengan teks dan lampirannya sendiri. Di sini kita hanya membahas pengiriman satuan; broadcast dibahas di artikel tentang API broadcast.

Cara kerja pengiriman pesan lewat API

Alurnya sederhana: kejadian di sistem Anda → permintaan ke API → pesan sampai ke pelanggan.

Seluruh metode ini hanyalah satu permintaan POST /v1/message yang berisi tiga hal: dari akun mana dikirim, ke siapa, dan apa isinya. Kunci API-nya sama dengan kunci untuk broadcast, dibuat di bagian API di dasbor:

bash
curl -X POST https://api.wsender.io/v1/message \
  -H "Authorization: key TOKEN_ANDA" \
  -H "Content-Type: application/json" \
  -d '{
    "channelId": "ID_AKUN",
    "phone": "+62 812-3456-7890",
    "message": { "kind": "text", "text": "Pesanan 84421 sudah dibayar. Kami tunggu besok pukul 12.00." },
    "idempotencyKey": "order-84421-paid"
  }'

Sebagai respons, Anda menerima pesan dengan ID dan statusnya sendiri:

json
{
  "id": "0f3c9a6e-4b12-4d8a-9f31-6c5a71e08d42",
  "mid": null,
  "status": "Queued",
  "channelId": "ID_AKUN",
  "target": "Phone",
  "phone": "6281234567890",
  "dialogId": null,
  "errorReason": null,
  "sendDate": null,
  "failDate": null,
  "wait": null,
  "expiresAt": "2026-09-07T21:15:04Z",
  "idempotencyKey": "order-84421-paid"
}

ID akun diambil dari daftar akun yang terhubung, GET /v1/channel, sedangkan idempotencyKey Anda tentukan sendiri: ini kunci idempotensi permintaan, penjelasannya ada di bawah. Kolom dialogId terisi ketika layanan menemukan percakapan dengan orang tersebut, dan lewat ID itu Anda nanti bisa membalas di dialog yang sama. Kalau Anda sudah pernah mengirim pesan ke orang itu, kolom ini langsung terisi di respons yang sama. mid muncul setelah pesan terkirim: ini ID pesan di dalam percakapan. wait menjelaskan apa yang menahan pesan di antrean, dan expiresAt menunjukkan sampai kapan pesan yang belum terkirim menunggu giliran. Setelah batas itu lewat, pesan berakhir dengan alasan Expired.

Respons datang seketika, tetapi artinya "sudah masuk antrean", belum "sudah sampai". Pesan dikirim oleh akun Anda yang terhubung, akun sungguhan di aplikasi messenger. Ini butuh beberapa detik, dan untuk nomor yang belum pernah dihubungi bisa sampai satu-dua menit: messenger tidak suka nomor dicek secara instan. Pengiriman dari bot lebih cepat, karena bot mengirim langsung lewat Bot API tanpa aplikasi messenger.

Perbedaan ini penting bagi operator yang sedang menelepon pelanggan. Kalimat yang tepat: "saya kirimkan pesannya sekarang". Bukan "pesannya sudah masuk".

Penerima pesan di WhatsApp, Telegram, dan bot

Penerima ditentukan dengan salah satu cara berikut, dan pilihan ini menentukan apakah nomor telepon diperlukan:

  • Nomor telepon dalam format apa pun yang biasa dipakai. Ini kasus paling umum: nomornya sudah ada di data pelanggan Anda.
  • Username di Telegram, kalau akun Anda sudah mengenalnya dari percakapan sebelumnya.
  • Dialog lewat ID dari API atau lewat alamat percakapan di messenger itu sendiri. Dialognya harus sudah dikenal akun Anda: alamat yang tidak dikenal langsung ditolak, layanan tidak akan mencoba menebak-nebak.

Di dalam permintaan, ini berupa empat kolom, dan yang diisi hanya satu. Layanan tidak menebak siapa yang ingin Anda hubungi:

text
"phone": "+62 812-3456-7890"
"username": "@budisantoso"
"dialogId": "ID_DIALOG_DARI_API"
"dialogMid": "123456789"

Opsi terakhir adalah alamat dialog di messenger itu sendiri, yaitu chat_id pada bot Telegram. Opsi ini praktis kalau sistem Anda sudah menyimpan alamat dari messenger dan Anda tidak ingin ikut menyimpan ID dari kami.

Untuk bot, penerima hanya bisa ditentukan lewat dialog. Penyebabnya ada di platform: pelanggan bot bisa saja tidak punya nomor atau username yang terlihat, tetapi dialognya selalu ada. Lagi pula, bot tetap tidak bisa mengirim pesan lebih dulu. Ini aturan platform, dan layanan tidak bisa mengubahnya.

Apa saja yang bisa dikirim

Teks, gambar, video, audio, dokumen, atau kartu kontak. Teks mendukung variasi kalimat dalam kurung siku: [Halo|Selamat siang] akan menjadi salah satu dari keduanya, supaya pesan tidak terlihat seperti template.

Ada jebakan yang sebaiknya Anda ketahui sebelum permintaan pertama, bukan sesudahnya: kurung siku dianggap spintax meskipun tanpa variasi. Teks [Penting] kode Anda 1234 akan sampai ke pelanggan sebagai Penting kode Anda 1234, tanpa kurung. Kalau template Anda memakai kurung siku sebagai hiasan, ganti dengan simbol lain.

Berkas diunggah sekali lewat permintaan terpisah, lalu dipakai lewat ID-nya di pesan sebanyak yang Anda perlukan. Satu daftar harga yang dikirim ke seratus pelanggan hanya memakan tempat satu berkas. Batasannya sederhana: maksimal 10 MB per berkas, dan berkas yang tidak dipakai disimpan satu hari lalu terhapus sendiri.

Unggahannya berupa form biasa dengan berkas, dan kolom terpenting di respons adalah id:

bash
curl -X POST https://api.wsender.io/v1/file \
  -H "Authorization: key TOKEN_ANDA" \
  -F "file=@daftar-harga-2026.pdf"

Kalau berkasnya sudah tersedia di tautan publik, Anda bisa memakai POST /v1/file/url dengan kolom url sebagai ganti form, dan server akan mengunduhnya sendiri. Setelah itu ID dimasukkan ke pesan, dan jenis pesan ditentukan oleh kolom kind: text, image, video, audio, file, atau contact. Gambar, video, dan dokumen bisa diberi keterangan caption, dan setiap lampiran punya nama fileName yang akan dilihat penerima:

json
{
  "channelId": "ID_AKUN",
  "phone": "+62 812-3456-7890",
  "message": {
    "kind": "file",
    "fileId": "ID_BERKAS_YANG_DIUNGGAH",
    "fileName": "Daftar harga Oktober",
    "caption": "Selamat siang, Kak. Ini daftar harga yang kemarin kita bahas. Harganya berlaku sampai akhir bulan."
  },
  "idempotencyKey": "price-request-1187"
}

Nama ini opsional: tanpanya, yang dipakai adalah nama berkas saat diunggah, sedangkan ekstensinya selalu ditambahkan otomatis, jadi tidak perlu Anda kirim. Gambar dikirim dengan cara yang sama, dengan "kind": "image", sedangkan kartu kontak tidak butuh berkas: { "kind": "contact", "phone": "+62 812-0000-0000" }.

Satu-satunya pengecualian: kartu kontak tidak bisa dikirim dari bot, layanan hanya mengirimnya dari akun messenger. Permintaan seperti ini langsung ditolak, jadi pesan tidak hilang diam-diam saat pengiriman.

Cara tahu pesan sudah terkirim

Respons atas permintaan hanya mengatakan "diterima". Selanjutnya layanan sendiri yang memberi tahu hasil akhirnya lewat tiga event webhook:

  • message.sent. Pesan sudah dikirim ke messenger.
  • message.failed. Pengiriman gagal, dan event menyertakan alasannya: penerima memblokir akun, orang ini tidak boleh dihubungi lebih dulu, atau akun pengirim terputus. Bisa juga selama beberapa menit pengiriman terus terkendala gangguan sementara di messenger sampai jumlah percobaan habis. Pesan seperti ini cukup diantrekan ulang.
  • message.notfound. Penerimanya tidak ada di messenger: tidak ada nomor atau akun seperti itu. Ini event tersendiri, terpisah dari kegagalan pengiriman. Tidak ada yang perlu diperbaiki, tetapi kontak tersebut sebaiknya ditandai di basis data Anda.

Langganan event ini harus eksplisit. Tentukan alamat webhook dan cantumkan event-event ini di filternya. Tanpa itu, event tidak akan datang, sekalipun webhook sudah diatur untuk event broadcast. Ini disengaja. Bagi yang mengirim seratus pesan sehari, event untuk setiap pesan memang berguna, sedangkan pengirim broadcast ke puluhan ribu penerima tidak boleh tiba-tiba kebanjiran event seperti itu.

Isi event: ID pesan, akun pengirim, penerima dalam bentuk yang Anda tentukan, status, alasan kegagalan, dan tanggal. Yang paling penting: kunci milik Anda sendiri, yang Anda kirim bersama permintaan. Dengan kunci itu Anda bisa menemukan permintaan terkait di sistem Anda tanpa perlu menyimpan ID dari kami.

Seperti inilah event kegagalan ketika penerima memblokir akun Anda:

json
{
  "id": "7d1e0b58-3a44-4f2c-9c81-15b0f7a9d6e3",
  "type": "message.failed",
  "date": "2026-09-07T09:15:42Z",
  "payload": {
    "message": {
      "id": "0f3c9a6e-4b12-4d8a-9f31-6c5a71e08d42",
      "channelId": "ID_AKUN",
      "phone": "6281234567890",
      "status": "Failed",
      "errorReason": "Blocked",
      "failDate": "2026-09-07T09:15:41Z",
      "idempotencyKey": "order-84421-paid"
    }
  }
}

Alasannya menunjukkan apa yang perlu dilakukan. Blocked dan RestrictedSend: orang ini tidak bisa dihubungi dari akun ini. ChannelDisconnected: akun terputus, hubungkan lagi lalu kirim ulang. Expired dan Unavailable: pesan tidak terkirim dalam batas waktu, dan bisa diantrekan lagi dengan kunci baru.

Saat membuat handler, perhatikan tiga hal:

  1. Satu event bisa datang dua kali. Misalnya, server Anda sudah menerimanya, tetapi responsnya hilang di jalan. Periksa id event dan jangan jalankan tindakan di sistem Anda untuk kedua kalinya.
  2. Pembatalan oleh Anda sendiri tidak memicu event. Anda sendiri yang membatalkan pesan dan sudah menerima respons atas permintaan itu, jadi tidak perlu ada pemberitahuan kedua soal tindakan Anda sendiri.
  3. Hasilnya bisa dicek lewat API. Gangguan pengiriman sementara ditangani layanan sendiri. Kalau pesan tidak berhasil dikirim dalam batas waktu, statusnya menjadi Failed, dan kalau webhook aktif sudah diatur, layanan membuat event message.failed beserta alasannya. Kalau server penerima webhook tidak bisa dijangkau, event bisa saja tidak sampai. Hasil terbarunya tetap tersedia lewat GET /v1/message/{id}.

Pengiriman webhook diulang dengan jeda yang makin panjang, tetapi setiap event disimpan paling lama satu hari. Setelah satu hari terus gagal tanpa satu pun pengiriman yang berhasil, webhook dijeda: event baru tidak lagi dikumpulkan.

Periksa health di respons GET /v1/webhook, perbaiki server penerima, lalu panggil POST /v1/webhook/resume. Kalau pengecekannya berhasil, pengiriman event yang masih tersimpan dilanjutkan. Event yang terlewat selama jeda dan yang sudah kedaluwarsa tidak dipulihkan.

Kalau tidak ingin repot dengan webhook, status pesan bisa diminta lewat ID-nya: GET /v1/message/{id} mengembalikan struktur yang sama dengan respons pengiriman, dengan status terkini: Queued, Sending, Sent, Failed, atau NotFound. Tapi terus terang saja: tepat setelah permintaan, statusnya masih "di antrean", dan hasil akhirnya tidak bisa diketahui dengan sekali cek, jadi Anda harus mengecek lagi. Metode ini sengaja tidak punya mode "tunggu sampai terkirim": menahan permintaan HTTP Anda tetap terbuka selama satu menit lebih buruk daripada mengirim event. Karena itu, di sini webhook adalah cara yang disarankan, dan perannya jauh lebih dari sekadar pelengkap.

Membalas pesan masuk dengan metode yang sama

Metode ini tidak hanya untuk pesan keluar. Saat ada pesan baru dari pelanggan, layanan memberi tahu lewat event message.received yang berisi teks, pengirim, dan dialog. Lalu Anda membalas dengan permintaan pengiriman yang sama, dengan dialog dari event sebagai penerima. Dengan satu metode ini Anda bisa membangun komunikasi dua arah: inbox sendiri, chatbot, atau integrasi dengan CRM. Kalau tidak perlu membuat inbox sendiri, operator cukup memakai Dialogs yang dibuka dari dasbor: di sana ada percakapan yang sama, dan balasan yang dikirim lewat API diberi tanda "API".

Balasan bisa dikirim dengan mengutip pesan tertentu, seperti di messenger saat Anda membalas pesan lama di tengah obrolan. Caranya, sertakan ID pesan yang dikutip di permintaan. ID ini ada di event pesan masuk dan juga di riwayat dialog yang diberikan API.

Tetap POST /v1/message, hanya saja penerimanya dialog dan ada tambahan kolom replyToMid:

json
{
  "channelId": "ID_AKUN",
  "dialogId": "ID_DIALOG_DARI_EVENT",
  "replyToMid": "MID_PESAN_PELANGGAN",
  "message": { "kind": "text", "text": "Ada, Kak, stoknya masih tersedia. Mau kami simpankan sampai sore?" },
  "idempotencyKey": "reply-5c2e-1"
}

Kedua ID ada di event pesan masuk: dialog di payload.dialog.id, pesannya sendiri di payload.message.mid. Yang bisa dikutip hanya pesan dari dialog yang sama. Pesan dari dialog lain akan ditolak: pengecekannya terjadi saat permintaan masuk, jadi kesalahan ini langsung ketahuan dan tidak muncul diam-diam saat pengiriman. Di akun messenger maupun bot Telegram, semua jenis pesan bisa dikutip.

Pesan aslinya bisa saja dihapus setelah permintaan Anda. Dalam kasus itu, akun messenger mengirim pesannya sebagai pesan biasa tanpa kutipan: teks tidak perlu hilang hanya gara-gara format. Bot tidak bisa begitu. Pesannya gagal dikirim, dan dari alasannya jelas apa yang perlu dilakukan: kirim ulang tanpa kutipan.

Permintaan ulang tidak membuat pesan kedua

Masalah khas pesan transaksional: koneksi putus tepat saat respons dikirim, kode Anda mengulang permintaan, dan pelanggan menerima dua pesan yang sama.

Kirim kunci unik Anda bersama permintaan. Permintaan ulang dengan kunci yang sama akan mengembalikan pesan yang sama beserta status terkininya, tanpa mengirim pesan kedua. Ini mekanisme idempotensi standar, dan di sini ia bekerja di tingkat pesan itu sendiri.

Selama pesan masih menunggu di antrean, pesan bisa dibatalkan dengan permintaan terpisah, DELETE /v1/message/{id}. Begitu akun pengirim mulai memprosesnya, pembatalan sudah terlambat: Anda akan menerima kode 409 dengan status terkini di body respons, dan hasilnya ditentukan oleh proses pengiriman. Pesan yang dibatalkan selanjutnya tampil sebagai Failed dengan alasan Canceled. Status "dibatalkan" tersendiri tidak ada.

Batasan pengiriman pesan lewat API

Kami jelaskan batasannya terus terang, supaya Anda tidak merancang hal yang tidak perlu.

  • Pemeriksaan topik terlarang tetap berlaku. Pesan diperiksa saat permintaan masuk, pesan yang ditolak tidak masuk antrean, dan alasannya ada di respons. Pengiriman langsung tidak bisa dipakai untuk melewati moderasi.
  • Limit harian paket dipakai bersama broadcast. Pesan langsung menghabiskan limit yang sama: satu pesan terkirim mengurangi satu pesan dari kuota paket, dan metode ini tidak punya limit sendiri. Tidak ada pengiriman berbayar di atas limit: kalau limit habis, permintaan ditolak. Sisa limit juga berkurang oleh pesan yang sudah ada di antrean proyek.
  • Ada jeda beberapa detik antar-pengiriman dari satu akun. Ini melindungi akun dari banjir pesan yang dikirim dalam loop. Percakapan langsung dengan pelanggan tidak akan terbentur jeda ini, tetapi upaya mengirim seribu pesan lewat metode ini akan terbentur.
  • Layanan tidak menyediakan nomor sendiri. Pesan dikirim dari akun atau bot Anda yang terhubung: reputasi nomor tetap milik Anda, dan balasan pelanggan masuk ke aplikasi messenger Anda seperti biasa.

Langkah selanjutnya

Metode ini berfungsi di paket biasa dan saat ini masih dalam tahap pengujian: belum ada harga tersendiri, dan pesannya dihitung seperti pesan biasa dalam paket. Di paket gratis tersedia 15 pesan per hari, cukup untuk membangun integrasi.

Mulailah dengan satu permintaan ke nomor Anda sendiri: hubungkan akun, ambil kunci di bagian API dasbor, lalu kirim pesan uji ke diri sendiri. Setelah itu tambahkan langganan event dan pasang panggilan API di titik sistem Anda tempat kejadian itu muncul.

Deskripsi lengkap semua metode ada di dokumentasi API, dan di sana permintaan bisa dijalankan langsung dari peramban. Spesifikasi untuk membuat klien secara otomatis ada di berkas OpenAPI.