Pembayaran Fitur Cara kerja Biaya FAQ Dokumentasi
Docs Untuk website, aplikasi, dan bot

Dokumentasi API

Semua yang perlu Anda panggil untuk menerima pembayaran lewat KodeBayar: QRIS, Binance Pay, dan cara memasangnya di bot Telegram.

Base URL https://kodebayar.id/api/v1

Ringkasan

Ada dua layanan, masing-masing dengan kunci dan alamat sendiri. Keduanya dipanggil dari server Anda dengan cara yang sama, dan keduanya bisa dipasang di website maupun bot.

Base URL
https://kodebayar.id/api/v1
Format
Permintaan dan jawaban berupa JSON (application/json).
Nominal
Rupiah bulat tanpa desimal. USDT boleh sampai empat angka di belakang koma.
Batas panggilan
60 permintaan per menit per kunci, dan 20 pembuatan transaksi per menit.

Alur dasar

1

Buat transaksi

Satu panggilan POST dari server Anda mengembalikan QR atau nominal USDT, beserta tautan halaman bayar.

2

Tampilkan ke pembeli

Arahkan ke pay_url, tampilkan QR sendiri, atau kirim sebagai foto di chat bot.

3

Tunggu kabar lunas

Kami mengirim webhook saat status berubah. Tanpa webhook, cek status lewat API.

Bentuk jawaban

JSON
// Berhasil
{ "status": "success", "data": { ... } }

// Gagal
{ "status": "error", "message": "..." }

Autentikasi

Kirim kunci API di header Authorization. Awalan kunci menentukan layanan dan modenya.

Header
Authorization: Bearer sk_live_xxxxxxxxxxxxxxxxxxxxxxxx
Content-Type: application/json
sk_live_
QRIS, pembayaran sungguhan. Aktif setelah merchant disetujui admin.
sk_test_
QRIS, mode uji. Aktif sejak merchant didaftarkan.
bk_live_
Binance Pay, USDT sungguhan. Aktif selama langganan Binance Pay berjalan dan akun Binance terhubung.
bk_test_
Binance Pay, mode uji. Tidak butuh akun Binance terhubung.

Kunci sk_ hanya berlaku di endpoint QRIS dan kunci bk_ hanya di endpoint /binance. Kunci yang salah jenis dijawab 401.

Simpan kunci di server. Jangan menaruhnya di JavaScript browser, aplikasi mobile, atau kode bot yang dibagikan ke orang lain.

Mode uji

Kunci uji membuat transaksi contoh yang tidak memengaruhi saldo dan tidak memanggil penyedia pembayaran. Webhook tetap dikirim, dengan is_sandbox: true, sehingga seluruh alur bisa dicoba tanpa uang sungguhan.

Untuk melunasi transaksi uji, buka pay_url lalu tekan tombol simulasi, atau panggil:

POST /transactions/{id}/simulate
POST /binance/transactions/{id}/simulate

QRIS

Pembayaran Rupiah. Biaya 0,7% + Rp 200 per transaksi lunas, dan dananya masuk ke saldo Anda.

Buat transaksi

POST /transactions
amount integer wajib
Nominal tagihan dalam Rupiah.
reference_id string wajib
ID pesanan dari sistem Anda, maks 64 karakter, unik per merchant.
customer_name string
Nama pembeli.
customer_email string
Email pembeli.
customer_phone string
Nomor telepon pembeli.
description string
Keterangan yang tampil di halaman bayar.
return_url string
Halaman bayar mengarahkan pembeli ke sini setelah lunas.
expires_in integer
Batas waktu bayar dalam menit, 5 sampai 1440.
Permintaan
curl -X POST https://kodebayar.id/api/v1/transactions \
  -H "Authorization: Bearer sk_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "amount": 100000,
    "reference_id": "INV-1042",
    "customer_name": "Budi Santoso",
    "description": "Pesanan #1042",
    "return_url": "https://toko-anda.com/terima-kasih"
  }'
Jawaban 201
{
  "status": "success",
  "data": {
    "trx_id": "TRX261004A8F3K2M9QX",
    "reference_id": "INV-1042",
    "channel": "qris",
    "status": "pending",
    "amount": 100000,
    "fee": 900,
    "fee_bearer": "customer",
    "total_amount": 100900,
    "net_amount": 100000,
    "pay_url": "https://kodebayar.id/pay/TRX261004A8F3K2M9QX",
    "qr_url": "https://kodebayar.id/pay/TRX261004A8F3K2M9QX/qr.svg",
    "qr_png_url": "https://kodebayar.id/pay/TRX261004A8F3K2M9QX/qr.png",
    "qr_string": "00020101021226...",
    "expires_at": "2026-10-04T15:30:00+07:00",
    "paid_at": null,
    "is_sandbox": false
  }
}
total_amount
Yang dibayar pembeli. Sudah termasuk biaya bila biaya ditanggung pembeli.
net_amount
Yang masuk ke saldo Anda setelah lunas.
pay_url
Halaman bayar siap pakai. Arahkan pembeli ke sini bila tidak ingin membuat tampilan sendiri.
qr_url
Gambar QR berformat SVG, bisa langsung dipasang di tag img pada website.
qr_png_url
Gambar QR berformat PNG, untuk tempat yang tidak menerima SVG, misalnya dikirim sebagai foto di bot Telegram.
qr_string
Isi QRIS mentah, bila Anda ingin membuat gambar QR sendiri.

