Panduan · Terbit
Bot Telegram untuk menerima pembayaran: tombol bayar QRIS dan Virtual Account, dua webhook di satu server, dan barang digital yang menurut Telegram wajib dibayar dengan Stars
Bot Telegram yang menagih lewat Kasera Pay bekerja dengan dua webhook di satu server. Telegram mengirim pesan pembeli ke bot, 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. Sebelum menulis kodenya, pastikan apa yang dijual. Untuk barang dan layanan digital yang dijual di dalam aplikasi Telegram, Telegram mewajibkan pembayaran dengan Telegram Stars.
Yang boleh dijual lewat bot dengan pembayaran di luar Telegram
Halaman pembayaran bot di dokumentasi resmi Telegram, dibaca saat panduan ini ditulis, menyatakan bahwa bot atau mini app yang menjual barang dan layanan digital di dalam aplikasi Telegram wajib memakai Telegram Stars, apa pun portal web atau penyedia pembayaran yang dimiliki penjual di luar Telegram. Telegram menyebut alasannya aturan Google Play dan App Store, dan menyatakan tidak bisa menampilkan bot kepada pengguna ponsel kalau barang digitalnya dijual dengan mata uang lain atau lewat penyedia pihak ketiga. Rinciannya ada di Bagian 6.2 ketentuan developer bot Telegram.
Untuk barang fisik dan layanan fisik, Telegram mengizinkan mata uang dan penyedia pembayaran lain. Jadi bot pesanan kue, katering, laundry kiloan, cetak, atau jasa servis cocok dengan susunan di bawah. Bot penjual voucher, akun, langganan aplikasi, atau file digital perlu membaca aturan Telegram itu lebih dulu, karena Kasera Pay tidak bisa mengubahnya. Dari sisi Kasera Pay sendiri, media bajakan dan barang yang melanggar hak cipta tidak pernah diizinkan, apa pun kanal penjualannya, sesuai kebijakan penggunaan.
1. Daftarkan webhook Telegram dengan secret token
curl https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/setWebhook \
-d url=https://bot.tokoanda.id/telegram/update \
-d secret_token=$TELEGRAM_WEBHOOK_SECRET \
-d 'allowed_updates=["message"]'Dengan secret_token, setiap update dari Telegram membawa header X-Telegram-Bot-Api-Secret-Token berisi nilai itu (1 sampai 256 karakter, hanya huruf, angka, garis bawah, dan tanda hubung). Tanpa pemeriksaan header ini, siapa pun yang tahu URL bot bisa mengirim update palsu atas nama chat mana pun. Pada pengukuran, update tanpa header dan update dengan nilai yang salah sama-sama dijawab 403 tanpa membuat pesanan. Port yang diterima Telegram untuk webhook hanya 443, 80, 88, dan 8443, dan selama webhook terpasang, getUpdates tidak bisa dipakai.
2. Tabel pesanan dan tabel event
CREATE TABLE orders (
id bigserial PRIMARY KEY,
update_id bigint NOT NULL UNIQUE, -- update Telegram yang melahirkan pesanan
chat_id bigint NOT NULL,
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. Telegram mengulang update yang tidak dijawab 2xx, dan Kasera Pay mengulang event yang tidak dijawab 2xx sampai tujuh kali dalam kurang lebih 33 jam. Jadi ada dua penjaga dedupe: update_id yang UNIQUE untuk Telegram, dan id event sebagai primary key untuk Kasera Pay.
3. Dari pesan pembeli ke tombol bayar
// POST /telegram/update
async function onUpdate(req: Request) {
if (!sameSecret(req.headers.get("x-telegram-bot-api-secret-token"),
process.env.TELEGRAM_WEBHOOK_SECRET!)) {
return new Response("forbidden", { status: 403 });
}
const update = await req.json();
const msg = update.message;
if (msg?.text !== "/pesan") return new Response("ok");
// Satu update = satu pesanan. Update yang dikirim ulang Telegram
// mendapat baris yang sama, bukan pesanan kedua.
const [order] = await sql`
INSERT INTO orders (update_id, chat_id, amount)
VALUES (${update.update_id}, ${msg.chat.id}, ${85000})
ON CONFLICT (update_id) DO UPDATE SET update_id = EXCLUDED.update_id
RETURNING id, amount, offered_at`;
if (order.offered_at) return new Response("ok");
// Gagal di sini dijawab 500, Telegram mengulang, dan percobaan
// berikutnya memakai key yang sama.
const tx = await createPayment(order);
// Hanya satu pemanggil yang lolos UPDATE ini, jadi tombol dikirim sekali.
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) {
await telegram("sendMessage", {
chat_id: msg.chat.id,
text: "Pesanan TG-" + order.id + ": Rp 85.000. Tombol berlaku 60 menit.",
reply_markup: {
inline_keyboard: [[{ text: "Bayar Rp 85.000", url: tx.checkout_url }]],
},
});
}
return new Response("ok");
}async function createPayment(order: { id: string; amount: number }) {
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 update_id atau isi pesan.
"Idempotency-Key": "tg-order-" + order.id,
},
body: JSON.stringify({
amount: order.amount, // dari database, bukan dari teks pembeli
description: "Pesanan TG-" + order.id,
external_id: "TG-" + order.id,
checkout: {},
}),
});
if (!res.ok) throw new Error("kasera " + res.status);
return (await res.json()) as { id: string; checkout_url: string };
}Ada empat hal yang terukur di sini. Pertama, 30 update kembar dengan update_id yang sama, dikirim bersamaan, menghasilkan satu pesanan, satu permintaan pembayaran, dan satu pesan tombol. Ke-29 panggilan create lainnya membawa key yang sama dan menerima permintaan pembayaran yang sudah ada, karena API Kasera Pay menjawab key yang sudah dipakai, termasuk yang tiba bersamaan, dengan pembayaran yang sama. Kedua, saat create pertama gagal dengan 503, bot menjawab 500, dan pengulangan update yang sama membuat pembayarannya dan mengirim tombolnya sekali. Pengulangan ketiga tidak membuat apa-apa lagi.
Ketiga, urutan yang tampak lebih rapi justru salah. Saat INSERT diganti ON CONFLICT DO NOTHING dan update yang sudah tercatat langsung dijawab 200, create yang gagal sekali membuat pesanan itu menggantung selamanya. Pada pengukuran, pengulangan dari Telegram dijawab 200, tidak ada permintaan pembayaran yang dibuat, tidak ada tombol yang dikirim, dan pembeli menunggu tanpa kabar. Keempat, tanpa dedupe update_id dan tanpa Idempotency-Key, 30 update kembar yang sama menghasilkan 30 permintaan pembayaran dan 30 pesan tombol.
Yang keempat itu mahal pada akun yang belum terverifikasi. Sebelum verifikasi selesai, akun hanya bisa membuat 10 permintaan pembayaran dengan total Rp 1.000.000 seumur akun, dan permintaan yang kedaluwarsa tetap memakai satu dari sepuluh. Bot publik yang dicoba-coba orang bisa menghabiskannya dalam satu sore. Bangun dan uji dengan kunci kp_test_, karena permintaan di mode tes tidak pernah terhitung, dan selesaikan verifikasi akun sebelum bot dibuka.
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 tombol sebaiknya menyebutnya. Kunci API yang salah atau dicabut dijawab 401 dengan kode unauthorized (dicoba langsung ke API saat panduan ini ditulis); galat seperti itu tidak sembuh dengan pengulangan, jadi lebih baik bot membalas pembeli bahwa pembayaran belum bisa dibuat daripada diam.
4. Webhook Kasera Pay di server yang sama
// POST /webhooks/kasera-pay
async function onKasera(req: Request) {
const raw = await req.text(); // byte persis, sebelum JSON.parse
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 'TG-' || id = ${event.data.external_id} AND status = 'baru'
RETURNING id`;
// Pesanan tidak ditemukan: rollback, jawab 500, Kasera Pay mengirim ulang.
if (paid.length !== 1) throw new Error("pesanan tidak ditemukan");
}
});
// Kabar ke pembeli di luar transaksi, oleh pengirim yang bisa diulang.
await kirimKabar().catch(() => {});
return new Response("ok");
}
// Dipanggil sesudah setiap webhook dan setiap menit.
async function kirimKabar() {
const rows = await sql`
UPDATE orders SET notified_at = now()
WHERE status = 'dibayar' AND notified_at IS NULL
RETURNING id, chat_id`;
for (const o of rows) {
try {
await telegram("sendMessage", {
chat_id: o.chat_id,
text: "Pembayaran TG-" + o.id + " diterima. Pesanan diproses.",
});
} 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, toleransi lima menit, dan dua entri v1 selama 24 jam setelah rotasi secret. Yang terukur pada kode ini: event dengan nama pelanggan CV Maju & Jaya, yang di payload ditulis \u0026, diterima 200. Tanda tangan dari secret lain dan stempel waktu 400 detik yang lalu sama-sama ditolak 400. Tiga puluh pengiriman kembar bersamaan dijawab 200 semuanya, dengan satu baris event dan satu pesan lunas ke pembeli. Event dengan external_id yang tidak dikenal dijawab 500 dan id event-nya tidak tersimpan, sehingga pengiriman ulang diproses normal setelah pesanannya ada.
5. Pesan lunas jangan dikirim di dalam handler
Bentuk yang paling sering ditulis adalah mengirim sendMessage setelah commit lalu melempar galat kalau Telegram gagal, dengan harapan Kasera Pay mengirim ulang. Pada pengukuran, saat Telegram menjawab 502, webhook dijawab 500, pengiriman ulang dari Kasera Pay menemukan id event yang sudah tercatat dan dijawab 200, dan pembeli tidak pernah dikabari walaupun pesanannya sudah dibayar.
Kode di bagian 4 memisahkan keduanya. Status dibayar dan id event di-commit dalam satu transaksi, sedangkan kabar ke pembeli dikirim oleh kirimKabar, yang menandai barisnya lebih dulu agar dua pemanggil bersamaan tidak mengirim pesan yang sama, lalu melepas tandanya kalau Telegram gagal. Pada pengukuran, saat Telegram menjawab 502, Kasera Pay menerima 200, pesanan tercatat dibayar dengan notified_at kosong, dan putaran berikutnya setelah Telegram pulih mengirim kabarnya tepat sekali. Harga bentuk ini satu: kalau proses mati tepat di antara menandai dan mengirim, satu kabar hilang. Itu pilihan yang disengaja, karena kabar ganda lebih membingungkan pembeli daripada kabar yang terlambat dan bisa dicek di riwayat pesanan.
6. Biaya per pesanan
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. Di bawah sekitar Rp 678.600 QRIS lebih murah, dan dari situ sampai Rp 1.000.000 Virtual Account yang lebih murah. Pendaftaran gratis, tanpa biaya bulanan, dan pencairan ke rekening Rp 3.000 per pencairan, bukan per pesanan. Menurut Syarat Layanan yang berlaku mulai 26 Oktober 2026, 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. Tabel lengkapnya ada di halaman biaya.
Sebelum bot dibuka untuk umum
- Barangnya fisik atau layanan fisik, atau aturan Telegram Stars untuk barang digital sudah dibaca.
- Header
X-Telegram-Bot-Api-Secret-Tokendiperiksa, dan URL webhook Kasera Pay terdaftar di dashboard, menu Developer, dengan secret per mode. update_idunik,Idempotency-Keyditurunkan dari id pesanan, dan create yang gagal dijawab 500, bukan 200.- Kabar lunas dikirim di luar transaksi webhook dan diulang oleh putaran berkala.
- Tangkapan layar bukti bayar tidak pernah dipakai sebagai penanda lunas.
Semua hasil di atas diukur untuk panduan ini pada Bun 1.4.2 (Bun.serve dan Bun SQL) dengan Postgres 16. API Telegram dan endpoint create Kasera Pay diganti server lokal yang meniru perilaku keduanya: pembacaan key yang sama, termasuk saat bersamaan, mengikuti kode API Kasera Pay. Urutan pemeriksaan lainnya ada di checklist sebelum go-live.
Pertanyaan yang sering muncul
Bolehkah QR-nya dikirim langsung sebagai foto di chat, tanpa membuka halaman checkout?
Boleh. Kirim payment_methods berisi satu kode, misalnya qris, dan respons create langsung membawa payment.qr_string yang bisa digambar menjadi QR dengan pustaka apa pun lalu dikirim lewat sendPhoto. Masalahnya ada di pembeli: kebanyakan membuka Telegram di ponsel yang sama dengan aplikasi banknya, dan kamera ponsel itu tidak bisa memindai layarnya sendiri. Pembeli harus menyimpan gambarnya lalu membukanya dari galeri di aplikasi pembayaran, kalau aplikasinya mendukung. Tombol ke checkout_url menyisakan pilihan Virtual Account di halaman yang sama.
Pembeli mengirim tangkapan layar bukti transfer ke bot. Apakah itu cukup?
Tidak. Tangkapan layar 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}. Kalau pembeli bilang sudah membayar tetapi pesanan belum berubah, baca status itu dari server, jangan dari gambar.
Apakah perlu framework bot seperti grammY atau Telegraf?
Tidak wajib, dan panduan ini sengaja tanpa framework agar setiap langkahnya terlihat. Framework bot membantu di sisi percakapan, tetapi tiga hal di sini tetap harus ditulis sendiri: dedupe update_id, Idempotency-Key per pesanan, dan pengiriman kabar lunas yang bisa diulang. Untuk pembuatan transaksi, SDK JavaScript resmi kasera-pay adalah jalan yang lebih pendek di stack Node dan Bun.