Panduan · Terbit
Bot WhatsApp untuk menerima pembayaran: jalur resmi Cloud API, tombol bayar QRIS dan Virtual Account, dan jendela 24 jam yang menentukan cara mengabari pembeli
Bot WhatsApp yang menagih lewat Kasera Pay bekerja dengan dua webhook di satu server. Meta mengirim pesan pembeli ke bot lewat WhatsApp Cloud API, bot membuat permintaan pembayaran lewat POST /v1/transactions dan membalas dengan tombol yang membuka halaman checkout (QRIS atau Virtual Account), lalu Kasera Pay mengirim payment.paid ke bot, dan bot mengabari pembeli di chat yang sama. Bedanya dengan bot Telegram ada di sisi WhatsApp: nomor bisnis harus lewat jalur resmi Meta, pesan bebas hanya boleh dikirim dalam 24 jam sejak pembeli terakhir menulis, dan pesan dari bisnis berbayar.
Perlu bot, atau cukup tautan dari dashboard
Penjual tanpa server tidak perlu bot. Formulir permintaan pembayaran di dashboard punya pilihan Kirim tautan ke WhatsApp, yang memakai saldo WhatsApp yang dibeli terpisah, satu saldo per pesan, dan saldonya kembali kalau pesan tidak sampai. Pilihan itu tidak tersedia di API. Caranya ada di panduan mengirim tautan pembayaran lewat WhatsApp. Bot baru masuk akal saat pesanan datang lebih cepat daripada admin bisa menyalin tautan, atau saat pembeli memesan di luar jam kerja.
Jalur resmi, bukan pustaka WhatsApp Web
Banyak tutorial bot WhatsApp memakai pustaka yang meniru WhatsApp Web dari nomor biasa. Ketentuan layanan WhatsApp, dibaca saat panduan ini ditulis, melarang pemakaian layanan dengan cara otomatis yang tidak diizinkan, termasuk pengiriman pesan otomatis, dan pemakaian non-pribadi kecuali diizinkan WhatsApp. Nomor yang diblokir di tengah penjualan memutus setiap pembeli yang sedang menunggu tombol bayar. Untuk nomor toko, jalur yang aman adalah WhatsApp Business Platform (Cloud API) lewat akun Meta Business dan aplikasi di Meta for Developers.
Kebijakan pesan bisnis WhatsApp juga mensyaratkan izin (opt-in) penerima. Bot yang hanya membalas pembeli yang menulis lebih dulu aman; bot yang menagih daftar nomor dari tempat lain tidak.
Biaya dari sisi Meta
Halaman harga WhatsApp Business Platform, dibaca saat panduan ini ditulis, menyatakan bahwa Meta menagih per pesan yang terkirim, dengan tarif per kategori dan per negara penerima. Pesan dari pembeli ke bisnis tidak ditagih. Sejak 1 Oktober 2026, pesan layanan (pesan bebas di dalam jendela 24 jam) gratis untuk 1.000 pesan pertama per nomor bisnis per bulan dan ditagih mulai pesan ke-1.001, dan template utility yang dikirim di dalam jendela tidak lagi gratis. Satu pesanan di susunan ini memakai dua pesan layanan: tombol bayar dan kabar lunas. Tarif per pesan ada di rate card Meta.
1. Verifikasi URL dan tanda tangan
// GET /whatsapp/webhook: dipanggil Meta sekali, saat URL didaftarkan
function onVerify(url: URL) {
const p = url.searchParams;
if (p.get("hub.mode") === "subscribe" &&
sameText(p.get("hub.verify_token") ?? "", process.env.WA_VERIFY_TOKEN!)) {
return new Response(p.get("hub.challenge") ?? "");
}
return new Response("forbidden", { status: 403 });
}
// Header X-Hub-Signature-256: "sha256=" + HMAC-SHA256(app secret, body mentah)
function metaSignatureOk(raw: string, header: string) {
const expected = "sha256=" +
createHmac("sha256", process.env.META_APP_SECRET!).update(raw).digest("hex");
return sameText(header, expected);
}Saat URL webhook didaftarkan, Meta mengirim GET dengan hub.mode=subscribe, hub.verify_token, dan hub.challenge. Server menjawab 200 berisi nilai hub.challenge apa adanya; jawaban lain membuat URL tidak terverifikasi dan webhook tidak dikirim. Setelah itu setiap POST membawa X-Hub-Signature-256, HMAC-SHA256 atas body dengan app secret sebagai kunci. Pada pengukuran, POST tanpa header dan POST yang ditandatangani secret lain sama-sama dijawab 401 tanpa membuat pesanan. Hitung HMAC dari teks body mentah, sebelum JSON.parse, sama seperti tanda tangan Kasera Pay.
2. Satu URL untuk pesan, kabar status, dan pesan berkelompok
// POST /whatsapp/webhook
async function onWhatsApp(req: Request) {
const raw = await req.text(); // byte persis, sebelum JSON.parse
if (!metaSignatureOk(raw, req.headers.get("x-hub-signature-256") ?? "")) {
return new Response("invalid signature", { status: 401 });
}
const body = JSON.parse(raw);
for (const entry of body.entry ?? []) {
for (const change of entry.changes ?? []) {
// Kabar status (sent, delivered, read) datang ke URL yang sama
// tanpa "messages". Satu POST juga bisa membawa beberapa pesan.
for (const msg of change.value?.messages ?? []) await onMessage(msg);
}
}
return new Response("ok");
}Kesalahan yang paling sering ditulis adalah membaca body.entry[0].changes[0].value.messages[0] langsung. Dua hal mematahkannya. Pertama, kabar status pesan keluar (terkirim, sampai, dibaca) datang ke URL yang sama tanpa messages. Pada pengukuran, payload status membuat baris itu melempar TypeError dan handler menjawab 500. Meta mengulang pengiriman yang tidak dijawab 200 sampai 7 hari, jadi setiap tombol bayar melahirkan kiriman gagal yang terus berulang. Kedua, Meta bisa mengelompokkan beberapa pesan dalam satu POST. Pada pengukuran, POST berisi pesan dari dua pembeli menjadi satu pesanan di versi naif, dan dua pesanan serta dua tombol di versi atas.
3. Tabel pembeli, pesanan, dan event
CREATE TABLE buyers (
wa_id text PRIMARY KEY, -- misalnya 6281234567890
last_inbound_at timestamptz NOT NULL -- pesan terakhir DARI pembeli
);
CREATE TABLE orders (
id bigserial PRIMARY KEY,
wa_message_id text NOT NULL UNIQUE, -- wamid pesan yang melahirkan pesanan
wa_id text NOT NULL REFERENCES buyers,
amount integer NOT NULL,
status text NOT NULL DEFAULT 'baru',
payment_request_id text,
offered_at timestamptz, -- tombol bayar sudah terkirim
paid_at timestamptz,
notified_at timestamptz -- kabar lunas sudah terkirim
);
CREATE TABLE webhook_events (
id text PRIMARY KEY,
type text NOT NULL,
received_at timestamptz NOT NULL DEFAULT now()
);Kedua webhook bersifat at-least-once, dan dokumentasi Meta sendiri menyebut pengulangannya bisa menghasilkan notifikasi ganda. Jadi ada dua penjaga dedupe: wa_message_id yang UNIQUE untuk Meta, dan id event sebagai primary key untuk Kasera Pay, yang mengulang event tidak dijawab 2xx sampai enam kali dalam kurang lebih 9 jam. Tabel buyers menyimpan kapan pembeli terakhir menulis, karena itulah yang menentukan bentuk kabar lunas di bagian 5.
4. Dari pesan pembeli ke tombol bayar
async function onMessage(msg: any) {
// Setiap pesan masuk membuka ulang jendela 24 jam pembeli itu.
await sql`
INSERT INTO buyers (wa_id, last_inbound_at)
VALUES (${msg.from}, to_timestamp(${Number(msg.timestamp)}))
ON CONFLICT (wa_id) DO UPDATE
SET last_inbound_at = GREATEST(buyers.last_inbound_at, EXCLUDED.last_inbound_at)`;
if (msg.type !== "text" || msg.text.body.trim().toLowerCase() !== "pesan") return;
// Satu wamid = satu pesanan. Kiriman ulang Meta mendapat baris yang sama.
const [order] = await sql`
INSERT INTO orders (wa_message_id, wa_id, amount)
VALUES (${msg.id}, ${msg.from}, ${85000})
ON CONFLICT (wa_message_id) DO UPDATE SET wa_message_id = EXCLUDED.wa_message_id
RETURNING id, amount, offered_at`;
if (order.offered_at) return;
const tx = await createPayment(order, msg.from);
const won = await sql`
UPDATE orders SET payment_request_id = ${tx.id}, offered_at = now()
WHERE id = ${order.id} AND offered_at IS NULL
RETURNING id`;
if (won.length === 1) {
try {
await wa(msg.from, {
type: "interactive",
interactive: {
type: "cta_url",
body: { text: "Pesanan WA-" + order.id + ": Rp 85.000. Tautan berlaku 60 menit." },
action: {
name: "cta_url",
parameters: { display_text: "Bayar Rp 85.000", url: tx.checkout_url },
},
},
});
} catch (err) {
await sql`UPDATE orders SET offered_at = NULL WHERE id = ${order.id}`;
throw err; // dijawab 500, Meta mengirim ulang, tombol dicoba lagi
}
}
}
async function createPayment(order: { id: string; amount: number }, waId: string) {
const res = await fetch("https://pay.kasera.id/v1/transactions", {
method: "POST",
headers: {
Authorization: "Bearer " + process.env.KASERA_PAY_KEY,
"Content-Type": "application/json",
// Dari id pesanan, bukan dari wamid atau isi pesan.
"Idempotency-Key": "wa-order-" + order.id,
},
body: JSON.stringify({
amount: order.amount, // dari database, bukan dari teks pembeli
description: "Pesanan WA-" + order.id,
external_id: "WA-" + order.id,
customer: { phone: "+" + waId },
checkout: {},
}),
});
if (!res.ok) throw new Error("kasera " + res.status);
return (await res.json()) as { id: string; checkout_url: string };
}Yang terukur di sini: 30 kiriman kembar dengan wamid yang sama, dikirim bersamaan, menghasilkan satu pesanan, satu permintaan pembayaran, dan satu tombol. Beberapa kiriman sempat memanggil create, tetapi dengan Idempotency-Key yang sama Kasera Pay mengembalikan permintaan yang sama. Saat Meta gagal sekali mengirim tombol, bot menjawab 500, tanda offered_at dilepas, dan kiriman ulang mengirim tombolnya tepat sekali tanpa permintaan pembayaran kedua. Saat create gagal dengan 503, kiriman ulang membuatnya dan menawarkan tombolnya.
Tiga detail khas WhatsApp. Label tombol cta_url paling banyak 20 karakter dan teks badannya 1.024 karakter; “Bayar Rp 85.000” muat. Nomor di msg.from datang tanpa tanda plus, misalnya 6281234567890, sedangkan dokumentasi Kasera Pay menulis nomor pembeli dalam format E.164, jadi tambahkan + sebelum mengirimnya sebagai customer.phone. Dan nominal selalu diambil dari database atau daftar harga di server, tidak dari teks yang diketik pembeli.
Permintaan pembayaran berlaku 60 menit secara bawaan, jadi kalimat di pesan sebaiknya menyebutnya. Sebelum verifikasi selesai, akun hanya bisa membuat 10 permintaan pembayaran dengan total Rp 1.000.000 seumur akun, dan bot publik yang dicoba-coba orang bisa menghabiskannya dalam satu sore. Bangun dan uji dengan kunci kp_test_, lalu selesaikan verifikasi akun sebelum nomor bot dibagikan.
5. Kabar lunas dan jendela 24 jam
// POST /webhooks/kasera-pay: verify() sama dengan contoh di dokumentasi webhook
async function onKasera(req: Request) {
const raw = await req.text();
if (!verify(raw, req.headers.get("kasera-signature-v1") ?? "",
process.env.KASERA_PAY_WEBHOOK_SECRET!)) {
return new Response("invalid signature", { status: 400 });
}
const event = JSON.parse(raw);
await sql.begin(async (tx) => {
const fresh = await tx`
INSERT INTO webhook_events (id, type) VALUES (${event.id}, ${event.type})
ON CONFLICT (id) DO NOTHING RETURNING id`;
if (fresh.length === 1 && event.type === "payment.paid") {
const paid = await tx`
UPDATE orders SET status = 'dibayar', paid_at = now()
WHERE 'WA-' || id = ${event.data.external_id}
AND amount = ${event.data.amount} AND status = 'baru'
RETURNING id`;
if (paid.length !== 1) throw new Error("pesanan tidak ditemukan");
}
});
await kirimKabar().catch(() => {});
return new Response("ok");
}
// Dipanggil sesudah setiap webhook dan setiap menit.
async function kirimKabar() {
const rows = await sql`
UPDATE orders o SET notified_at = now()
FROM buyers b
WHERE b.wa_id = o.wa_id AND o.status = 'dibayar' AND o.notified_at IS NULL
RETURNING o.id, o.wa_id,
b.last_inbound_at > now() - interval '24 hours' AS window_open`;
for (const o of rows) {
const label = "WA-" + o.id;
try {
await wa(o.wa_id, o.window_open
? { type: "text", text: { body: "Pembayaran " + label + " diterima. Pesanan diproses." } }
: { type: "template", template: {
name: "pembayaran_diterima", // template utility yang sudah disetujui
language: { code: "id" },
components: [{ type: "body", parameters: [{ type: "text", text: label }] }],
} });
} catch {
await sql`UPDATE orders SET notified_at = NULL WHERE id = ${o.id}`;
}
}
}Fungsi verify sama dengan contoh Node.js di dokumentasi webhook: HMAC-SHA256 atas t + "." + body dari header Kasera-Signature-V1, dengan toleransi lima menit. Pada pengukuran, 30 pengiriman payment.paid kembar bersamaan dijawab 200 semuanya, dengan satu baris event dan satu kabar lunas. Event yang nominalnya tidak sama dengan pesanan dijawab 500 dan pesanannya tetap baru. Saat Meta gagal mengirim kabar lunas, Kasera Pay tetap menerima 200, pesanan tercatat dibayar dengan notified_at kosong, dan putaran berikutnya mengirim kabarnya tepat sekali.
Bagian yang tidak ada di Telegram adalah jendela 24 jam. Pesan bebas, termasuk teks “Pembayaran diterima”, hanya boleh dikirim dalam 24 jam sejak pesan terakhir pembeli. Di luar itu Meta menolaknya dengan kode galat 131047 dan meminta template. Tautan bawaan berlaku 60 menit, jadi kabar lunas biasanya jatuh di dalam jendela, tetapi pembayaran yang masuk terlambat sesudah kedaluwarsa tetap menjadi succeeded dengan payment.paid, dan masa berlaku bisa diatur sampai 24 jam lewat API. Karena itu kirimKabar memeriksa last_inbound_at lebih dulu. Pada pengukuran, pembeli yang terakhir menulis 25 jam sebelumnya menerima template pembayaran_diterima, bukan teks bebas. Template itu harus dibuat dan disetujui di WhatsApp Manager sebelum bot dibuka, dengan kategori utility dan bahasa id.
6. Biaya per pesanan di Kasera Pay
Biaya dipotong dari pembayaran yang berhasil, sesuai metode yang dipilih pembeli di checkout. Untuk pesanan Rp 85.000: QRIS 0,7% (dibulatkan ke atas) ditambah Rp 250, yaitu Rp 845, atau Virtual Account Rp 5.000. Pembayaran di atas Rp 1.000.000 dikenakan 1% atas seluruh nominal untuk QRIS maupun Virtual Account, dan wajib membawa tanggal pengiriman serta nama dan kontak pembeli. Tanpa biaya bulanan; pencairan ke rekening Rp 3.000 per pencairan, bukan per pesanan. Tabel lengkapnya ada di halaman biaya. Biaya pesan Meta di atas terpisah dan dibayar ke Meta.
Sebelum nomor bot dibagikan
- Nomor bot terdaftar di Cloud API, bukan dijalankan lewat pustaka WhatsApp Web, dan keputusan soal nomor admin sudah diambil.
hub.verify_tokendanX-Hub-Signature-256diperiksa, dan URL webhook Kasera Pay terdaftar di dashboard, menu Developer, dengan secret per mode.- Payload tanpa
messagesdijawab 200, setiap pesan dalam satu POST diproses,wamidunik, danIdempotency-Keyditurunkan dari id pesanan. - Template utility untuk kabar lunas sudah disetujui, dan kabar dikirim di luar transaksi webhook oleh putaran berkala.
- Foto bukti transfer tidak pernah dipakai sebagai penanda lunas.
Semua hasil di atas diukur pada Bun 1.4.4 dan Postgres 16, dengan Graph API WhatsApp dan endpoint create Kasera Pay diganti server lokal yang meniru perilaku keduanya. Urutan pemeriksaan lainnya ada di checklist sebelum go-live.
Pertanyaan yang sering muncul
Bolehkah memakai nomor WhatsApp Business yang sekarang dipakai admin di ponsel?
Nomor yang didaftarkan ke Cloud API dipakai oleh API, bukan oleh aplikasi WhatsApp biasa di ponsel. Toko yang ingin admin tetap membalas dari ponsel sebaiknya memakai nomor kedua khusus bot, atau memeriksa ketersediaan fitur koeksistensi aplikasi WhatsApp Business dengan Cloud API di dokumentasi Meta sebelum nomor didaftarkan.
Bolehkah QRIS dikirim langsung sebagai gambar di chat?
Boleh. Kirim payment_methods berisi satu kode, qris, dan jawaban create langsung membawa payment.qr_string yang bisa digambar menjadi QR lalu dikirim sebagai pesan gambar. Masalahnya ada di pembeli: kebanyakan membuka WhatsApp di ponsel yang sama dengan aplikasi banknya, dan kamera ponsel itu tidak bisa memindai layarnya sendiri. Tombol ke checkout_url menyisakan pilihan Virtual Account di halaman yang sama.
Pembeli mengirim foto bukti transfer ke bot. Apakah itu cukup?
Tidak. Foto bisa disunting dan bisa diteruskan dari pembelian orang lain. Penanda lunas hanya payment.paid yang bertanda tangan sah, atau status succeeded yang dibaca server bot sendiri dari GET /v1/transactions/{id}. Bot juga tidak perlu meminta nomor rekening pembeli di chat: kebijakan pesan bisnis WhatsApp melarang meminta nomor rekening lengkap, dan pembayaran lewat halaman checkout tidak membutuhkannya.