Cek status

GET /transactions/{id}

{id} boleh berisi trx_id atau reference_id. Bentuk jawabannya sama seperti saat membuat transaksi. Pakai sebagai cadangan bila webhook terlambat; jangan dipanggil terus-menerus untuk tiap transaksi.

pending
Menunggu pembayaran.
paid
Lunas. Dana masuk ke saldo tertahan.
expired
Lewat batas waktu tanpa pembayaran.
cancelled
Dibatalkan lewat API.
failed
Gagal diproses penyedia pembayaran.

Daftar transaksi

GET /transactions?status=paid&per_page=20

Mengembalikan transaksi terbaru lebih dulu. status, page, dan per_page (maks 100) opsional. Informasi halaman ada di meta.

Endpoint ini juga cara paling hemat untuk memantau banyak pesanan sekaligus tanpa webhook: satu panggilan mengembalikan semua yang baru lunas. Lihat Tahu kapan lunas.

Batalkan transaksi

POST /transactions/{id}/cancel

Hanya untuk transaksi berstatus pending. Setelah dibatalkan, QR tidak bisa dibayar lagi dan webhook berstatus cancelled dikirim.

Hitung biaya

GET /fee?amount=100000

Biaya saat ini 0,7% + Rp 200 per transaksi. Bagian persen dibulatkan ke atas ke Rupiah terdekat. Siapa yang menanggung biaya diatur per merchant di dashboard.

Jawaban 200
{
  "status": "success",
  "data": {
    "amount": 100000,
    "fee_flat": 200,
    "fee_percent": 0.7,
    "fee": 900,
    "fee_bearer": "customer",
    "total_amount": 100900,
    "net_amount": 100000
  }
}

Webhook

Isi URL webhook di halaman merchant. Setiap kali status transaksi berubah menjadi paid, expired, cancelled, atau failed, kami mengirim HTTP POST berisi JSON ke URL tersebut.

Isi webhook
{
  "event": "payment.status_updated",
  "trx_id": "TRX261004A8F3K2M9QX",
  "reference_id": "INV-1042",
  "channel": "qris",
  "status": "paid",
  "amount": 100000,
  "fee": 900,
  "fee_bearer": "customer",
  "total_amount": 100900,
  "net_amount": 100000,
  "customer_name": "Budi Santoso",
  "customer_email": null,
  "paid_at": "2026-10-04T14:35:12+07:00",
  "created_at": "2026-10-04T14:30:00+07:00",
  "is_sandbox": false
}

Verifikasi tanda tangan

Setiap webhook membawa header X-Digitals-Signature dan X-Digitals-Timestamp. Hitung ulang tanda tangan dengan webhook secret merchant, lalu tolak permintaan yang tidak cocok.

Rumus
signature = HMAC-SHA256(timestamp + "." + raw_body, webhook_secret)
webhook.php
$payload   = file_get_contents('php://input');
$timestamp = $_SERVER['HTTP_X_DIGITALS_TIMESTAMP'] ?? '';
$signature = $_SERVER['HTTP_X_DIGITALS_SIGNATURE'] ?? '';

$expected = hash_hmac('sha256', $timestamp . '.' . $payload, $webhookSecret);

if (! hash_equals($expected, $signature)) {
    http_response_code(401);
    exit;
}

$data = json_decode($payload, true);

if ($data['status'] === 'paid') {
    // Tandai pesanan $data['reference_id'] lunas (cek dulu supaya tidak diproses dua kali).
}

http_response_code(200);
webhook.js
const crypto = require('crypto');

app.post('/webhook/digitals', express.raw({ type: 'application/json' }), (req, res) => {
  const payload = req.body.toString();
  const expected = crypto
    .createHmac('sha256', process.env.DIGITALS_WEBHOOK_SECRET)
    .update(`${req.headers['x-digitals-timestamp']}.${payload}`)
    .digest('hex');
  const given = String(req.headers['x-digitals-signature'] || '');

  if (given.length !== expected.length || !crypto.timingSafeEqual(Buffer.from(given), Buffer.from(expected))) {
    return res.sendStatus(401);
  }

  const data = JSON.parse(payload);
  if (data.status === 'paid') {
    // Tandai pesanan data.reference_id lunas.
  }

  res.sendStatus(200);
});

Aturan pengiriman

Jawaban
Balas dengan HTTP 2xx dalam 15 detik.
Pengulangan
Bila gagal, kami mengulang setelah 15 detik, 1 menit, 5 menit, lalu 30 menit (total 5 percobaan).
Kiriman ganda
Webhook yang sama bisa tiba lebih dari sekali, jadi pastikan pesanan tidak diproses dua kali.
Riwayat
Riwayat pengiriman dan tombol kirim ulang ada di menu Webhook Logs.

Binance Pay

