Panduan · Terbit
Integrasi payment gateway di Supabase: Edge Functions sebagai backend yang memegang kunci API, verify_jwt yang harus dimatikan hanya untuk webhook, dan fungsi Postgres yang menjadi satu-satunya penulis status lunas
Aplikasi yang backend-nya Supabase memasang Kasera Pay dengan dua Edge Functions: satu yang membuat permintaan pembayaran atas nama pengguna yang login, dan satu yang menerima webhook. Aplikasi tidak pernah memegang API key dan tidak pernah menulis status pembayaran. Tiga hal khas Supabase menentukan hasilnya: verify_jwt harus dimatikan untuk fungsi webhook saja, tanda tangan harus diverifikasi dari await req.text(), dan status lunas hanya boleh ditulis oleh satu fungsi Postgres yang juga mencatat event. Tanpa RLS yang menutup penulisan dari klien, siapa pun yang login bisa menandai pesanannya sendiri lunas dengan satu panggilan update dari konsol peramban.
Contoh di bawah memakai SDK JavaScript resmi lewat specifier npm:kasera-pay, yang berjalan di Deno, dan pembungkus withSupabase dari @supabase/server, yang memeriksa JWT pengguna, menangani preflight CORS, dan menyediakan dua klien database. Kalau backend proyeknya Firebase, pola yang setara ada di panduan integrasi Firebase.
Alurnya, sebelum menulis kode
Aplikasi memanggil bayar-pesanan dengan id pesanan. Fungsi itu membaca total dari tabel orders, membuat permintaan pembayaran, lalu mengembalikan checkout_url. Pembeli membayar dengan QRIS atau Virtual Account delapan bank. Kasera Pay mengirim payment.paid bertanda tangan ke kasera-webhook, fungsi itu memanggil terapkan_event_kasera, dan halaman pesanan yang berlangganan Realtime menampilkan status lunas tanpa polling. Kepulangan pembeli ke return_url tidak dianggap bukti apa pun. Status permintaan pembayaran yang dipakai di sini adalah pending, succeeded, dan expired; payment.paid adalah nama event-nya, bukan status.
1. Fungsi dan rahasia
supabase functions new bayar-pesanan
supabase functions new kasera-webhook
# Lokal: dibaca oleh supabase start dan functions serve. Masukkan ke .gitignore.
# supabase/functions/.env
KASERA_PAY_KEY=kp_test_...
KASERA_PAY_WEBHOOK_SECRET=whsec_...
# Produksi: langsung terbaca oleh fungsi, tanpa deploy ulang.
supabase secrets set KASERA_PAY_KEY=kp_test_...
supabase secrets set KASERA_PAY_WEBHOOK_SECRET=whsec_...Nama rahasia tidak boleh diawali SUPABASE_, karena awalan itu dipakai variabel bawaan. Rahasia produksi langsung terbaca oleh fungsi yang sudah ter-deploy tanpa deploy ulang, jadi mengganti kp_test_ dengan kp_live_ berlaku seketika. Itu memudahkan, sekaligus berarti kunci live dan signing secret live harus diganti bersamaan: webhook mode tes dan live adalah endpoint terpisah dengan secret masing-masing, dan secret yang tidak cocok membuat setiap pengiriman ditolak 400.
2. Tabel, RLS, dan satu fungsi penulis
create table public.orders (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references auth.users (id),
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 timestamptz
);
alter table public.orders enable row level security;
grant select on public.orders to authenticated;
create policy "pemilik membaca pesanannya" on public.orders
for select to authenticated using ((select auth.uid()) = user_id);
-- Sengaja tidak ada policy insert, update, atau delete untuk klien.
create table public.kasera_events (
id text primary key,
type text not null,
received_at timestamptz not null default now()
);
alter table public.kasera_events enable row level security; -- tanpa policy
alter publication supabase_realtime add table public.orders;
create function public.terapkan_event_kasera(
p_event_id text, p_type text, p_order_id uuid,
p_payment_request_id text, p_paid_at timestamptz
) returns void language plpgsql set search_path = '' as $$
begin
insert into public.kasera_events (id, type) values (p_event_id, p_type)
on conflict (id) do nothing;
if not found then
return; -- pengiriman ulang event yang sama
end if;
if p_type = 'payment.paid' then
-- Uang yang masuk selalu menang, termasuk dari percobaan lama.
update public.orders
set status = 'LUNAS', paid_at = coalesce(p_paid_at, now())
where id = p_order_id;
elsif p_type = 'payment.expired' then
update public.orders
set status = 'KEDALUWARSA', attempt = attempt + 1
where id = p_order_id
and status <> 'LUNAS'
and payment_request_id = p_payment_request_id;
end if;
end $$;
revoke execute on function public.terapkan_event_kasera(text, text, uuid, text, timestamptz)
from public, anon, authenticated;Tabel orders hanya punya policy baca. Baris pesanan, termasuk total-nya, dibuat oleh fungsi server yang membaca harga dari tabel produk, bukan oleh klien. Kalau klien boleh menyisipkan pesanan dengan total sendiri, pembeli yang sedikit paham bisa membuat pesanan Rp 1.000 untuk barang Rp 500.000 dan membayarnya dengan sah. Kunci rahasia Supabase yang dipakai supabaseAdmin melewati RLS, jadi menutup penulisan dari klien tidak menghalangi kedua Edge Functions.
terapkan_event_kasera adalah inti panduan ini. Sisipan ke kasera_events dengan on conflict do nothing adalah penentu duplikat: dua pengiriman event yang sama yang tiba berbarengan tidak bisa sama-sama berhasil menyisipkan, karena yang kedua menunggu yang pertama selesai lalu tidak menyisipkan apa pun, dan found bernilai false. Karena sisipan dan pembaruan pesanan ada di satu fungsi, keduanya satu transaksi. Pemeriksaan payment_request_id pada cabang kedaluwarsa menjaga urutan antar percobaan: tanpa itu, payment.expired dari percobaan pertama yang terlambat tiba bisa menandai kedaluwarsa pesanan yang sedang menunggu pembayaran percobaan kedua.
3. Fungsi yang membuat permintaan pembayaran
// supabase/functions/bayar-pesanan/index.ts
import { withSupabase } from "npm:@supabase/server@^1";
import { KaseraPay, KaseraPayError } from "npm:kasera-pay@0.1.0";
const kasera = new KaseraPay(Deno.env.get("KASERA_PAY_KEY")!);
export default {
fetch: withSupabase({ auth: "user" }, async (req, ctx) => {
const { orderId } = await req.json();
// ctx.supabase tunduk pada RLS: pesanan milik orang lain tidak terlihat.
// Total dibaca dari tabel, tidak pernah dari body permintaan.
const { data: order } = await ctx.supabase
.from("orders")
.select("id, total, status, attempt")
.eq("id", orderId)
.maybeSingle();
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.createTransaction(
{
amount: order.total,
description: "Pesanan " + order.id,
external_id: order.id,
return_url: "https://toko.example/pesanan/" + order.id,
checkout: {},
},
// Ketukan kedua mengembalikan permintaan yang sama. Nomor percobaan
// naik saat permintaan sebelumnya kedaluwarsa.
{ idempotencyKey: "order-" + order.id + "-" + order.attempt },
);
// Klien pengguna tidak boleh menulis orders, jadi yang menulis supabaseAdmin.
await ctx.supabaseAdmin
.from("orders")
.update({ payment_request_id: tx.id, status: "MENUNGGU" })
.eq("id", order.id)
.neq("status", "LUNAS");
return Response.json({ checkoutUrl: tx.checkout_url });
} catch (e) {
if (e instanceof KaseraPayError) {
return Response.json({ error: e.code }, { status: 502 });
}
throw e;
}
}),
};auth: “user” membiarkan verify_jwt bawaan tetap menyala dan memberi ctx.supabase yang tunduk pada RLS pengguna tersebut. Itu sebabnya pemeriksaan kepemilikan pesanan tidak ditulis manual: pesanan milik orang lain sama sekali tidak terlihat dan hasilnya 404.
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 fungsi Postgres saat payment.expired tiba. Hanya header Idempotency-Key yang mencegah duplikat; external_id sekadar label. Latar belakangnya ada di idempotency untuk pembayaran.
4. Fungsi webhook
# supabase/config.toml
# Hanya fungsi webhook. bayar-pesanan tetap memeriksa JWT pengguna.
[functions.kasera-webhook]
verify_jwt = false// supabase/functions/kasera-webhook/index.ts
import { withSupabase } from "npm:@supabase/server@^1";
import { constructWebhookEvent, SignatureError } from "npm:kasera-pay@0.1.0";
const SECRET = Deno.env.get("KASERA_PAY_WEBHOOK_SECRET")!;
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
export default {
// Kasera Pay tidak membawa JWT Supabase. Pengamannya tanda tangan di bawah.
fetch: withSupabase({ auth: "none" }, async (req, ctx) => {
if (req.method !== "POST") return new Response(null, { status: 405 });
let event;
try {
event = await constructWebhookEvent(
await req.text(), // byte mentah, dibaca sebelum apa pun
req.headers.get("kasera-signature-v1") ?? "",
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,
// jadi data dibaca lewat bentuk umum supaya payment.expired ikut lolos.
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 || !UUID.test(data.external_id)) return new Response("ignored");
const { error } = await ctx.supabaseAdmin.rpc("terapkan_event_kasera", {
p_event_id: event.id,
p_type: type,
p_order_id: data.external_id,
p_payment_request_id: data.payment_request_id ?? null,
p_paid_at: data.paid_at ?? null,
});
if (error) {
console.error(error);
return new Response("retry", { status: 500 }); // Kasera Pay mengulang
}
return new Response("ok");
}),
};verify_jwt = false hanya untuk fungsi ini. Menaruhnya di level proyek, atau men-deploy bayar-pesanan dengan --no-verify-jwt karena “webhook-nya jalan setelah itu”, membuka fungsi pembuat pembayaran untuk siapa saja.
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 setiap pengiriman sah ditolak. constructWebhookEvent memeriksa header Kasera-Signature-V1, termasuk toleransi waktu dan dua entri v1 selama rotasi signing secret.
Pengiriman bersifat at-least-once, dan jawaban selain 2xx membuat Kasera Pay mengulang sampai tujuh kali dalam kurang lebih 33 jam. Karena itu galat database dijawab 500, bukan 200. Event kedaluwarsa hanya dikirim ke endpoint yang mencentangnya; endpoint lama menerima payment.paid saja sampai diubah. Rinciannya ada di referensi webhook. URL yang didaftarkan di dashboard, menu Developer, berbentuk https://<project-ref>.supabase.co/functions/v1/kasera-webhook.
5. Sisi aplikasi
const { data, error } = await supabase.functions.invoke("bayar-pesanan", {
body: { orderId },
});
if (!error) window.location.assign(data.checkoutUrl);
// Halaman pesanan: status berubah saat webhook menulisnya.
supabase
.channel("pesanan-" + orderId)
.on(
"postgres_changes",
{ event: "UPDATE", schema: "public", table: "orders", filter: "id=eq." + orderId },
(p) => { if (p.new.status === "LUNAS") tampilkanLunas(); },
)
.subscribe();functions.invoke mengirim JWT sesi pengguna secara otomatis. Langganan Realtime tunduk pada policy baca yang sama, jadi pengguna hanya menerima perubahan pesanannya sendiri, dan tabelnya harus sudah masuk publikasi supabase_realtime seperti di langkah 2.
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. Fungsi yang dijalankan dengan supabase functions serve hanya bisa dijangkau dari mesin sendiri, jadi webhook ke sana butuh terowongan; caranya ada di webhook di localhost dan mode tes. Lima hal yang layak dicoba: ketukan ganda menghasilkan satu permintaan pembayaran, event yang dikirim ulang tidak mengubah apa pun, simulasi kedaluwarsa lalu bayar ulang menghasilkan permintaan baru, update status dari konsol peramban ditolak RLS, dan fungsi Postgres yang sengaja dibuat gagal menghasilkan 500, bukan 200.
Pertanyaan yang sering muncul
Kenapa webhook dijawab 401 padahal kodenya belum sempat berjalan?
Karena verify_jwt masih menyala untuk fungsi webhook. Secara bawaan, Edge Functions menolak permintaan tanpa JWT yang sah sebelum handler dipanggil, dan Kasera Pay tidak mengirim JWT Supabase. Matikan verify_jwt di supabase/config.toml khusus untuk kasera-webhook. Fungsi itu tetap aman karena setiap pengiriman diverifikasi lewat Kasera-Signature-V1, dan pengiriman yang gagal verifikasi dijawab 400.
Bisakah Kasera Pay dipanggil langsung dari aplikasi dengan supabase-js tanpa Edge Functions?
Tidak boleh. Membuat permintaan pembayaran membutuhkan API key rahasia, dan apa pun yang ikut di bundle JavaScript atau aplikasi terpasang bisa dibaca siapa saja. Edge Functions adalah tempat terdekat untuk kunci itu di proyek Supabase. Aplikasi hanya memanggil functions.invoke dan membuka checkout_url yang dikembalikan.
Kenapa dedupe dan pembaruan pesanan ditulis sebagai fungsi Postgres, bukan dua panggilan supabase-js?
Setiap panggilan supabase-js adalah permintaan HTTP tersendiri dengan transaksinya sendiri. Menyisipkan event lalu memperbarui pesanan dalam dua panggilan meninggalkan celah: kalau panggilan kedua gagal, event sudah tercatat, pengiriman ulang dianggap duplikat, dan pesanan tidak pernah lunas. Satu fungsi plpgsql yang dipanggil lewat rpc berjalan dalam satu transaksi, jadi keduanya terjadi bersama atau tidak sama sekali.
Apakah SDK kasera-pay berjalan di Deno?
Ya. SDK JavaScript resmi mendukung Node 20.19+, Deno, Bun, dan edge runtime. Verifikasi tanda tangannya memakai Web Crypto, yang tersedia di runtime Edge Functions, jadi tidak ada penyedia kripto tambahan yang perlu dipasang.