Panduan · Terbit
Integrasi payment gateway di Google Sheets dengan Apps Script: tagihan dari baris spreadsheet, Idempotency-Key yang tidak boleh berasal dari nomor baris, dan kenapa statusnya ditarik, bukan dikirim lewat webhook
Jawaban singkatnya: Google Sheets bisa menjadi alat penagihan yang utuh tanpa server sendiri. Apps Script membuat tagihan untuk baris yang dipilih lewat POST /v1/transactions, menulis tautan pembayarannya ke sel, lalu setiap sepuluh menit sebuah pemicu berwaktu menarik status terbaru dari daftar transaksi. Dua hal menentukan apakah susunan ini aman: Idempotency-Key yang berasal dari nomor tagihan, bukan dari nomor baris, dan status yang ditarik, bukan diterima lewat webhook.
Panduan ini ditulis untuk bendahara komunitas, pengelola kos, penyelenggara kelas, dan penjual yang daftar tagihannya sudah hidup di spreadsheet dan hanya ingin mengganti langkah transfer manual dengan tautan yang terverifikasi otomatis. Kalau daftar itu masih berupa formulir ditambah mutasi rekening, perbandingan kedua cara ada di Google Form dan transfer manual melawan tautan pembayaran. Fakta tentang Apps Script di bawah dibaca dari dokumentasi resmi Google saat panduan ini ditulis, 25 September 2026.
1. Susunan kolom
Lembar "Tagihan", baris 1 berisi judul kolom:
A Nomor tagihan INV-2610-014 (diisi manusia, unik, tidak pernah dipakai ulang)
B Nama Rina Aulia
C Email rina@contoh.id (opsional)
D Nominal 250000 (angka, rupiah utuh)
E Keterangan Iuran Oktober 2026 · Rina
F ID pembayaran payreq_... (diisi skrip)
G Tautan https://pay.kasera.id/p/... (diisi skrip)
H Status pending (diisi skrip)
I Dibayar pada (diisi skrip)Kolom A adalah satu-satunya kolom yang harus dijaga ketat: satu nomor untuk satu tagihan, tidak pernah dipakai ulang, bahkan setelah tagihannya kedaluwarsa. Kolom E tampil di halaman pembayaran dan ikut ke ekspor CSV serta kotak pencarian dashboard, jadi isinya sebaiknya bisa dibaca pembayar sekaligus dicari penagih.
2. API key di Script Properties, dan siapa yang bisa membacanya
// Kode.gs: Ekstensi > Apps Script dari spreadsheet penagihan.
// API key disimpan di Setelan project > Properti skrip dengan nama
// KASERA_PAY_KEY. Tidak pernah di sel, tidak pernah di kode.
const BASE_URL = 'https://pay.kasera.id';
const LEMBAR = 'Tagihan';
const KOL = { nomor: 1, nama: 2, email: 3, nominal: 4, ket: 5, id: 6, tautan: 7, status: 8, dibayar: 9 };
function onOpen() {
SpreadsheetApp.getUi()
.createMenu('Kasera Pay')
.addItem('Buat tagihan untuk baris terpilih', 'buatTagihanTerpilih')
.addItem('Perbarui status sekarang', 'perbaruiStatus')
.addToUi();
}
function kasera_(method, path, body, idempotencyKey) {
const key = PropertiesService.getScriptProperties().getProperty('KASERA_PAY_KEY');
const opsi = {
method: method,
headers: { Authorization: 'Bearer ' + key },
muteHttpExceptions: true, // tanpa ini, 4xx dan 5xx dilempar tanpa isi error-nya
};
if (idempotencyKey) opsi.headers['Idempotency-Key'] = idempotencyKey;
if (body) {
opsi.contentType = 'application/json';
opsi.payload = JSON.stringify(body);
}
const res = UrlFetchApp.fetch(BASE_URL + path, opsi);
const teks = res.getContentText();
return { status: res.getResponseCode(), json: teks ? JSON.parse(teks) : {} };
}Script Properties lebih baik daripada sel atau konstanta di kode, tetapi bukan brankas. Skrip yang terikat pada spreadsheet bisa dibuka oleh siapa pun yang punya akses edit ke spreadsheet itu, termasuk setelan project tempat properti skrip terlihat. Jadi batasi akses edit hanya untuk pemegang kunci. Staf yang cukup membaca status bisa diberi akses lihat, atau diberi spreadsheet terpisah yang menarik kolom status. Selama membangun, pakai kunci kp_test_. Cara menyimpan dan mengganti kunci yang sempat bocor ada di menyimpan dan merotasi kunci API.
Menu dipasang lewat onOpen, sedangkan pekerjaan yang memanggil API dijalankan dari menu. Pemicu sederhana seperti onEdit tidak bisa memakai layanan yang butuh otorisasi, dan membuat tagihan dari rumus khusus lebih buruk lagi: rumus dihitung ulang setiap kali sel yang dirujuknya berubah, sehingga satu ketikan bisa berarti satu panggilan API baru.
3. Membuat tagihan untuk baris terpilih
function buatTagihanTerpilih() {
const ss = SpreadsheetApp.getActive();
const sheet = ss.getSheetByName(LEMBAR);
const pilihan = sheet.getActiveRange();
const kunci = LockService.getDocumentLock();
kunci.waitLock(30000); // dua orang menekan menu bersamaan: yang kedua menunggu
try {
for (let r = Math.max(2, pilihan.getRow()); r <= pilihan.getLastRow(); r++) {
const v = sheet.getRange(r, 1, 1, 9).getValues()[0];
const nomor = String(v[KOL.nomor - 1]).trim();
if (!nomor || v[KOL.id - 1]) continue; // tanpa nomor, atau sudah punya tagihan
const nominal = Number(v[KOL.nominal - 1]);
if (!Number.isInteger(nominal)) {
sheet.getRange(r, KOL.status).setValue('nominal bukan rupiah utuh');
continue;
}
const body = {
amount: nominal,
description: String(v[KOL.ket - 1] || 'Tagihan ' + nomor),
external_id: nomor, // label untuk pencarian, bukan pencegah duplikat
checkout: { steps: ['customer', 'payment_method', 'payment'] },
};
if (v[KOL.nama - 1]) body.customer = { name: String(v[KOL.nama - 1]) };
if (v[KOL.email - 1]) body.customer = Object.assign(body.customer || {}, { email: String(v[KOL.email - 1]) });
// Kunci = ID spreadsheet + nomor tagihan. Bukan nomor baris.
const res = kasera_('post', '/v1/transactions', body, ss.getId() + ':' + nomor);
if (res.status === 201 || res.status === 200) {
sheet.getRange(r, KOL.id, 1, 3).setValues([[res.json.id, res.json.checkout_url, res.json.status]]);
} else {
const e = res.json.error || {};
sheet.getRange(r, KOL.status).setValue('ditolak ' + res.status + ' ' + (e.code || ''));
}
}
} finally {
kunci.releaseLock();
}
}Kuncinya ID spreadsheet ditambah nomor tagihan. Hanya header Idempotency-Key yang mencegah satu baris menjadi dua pembayaran; external_id hanyalah label yang disimpan dan bisa difilter. Kunci disimpan permanen per akun dan tidak pernah kedaluwarsa, sehingga nomor baris jelas salah (berubah setiap lembar diurutkan) dan nomor tagihan saja pun berisiko kalau nomornya dipakai ulang di spreadsheet lain. Latar belakangnya ada di idempotency untuk pembayaran.
Tiga jawaban yang perlu dibedakan. 201 berarti tagihan baru dibuat. 200 berarti kunci itu sudah pernah dipakai dan yang kembali adalah tagihan aslinya, bukan tagihan kedua. 409 idempotency_conflict berarti kunci itu menempel pada body yang berbeda, karena yang dibandingkan adalah hash byte persis. Mengubah nominal setelah kolom ID dikosongkan menghasilkan 409, dan jalan keluarnya nomor tagihan baru.
Batas enam menit. Satu eksekusi Apps Script berhenti setelah 6 menit. Seleksi ratusan baris bisa terpotong, dan itu aman karena dua sebab: baris yang sudah punya ID dilewati, dan baris yang tagihannya sempat dibuat tetapi belum tertulis ke sel mendapat tagihan yang sama kembali dengan 200. LockService menjaga dua orang yang menekan menu bersamaan agar tidak menulis ke baris yang sama berebutan.
Objek checkout dengan langkah customer membuat halaman pembayaran menanyakan data yang belum ada, misalnya nama yang dibutuhkan Virtual Account. Tanpa objek itu, permintaannya menjadi Direct API dan metode yang kekurangan data ditolak 422 customer_required. Karena payment_methods tidak dikirim, pembayar memilih sendiri antara QRIS dan Virtual Account yang aktif di akun. Semua field lainnya ada di referensi pembuatan permintaan pembayaran.
4. Status ditarik, bukan dikirim
Integrasi dengan server sendiri memakai webhook. Apps Script tidak cocok untuk peran itu, karena dua alasan yang tidak bisa diakali dari sisi skrip. Objek event doPost hanya memuat parameter dan isi body, tanpa header, sehingga Kasera-Signature-V1 tidak pernah terbaca dan kiriman palsu tidak bisa ditolak. Lalu keluaran ContentService dialihkan ke URL sekali pakai di script.googleusercontent.com, sementara pengirim webhook Kasera Pay menolak mengikuti redirect apa pun dan menghitungnya sebagai kegagalan, lalu mengulangnya sampai 7 kali dalam sekitar 33 jam.
Gantinya, pemicu berwaktu menyapu daftar transaksi:
// Menyapu daftar transaksi, terbaru dulu, 100 per panggilan, sampai tiga
// hari ke belakang. Satu panggilan memperbarui sampai 100 baris sekaligus.
function perbaruiStatus() {
const sheet = SpreadsheetApp.getActive().getSheetByName(LEMBAR);
const akhir = sheet.getLastRow();
if (akhir < 2) return;
const barisPerId = {};
sheet.getRange(2, KOL.id, akhir - 1, 1).getValues().forEach((r, i) => {
if (r[0]) barisPerId[String(r[0])] = i + 2;
});
const batas = Date.now() - 3 * 24 * 60 * 60 * 1000;
let setelah = '';
for (let halaman = 0; halaman < 20; halaman++) {
const res = kasera_('get', '/v1/transactions?limit=100' + (setelah ? '&starting_after=' + setelah : ''));
if (res.status !== 200) throw new Error('Kasera Pay menjawab ' + res.status);
const data = res.json.data || [];
data.forEach((t) => {
const r = barisPerId[t.id];
if (r) sheet.getRange(r, KOL.status, 1, 2).setValues([[t.status, t.paid_at || '']]);
});
if (!res.json.has_more || data.length === 0) break;
const terakhir = data[data.length - 1];
if (new Date(terakhir.created_at).getTime() < batas) break;
setelah = terakhir.id;
}
}
// Dijalankan sekali dari editor. Menghapus pemicu lama dulu supaya tidak dobel.
function pasangPemicu() {
ScriptApp.getProjectTriggers()
.filter((t) => t.getHandlerFunction() === 'perbaruiStatus')
.forEach((t) => ScriptApp.deleteTrigger(t));
ScriptApp.newTrigger('perbaruiStatus').timeBased().everyMinutes(10).create();
}Status yang ditulis adalah pending selama belum dibayar, succeeded setelah dibayar, dan expired setelah tenggatnya lewat (bawaannya 60 menit). Jendela tiga hari disengaja: pembayaran yang tiba tepat di batas tenggat bisa tercatat berhasil setelah baris itu sempat terbaca expired, dan uang pembeli selalu menang. Sapuan berikutnya membalik barisnya menjadi succeeded tanpa kode tambahan.
Kenapa daftar, bukan satu panggilan per baris. Akun Google biasa punya kuota 20.000 panggilan UrlFetch per hari, dan Google Workspace 100.000. Pemicu setiap lima menit yang memeriksa 50 baris satu per satu menghabiskan 288 × 50 = 14.400 panggilan sehari. Sapuan daftar tiap sepuluh menit memakai 144 panggilan sehari selama transaksi tiga hari terakhir kurang dari seratus. Filter di endpoint daftar hanya berlaku per halaman, jadi skrip di atas menyapu tanpa filter lalu mencocokkan ID di sisinya sendiri. Timbangan umum antara menarik dan menerima ada di webhook melawan menanyakan status berkala.
5. Menguji sebelum tautan pertama dikirim
Dengan kunci kp_test_, tagihan tes berbiaya dan berperilaku sama seperti live tetapi tidak bisa dibayar sungguhan. Hasilnya diarahkan sendiri:
# Mode tes: ambil token dari kolom Tautan (bagian setelah /p/), lalu arahkan hasilnya.
curl -X POST https://pay.kasera.id/api/v1/checkout/{token}/simulate-payment \
-H "Content-Type: application/json" \
-d '{ "outcome": "succeeded" }'Uji empat hal: satu baris menghasilkan satu tagihan, menekan menu dua kali pada baris yang sama tidak menambah tagihan, baris yang disimulasikan berhasil berubah menjadi succeeded pada sapuan berikutnya, dan seleksi yang berisi baris tanpa nominal berhenti di baris itu saja. Setelah itu ganti properti skrip dengan kunci kp_live_, dan kosongkan kolom F sampai I dari baris tes supaya tidak tercampur.
Batas susunan ini
Spreadsheet cocok selama satu orang atau tim kecil yang memegang daftarnya dan jeda sampai sepuluh menit antara pembayaran dan status bisa diterima. Begitu status harus langsung memicu sesuatu, misalnya membuka akses kelas atau mengurangi stok, pekerjaannya pindah ke server yang menerima webhook dengan tanda tangan terperiksa. Biaya tidak berubah di jalur mana pun: QRIS 0,7% + Rp 250 dan Virtual Account Rp 5.000 per transaksi berhasil, tanpa biaya bulanan.
Pertanyaan yang sering muncul
Bisakah Apps Script dipakai sebagai URL webhook Kasera Pay?
Tidak disarankan, karena dua hal yang tidak bisa diakali dari sisi skrip. Objek event doPost di Apps Script tidak memuat header permintaan, jadi header Kasera-Signature-V1 tidak pernah terbaca dan kiriman palsu tidak bisa dibedakan dari yang asli. Selain itu, menurut dokumentasi Google, keluaran ContentService dialihkan ke URL sekali pakai di script.googleusercontent.com, sedangkan pengirim webhook Kasera Pay menolak mengikuti redirect apa pun dan menghitungnya sebagai pengiriman gagal, lalu mengulangnya sampai 7 kali dalam sekitar 33 jam. Menarik status dengan pemicu berwaktu lebih sederhana dan bisa dipercaya.
Kenapa Idempotency-Key tidak memakai nomor baris saja?
Karena nomor baris berubah setiap kali lembar diurutkan, difilter lalu disalin, atau disisipi baris baru. Setelah pengurutan, baris 7 bisa berisi tagihan orang lain, dan kunci yang sama akan mengembalikan tagihan milik pembeli sebelumnya dengan status 200. Kunci juga disimpan permanen per akun dan tidak pernah kedaluwarsa, jadi nomor tagihan yang dipakai ulang di spreadsheet lain tahun depan pun akan mengembalikan tagihan lama. Menggabungkan ID spreadsheet dengan nomor tagihan menutup kedua celah itu.
Apa arti jawaban 409 idempotency_conflict di kolom Status?
Kunci yang dikirim sudah menempel pada tagihan lain dengan isi berbeda. Kasera Pay membandingkan hash byte persis dari body, jadi mengubah nominal atau keterangan setelah kolom ID pembayaran dikosongkan, atau mengubah kode skrip sehingga body tersusun berbeda, menghasilkan 409. Tagihan pertama tetap ada. Kalau isinya memang harus berubah, beri baris itu nomor tagihan baru.
Berapa baris yang bisa ditagih dalam sekali tekan menu?
Satu eksekusi Apps Script berhenti setelah 6 menit, dan setiap baris adalah satu panggilan jaringan, jadi ratusan baris bisa terpotong di tengah. Itu aman: baris yang sudah punya ID pembayaran dilewati saat menu ditekan lagi, dan baris yang tagihannya sudah dibuat tetapi belum sempat ditulis ke sel akan mendapat tagihan yang sama kembali dengan status 200 karena kuncinya identik.