Pembayaran USDT untuk pembeli luar negeri. Dananya masuk langsung ke akun Binance Anda: tanpa saldo, masa tahan, atau penarikan.

Persiapan

Binance Pay adalah layanan terpisah dari QRIS: punya kunci sendiri (bk_live_ dan bk_test_) dan endpoint sendiri di bawah /binance. Kunci merchant (sk_) tidak berlaku di sini, dan sebaliknya.

1

Aktifkan langganan

Binance Pay adalah fitur berlangganan. Pilih paket 1, 3, atau 6 bulan di menu Binance Pay dan bayar lewat QRIS.

2

Hubungkan akun Binance

Di halaman Akun Binance, isi API Key khusus baca dari Binance. Kunci itu hanya dipakai membaca USDT yang masuk.

3

Ambil kunci API

Di halaman Kunci API, salin kunci bk_ dan webhook secret, lalu isi URL webhook Anda.

Buat tagihan USDT

POST /binance/transactions

Parameter lainnya sama dengan transaksi QRIS (customer_name, description, return_url, expires_in). Harganya dikirim dengan salah satu dari dua cara:

amount integer
Harga dalam Rupiah. Dikonversi ke USDT dengan kurs pasar saat tagihan dibuat, dibulatkan ke atas ke 0,0001 USDT terdekat, lalu dikunci sampai tagihan kedaluwarsa.
amount_usdt number
Harga langsung dalam USDT, maksimal empat desimal. Didahulukan bila keduanya dikirim.
reference_id string wajib
ID pesanan dari sistem Anda, maks 64 karakter, unik.
Permintaan
curl -X POST https://kodebayar.id/api/v1/binance/transactions \
  -H "Authorization: Bearer bk_live_..." \
  -H "Content-Type: application/json" \
  -d '{"amount": 150000, "reference_id": "INV-1042"}'
Jawaban 201
{
  "status": "success",
  "data": {
    "trx_id": "TRX261004A8F3K2M9QX",
    "reference_id": "INV-1042",
    "channel": "binance",
    "status": "pending",
    "currency": "USDT",
    "amount": 150000,
    "exchange_rate": 17867,
    "amount_usdt": 8.3954,
    "fee_usdt": 0,
    "pay_amount_usdt": 8.3955,
    "binance_pay_id": "123456789",
    "pay_url": "https://kodebayar.id/pay/TRX261004A8F3K2M9QX",
    "expires_at": "2026-10-04T15:30:00+07:00"
  }
}
pay_amount_usdt
Yang harus dikirim pembeli: amount_usdt + fee_usdt, dan bisa naik beberapa 0,0001 USDT karena pembayaran dikenali dari nominalnya, sehingga tiap tagihan harus unik. Selalu tampilkan nilai ini apa adanya, jangan dibulatkan.
binance_pay_id
Binance ID Anda, tujuan transfer pembeli lewat Binance Pay.
exchange_rate
Rupiah per 1 USDT yang dipakai untuk tagihan ini. null (begitu juga amount) bila tagihan dibuat dengan amount_usdt.
amount_usdt
Harga dalam USDT, sebelum biaya.
fee_usdt
Biaya layanan dalam USDT (persen dari harga, dibulatkan ke atas). Bisa 0.
pay_url
Halaman bayar siap pakai yang menampilkan nominal, Binance ID, dan status otomatis.

Bila kurs sedang tidak tersedia, pembuatan tagihan dari amount dijawab 503; coba lagi atau kirim amount_usdt.

Semua endpoint

Semuanya memakai kunci bk_. {id} boleh berisi trx_id atau reference_id.

POST /binance/transactions
Membuat tagihan USDT.
GET /binance/transactions/{id}
Cek status satu tagihan.
GET /binance/transactions
Daftar tagihan. Parameter opsional status dan per_page.
POST /binance/transactions/{id}/cancel
Membatalkan tagihan yang masih menunggu.
POST /binance/transactions/{id}/simulate
Hanya kunci bk_test_: menandai tagihan uji lunas.
POST /binance/payment/check
Mengecek pembayaran satu tagihan saat itu juga, tanpa menunggu pengecekan otomatis.
GET /binance/status
Status aktivasi dan koneksi akun Binance, Binance ID, galat pengecekan terakhir, biaya, dan kurs saat ini.
GET /binance/history
USDT yang masuk ke akun Binance Anda, langsung dari Binance: order_id, nominal, nama pengirim, waktu, dan tagihan yang dilunasinya. Parameter opsional hours (bawaan 24, maks 168) dan limit (bawaan 50, maks 100).

Cek pembayaran saat itu juga

Permintaan
curl -X POST https://kodebayar.id/api/v1/binance/payment/check \
  -H "Authorization: Bearer bk_live_..." \
  -H "Content-Type: application/json" \
  -d '{"trx_id": "INV-1042", "order_id": "M_P_71505104267788288"}'

order_id opsional: ID transaksi Binance yang diberikan pembeli. Bila diisi, pembayaran itu dipasangkan langsung ke tagihan tersebut, asalkan memang USDT yang masuk ke akun Anda, nominalnya persis sama dengan pay_amount_usdt, dan belum dipakai melunasi tagihan lain. Jawabannya berbentuk sama seperti cek status; periksa status.

