Panduan · Terbit
Integrasi payment gateway di Cloudflare Workers: fetch yang harus dibungkus sebelum SDK bisa memanggil API, rahasia lewat wrangler secret, dan batch D1 sebagai satu-satunya penulis status lunas
Backend yang berjalan di Cloudflare Workers memasang Kasera Pay dengan dua rute di satu Worker: /bayar yang membuat permintaan pembayaran, dan /kasera-webhook yang menerima kabar pembayaran. Status pesanan disimpan di D1. Empat hal khas Workers menentukan hasilnya. SDK resmi harus diberi fetch yang dibungkus, karena tanpa itu setiap panggilan gagal. Tanda tangan harus diverifikasi dari await req.text(). Dedupe event dan pembaruan pesanan harus berada dalam satu env.DB.batch, karena D1 tidak punya transaksi interaktif. Dan penulisan itu harus selesai sebelum webhook dijawab, tidak pernah dilempar ke ctx.waitUntil.
Contoh di bawah memakai SDK JavaScript resmi versi 0.1.0, yang tidak punya dependensi dan memakai Web Crypto, jadi tidak butuh flag nodejs_compat. Seluruh kode di halaman ini dijalankan di workerd lewat Miniflare dengan D1 lokal saat panduan ini ditulis. Kalau backend proyeknya Supabase, bentuk yang setara ada di panduan integrasi Supabase.
Alurnya, sebelum menulis kode
Aplikasi memanggil /bayar dengan id pesanan. Worker membaca total dari tabel orders di D1, membuat permintaan pembayaran, dan mengembalikan checkout_url. Pembeli membayar dengan QRIS atau Virtual Account delapan bank. Kasera Pay mengirim payment.paid bertanda tangan ke /kasera-webhook, dan Worker menandai pesanan lunas. Kepulangan pembeli ke return_url bukan bukti apa pun. Status permintaan pembayaran adalah pending, succeeded, dan expired; payment.paid adalah nama event-nya, bukan status.
1. Proyek, D1, dan rahasia
npm install kasera-pay@0.1.0
npx wrangler d1 create toko
npx wrangler d1 execute toko --remote --file=schema.sql
# Lokal: dibaca oleh wrangler dev. Masukkan ke .gitignore.
# .dev.vars
KASERA_PAY_KEY=kp_test_...
KASERA_PAY_WEBHOOK_SECRET=whsec_...
# Produksi: setiap perintah langsung men-deploy versi baru Worker.
npx wrangler secret put KASERA_PAY_KEY
npx wrangler secret put KASERA_PAY_WEBHOOK_SECRET// wrangler.jsonc
{
"name": "toko-api",
"main": "src/index.ts",
"compatibility_date": "2026-09-01",
"d1_databases": [
{ "binding": "DB", "database_name": "toko", "database_id": "<dari d1 create>" }
]
}CREATE TABLE orders (
id TEXT PRIMARY KEY,
total INTEGER NOT NULL CHECK (total > 0), -- rupiah utuh
status TEXT NOT NULL DEFAULT 'BARU',
attempt INTEGER NOT NULL DEFAULT 0,
payment_request_id TEXT,
paid_at TEXT
);
CREATE TABLE kasera_events (
id TEXT PRIMARY KEY,
type TEXT NOT NULL,
received_at TEXT NOT NULL DEFAULT (datetime('now'))
);Pakai salah satu, .dev.vars atau .env, bukan keduanya. wrangler secret put membuat versi baru Worker dan langsung men-deploy-nya, jadi mengganti kp_test_ dengan kp_live_ berlaku seketika. Ganti kunci live dan signing secret live bersamaan: webhook mode tes dan live adalah endpoint terpisah dengan secret masing-masing, dan secret yang tidak cocok membuat setiap pengiriman ditolak 400. Proyek yang memakai gradual deployments memakai wrangler versions secret put supaya rahasia baru ikut versi yang dirilis bertahap.
2. Klien SDK dan fetch yang dibungkus
// src/index.ts
import {
KaseraPay,
KaseraPayError,
constructWebhookEvent,
SignatureError,
} from "kasera-pay";
interface Env {
DB: D1Database;
KASERA_PAY_KEY: string;
KASERA_PAY_WEBHOOK_SECRET: string;
}
export default {
async fetch(req, env) {
const { pathname } = new URL(req.url);
if (req.method === "POST" && pathname === "/kasera-webhook") return webhook(req, env);
if (req.method === "POST" && pathname === "/bayar") return bayar(req, env);
return new Response("not found", { status: 404 });
},
} satisfies ExportedHandler<Env>;
function kasera(env: Env) {
// SDK memanggil fetch sebagai this.fetch(...), dan Workers menolaknya dengan
// "Illegal invocation". Membungkus fetch global membuatnya dipanggil biasa.
return new KaseraPay(env.KASERA_PAY_KEY, {
fetch: (input, init) => fetch(input, init),
});
}Ini bagian yang paling mudah terlewat. SDK menyimpan globalThis.fetch sebagai properti lalu memanggilnya sebagai this.fetch(...). Node menerimanya. Workers tidak: fetch yang dipanggil dengan this selain objek global melempar TypeError: Illegal invocation. SDK menangkap galat itu dan melemparkan KaseraPayError dengan status 0 dan code network_error, sehingga di log terlihat seperti koneksi yang putus. Permintaannya tidak pernah sampai ke Kasera Pay. Opsi fetch: (input, init) => fetch(input, init) menyelesaikannya.
Klien dibuat di dalam handler karena env baru tersedia di sana. Membuatnya per permintaan murah: konstruktornya hanya menyimpan tiga nilai.
3. Rute yang membuat permintaan pembayaran
async function bayar(req: Request, env: Env) {
// Memastikan pesanan ini milik pengguna yang login adalah tugas aplikasi.
const { orderId } = await req.json<{ orderId: string }>();
const order = await env.DB.prepare(
"SELECT id, total, status, attempt FROM orders WHERE id = ?1",
)
.bind(orderId)
.first<{ id: string; total: number; status: string; attempt: number }>();
if (!order) return Response.json({ error: "not_found" }, { status: 404 });
if (order.status === "LUNAS") return Response.json({ error: "sudah_lunas" }, { status: 409 });
try {
const tx = await kasera(env).createTransaction(
{
amount: order.total, // dari D1, tidak pernah dari body permintaan
description: "Pesanan " + order.id,
external_id: order.id,
return_url: "https://toko.example/pesanan/" + order.id,
checkout: {},
},
{ idempotencyKey: "order-" + order.id + "-" + order.attempt },
);
await env.DB.prepare(
"UPDATE orders SET payment_request_id = ?1, status = 'MENUNGGU' WHERE id = ?2 AND status <> 'LUNAS'",
)
.bind(tx.id, order.id)
.run();
return Response.json({ checkoutUrl: tx.checkout_url });
} catch (e) {
if (e instanceof KaseraPayError) return Response.json({ error: e.code }, { status: 502 });
throw e;
}
}Total dibaca dari D1, tidak pernah dari body. Kalau klien boleh mengirim nominal sendiri, pembeli yang sedikit paham bisa membayar Rp 1.000 untuk barang Rp 500.000 dengan sah. Kunci idempotensinya dibentuk dari id pesanan dan nomor percobaan. Id saja tidak cukup: key yang sama dengan body yang sama selalu mengembalikan permintaan asli, termasuk yang sudah kedaluwarsa, sehingga pembeli yang kembali setelah 60 menit tidak akan pernah bisa membayar lagi. Nomor percobaan dinaikkan oleh webhook saat payment.expired tiba. Hanya header Idempotency-Key yang mencegah duplikat; external_id sekadar label. Alasannya ada di idempotency untuk pembayaran.
4. Rute webhook
async function webhook(req: Request, env: Env) {
let event;
try {
event = await constructWebhookEvent(
await req.text(), // byte mentah, dibaca sebelum apa pun
req.headers.get("kasera-signature-v1") ?? "",
env.KASERA_PAY_WEBHOOK_SECRET,
);
} catch (e) {
if (e instanceof SignatureError) return new Response("bad signature", { status: 400 });
throw e;
}
// Tipe kasera-pay 0.1.0 baru mengenal payment.paid dan test.ping.
const type: string = event.type;
const data =
"data" in event
? (event.data as {
external_id?: string | null;
payment_request_id?: string;
paid_at?: string | null;
})
: undefined;
if (!data?.external_id) return new Response("ignored");
const seen = "NOT EXISTS (SELECT 1 FROM kasera_events WHERE id = ?3)";
const update =
type === "payment.paid"
? env.DB.prepare(
`UPDATE orders SET status = 'LUNAS', paid_at = coalesce(?2, datetime('now'))
WHERE id = ?1 AND ${seen}`,
).bind(data.external_id, data.paid_at ?? null, event.id)
: type === "payment.expired"
? env.DB.prepare(
`UPDATE orders SET status = 'KEDALUWARSA', attempt = attempt + 1
WHERE id = ?1 AND status <> 'LUNAS' AND payment_request_id = ?2 AND ${seen}`,
).bind(data.external_id, data.payment_request_id ?? null, event.id)
: null;
const record = env.DB.prepare(
"INSERT INTO kasera_events (id, type) VALUES (?1, ?2) ON CONFLICT (id) DO NOTHING",
).bind(event.id, type);
try {
// Satu batch D1 adalah satu transaksi: dua-duanya tertulis, atau tidak sama sekali.
await env.DB.batch(update ? [update, record] : [record]);
} catch (e) {
console.error(e);
return new Response("retry", { status: 500 }); // Kasera Pay mengulang
}
return new Response("ok");
}Body dibaca sekali dengan req.text() dan string itulah yang diverifikasi. Membaca req.json() lebih dulu menghabiskan stream, dan menyusun ulang objek dengan JSON.stringify menghasilkan isi yang sama dengan byte yang berbeda, sehingga pengiriman yang sah ditolak. constructWebhookEvent memeriksa header Kasera-Signature-V1, toleransi waktu lima menit, dan dua entri v1 selama rotasi signing secret.
D1 tidak menyediakan BEGIN yang bisa diselingi kode JavaScript, jadi pola “sisipkan event, periksa hasilnya, lalu perbarui pesanan” tidak bisa dibuat atomik. Yang atomik adalah batch: pernyataannya berjalan berurutan sebagai satu transaksi SQL, dan kalau satu gagal, semuanya dibatalkan. Karena itu pemeriksaan duplikat dipindah ke dalam pernyataan UPDATE itu sendiri lewat NOT EXISTS, dan sisipan event menyusul di batch yang sama. Satu database D1 memproses kueri satu per satu, jadi dua pengiriman event yang sama yang tiba berbarengan dijalankan bergiliran, dan yang kedua tidak mengubah apa pun. Tanpa pemeriksaan itu, payment.expired yang terkirim dua kali menaikkan nomor percobaan dua kali. Pemeriksaan payment_request_id menjaga urutan antar percobaan: kabar kedaluwarsa dari percobaan lama yang terlambat tiba tidak menimpa pesanan yang sedang menunggu percobaan baru. payment.paid selalu menang, dari percobaan mana pun.
Pengiriman bersifat at-least-once, dan jawaban selain 2xx membuat Kasera Pay mengulang sampai tujuh kali dalam kurang lebih 33 jam. Itu sebabnya galat D1 dijawab 500. Event kedaluwarsa hanya dikirim ke endpoint yang mencentangnya; endpoint lama menerima payment.paid saja sampai diubah. Rinciannya ada di referensi webhook.
5. Hostname yang didaftarkan sebagai webhook
URL webhook didaftarkan di dashboard, menu Developer, dan wajib https ke alamat publik. Kalau Worker dipasang di route domain sendiri, periksa pengaturan bot domain itu. Dokumentasi Cloudflare menyatakan Bot Fight Mode bisa menantang lalu lintas API, dan tidak bisa dilewati dengan aturan WAF maupun Page Rules. Pengiriman webhook yang ditantang tidak pernah sampai ke kode Worker; di log Worker tidak ada apa pun, sementara di dashboard Kasera Pay pengirimannya gagal dan diulang. Pakai hostname yang tidak memakai Bot Fight Mode untuk rute webhook, atau Super Bot Fight Mode dengan aturan skip yang memang didukung di sana.
Webhook adalah jalur utama, bukan satu-satunya. Worker yang sama bisa diberi Cron Trigger yang menanyakan status pesanan yang terlalu lama menunggu sebagai jaring pengaman; untung-ruginya dibahas di perbandingan webhook dan menanyakan status berkala.
6. Menguji sebelum kunci live dipasang
Dengan kunci kp_test_, halaman checkout menampilkan tombol simulasi, dan hasil berhasil maupun kedaluwarsa juga bisa dipicu lewat endpoint simulasi di dokumentasi mode tes. wrangler dev hanya bisa dijangkau dari mesin sendiri, jadi webhook ke sana butuh terowongan, misalnya cloudflared tunnel --url http://localhost:8787. Lima hal yang layak dicoba: pembuatan pembayaran pertama berhasil, yang membuktikan fetch sudah dibungkus; ketukan ganda menghasilkan satu permintaan pembayaran; event yang dikirim ulang tidak mengubah apa pun; simulasi kedaluwarsa lalu bayar ulang menghasilkan permintaan baru; dan tabel yang sengaja dikunci atau dihapus membuat webhook dijawab 500, bukan 200.
Pertanyaan yang sering muncul
Kenapa setiap pembuatan pembayaran gagal dengan network_error padahal jaringannya baik?
Karena SDK kasera-pay 0.1.0 menyimpan fetch global sebagai properti lalu memanggilnya sebagai this.fetch(...). Runtime Workers menolak fetch yang dipanggil dengan this selain objek global dan melempar TypeError: Illegal invocation. SDK membungkus galat itu menjadi KaseraPayError dengan status 0, code network_error, dan pesan berisi Illegal invocation, sehingga terlihat seperti masalah koneksi. Permintaannya tidak pernah berangkat. Berikan opsi fetch: (input, init) => fetch(input, init) saat membuat klien.
Bolehkah webhook langsung dijawab 200 lalu penulisan ke D1 dikerjakan di ctx.waitUntil?
Jangan. Setelah jawaban 2xx terkirim, Kasera Pay menganggap event itu selesai dan tidak mengulangnya. Kalau penulisan di waitUntil gagal, atau dibatalkan karena melewati batas 30 detik setelah jawaban dikirim, pesanan yang sudah dibayar tetap tercatat menunggu dan tidak ada pengiriman ulang yang memperbaikinya. Tulis dulu, lalu jawab: 200 kalau batch berhasil, 500 kalau gagal.
Apakah API key boleh dipakai di Worker yang juga menyajikan frontend?
Boleh, selama kunci itu hanya dibaca dari env di dalam kode Worker dan tidak pernah dikirim ke peramban. Yang tidak boleh adalah menaruhnya di variabel yang ikut di bundle aset statis. Rahasia yang diisi lewat wrangler secret put dibaca lewat env di kode Worker, terpisah dari aset statis yang dikirim ke peramban.
Apakah perlu flag nodejs_compat?
Tidak untuk contoh ini. SDK kasera-pay tidak punya dependensi dan memverifikasi tanda tangan lewat Web Crypto (crypto.subtle), yang tersedia bawaan di Workers. Flag itu baru perlu kalau bagian lain aplikasi memakai modul Node.