Dibatasi 10 panggilan per menit. history dan payment/check membaca langsung dari Binance. Panggil hanya saat pembeli menekan tombol "Saya sudah bayar", bukan otomatis berulang.

Webhook dan catatan

Pembayaran biasanya terdeteksi 15 sampai 30 detik setelah pembeli mengirim. Webhook payment.status_updated lalu dikirim ke URL webhook yang diisi di halaman Kunci API, ditandatangani dengan webhook secret dari halaman yang sama. Cara memeriksa tanda tangannya sama dengan webhook QRIS.

Isi webhook
{
  "event": "payment.status_updated",
  "trx_id": "TRX261004A8F3K2M9QX",
  "reference_id": "INV-1042",
  "channel": "binance",
  "status": "paid",
  "currency": "USDT",
  "amount": 150000,
  "exchange_rate": 17867,
  "amount_usdt": 8.3954,
  "fee_usdt": 0,
  "pay_amount_usdt": 8.3955,
  "binance_transaction_id": "M_P_71505104267788288",
  "paid_at": "2026-10-04T14:35:12+07:00",
  "is_sandbox": false
}

Yang perlu diperhatikan

Nominal persis
Pembeli harus mengirim nominal persis, sampai empat angka di belakang koma. Nominal yang berbeda tidak dikenali; dananya tetap berada di akun Binance Anda.
Pembayaran telat
Tagihan yang sudah expired masih bisa menjadi paid bila pembeli membayar dalam 24 jam, karena nominalnya tidak dipakai tagihan lain selama itu. Webhook paid bisa datang setelah webhook expired.
Isi webhook
Berbeda dari QRIS: webhook QRIS membawa total_amount dan net_amount dalam Rupiah, webhook Binance membawa pay_amount_usdt. Bila satu alamat menerima keduanya, periksa channel dan pakai secret yang sesuai.
Langganan habis
Kunci bk_live_ dijawab 403 sampai langganan diperpanjang. Tagihan yang sudah dibuat tetap dicek sampai lunas atau kedaluwarsa, dan kunci Anda tidak berubah.
Mode uji
Kunci bk_test_ tidak butuh akun Binance terhubung. Tagihannya dilunasi lewat tombol simulasi di pay_url atau endpoint simulate.
Batas tagihan
Harga minimal Rp 1.000 (untuk amount_usdt, setaranya menurut kurs saat itu), dan total maksimal 50.000 USDT.
Galat khusus
401 bila memakai kunci sk_, 403 bila langganan Binance Pay belum aktif atau sudah habis, atau akun Binance belum dihubungkan, 503 bila kurs tidak tersedia atau layanan sedang dimatikan.

Bot Telegram

Pasang QRIS dan Binance Pay sebagai pilihan bayar di bot Anda. API-nya sama; yang berbeda hanya cara menampilkannya.

Bedanya dengan website

Bot adalah program di server Anda, jadi ia memanggil API yang sama dengan kunci yang sama. Tidak ada endpoint khusus Telegram. Perbedaannya ada di tampilan dan di cara bot tahu pembayaran sudah masuk:

Menampilkan QRIS

WebsiteArahkan ke pay_url, atau pasang gambar QR di halaman.

BotKirim gambar QR sebagai foto di chat, memakai qr_png_url.

Menampilkan Binance Pay

WebsiteHalaman bayar dengan nominal dan Binance ID.

BotPesan teks berisi Binance ID dan nominal yang tersalin dengan sekali ketuk.

Setelah lunas

WebsitePembeli diarahkan ke return_url.

BotTidak ada pengalihan. Bot mengirim pesan konfirmasi dan menghapus foto QR.

Tahu sudah lunas

WebsiteWebhook ke server website.

BotWebhook bila bot punya alamat publik, atau cek daftar transaksi secara berkala.

Yang perlu disiapkan

Token bot
Dibuat lewat @BotFather di Telegram.
Kunci API
sk_ untuk QRIS dan, bila dipakai, bk_ untuk Binance Pay. Mulai dengan kunci uji.
Tempat menyimpan pesanan
Tiap transaksi perlu dicatat bersama ID chat pembelinya, supaya bot tahu harus mengabari siapa saat lunas.

Simpan token dan kunci di variabel lingkungan server, bukan di dalam kode. Contoh di bawah membacanya dari BOT_TOKEN, QRIS_API_KEY, dan BINANCE_API_KEY.

QRIS di bot

Buat transaksi seperti biasa, lalu kirim qr_png_url sebagai foto. Telegram mengambil gambarnya sendiri dari alamat itu, jadi bot tidak perlu membuat atau mengunggah berkas gambar. Pembeli tidak perlu menekan tombol apa pun setelah membayar: bot yang mengabari.

1

Pembeli memilih bayar QRIS

Bot memanggil POST /transactions dan mencatat reference_id bersama ID chat.

2

Bot mengirim foto QR

Sertakan total_amount dan batas waktu di keterangan foto.

3

Pembeli scan dan membayar

Dari aplikasi bank atau dompet digital mana pun.

4

Bot mengonfirmasi

Saat status menjadi paid, hapus foto QR dan kirim pesan atau produknya.

Contoh lengkap

Bot kecil yang berjalan apa adanya: perintah /beli membuat transaksi Rp 25.000, mengirim QR, lalu mengabari saat lunas atau kedaluwarsa. Contoh ini memakai cek berkala, sehingga bisa dijalankan tanpa alamat publik.

bot.js
// npm install telegraf        (butuh Node.js 18 ke atas)
const { Telegraf } = require('telegraf');

const API = 'https://kodebayar.id/api/v1';
const KEY = process.env.QRIS_API_KEY;              // sk_live_... atau sk_test_...
const bot = new Telegraf(process.env.BOT_TOKEN);

// Pesanan yang menunggu pembayaran: reference_id -> { chatId, messageId, expiresAt }.
// Di bot sungguhan simpan di database, supaya tidak hilang saat bot dinyalakan ulang.
const waiting = new Map();

async function api(method, path, body) {
  const res = await fetch(API + path, {
    method,
    headers: { Authorization: `Bearer ${KEY}`, 'Content-Type': 'application/json' },
    body: body ? JSON.stringify(body) : undefined,
  });
  const json = await res.json();
  if (!res.ok) throw new Error(json.message || `HTTP ${res.status}`);
  return json.data;
}

// 1. Pembeli memesan: buat transaksi, lalu kirim QR sebagai foto.
bot.command('beli', async (ctx) => {
  const reference = `TG-${ctx.chat.id}-${Date.now()}`;
  try {
    const trx = await api('POST', '/transactions', { amount: 25000, reference_id: reference, expires_in: 15 });
    const photo = await ctx.replyWithPhoto(trx.qr_png_url, {
      caption: `Scan QRIS ini untuk membayar Rp ${trx.total_amount.toLocaleString('id-ID')}.\nBerlaku 15 menit.`,
    });
    waiting.set(reference, { chatId: ctx.chat.id, messageId: photo.message_id, expiresAt: Date.parse(trx.expires_at) });
  } catch (err) {
    console.error(err);
    await ctx.reply('Pembayaran sedang tidak tersedia. Coba lagi sebentar.');
  }
});

// 2. Pesanan selesai: hapus foto QR dan kabari pembeli.
async function selesai(reference, pesan) {
  const order = waiting.get(reference);
  if (!order) return;
  waiting.delete(reference);                       // hapus dulu, supaya tidak diproses dua kali
  await bot.telegram.deleteMessage(order.chatId, order.messageId).catch(() => {});
  await bot.telegram.sendMessage(order.chatId, pesan);
}

// 3. Satu panggilan tiap 5 detik untuk SEMUA pesanan, bukan satu panggilan per pesanan.
setInterval(async () => {
  if (waiting.size === 0) return;
  try {
    const paid = await api('GET', '/transactions?status=paid&per_page=50');
    for (const trx of paid) {
      if (waiting.has(trx.reference_id)) {
        await selesai(trx.reference_id, 'Pembayaran diterima. Pesanan Anda diproses.');
        // Kirim produk ke pembeli di sini.
      }
    }

    for (const [reference, order] of waiting) {
      if (Date.now() < order.expiresAt) continue;
      // Cek sekali lagi sebelum menyatakan kedaluwarsa: pembeli bisa saja membayar di detik terakhir.
      const last = await api('GET', `/transactions/${reference}`);
      await selesai(reference, last.status === 'paid'
        ? 'Pembayaran diterima. Pesanan Anda diproses.'
        : 'Waktu pembayaran habis. Ketik /beli untuk membuat QR baru.');
    }
  } catch (err) {
    console.error(err);
  }
}, 5000);

bot.launch();
bot.py
# pip install "python-telegram-bot[job-queue]" httpx
import os
import time
from datetime import datetime

import httpx
from telegram import Update
from telegram.ext import Application, CommandHandler, ContextTypes

API = "https://kodebayar.id/api/v1"
KEY = os.environ["QRIS_API_KEY"]                   # sk_live_... atau sk_test_...
client = httpx.AsyncClient(headers={"Authorization": f"Bearer {KEY}"}, timeout=20)

# Pesanan yang menunggu pembayaran: reference_id -> {chat_id, message_id, expires_at}.
# Di bot sungguhan simpan di database, supaya tidak hilang saat bot dinyalakan ulang.
waiting = {}


async def api(method, path, body=None):
    res = await client.request(method, API + path, json=body)
    data = res.json()
    if res.is_error:
        raise RuntimeError(data.get("message", f"HTTP {res.status_code}"))
    return data["data"]


# 1. Pembeli memesan: buat transaksi, lalu kirim QR sebagai foto.
async def beli(update: Update, context: ContextTypes.DEFAULT_TYPE):
    chat_id = update.effective_chat.id
    reference = f"TG-{chat_id}-{int(time.time() * 1000)}"
    try:
        trx = await api("POST", "/transactions", {"amount": 25000, "reference_id": reference, "expires_in": 15})
    except Exception as err:
        print(err)
        await update.message.reply_text("Pembayaran sedang tidak tersedia. Coba lagi sebentar.")
        return

    total = f"{trx['total_amount']:,}".replace(",", ".")
    photo = await update.message.reply_photo(
        trx["qr_png_url"], caption=f"Scan QRIS ini untuk membayar Rp {total}.\nBerlaku 15 menit."
    )
    waiting[reference] = {
        "chat_id": chat_id,
        "message_id": photo.message_id,
        "expires_at": datetime.fromisoformat(trx["expires_at"]).timestamp(),
    }


# 2. Pesanan selesai: hapus foto QR dan kabari pembeli.
async def selesai(bot, reference, pesan):
    order = waiting.pop(reference, None)           # diambil dulu, supaya tidak diproses dua kali
    if not order:
        return
    try:
        await bot.delete_message(order["chat_id"], order["message_id"])
    except Exception:
        pass
    await bot.send_message(order["chat_id"], pesan)


# 3. Satu panggilan tiap 5 detik untuk SEMUA pesanan, bukan satu panggilan per pesanan.
async def cek(context: ContextTypes.DEFAULT_TYPE):
    if not waiting:
        return
    try:
        for trx in await api("GET", "/transactions?status=paid&per_page=50"):
            if trx["reference_id"] in waiting:
                await selesai(context.bot, trx["reference_id"], "Pembayaran diterima. Pesanan Anda diproses.")
                # Kirim produk ke pembeli di sini.

        for reference, order in list(waiting.items()):
            if time.time() < order["expires_at"]:
                continue
            # Cek sekali lagi sebelum menyatakan kedaluwarsa: pembeli bisa saja membayar di detik terakhir.
            last = await api("GET", f"/transactions/{reference}")
            await selesai(
                context.bot,
                reference,
                "Pembayaran diterima. Pesanan Anda diproses."
                if last["status"] == "paid"
                else "Waktu pembayaran habis. Ketik /beli untuk membuat QR baru.",
            )
    except Exception as err:
        print(err)


app = Application.builder().token(os.environ["BOT_TOKEN"]).build()
app.add_handler(CommandHandler("beli", beli))
app.job_queue.run_repeating(cek, interval=5)
app.run_polling()

Binance Pay di bot

Tagihan USDT tidak punya QR. Kirim binance_pay_id dan pay_amount_usdt sebagai teks di dalam tag <code>, supaya pembeli bisa menyalinnya dengan sekali ketuk lalu menempelkannya di aplikasi Binance.

Kunci dan alamat
Pakai kunci bk_ dan endpoint /binance/transactions.
Nominal
Tampilkan pay_amount_usdt apa adanya, sampai empat desimal. Jangan dibulatkan.
Tombol Saya sudah bayar
Opsional. Status berubah sendiri dalam 15 sampai 30 detik; tombol hanya mempercepat lewat payment/check.
Batas tombol
payment/check dibatasi 10 panggilan per menit untuk seluruh bot, jadi jangan dipanggil otomatis.
bot.js
// Lanjutan contoh QRIS di atas. Binance memakai kunci dan alamat sendiri.
const { Markup } = require('telegraf');
const BINANCE_KEY = process.env.BINANCE_API_KEY;   // bk_live_... atau bk_test_...

async function binance(method, path, body) {
  const res = await fetch(API + '/binance' + path, {
    method,
    headers: { Authorization: `Bearer ${BINANCE_KEY}`, 'Content-Type': 'application/json' },
    body: body ? JSON.stringify(body) : undefined,
  });
  const json = await res.json();
  if (!res.ok) throw new Error(json.message || `HTTP ${res.status}`);
  return json.data;
}

// 1. Kirim Binance ID dan nominal sebagai teks. Isi tag <code> tersalin dengan sekali ketuk.
bot.command('usdt', async (ctx) => {
  const reference = `TG-${ctx.chat.id}-${Date.now()}`;
  try {
    const bill = await binance('POST', '/transactions', { amount: 25000, reference_id: reference, expires_in: 30 });
    await ctx.replyWithHTML(
      'Kirim lewat <b>Binance Pay</b>:\n\n' +
        `Binance ID: <code>${bill.binance_pay_id}</code>\n` +
        `Nominal: <code>${bill.pay_amount_usdt}</code> USDT\n\n` +
        'Ketuk angkanya untuk menyalin. Nominal harus persis, jangan dibulatkan.',
      Markup.inlineKeyboard([Markup.button.callback('Saya sudah bayar', `cek:${reference}`)]),
    );
  } catch (err) {
    console.error(err);
    await ctx.reply('Pembayaran USDT sedang tidak tersedia. Coba lagi sebentar.');
  }
});

// 2. Tombol "Saya sudah bayar": minta pengecekan saat itu juga.
bot.action(/^cek:(.+)$/, async (ctx) => {
  try {
    const bill = await binance('POST', '/payment/check', { trx_id: ctx.match[1] });
    if (bill.status === 'paid') {
      await ctx.answerCbQuery();
      await ctx.editMessageText('Pembayaran USDT diterima. Pesanan Anda diproses.');
      // Kirim produk ke pembeli di sini (pastikan hanya sekali per reference_id).
      return;
    }
    await ctx.answerCbQuery('Belum terlihat. Tunggu 15 sampai 30 detik, lalu coba lagi.', { show_alert: true });
  } catch (err) {
    console.error(err);
    await ctx.answerCbQuery('Belum bisa dicek. Coba lagi sebentar.', { show_alert: true });
  }
});
bot.py
# Lanjutan contoh QRIS di atas. Binance memakai kunci dan alamat sendiri.
from telegram import InlineKeyboardButton, InlineKeyboardMarkup
from telegram.ext import CallbackQueryHandler

BINANCE_KEY = os.environ["BINANCE_API_KEY"]        # bk_live_... atau bk_test_...


async def binance(method, path, body=None):
    res = await client.request(
        method, API + "/binance" + path, json=body, headers={"Authorization": f"Bearer {BINANCE_KEY}"}
    )
    data = res.json()
    if res.is_error:
        raise RuntimeError(data.get("message", f"HTTP {res.status_code}"))
    return data["data"]


# 1. Kirim Binance ID dan nominal sebagai teks. Isi tag <code> tersalin dengan sekali ketuk.
async def usdt(update: Update, context: ContextTypes.DEFAULT_TYPE):
    reference = f"TG-{update.effective_chat.id}-{int(time.time() * 1000)}"
    try:
        bill = await binance("POST", "/transactions", {"amount": 25000, "reference_id": reference, "expires_in": 30})
    except Exception as err:
        print(err)
        await update.message.reply_text("Pembayaran USDT sedang tidak tersedia. Coba lagi sebentar.")
        return

    await update.message.reply_html(
        "Kirim lewat <b>Binance Pay</b>:\n\n"
        f"Binance ID: <code>{bill['binance_pay_id']}</code>\n"
        f"Nominal: <code>{bill['pay_amount_usdt']}</code> USDT\n\n"
        "Ketuk angkanya untuk menyalin. Nominal harus persis, jangan dibulatkan.",
        reply_markup=InlineKeyboardMarkup(
            [[InlineKeyboardButton("Saya sudah bayar", callback_data=f"cek:{reference}")]]
        ),
    )


# 2. Tombol "Saya sudah bayar": minta pengecekan saat itu juga.
async def cek_usdt(update: Update, context: ContextTypes.DEFAULT_TYPE):
    query = update.callback_query
    try:
        bill = await binance("POST", "/payment/check", {"trx_id": query.data.split(":", 1)[1]})
    except Exception as err:
        print(err)
        await query.answer("Belum bisa dicek. Coba lagi sebentar.", show_alert=True)
        return

    if bill["status"] == "paid":
        await query.answer()
        await query.edit_message_text("Pembayaran USDT diterima. Pesanan Anda diproses.")
        # Kirim produk ke pembeli di sini (pastikan hanya sekali per reference_id).
    else:
        await query.answer("Belum terlihat. Tunggu 15 sampai 30 detik, lalu coba lagi.", show_alert=True)


# Daftarkan sebelum app.run_polling():
app.add_handler(CommandHandler("usdt", usdt))
app.add_handler(CallbackQueryHandler(cek_usdt, pattern=r"^cek:"))

Untuk konfirmasi otomatis tanpa tombol, pakai cara yang sama dengan QRIS di bagian berikut: webhook, atau cek GET /binance/transactions?status=paid secara berkala.

Tahu kapan lunas

Bot tidak perlu memberi tahu kami bahwa pembeli sudah membayar. Status transaksi berubah sendiri di sistem kami; bot hanya perlu mengetahuinya. Ada dua cara:

Dianjurkan

Webhook

Kami yang mengabari bot begitu status berubah. Tanpa jeda dan tanpa memakai jatah panggilan. Butuh alamat HTTPS publik, misalnya bot di VPS dengan domain.

Tanpa alamat publik

Cek berkala

Bot bertanya tiap beberapa detik. Cocok untuk bot yang berjalan di komputer sendiri atau hosting tanpa domain. Ada jeda beberapa detik.

Cara 1: webhook

Isi URL webhook di halaman merchant (QRIS) atau di halaman Kunci API (Binance Pay). Saat webhook tiba, periksa tanda tangannya, cari pesanan dari reference_id, lalu kirim pesan ke chat pembelinya.

webhook.js
// npm install express        Dijalankan di proses yang sama dengan bot.
const crypto = require('crypto');
const express = require('express');
const app = express();

app.post('/webhook/qris', express.raw({ type: 'application/json' }), async (req, res) => {
  const payload = req.body.toString();
  const expected = crypto
    .createHmac('sha256', process.env.QRIS_WEBHOOK_SECRET)
    .update(`${req.headers['x-digitals-timestamp']}.${payload}`)
    .digest('hex');
  const given = String(req.headers['x-digitals-signature'] || '');

  if (given.length !== expected.length || !crypto.timingSafeEqual(Buffer.from(given), Buffer.from(expected))) {
    return res.sendStatus(401);
  }

  res.sendStatus(200);                             // jawab dulu, baru kabari pembeli

  const data = JSON.parse(payload);
  if (data.status === 'paid') {
    await selesai(data.reference_id, 'Pembayaran diterima. Pesanan Anda diproses.');
  } else if (data.status === 'expired') {
    await selesai(data.reference_id, 'Waktu pembayaran habis. Ketik /beli untuk membuat QR baru.');
  }
});

app.listen(3000);
webhook.py
# pip install flask requests        Layanan kecil terpisah dari proses bot.
import hashlib
import hmac
import os

import requests
from flask import Flask, request

app = Flask(__name__)
SECRET = os.environ["QRIS_WEBHOOK_SECRET"].encode()
TELEGRAM = f"https://api.telegram.org/bot{os.environ['BOT_TOKEN']}"


@app.post("/webhook/qris")
def webhook():
    payload = request.get_data()
    timestamp = request.headers.get("X-Digitals-Timestamp", "").encode()
    expected = hmac.new(SECRET, timestamp + b"." + payload, hashlib.sha256).hexdigest()

    if not hmac.compare_digest(expected, request.headers.get("X-Digitals-Signature", "")):
        return "", 401

    data = request.get_json()
    if data["status"] == "paid":
        # Ambil chat_id dari pesanan yang Anda simpan saat membuat transaksi,
        # dan pastikan pesanan ini belum pernah diproses.
        chat_id = cari_chat_id(data["reference_id"])
        requests.post(f"{TELEGRAM}/sendMessage", json={"chat_id": chat_id, "text": "Pembayaran diterima. Pesanan Anda diproses."})

    return "", 200

Dengan webhook, bagian cek berkala (setInterval atau run_repeating) di contoh lengkap tidak diperlukan lagi.

Cara 2: cek berkala

Jangan mengecek tiap transaksi satu per satu. Batas API adalah 60 panggilan per menit per kunci, sehingga mengecek tiap pesanan setiap 5 detik sudah mentok saat ada 5 pembeli bersamaan.

Yang benar
Satu panggilan GET /transactions?status=paid&per_page=50 tiap 5 detik, lalu cocokkan hasilnya dengan pesanan yang sedang menunggu. Itu 12 panggilan per menit, berapa pun jumlah pembelinya.
Kedaluwarsa
Hitung sendiri dari expires_at tanpa memanggil API. Tepat sebelum menyatakan kedaluwarsa, cek transaksi itu sekali lewat GET /transactions/{id}.
Saat tidak ada pesanan
Berhenti mengecek. Contoh lengkap di atas melewati putaran bila daftar tunggunya kosong.
Setelah bot mati
Saat dinyalakan lagi, cek sekali tiap pesanan yang masih tercatat menunggu, karena pembayaran bisa masuk selama bot mati.

Yang perlu diperhatikan

reference_id
Harus unik untuk tiap transaksi. Gabungan ID chat dan waktu, seperti TG-123456789-1759570200000, mudah dilacak dan tidak bentrok.
Jangan proses dua kali
Webhook bisa tiba lebih dari sekali, dan cek berkala bisa melihat transaksi yang sama berulang. Tandai pesanan selesai lebih dulu, baru kirim produknya.
Foto QR
Hapus atau ganti keterangannya setelah lunas atau kedaluwarsa, supaya pembeli tidak membayar QR yang sudah tidak berlaku.
Alamat gambar
Telegram mengambil qr_png_url dari servernya sendiri. Kirim alamatnya apa adanya; tidak perlu diunduh dulu.
Pembayaran telat (Binance)
Tagihan USDT yang kedaluwarsa masih bisa lunas dalam 24 jam. Dengan webhook, bot tetap dikabari; siapkan pesan untuk kasus itu.
Mode uji
Pakai sk_test_ atau bk_test_ selama membuat bot. Lunasi transaksinya dengan membuka pay_url lalu menekan tombol simulasi, atau lewat endpoint simulate.
Galat
Bila API menjawab galat, tampilkan pesan umum ke pembeli dan catat pesan aslinya. Jangan teruskan pesan mentah ke chat.

Kode galat

Jawaban gagal selalu berbentuk { "status": "error", "message": "..." }. Tampilkan pesan umum ke pembeli dan catat message aslinya di log Anda.

401
API key kosong, salah, atau jenisnya tidak cocok (kunci sk_ untuk QRIS, bk_ untuk Binance).
403
Merchant belum disetujui (kunci live), akun dibekukan, atau untuk Binance Pay: langganan belum aktif atau sudah habis, atau akun Binance belum dihubungkan.
404
Transaksi tidak ditemukan.
422
Parameter tidak valid, reference_id sudah dipakai, atau nominal di luar batas Rp 1.000 sampai Rp 10.000.000.
429
Terlalu banyak permintaan. Tunggu sesuai header Retry-After.
502 / 503
Penyedia pembayaran sedang bermasalah, kurs USDT tidak tersedia, atau layanan sedang dalam pemeliharaan. Coba lagi beberapa saat.

Siap mencoba?

Kunci uji langsung aktif setelah mendaftar. Tidak perlu menunggu persetujuan untuk mulai menulis kode.

Daftar Gratis